diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd9dbad..8a642471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ ## [7.0.6] - 2026-07-19 +### Security + +- 보안 환경설정의 계정 잠금 시간을 `0`(무한대)으로 설정하면 실제로 무기한 잠기도록 수정했습니다. 화면 안내는 "0을 입력하면 무한대"였지만 실제로는 1분 뒤 자동 해제되어, 무차별 대입 시도를 영구 차단할 수 없었습니다. 무기한 잠긴 계정에는 남은 시간 대신 관리자 문의 안내가 표시됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 보안에 비밀번호 최소 길이와 특수문자 필수 여부를 추가했습니다. 두 항목은 설정 화면에만 있고 실제로는 어디에도 적용되지 않던 값으로, 이제 비밀번호를 새로 정하는 모든 화면(회원가입·비밀번호 변경·재설정·프로필 수정·관리자의 회원 등록과 수정)에 적용됩니다. 로그인에는 적용하지 않습니다 — 정책을 올렸을 때 기존 비밀번호를 쓰는 회원이 자기 계정에 로그인조차 못 하게 되는 것을 막기 위함입니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 본인인증 메일의 인증번호 자리수 설정이 4~10 범위를 벗어나면 조용히 잘려 저장 값과 다르게 동작하던 문제를 수정했습니다. 이제 허용 범위가 설정 화면에 표시되고, 범위를 벗어난 값은 기본값(6자리)으로 처리하며 기록을 남깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 보안의 로그인 시도 제한 항목에 IP 단위 요청 제한이 어떻게 계산되는지(설정값의 6배, 분당 최소 30회 보장) 안내를 추가했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 업로드의 최대 파일 크기와 허용 파일 형식이 실제 업로드에 적용되도록 수정했습니다. 이전에는 설정을 바꿔도 어느 경로에서도 반영되지 않아, 허용 형식 목록에 없는 확장자(예: `.php`)도 업로드할 수 있었습니다. 허용 형식 목록을 비워 두면 종전처럼 형식을 제한하지 않습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.) +- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다. @@ -35,6 +47,7 @@ ### Changed +- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다. - 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다. ### Fixed @@ -59,6 +72,8 @@ - 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다. - 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다. - 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다. +- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다. +- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다. #### 설정 저장·입력 검증 @@ -72,6 +87,7 @@ #### 안내 문구·주소 처리 - 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다. +- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다. - 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다. - 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다. - 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다. diff --git a/app/Contracts/Repositories/UserRepositoryInterface.php b/app/Contracts/Repositories/UserRepositoryInterface.php index 703811aa..a3fc7bbe 100644 --- a/app/Contracts/Repositories/UserRepositoryInterface.php +++ b/app/Contracts/Repositories/UserRepositoryInterface.php @@ -144,17 +144,21 @@ interface UserRepositoryInterface * `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를 * 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다. * + * `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리하며, + * 이때 `locked_until` 은 NULL, `locked_permanently` 는 true 가 되고 null 을 반환합니다. + * * @param User $user 잠글 사용자 - * @param int $minutes 잠금 유지 시간(분) - * @return Carbon 잠금 해제 시각 + * @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한 + * @return Carbon|null 잠금 해제 시각 (영구 잠금은 null) */ - public function lockAccount(User $user, int $minutes): Carbon; + public function lockAccount(User $user, int $minutes): ?Carbon; /** * 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다. * - * 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`, - * `locked_until=null`, `last_failed_login_at=null`). + * 정상 로그인 성공 시 또는 관리자 수동 해제 시 호출됩니다 + * (`failed_login_attempts=0`, `locked_until=null`, `locked_permanently=false`, + * `last_failed_login_at=null`). * * @param User $user 대상 사용자 */ @@ -163,10 +167,44 @@ interface UserRepositoryInterface /** * 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다. * - * `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다. + * `locked_permanently` 가 true 면 항상 true 입니다. 그 외에는 `locked_until` 이 + * NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다. * * @param User $user 대상 사용자 * @return bool 잠금 여부 */ public function isLocked(User $user): bool; + + /** + * UUID 로 사용자를 찾습니다. + * + * @param string $uuid 사용자 UUID + * @return User|null 찾은 사용자 모델 또는 null + */ + public function findByUuid(string $uuid): ?User; + + /** + * UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다. + * + * @param array $uuids 사용자 UUID 배열 + * @return array 사용자 ID 배열 + */ + public function getIdsByUuids(array $uuids): array; + + /** + * 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다. + * + * @param array $ids 사용자 ID 배열 + * @param array $data 갱신할 컬럼 값 + * @return int 갱신된 행 수 + */ + public function updateManyByIds(array $ids, array $data): int; + + /** + * 사용자 ID 목록의 인증 토큰을 모두 삭제합니다. + * + * @param array $ids 사용자 ID 배열 + * @return int 삭제된 토큰 수 + */ + public function deleteTokensByUserIds(array $ids): int; } diff --git a/app/Exceptions/Auth/AccountLockedException.php b/app/Exceptions/Auth/AccountLockedException.php index 71ba0fec..192c7772 100644 --- a/app/Exceptions/Auth/AccountLockedException.php +++ b/app/Exceptions/Auth/AccountLockedException.php @@ -12,6 +12,10 @@ use Symfony\Component\HttpKernel\Exception\HttpException; * HTTP 423 Locked 응답으로 매핑되며, 프론트엔드 토스트는 다국어 키 * `auth.account_locked` 로 잔여 분(`minutes` 플레이스홀더)을 노출합니다. * + * `login_lockout_time = 0`(무한대) 으로 잠긴 계정은 해제 시각이 없으므로 + * `lockedUntil` / `remainingMinutes` 가 모두 null 입니다. 이 경우 프론트엔드는 + * 잔여 시간 대신 `auth.account_locked_permanently` 안내를 노출합니다. + * * 컨트롤러는 본 예외를 별도로 catch 하여 ResponseHelper 응답을 만들거나, * 글로벌 예외 핸들러가 자동으로 423 JSON 응답으로 변환합니다. * @@ -20,15 +24,26 @@ use Symfony\Component\HttpKernel\Exception\HttpException; class AccountLockedException extends HttpException { public function __construct( - public readonly Carbon $lockedUntil, - public readonly int $remainingMinutes, + public readonly ?Carbon $lockedUntil, + public readonly ?int $remainingMinutes, ?string $message = null, ) { parent::__construct( 423, - $message ?? 'auth.account_locked', + $message ?? ($lockedUntil === null ? 'auth.account_locked_permanently' : 'auth.account_locked'), null, - ['Retry-After' => max(1, $remainingMinutes * 60)] + // 영구 잠금은 재시도 시점을 제시할 수 없으므로 Retry-After 를 붙이지 않는다. + $remainingMinutes === null ? [] : ['Retry-After' => max(1, $remainingMinutes * 60)] ); } + + /** + * 영구 잠금 여부를 반환합니다. + * + * @return bool 해제 시각이 없는 무기한 잠금이면 true + */ + public function isPermanent(): bool + { + return $this->lockedUntil === null; + } } diff --git a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php index 968a8d9c..4a4ba975 100644 --- a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php +++ b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php @@ -29,6 +29,15 @@ use Illuminate\Support\Str; */ class MailIdentityProvider implements IdentityVerificationInterface { + /** @var int 인증코드 최소 길이 */ + public const MIN_CODE_LENGTH = 4; + + /** @var int 인증코드 최대 길이 */ + public const MAX_CODE_LENGTH = 10; + + /** @var int 인증코드 기본 길이 (경계 위반 시 폴백) */ + public const DEFAULT_CODE_LENGTH = 6; + public const ID = 'g7:core.mail'; /** @@ -314,7 +323,11 @@ class MailIdentityProvider implements IdentityVerificationInterface 'code_length' => [ 'label' => __('identity.providers.mail.settings.code_length'), 'type' => 'integer', - 'default' => 6, + 'default' => self::DEFAULT_CODE_LENGTH, + // 경계는 스키마가 선언한다 — 서비스가 자체 상수로 조용히 클램프하면 + // 관리자가 설정한 값이 화면 안내와 다르게 동작한다. + 'min' => self::MIN_CODE_LENGTH, + 'max' => self::MAX_CODE_LENGTH, 'help' => __('identity.providers.mail.settings.code_length_help'), ], 'from_address' => [ @@ -345,9 +358,30 @@ class MailIdentityProvider implements IdentityVerificationInterface return $purpose === 'password_reset' ? 'link' : 'text_code'; } + /** + * 지정 길이의 숫자 인증코드를 생성합니다. + * + * 경계를 벗어난 값은 조용히 클램프하지 않습니다 — 클램프는 관리자가 설정한 값과 + * 실제 동작을 어긋나게 만듭니다. 다만 런타임 인증 흐름을 중단시키는 것은 위험하므로 + * 예외 대신 경고 로그 + 스키마 기본값 폴백으로 처리합니다. + * + * @param int $length 요청 코드 길이 + * @return string 생성된 숫자 코드 + */ protected function generateNumericCode(int $length): string { - $length = max(4, min(10, $length)); + if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) { + Log::warning('IDV 인증코드 길이가 허용 범위를 벗어나 기본값으로 대체됨', [ + 'provider_id' => self::ID, + 'requested' => $length, + 'min' => self::MIN_CODE_LENGTH, + 'max' => self::MAX_CODE_LENGTH, + 'applied' => self::DEFAULT_CODE_LENGTH, + ]); + + $length = self::DEFAULT_CODE_LENGTH; + } + $code = ''; for ($i = 0; $i < $length; $i++) { $code .= (string) random_int(0, 9); diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index f599414a..6bbe445e 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -30,6 +30,7 @@ use App\Extension\Helpers\GithubHelper; use App\Extension\Helpers\IdentityMessageSyncHelper; use App\Extension\Helpers\IdentityPolicySyncHelper; use App\Extension\Helpers\NotificationSyncHelper; +use App\Extension\Testing\ExtensionTestAllowlist; use App\Extension\Vendor\Exceptions\VendorInstallException; use App\Extension\Vendor\VendorInstallContext; use App\Extension\Vendor\VendorInstallResult; @@ -2993,6 +2994,13 @@ class ModuleManager implements ModuleManagerInterface return; } + // 테스트 allowlist 확인 — allowlist 밖 모듈은 ServiceProvider 가 등록되지 않으므로 + // 리스너만 등록하면 훅 발화 시 Repository 바인딩이 없어 컨테이너 해석이 실패한다. + // (모듈 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다) + if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('module', $module->getIdentifier())) { + return; + } + // 모듈 활성화 상태 확인 (비활성화된 모듈의 훅은 등록하지 않음) $activeIdentifiers = self::getActiveModuleIdentifiers(); if (! in_array($module->getIdentifier(), $activeIdentifiers, true)) { @@ -4546,6 +4554,11 @@ class ModuleManager implements ModuleManagerInterface 'updated_at' => now(), ]); + // 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면 + // 그 목록에는 이 모듈이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도 + // 캐시 TTL(기본 하루) 동안 이 모듈의 화면이 계속 404 로 남는다. + self::invalidateModuleStatusCache(); + throw new \RuntimeException( __('modules.errors.update_failed', [ 'module' => $identifier, diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 54fdce84..e33da04e 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -29,6 +29,7 @@ use App\Extension\Helpers\GithubHelper; use App\Extension\Helpers\IdentityMessageSyncHelper; use App\Extension\Helpers\IdentityPolicySyncHelper; use App\Extension\Helpers\NotificationSyncHelper; +use App\Extension\Testing\ExtensionTestAllowlist; use App\Extension\Vendor\Exceptions\VendorInstallException; use App\Extension\Vendor\VendorInstallContext; use App\Extension\Vendor\VendorInstallResult; @@ -2828,6 +2829,13 @@ class PluginManager implements PluginManagerInterface protected function registerPluginHookListeners(PluginInterface $plugin): void { + // 테스트 allowlist 확인 — allowlist 밖 플러그인은 ServiceProvider 가 등록되지 않으므로 + // 리스너만 등록하면 훅 발화 시 의존 바인딩이 없어 컨테이너 해석이 실패한다. + // (플러그인 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다) + if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('plugin', $plugin->getIdentifier())) { + return; + } + // 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음) $activeIdentifiers = self::getActivePluginIdentifiers(); if (! in_array($plugin->getIdentifier(), $activeIdentifiers, true)) { @@ -4726,6 +4734,11 @@ class PluginManager implements PluginManagerInterface 'updated_at' => now(), ]); + // 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면 + // 그 목록에는 이 플러그인이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도 + // 캐시 TTL(기본 하루) 동안 이 플러그인의 화면이 계속 404 로 남는다. + self::invalidatePluginStatusCache(); + throw new \RuntimeException( __('plugins.errors.update_failed', [ 'plugin' => $identifier, diff --git a/app/Extension/Testing/ExtensionTestAllowlist.php b/app/Extension/Testing/ExtensionTestAllowlist.php index 7efb9564..f803ca09 100644 --- a/app/Extension/Testing/ExtensionTestAllowlist.php +++ b/app/Extension/Testing/ExtensionTestAllowlist.php @@ -2,6 +2,9 @@ namespace App\Extension\Testing; +use App\Extension\HookListenerRegistrar; +use App\Extension\HookManager; + /** * 테스트 환경 확장 로딩 allowlist * @@ -50,6 +53,8 @@ class ExtensionTestAllowlist */ public static function set(array $extensions): void { + $previous = self::signature(); + self::$plugins = []; self::$modules = []; self::$configured = true; @@ -63,6 +68,27 @@ class ExtensionTestAllowlist self::$modules[] = basename($extension); } } + + if (self::signature() !== $previous) { + self::forgetRegisteredHooks(); + } + } + + /** + * allowlist 가 바뀌었을 때 남아 있는 훅 등록을 비웁니다. + * + * `HookManager` 의 훅/필터 맵과 `HookListenerRegistrar` 의 등록 이력은 static 이라 + * 테스트 클래스가 바뀌어도 프로세스에 그대로 남습니다. 앞 클래스가 허용했던 확장의 + * 리스너가 남아 있으면, 그 확장을 배선하지 않은 다음 클래스의 앱에서 훅이 발화할 때 + * `app($listenerClass)` 해석이 실패합니다 (Filter 훅은 동기 실행이라 그대로 500 이 된다). + * + * 비운 직후 새 앱이 부팅되며 코어·허용 확장 리스너를 다시 등록하므로, 이 초기화는 + * "이번 allowlist 에 맞는 등록만 남긴다" 는 의미가 됩니다. + */ + private static function forgetRegisteredHooks(): void + { + HookManager::resetAll(); + HookListenerRegistrar::clear(); } /** @@ -77,6 +103,25 @@ class ExtensionTestAllowlist self::$configured = false; } + /** + * 현재 allowlist 를 비교 가능한 문자열로 반환합니다. + * + * @return string allowlist 서명 (미설정이면 빈 문자열) + */ + private static function signature(): string + { + if (! self::$configured) { + return ''; + } + + $modules = self::$modules; + $plugins = self::$plugins; + sort($modules); + sort($plugins); + + return 'm:'.implode(',', $modules).'|p:'.implode(',', $plugins); + } + /** * 가드 활성 여부를 반환합니다. * diff --git a/app/Extension/Traits/CachesModuleStatus.php b/app/Extension/Traits/CachesModuleStatus.php index 73a1b88e..dcb3051c 100644 --- a/app/Extension/Traits/CachesModuleStatus.php +++ b/app/Extension/Traits/CachesModuleStatus.php @@ -29,16 +29,49 @@ trait CachesModuleStatus return []; } - return self::resolveStatusCache()->remember( + return self::rememberNonEmpty( 'ext.modules.active_identifiers', fn () => Module::where('status', ExtensionStatus::Active->value) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.modules'] + ->toArray() ); } + /** + * 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다. + * + * 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서 + * 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 확장이 + * 꺼진 것처럼 동작한다 — 관리자 화면이 통째로 404 가 되고 스스로 회복되지 않는다. + * 빈 결과는 재계산 비용이 사실상 없는 단순 조회이므로 캐시하지 않는 편이 안전하다. + * + * @param string $key 캐시 키 + * @param \Closure $resolver 목록 계산 클로저 + * @return array 조회된 identifier 배열 + */ + private static function rememberNonEmpty(string $key, \Closure $resolver): array + { + $cache = self::resolveStatusCache(); + $cached = $cache->get($key); + + if (is_array($cached) && $cached !== []) { + return $cached; + } + + $fresh = $resolver(); + + if ($fresh !== []) { + $cache->put( + $key, + $fresh, + (int) g7_core_settings('cache.extension_status_ttl', 86400), + ['ext.status', 'ext.modules'] + ); + } + + return $fresh; + } + /** * 설치된 모듈 (active + inactive) identifier 목록을 조회합니다. * @@ -50,14 +83,12 @@ trait CachesModuleStatus return []; } - return self::resolveStatusCache()->remember( + return self::rememberNonEmpty( 'ext.modules.installed_identifiers', fn () => Module::whereIn('status', [ ExtensionStatus::Active->value, ExtensionStatus::Inactive->value, - ])->pluck('identifier')->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.modules'] + ])->pluck('identifier')->toArray() ); } diff --git a/app/Extension/Traits/CachesPluginStatus.php b/app/Extension/Traits/CachesPluginStatus.php index de1caa82..14ce9b16 100644 --- a/app/Extension/Traits/CachesPluginStatus.php +++ b/app/Extension/Traits/CachesPluginStatus.php @@ -29,16 +29,49 @@ trait CachesPluginStatus return []; } - return self::resolvePluginStatusCache()->remember( + return self::rememberNonEmptyPluginList( 'ext.plugins.active_identifiers', fn () => Plugin::where('status', ExtensionStatus::Active->value) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.plugins'] + ->toArray() ); } + /** + * 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다. + * + * 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서 + * 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 + * 플러그인이 꺼진 것처럼 동작하며 스스로 회복되지 않는다. 빈 결과는 재계산 비용이 사실상 + * 없는 단순 조회이므로 캐시하지 않는 편이 안전하다. + * + * @param string $key 캐시 키 + * @param \Closure $resolver 목록 계산 클로저 + * @return array 조회된 identifier 배열 + */ + private static function rememberNonEmptyPluginList(string $key, \Closure $resolver): array + { + $cache = self::resolvePluginStatusCache(); + $cached = $cache->get($key); + + if (is_array($cached) && $cached !== []) { + return $cached; + } + + $fresh = $resolver(); + + if ($fresh !== []) { + $cache->put( + $key, + $fresh, + (int) g7_core_settings('cache.extension_status_ttl', 86400), + ['ext.status', 'ext.plugins'] + ); + } + + return $fresh; + } + /** * 설치된 플러그인 (active + inactive) identifier 목록을 조회합니다. * @@ -50,14 +83,12 @@ trait CachesPluginStatus return []; } - return self::resolvePluginStatusCache()->remember( + return self::rememberNonEmptyPluginList( 'ext.plugins.installed_identifiers', fn () => Plugin::whereIn('status', [ ExtensionStatus::Active->value, ExtensionStatus::Inactive->value, - ])->pluck('identifier')->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.plugins'] + ])->pluck('identifier')->toArray() ); } diff --git a/app/Extension/Traits/CachesTemplateStatus.php b/app/Extension/Traits/CachesTemplateStatus.php index 746e06cd..e581ec7b 100644 --- a/app/Extension/Traits/CachesTemplateStatus.php +++ b/app/Extension/Traits/CachesTemplateStatus.php @@ -29,13 +29,11 @@ trait CachesTemplateStatus return []; } - return self::resolveTemplateStatusCache()->remember( + return self::rememberNonEmptyTemplateList( 'ext.templates.active_identifiers', fn () => Template::where('status', ExtensionStatus::Active->value) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.templates'] + ->toArray() ); } @@ -51,14 +49,12 @@ trait CachesTemplateStatus return []; } - return self::resolveTemplateStatusCache()->remember( + return self::rememberNonEmptyTemplateList( "ext.templates.active_identifiers_{$type}", fn () => Template::where('status', ExtensionStatus::Active->value) ->where('type', $type) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.templates'] + ->toArray() ); } @@ -73,17 +69,50 @@ trait CachesTemplateStatus return []; } - return self::resolveTemplateStatusCache()->remember( + return self::rememberNonEmptyTemplateList( 'ext.templates.installed_identifiers', fn () => Template::whereIn('status', [ ExtensionStatus::Active->value, ExtensionStatus::Inactive->value, - ])->pluck('identifier')->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.templates'] + ])->pluck('identifier')->toArray() ); } + /** + * 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다. + * + * 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서 + * 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 + * 템플릿이 꺼진 것처럼 동작하며 스스로 회복되지 않는다. 빈 결과는 재계산 비용이 사실상 + * 없는 단순 조회이므로 캐시하지 않는 편이 안전하다. + * + * @param string $key 캐시 키 + * @param \Closure $resolver 목록 계산 클로저 + * @return array 조회된 identifier 배열 + */ + private static function rememberNonEmptyTemplateList(string $key, \Closure $resolver): array + { + $cache = self::resolveTemplateStatusCache(); + $cached = $cache->get($key); + + if (is_array($cached) && $cached !== []) { + return $cached; + } + + $fresh = $resolver(); + + if ($fresh !== []) { + $cache->put( + $key, + $fresh, + (int) g7_core_settings('cache.extension_status_ttl', 86400), + ['ext.status', 'ext.templates'] + ); + } + + return $fresh; + } + /** * 템플릿 상태 캐시를 무효화합니다. * 템플릿 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다. diff --git a/app/Helpers/PermissionHelper.php b/app/Helpers/PermissionHelper.php index b8c4c32c..de3656c4 100644 --- a/app/Helpers/PermissionHelper.php +++ b/app/Helpers/PermissionHelper.php @@ -2,7 +2,6 @@ namespace App\Helpers; -use App\Enums\ScopeType; use App\Models\Permission; use App\Models\User; use App\Providers\AuthServiceProvider; @@ -26,6 +25,20 @@ class PermissionHelper */ protected static array $permissionCache = []; + /** + * 권한 스코프 캐시를 무효화합니다. + * + * 캐시는 프로세스 수명 동안 유지되므로, 권한 행이 다시 만들어지는 시점 + * (권한 재시드 · 코어 업데이트 · 확장 install) 에 비워 주어야 한다. + * 테스트에서는 매 케이스마다 DB 가 초기화되는데 캐시만 남아, 앞 케이스가 심은 + * `resource_route_key = null` 이 뒤 케이스로 새어 스코프 검사가 통째로 건너뛰어진다 + * (개별 실행은 통과하고 스위트에서만 어긋나는 형태로 드러난다). + */ + public static function clearPermissionScopeCache(): void + { + self::$permissionCache = []; + } + /** * 단일 권한 체크 * @@ -104,7 +117,6 @@ class PermissionHelper * @param Builder $query Eloquent 쿼리 빌더 * @param string $permission 권한 식별자 * @param User|null $user 사용자 (null이면 현재 인증 사용자) - * @return void */ public static function applyPermissionScope(Builder $query, string $permission, ?User $user = null): void { diff --git a/app/Http/Controllers/Api/Admin/AuthController.php b/app/Http/Controllers/Api/Admin/AuthController.php index 90fbbec6..439f274d 100644 --- a/app/Http/Controllers/Api/Admin/AuthController.php +++ b/app/Http/Controllers/Api/Admin/AuthController.php @@ -4,11 +4,11 @@ namespace App\Http\Controllers\Api\Admin; use App\Exceptions\Auth\AccountLockedException; use App\Http\Controllers\Api\Base\AdminBaseController; +use App\Http\Requests\Auth\AuthenticatedRequest; use App\Http\Requests\Auth\LoginRequest; use App\Http\Resources\UserResource; use App\Services\AuthService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; class AuthController extends AdminBaseController @@ -22,7 +22,7 @@ class AuthController extends AdminBaseController /** * 관리자를 로그인시킵니다. * - * @param LoginRequest $request 로그인 요청 데이터 + * @param LoginRequest $request 로그인 요청 데이터 * @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답 */ public function login(LoginRequest $request): JsonResponse @@ -36,7 +36,7 @@ class AuthController extends AdminBaseController $user = $data['user']; // 관리자 권한 확인 - if (!$user->isAdmin()) { + if (! $user->isAdmin()) { return $this->forbidden('auth.admin_required'); } @@ -45,10 +45,17 @@ class AuthController extends AdminBaseController return $this->success('auth.admin_login_success', $data); } catch (AccountLockedException $e) { - return $this->error('auth.account_locked', 423, [ - 'locked_until' => $e->lockedUntil->toIso8601String(), - 'retry_after_seconds' => $e->remainingMinutes * 60, - ], ['minutes' => $e->remainingMinutes]); + // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출. + return $this->error( + $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', + 423, + [ + 'locked_until' => $e->lockedUntil?->toIso8601String(), + 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, + 'permanent' => $e->isPermanent(), + ], + ['minutes' => $e->remainingMinutes] + ); } catch (ValidationException $e) { return $this->unauthorized('auth.login_failed'); } @@ -57,10 +64,10 @@ class AuthController extends AdminBaseController /** * 관리자를 로그아웃시킵니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 로그아웃 성공 메시지 */ - public function logout(Request $request): JsonResponse + public function logout(AuthenticatedRequest $request): JsonResponse { $this->authService->logout($request->user()); @@ -70,10 +77,10 @@ class AuthController extends AdminBaseController /** * 현재 로그인된 관리자의 정보를 반환합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 관리자 정보를 포함한 JSON 응답 */ - public function user(Request $request): JsonResponse + public function user(AuthenticatedRequest $request): JsonResponse { $user = $request->user(); @@ -89,10 +96,10 @@ class AuthController extends AdminBaseController /** * 관리자의 인증 토큰을 갱신합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 새로운 토큰과 관리자 정보를 포함한 JSON 응답 */ - public function refresh(Request $request): JsonResponse + public function refresh(AuthenticatedRequest $request): JsonResponse { try { $data = $this->authService->refreshToken($request->user()); @@ -101,6 +108,7 @@ class AuthController extends AdminBaseController if (isset($data['user'])) { $data['user'] = new UserResource($data['user']); } + return $this->success('common.success', $data); } catch (ValidationException $e) { return $this->unauthorized('auth.unauthenticated'); diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index f3f3fc9e..c3e6d14f 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -42,6 +42,7 @@ class SettingsController extends AdminBaseController try { $settings = $this->settingsService->getAllSettings(); $settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers(); + $settings['_meta'] = ['limits' => config('core.settings_limits', [])]; return $this->success('settings.fetch_success', (new SettingsResource($settings))->toArray(request()) @@ -69,6 +70,7 @@ class SettingsController extends AdminBaseController // 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용) $allSettings = $this->settingsService->getAllSettings(); $allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers(); + $allSettings['_meta'] = ['limits' => config('core.settings_limits', [])]; return $this->success('settings.save_success', [ 'settings' => $allSettings, diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php index b399270d..65baa1b1 100644 --- a/app/Http/Controllers/Api/Admin/UserController.php +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -129,6 +129,30 @@ class UserController extends AdminBaseController } } + /** + * 사용자의 계정 잠금을 해제합니다. + * + * 로그인 실패 누적으로 잠긴 계정(특히 잠금 시간 0 = 무한대 설정으로 영구 잠긴 계정)을 + * 관리자가 수동으로 풀어 줍니다. 이 경로가 없으면 영구 잠금 계정은 성공 로그인 자체가 + * 불가하므로 복구 수단이 없습니다. + * + * @param User $user 잠금을 해제할 사용자 모델 + * @return JsonResponse 갱신된 사용자 정보를 포함한 JSON 응답 + */ + public function unlock(User $user): JsonResponse + { + try { + $unlocked = $this->userService->unlockAccount($user); + + return $this->successWithResource( + 'auth.account_unlocked', + new UserResource($unlocked) + ); + } catch (Exception $e) { + return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]); + } + } + /** * 사용자를 삭제합니다. * diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index 4f5e0d40..d0532e17 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Auth; use App\Exceptions\Auth\AccountLockedException; use App\Http\Controllers\Api\Base\AuthBaseController; +use App\Http\Requests\Auth\AuthenticatedRequest; use App\Http\Requests\Auth\ForgotPasswordRequest; use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\RegisterRequest; @@ -12,7 +13,6 @@ use App\Http\Requests\Auth\ValidateResetTokenRequest; use App\Http\Resources\UserResource; use App\Services\AuthService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; class AuthController extends AuthBaseController @@ -52,10 +52,17 @@ class AuthController extends AuthBaseController return $this->success('auth.login_success', $data); } catch (AccountLockedException $e) { - return $this->error('auth.account_locked', 423, [ - 'locked_until' => $e->lockedUntil->toIso8601String(), - 'retry_after_seconds' => $e->remainingMinutes * 60, - ], ['minutes' => $e->remainingMinutes]); + // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출. + return $this->error( + $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', + 423, + [ + 'locked_until' => $e->lockedUntil?->toIso8601String(), + 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, + 'permanent' => $e->isPermanent(), + ], + ['minutes' => $e->remainingMinutes] + ); } catch (ValidationException $e) { return $this->unauthorized('auth.login_failed'); } @@ -84,10 +91,10 @@ class AuthController extends AuthBaseController /** * 사용자를 로그아웃시킵니다. (현재 디바이스만) * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 로그아웃 성공 메시지 */ - public function logout(Request $request): JsonResponse + public function logout(AuthenticatedRequest $request): JsonResponse { $this->authService->logout($request->user()); @@ -97,10 +104,10 @@ class AuthController extends AuthBaseController /** * 모든 디바이스에서 사용자를 로그아웃시킵니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 로그아웃 성공 메시지 */ - public function logoutFromAllDevices(Request $request): JsonResponse + public function logoutFromAllDevices(AuthenticatedRequest $request): JsonResponse { $this->authService->logoutFromAllDevices($request->user()); @@ -110,10 +117,10 @@ class AuthController extends AuthBaseController /** * 현재 로그인된 사용자의 정보를 반환합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 사용자 정보를 포함한 JSON 응답 */ - public function user(Request $request): JsonResponse + public function user(AuthenticatedRequest $request): JsonResponse { $user = $request->user(); @@ -131,10 +138,10 @@ class AuthController extends AuthBaseController /** * 사용자의 인증 토큰을 갱신합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답 */ - public function refresh(Request $request): JsonResponse + public function refresh(AuthenticatedRequest $request): JsonResponse { $data = $this->authService->refreshToken($request->user()); diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php index 82f5b0ab..13d39d9e 100644 --- a/app/Http/Controllers/Api/Public/PublicTemplateController.php +++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Public; +use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Extension\Helpers\EditorSpecAssembler; use App\Extension\Traits\ClearsTemplateCaches; @@ -12,6 +13,7 @@ use App\Services\TemplateLayoutAttachmentService; use App\Services\TemplateService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\BinaryFileResponse; /** @@ -40,22 +42,36 @@ class PublicTemplateController extends PublicBaseController // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화 $cacheVersion = request()->query('v', 0); - // 캐싱된 응답 반환 (1시간 유효) - $routesData = $this->cached( - "template.routes.{$identifier}.v{$cacheVersion}", - function () use ($identifier) { - // Service에서 템플릿 + 모듈 routes 데이터 병합 조회 - $result = $this->templateService->getRoutesDataWithModules($identifier); + // 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다. + // 그 순간의 응답을 버전 키에 캐시하면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 + // 해당 모듈의 모든 화면이 404 로 남는다 — 버전은 이미 올라간 뒤라 스스로 회복되지 않는다. + // 따라서 열화 스냅샷은 그대로 응답하되 캐시에 남기지 않는다. + $buildRoutes = function () use ($identifier) { + $result = $this->templateService->getRoutesDataWithModules($identifier); - // 에러 처리 - if (! $result['success']) { - return ['error' => $result['error']]; - } + if (! $result['success']) { + return ['error' => $result['error']]; + } - return ['success' => true, 'data' => $result['data']]; - }, - 3600 - ); + return ['success' => true, 'data' => $result['data']]; + }; + + $cacheKey = "template.routes.{$identifier}.v{$cacheVersion}"; + $cache = app(CacheInterface::class); + $routesData = $cache->get($cacheKey); + + if ($routesData === null) { + $routesData = $buildRoutes(); + + if ($this->templateService->lastRouteMergeWasDegraded()) { + Log::warning('라우트 병합이 열화 상태여서 캐시하지 않습니다 (확장 업데이트 진행 중 추정)', [ + 'template' => $identifier, + 'cache_version' => $cacheVersion, + ]); + } else { + $cache->put($cacheKey, $routesData, 3600); + } + } // 에러 응답 처리 if (isset($routesData['error'])) { diff --git a/app/Http/Requests/Attachment/UploadAttachmentRequest.php b/app/Http/Requests/Attachment/UploadAttachmentRequest.php index 88bdc844..0acb70f7 100644 --- a/app/Http/Requests/Attachment/UploadAttachmentRequest.php +++ b/app/Http/Requests/Attachment/UploadAttachmentRequest.php @@ -29,10 +29,18 @@ class UploadAttachmentRequest extends FormRequest */ public function rules(): array { - $maxSize = config('attachment.max_size', 10) * 1024; // MB to KB + // config/attachment.max_file_size 는 KB 단위 (관리자 설정 MB → KB 변환은 SettingsServiceProvider 담당) + $maxSize = (int) config('attachment.max_file_size', 10240); + $extensions = $this->allowedExtensions(); + + $fileRules = ['required', 'file', 'max:'.$maxSize]; + // 목록이 비면 확장자 제한 없음 (탈출구) — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($extensions !== []) { + $fileRules[] = 'extensions:'.implode(',', $extensions); + } $rules = [ - 'file' => ['required', 'file', 'max:' . $maxSize], + 'file' => $fileRules, 'attachmentable_type' => ['nullable', 'string', 'max:255'], 'attachmentable_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], @@ -54,7 +62,8 @@ class UploadAttachmentRequest extends FormRequest return [ 'file.required' => __('attachment.validation.file_required'), 'file.file' => __('attachment.validation.file_invalid'), - 'file.max' => __('attachment.validation.file_max', ['max' => config('attachment.max_size', 10)]), + 'file.max' => __('attachment.validation.file_max', ['max' => (int) config('attachment.max_file_size', 10240) / 1024]), + 'file.extensions' => __('attachment.validation.file_extension_invalid', ['extensions' => implode(', ', $this->allowedExtensions())]), 'attachmentable_type.string' => __('attachment.validation.type_invalid'), 'attachmentable_id.integer' => __('attachment.validation.id_invalid'), ]; @@ -62,8 +71,6 @@ class UploadAttachmentRequest extends FormRequest /** * 검증 전 데이터 준비 - * - * @return void */ protected function prepareForValidation(): void { @@ -72,4 +79,25 @@ class UploadAttachmentRequest extends FormRequest 'source_type' => $this->source_type ?? AttachmentSourceType::Core->value, ]); } + + /** + * 허용 확장자 목록을 반환합니다 (소문자 정규화 + 확장 훅 적용). + * + * 빈 배열은 "확장자 제한 없음" 을 의미합니다. + * + * @return array 허용 확장자 목록 + */ + protected function allowedExtensions(): array + { + $extensions = (array) config('attachment.allowed_extensions', []); + + $extensions = array_values(array_filter(array_map( + static fn ($ext) => strtolower(trim(ltrim((string) $ext, '.'))), + $extensions + ))); + + return array_values(array_unique( + (array) HookManager::applyFilters('core.attachment.allowed_extensions', $extensions, $this) + )); + } } diff --git a/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php b/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php index 32af6a4b..ef985e99 100644 --- a/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php +++ b/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php @@ -29,11 +29,19 @@ class UploadBatchAttachmentRequest extends FormRequest */ public function rules(): array { - $maxSize = config('attachment.max_size', 10) * 1024; // MB to KB + // config/attachment.max_file_size 는 KB 단위 (관리자 설정 MB → KB 변환은 SettingsServiceProvider 담당) + $maxSize = (int) config('attachment.max_file_size', 10240); + $extensions = $this->allowedExtensions(); + + $fileRules = ['file', 'max:'.$maxSize]; + // 목록이 비면 확장자 제한 없음 (탈출구) — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($extensions !== []) { + $fileRules[] = 'extensions:'.implode(',', $extensions); + } $rules = [ 'files' => ['required', 'array', 'min:1'], - 'files.*' => ['file', 'max:' . $maxSize], + 'files.*' => $fileRules, 'attachmentable_type' => ['nullable', 'string', 'max:255'], 'attachmentable_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], @@ -57,7 +65,8 @@ class UploadBatchAttachmentRequest extends FormRequest 'files.array' => __('attachment.validation.files_array'), 'files.min' => __('attachment.validation.files_min'), 'files.*.file' => __('attachment.validation.file_invalid'), - 'files.*.max' => __('attachment.validation.file_max', ['max' => config('attachment.max_size', 10)]), + 'files.*.max' => __('attachment.validation.file_max', ['max' => (int) config('attachment.max_file_size', 10240) / 1024]), + 'files.*.extensions' => __('attachment.validation.file_extension_invalid', ['extensions' => implode(', ', $this->allowedExtensions())]), 'attachmentable_type.string' => __('attachment.validation.type_invalid'), 'attachmentable_id.integer' => __('attachment.validation.id_invalid'), ]; @@ -65,8 +74,6 @@ class UploadBatchAttachmentRequest extends FormRequest /** * 검증 전 데이터 준비 - * - * @return void */ protected function prepareForValidation(): void { @@ -75,4 +82,25 @@ class UploadBatchAttachmentRequest extends FormRequest 'source_type' => $this->source_type ?? AttachmentSourceType::Core->value, ]); } + + /** + * 허용 확장자 목록을 반환합니다 (소문자 정규화 + 확장 훅 적용). + * + * 빈 배열은 "확장자 제한 없음" 을 의미합니다. + * + * @return array 허용 확장자 목록 + */ + protected function allowedExtensions(): array + { + $extensions = (array) config('attachment.allowed_extensions', []); + + $extensions = array_values(array_filter(array_map( + static fn ($ext) => strtolower(trim(ltrim((string) $ext, '.'))), + $extensions + ))); + + return array_values(array_unique( + (array) HookManager::applyFilters('core.attachment.allowed_extensions', $extensions, $this) + )); + } } diff --git a/app/Http/Requests/Auth/AuthenticatedRequest.php b/app/Http/Requests/Auth/AuthenticatedRequest.php new file mode 100644 index 00000000..6d67f246 --- /dev/null +++ b/app/Http/Requests/Auth/AuthenticatedRequest.php @@ -0,0 +1,38 @@ + 빈 배열 (본문 입력 없음) + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2926d4a8..aed4f22b 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -3,12 +3,15 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class LoginRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -18,13 +21,15 @@ class LoginRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'email' => 'required|email', - 'password' => 'required|string|min:6', + // 로그인은 기존 비밀번호를 대조하는 행위이지 새 정책을 강제하는 지점이 아니다. + // 길이 규칙을 두면 정책 미달 비밀번호에 422 를 주어 미인증 공격자에게 그 사실을 누설한다. + 'password' => 'required|string', ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 @@ -42,7 +47,6 @@ class LoginRequest extends FormRequest 'email.required' => __('validation.auth.email.required'), 'email.email' => __('validation.auth.email.email'), 'password.required' => __('validation.auth.password.required'), - 'password.min' => __('validation.auth.password.min'), ]; } } diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php index fa6face9..5e119ba9 100644 --- a/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -31,7 +32,7 @@ class RegisterRequest extends FormRequest 'name' => 'required|string|max:255', 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)], - 'password' => 'required|string|min:8|confirmed', + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], 'mobile' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], 'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], 'language' => 'nullable|string|in:'.implode(',', config('app.supported_locales', ['ko', 'en'])), diff --git a/app/Http/Requests/Auth/ResetPasswordRequest.php b/app/Http/Requests/Auth/ResetPasswordRequest.php index 8975ec1a..4f83b208 100644 --- a/app/Http/Requests/Auth/ResetPasswordRequest.php +++ b/app/Http/Requests/Auth/ResetPasswordRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,6 +13,8 @@ class ResetPasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -20,14 +24,14 @@ class ResetPasswordRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'token' => 'required|string', 'email' => ['required', 'email', Rule::exists(User::class, 'email')], - 'password' => 'required|string|min:8|confirmed', + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index c5859eec..5e1ea0c9 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests\Settings; use App\Extension\HookManager; use App\Models\Attachment; use App\Search\Engines\DatabaseFulltextEngine; +use App\Support\AllowedExtensions; use App\Support\AssetUrl; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\Validator; @@ -106,6 +107,28 @@ class SaveSettingsRequest extends FormRequest 'advanced.geoip_enabled', 'advanced.geoip_auto_update_enabled', ]); + + $this->normalizeAllowedExtensions(); + } + + /** + * 허용 확장자를 저장 형태(소문자 확장자 배열)로 정규화합니다. + * + * 화면 입력 칸은 콤마로 구분한 한 줄 텍스트라 이 값은 문자열로 들어온다. 문자열 그대로 + * 저장하면 업로드 제한을 적용하는 지점이 그 값을 쓰지 못해, 저장은 성공하는데 제한은 + * 종전 목록으로 남는다. 형태 판정은 저장 경로 진입 전 이 한 곳에서만 수행한다. + */ + private function normalizeAllowedExtensions(): void + { + $upload = $this->input('upload', []); + + if (! is_array($upload) || ! array_key_exists('allowed_extensions', $upload)) { + return; + } + + $upload['allowed_extensions'] = AllowedExtensions::normalize($upload['allowed_extensions']); + + $this->merge(['upload' => $upload]); } /** @@ -202,11 +225,11 @@ class SaveSettingsRequest extends FormRequest 'mail.from_name' => $this->getTabRules($tab, 'mail', 'string|max:255'), // 업로드 설정 - 'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:1|max:1024'), + 'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:'.config('core.settings_limits.upload_max_file_size_min', 1).'|max:'.config('core.settings_limits.upload_max_file_size_max', 1024)), 'upload.allowed_extensions' => $this->getAllowedExtensionsRules($tab), - 'upload.image_max_width' => ['nullable', 'integer', 'min:100', 'max:10000'], - 'upload.image_max_height' => ['nullable', 'integer', 'min:100', 'max:10000'], - 'upload.image_quality' => ['nullable', 'integer', 'min:1', 'max:100'], + 'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)], + 'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)], + 'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)], // SEO 설정 'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'], @@ -220,16 +243,16 @@ class SaveSettingsRequest extends FormRequest 'seo.bot_detection_enabled' => ['nullable', 'boolean'], 'seo.bot_detection_library_enabled' => ['nullable', 'boolean'], 'seo.og_default_site_name' => ['nullable', 'string', 'max:200'], - 'seo.og_image_default_width' => ['nullable', 'integer', 'min:0', 'max:8000'], - 'seo.og_image_default_height' => ['nullable', 'integer', 'min:0', 'max:8000'], + 'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)], + 'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)], 'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])], 'seo.twitter_default_site' => ['nullable', 'string', 'max:50'], 'seo.cache_enabled' => ['nullable', 'boolean'], - 'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'], + 'seo.cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_cache_ttl_min', 60), 'max:'.config('core.settings_limits.seo_cache_ttl_max', 86400)], 'seo.sitemap_enabled' => ['nullable', 'boolean'], - 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], + 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.seo_sitemap_cache_ttl_max', 604800)], // 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다. - 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'], + 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_urls_per_file_min', 1000), 'max:'.config('core.settings_limits.seo_sitemap_urls_per_file_max', 50000)], 'seo.sitemap_gzip' => ['nullable', 'boolean'], 'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'], 'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'], @@ -243,8 +266,17 @@ class SaveSettingsRequest extends FormRequest 'security.force_https' => $this->getTabRules($tab, 'security', 'boolean'), 'security.login_attempt_enabled' => $this->getTabRules($tab, 'security', 'boolean'), 'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab), - 'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'], - 'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'], + 'security.max_login_attempts' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_max_login_attempts_min', 0), 'max:'.config('core.settings_limits.security_max_login_attempts_max', 100)], + 'security.login_lockout_time' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_login_lockout_time_min', 0), 'max:'.config('core.settings_limits.security_login_lockout_time_max', 1440)], + // 비밀번호 정책 — 저장 규칙이 없어 whitelist 에서 탈락하던 고아 설정을 배선. + // 경계값은 config('core.settings_limits') 가 SSoT — 설정 화면도 같은 값을 받아 쓴다. + 'security.password_min_length' => [ + 'nullable', + 'integer', + 'min:'.config('core.settings_limits.security_password_min_length_min', 6), + 'max:'.config('core.settings_limits.security_password_min_length_max', 64), + ], + 'security.require_password_special_char' => ['nullable', 'boolean'], // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지 'security.allow_internal_outbound_urls' => ['nullable', 'boolean'], @@ -258,7 +290,7 @@ class SaveSettingsRequest extends FormRequest 'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab), // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지. // 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다. - 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], + 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_max', 604800)], // 디버그 설정 (advanced 탭) 'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'), @@ -284,35 +316,35 @@ class SaveSettingsRequest extends FormRequest 'drivers.s3_url' => ['nullable', 'url', 'max:500'], 'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]), 'drivers.redis_host' => ['nullable', 'string', 'max:255'], - 'drivers.redis_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.redis_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_port_min', 1), 'max:'.config('core.settings_limits.drivers_redis_port_max', 65535)], 'drivers.redis_password' => ['nullable', 'string', 'max:255'], - 'drivers.redis_database' => ['nullable', 'integer', 'min:0', 'max:15'], + 'drivers.redis_database' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_database_min', 0), 'max:'.config('core.settings_limits.drivers_redis_database_max', 15)], 'drivers.memcached_host' => ['nullable', 'string', 'max:255'], - 'drivers.memcached_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.memcached_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_memcached_port_min', 1), 'max:'.config('core.settings_limits.drivers_memcached_port_max', 65535)], 'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS)]), - 'drivers.session_lifetime' => ['nullable', 'integer', 'min:1', 'max:43200'], // 1분 ~ 30일 + 'drivers.session_lifetime' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_session_lifetime_min', 1), 'max:'.config('core.settings_limits.drivers_session_lifetime_max', 43200)], // 1분 ~ 30일 'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]), 'drivers.websocket_enabled' => ['nullable', 'boolean'], 'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_app_secret' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_host' => ['nullable', 'string', 'max:255'], - 'drivers.websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.websocket_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_port_max', 65535)], 'drivers.websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)], 'drivers.websocket_verify_ssl' => ['nullable', 'boolean'], 'drivers.websocket_server_host' => ['nullable', 'string', 'max:255'], - 'drivers.websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.websocket_server_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_server_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_server_port_max', 65535)], 'drivers.websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)], 'drivers.search_engine_driver' => ['nullable', Rule::in($this->getSupportedSearchEngineDrivers())], 'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]), 'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]), - 'drivers.log_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'drivers.log_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_log_days_min', 1), 'max:'.config('core.settings_limits.drivers_log_days_max', 365)], // 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨 'identity.default_provider' => ['nullable', 'string', 'max:100'], 'identity.purpose_providers' => ['sometimes', 'array'], - 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'), - 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'), + 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_challenge_ttl_minutes_min', 1).'|max:'.config('core.settings_limits.identity_challenge_ttl_minutes_max', 1440)), + 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_max_attempts_min', 1).'|max:'.config('core.settings_limits.identity_max_attempts_max', 20)), ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 @@ -374,8 +406,8 @@ class SaveSettingsRequest extends FormRequest return [ 'nullable', 'integer', - 'min:0', - 'max:3600', + 'min:'.config('core.settings_limits.security_auth_token_lifetime_min', 0), + 'max:'.config('core.settings_limits.security_auth_token_lifetime_max', 3600), function ($attribute, $value, $fail) { // 0은 무한대를 의미하므로 허용, 그 외에는 30 이상이어야 함 if ($value !== null && $value !== 0 && $value < 30) { @@ -436,8 +468,8 @@ class SaveSettingsRequest extends FormRequest return array_merge($baseRules, [ 'integer', - 'min:0', - 'max:14400', + 'min:'.config('core.settings_limits.advanced_cache_ttl_min', 0), + 'max:'.config('core.settings_limits.advanced_cache_ttl_max', 14400), ]); } @@ -780,6 +812,119 @@ class SaveSettingsRequest extends FormRequest 'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'), 'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'), 'identity.max_attempts' => __('validation.attributes.identity_max_attempts'), + // notifications + 'notifications.channels' => __('validation.attributes.channels'), + // general + 'general.currency' => __('validation.attributes.currency'), + 'general.maintenance_mode' => __('validation.attributes.maintenance_mode'), + 'general.asset_url_mode' => __('validation.attributes.asset_url_mode'), + 'general.site_logo' => __('validation.attributes.site_logo'), + // mail + 'mail.mailer' => __('validation.attributes.mailer'), + 'mail.host' => __('validation.attributes.host'), + 'mail.port' => __('validation.attributes.port'), + 'mail.username' => __('validation.attributes.username'), + 'mail.password' => __('validation.attributes.password'), + 'mail.encryption' => __('validation.attributes.encryption'), + 'mail.mailgun_domain' => __('validation.attributes.mailgun_domain'), + 'mail.mailgun_secret' => __('validation.attributes.mailgun_secret'), + 'mail.mailgun_endpoint' => __('validation.attributes.mailgun_endpoint'), + 'mail.ses_key' => __('validation.attributes.ses_key'), + 'mail.ses_secret' => __('validation.attributes.ses_secret'), + 'mail.ses_region' => __('validation.attributes.ses_region'), + 'mail.from_address' => __('validation.attributes.from_address'), + 'mail.from_name' => __('validation.attributes.from_name'), + // upload + 'upload.max_file_size' => __('validation.attributes.max_file_size'), + 'upload.allowed_extensions' => __('validation.attributes.allowed_extensions'), + 'upload.image_max_width' => __('validation.attributes.image_max_width'), + 'upload.image_max_height' => __('validation.attributes.image_max_height'), + 'upload.image_quality' => __('validation.attributes.image_quality'), + // seo + 'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'), + 'seo.meta_description' => __('validation.attributes.meta_description'), + 'seo.meta_keywords' => __('validation.attributes.meta_keywords'), + 'seo.google_analytics_id' => __('validation.attributes.google_analytics_id'), + 'seo.google_site_verification' => __('validation.attributes.google_site_verification'), + 'seo.naver_site_verification' => __('validation.attributes.naver_site_verification'), + 'seo.bot_user_agents' => __('validation.attributes.bot_user_agents'), + 'seo.bot_detection_enabled' => __('validation.attributes.bot_detection_enabled'), + 'seo.bot_detection_library_enabled' => __('validation.attributes.bot_detection_library_enabled'), + 'seo.og_default_site_name' => __('validation.attributes.og_default_site_name'), + 'seo.og_image_default_width' => __('validation.attributes.og_image_default_width'), + 'seo.og_image_default_height' => __('validation.attributes.og_image_default_height'), + 'seo.twitter_default_card' => __('validation.attributes.twitter_default_card'), + 'seo.twitter_default_site' => __('validation.attributes.twitter_default_site'), + 'seo.cache_enabled' => __('validation.attributes.seo_page_cache_enabled'), + 'seo.cache_ttl' => __('validation.attributes.cache_ttl'), + 'seo.sitemap_enabled' => __('validation.attributes.sitemap_enabled'), + 'seo.sitemap_cache_ttl' => __('validation.attributes.sitemap_cache_ttl'), + 'seo.sitemap_urls_per_file' => __('validation.attributes.sitemap_urls_per_file'), + 'seo.sitemap_gzip' => __('validation.attributes.sitemap_gzip'), + 'seo.sitemap_serve_stale_on_miss' => __('validation.attributes.sitemap_serve_stale_on_miss'), + 'seo.sitemap_max_urls_per_contributor' => __('validation.attributes.sitemap_max_urls_per_contributor'), + 'seo.sitemap_hreflang_enabled' => __('validation.attributes.sitemap_hreflang_enabled'), + 'seo.sitemap_schedule' => __('validation.attributes.sitemap_schedule'), + 'seo.sitemap_schedule_time' => __('validation.attributes.sitemap_schedule_time'), + 'seo.generator_enabled' => __('validation.attributes.generator_enabled'), + 'seo.generator_content' => __('validation.attributes.generator_content'), + // security + 'security.force_https' => __('validation.attributes.force_https'), + 'security.login_attempt_enabled' => __('validation.attributes.login_attempt_enabled'), + 'security.auth_token_lifetime' => __('validation.attributes.auth_token_lifetime'), + 'security.max_login_attempts' => __('validation.attributes.max_login_attempts'), + 'security.login_lockout_time' => __('validation.attributes.login_lockout_time'), + 'security.password_min_length' => __('validation.attributes.password_min_length'), + 'security.require_password_special_char' => __('validation.attributes.require_password_special_char'), + 'security.allow_internal_outbound_urls' => __('validation.attributes.allow_internal_outbound_urls'), + // advanced + 'advanced.cache_enabled' => __('validation.attributes.advanced_cache_enabled'), + 'advanced.layout_cache_enabled' => __('validation.attributes.layout_cache_enabled'), + 'advanced.layout_cache_ttl' => __('validation.attributes.layout_cache_ttl'), + 'advanced.stats_cache_enabled' => __('validation.attributes.stats_cache_enabled'), + 'advanced.stats_cache_ttl' => __('validation.attributes.stats_cache_ttl'), + 'advanced.seo_cache_enabled' => __('validation.attributes.seo_cache_enabled'), + 'advanced.seo_cache_ttl' => __('validation.attributes.seo_cache_ttl'), + 'advanced.seo_sitemap_cache_ttl' => __('validation.attributes.seo_sitemap_cache_ttl'), + 'advanced.debug_mode' => __('validation.attributes.debug_mode'), + 'advanced.sql_query_log' => __('validation.attributes.sql_query_log'), + 'advanced.core_update_github_url' => __('validation.attributes.core_update_github_url'), + 'advanced.core_update_github_token' => __('validation.attributes.core_update_github_token'), + 'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'), + 'advanced.geoip_license_key' => __('validation.attributes.geoip_license_key'), + 'advanced.geoip_auto_update_enabled' => __('validation.attributes.geoip_auto_update_enabled'), + // drivers + 'drivers.storage_driver' => __('validation.attributes.storage_driver'), + 'drivers.s3_bucket' => __('validation.attributes.s3_bucket'), + 'drivers.s3_region' => __('validation.attributes.s3_region'), + 'drivers.s3_access_key' => __('validation.attributes.s3_access_key'), + 'drivers.s3_secret_key' => __('validation.attributes.s3_secret_key'), + 'drivers.s3_url' => __('validation.attributes.s3_url'), + 'drivers.cache_driver' => __('validation.attributes.cache_driver'), + 'drivers.redis_host' => __('validation.attributes.redis_host'), + 'drivers.redis_port' => __('validation.attributes.redis_port'), + 'drivers.redis_password' => __('validation.attributes.redis_password'), + 'drivers.redis_database' => __('validation.attributes.redis_database'), + 'drivers.memcached_host' => __('validation.attributes.memcached_host'), + 'drivers.memcached_port' => __('validation.attributes.memcached_port'), + 'drivers.session_driver' => __('validation.attributes.session_driver'), + 'drivers.session_lifetime' => __('validation.attributes.session_lifetime'), + 'drivers.queue_driver' => __('validation.attributes.queue_driver'), + 'drivers.websocket_enabled' => __('validation.attributes.websocket_enabled'), + 'drivers.websocket_app_id' => __('validation.attributes.websocket_app_id'), + 'drivers.websocket_app_key' => __('validation.attributes.websocket_app_key'), + 'drivers.websocket_app_secret' => __('validation.attributes.websocket_app_secret'), + 'drivers.websocket_host' => __('validation.attributes.websocket_host'), + 'drivers.websocket_port' => __('validation.attributes.websocket_port'), + 'drivers.websocket_scheme' => __('validation.attributes.websocket_scheme'), + 'drivers.websocket_verify_ssl' => __('validation.attributes.websocket_verify_ssl'), + 'drivers.websocket_server_host' => __('validation.attributes.websocket_server_host'), + 'drivers.websocket_server_port' => __('validation.attributes.websocket_server_port'), + 'drivers.websocket_server_scheme' => __('validation.attributes.websocket_server_scheme'), + 'drivers.search_engine_driver' => __('validation.attributes.search_engine_driver'), + 'drivers.log_driver' => __('validation.attributes.log_driver'), + 'drivers.log_level' => __('validation.attributes.log_level'), + 'drivers.log_days' => __('validation.attributes.log_days'), ]; } } diff --git a/app/Http/Requests/User/ChangePasswordRequest.php b/app/Http/Requests/User/ChangePasswordRequest.php index ad81dcdc..af3bd322 100644 --- a/app/Http/Requests/User/ChangePasswordRequest.php +++ b/app/Http/Requests/User/ChangePasswordRequest.php @@ -3,8 +3,9 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rules\Password; /** * 비밀번호 변경 요청 클래스 @@ -15,6 +16,8 @@ class ChangePasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -24,13 +27,13 @@ class ChangePasswordRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'current_password' => ['required', 'string', 'current_password:sanctum'], - 'password' => ['required', 'string', 'confirmed', Password::min(8)], + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], 'password_confirmation' => ['required', 'string'], ]; @@ -54,4 +57,4 @@ class ChangePasswordRequest extends FormRequest 'password_confirmation.required' => __('user.validation.password_confirmation_required'), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/User/CreateUserRequest.php b/app/Http/Requests/User/CreateUserRequest.php index e0d5f595..e59cac4d 100644 --- a/app/Http/Requests/User/CreateUserRequest.php +++ b/app/Http/Requests/User/CreateUserRequest.php @@ -5,14 +5,17 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\Role; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class CreateUserRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -22,7 +25,7 @@ class CreateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -33,7 +36,7 @@ class CreateUserRequest extends FormRequest 'name' => 'required|string|max:255', 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'email', Rule::unique(User::class, 'email'), 'max:255'], - 'password' => ['required', 'confirmed', Password::min(8)], + 'password' => ['required', 'confirmed', PasswordPolicy::rule()], 'language' => 'nullable|string|in:'.$supportedLocales, 'country' => 'nullable|string|size:2|alpha', 'timezone' => ['nullable', 'string', 'timezone'], @@ -122,8 +125,6 @@ class CreateUserRequest extends FormRequest * 유효성 검사를 위해 데이터를 준비합니다. * * role_ids와 roles가 동시에 전송될 경우 role_ids를 우선하여 roles를 제거합니다. - * - * @return void */ protected function prepareForValidation(): void { diff --git a/app/Http/Requests/User/UpdateProfileRequest.php b/app/Http/Requests/User/UpdateProfileRequest.php index 3db5feee..fd7cdbf2 100644 --- a/app/Http/Requests/User/UpdateProfileRequest.php +++ b/app/Http/Requests/User/UpdateProfileRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -19,6 +21,8 @@ class UpdateProfileRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -28,7 +32,7 @@ class UpdateProfileRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -40,7 +44,7 @@ class UpdateProfileRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'nickname' => ['nullable', 'string', 'max:50'], 'email' => ['required', 'email', 'max:255', Rule::unique(User::class, 'email')->ignore($userId)], - 'password' => ['nullable', 'confirmed', Password::min(8)], + 'password' => ['nullable', 'confirmed', PasswordPolicy::rule()], 'current_password' => ['required_with:password', 'current_password'], 'language' => ['nullable', 'string', Rule::in($supportedLocales)], 'country' => ['nullable', 'string', 'size:2', 'alpha'], @@ -124,5 +128,4 @@ class UpdateProfileRequest extends FormRequest ]); } } - } diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index 677e2fae..ff4b5f4d 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -5,14 +5,17 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\Role; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class UpdateUserRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 permission 미들웨어에서 검증) */ public function authorize(): bool { @@ -24,8 +27,6 @@ class UpdateUserRequest extends FormRequest * * API 응답에서 로드된 roles(객체 배열)와 사용자가 선택한 role_ids가 * 동시에 전송될 수 있으므로, role_ids가 있으면 roles를 제거합니다. - * - * @return void */ protected function prepareForValidation(): void { @@ -37,7 +38,7 @@ class UpdateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -54,7 +55,7 @@ class UpdateUserRequest extends FormRequest 'max:255', Rule::unique(User::class, 'email')->ignore($userId), ], - 'password' => ['nullable', 'confirmed', Password::min(8)], + 'password' => ['nullable', 'confirmed', PasswordPolicy::rule()], 'language' => 'nullable|string|in:'.$supportedLocales, 'country' => 'nullable|string|size:2|alpha', 'timezone' => ['nullable', 'string', 'timezone'], diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 6abc8929..3c397b3b 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -260,6 +260,14 @@ class UserResource extends BaseApiResource 'blocked_at' => $this->getValue('blocked_at') ? $this->formatDateTimeStringForUser($this->getValue('blocked_at')) : null, + // 로그인 실패 누적 잠금 상태 — 관리자만 확인·해제할 수 있어야 하므로 관리자 응답에만 노출 + 'failed_login_attempts' => (int) ($this->getValue('failed_login_attempts') ?? 0), + 'locked_permanently' => (bool) $this->getValue('locked_permanently'), + 'locked_until' => $this->getValue('locked_until') + ? $this->formatDateTimeStringForUser($this->getValue('locked_until')) + : null, + 'is_locked' => (bool) $this->getValue('locked_permanently') + || ($this->getValue('locked_until') !== null && $this->resource->locked_until?->isFuture()), ]); // Filter 훅: 모듈이 자신의 데이터를 응답에 병합 diff --git a/app/Jobs/DispatchHookListenerJob.php b/app/Jobs/DispatchHookListenerJob.php index 3bcee7a6..9371090f 100644 --- a/app/Jobs/DispatchHookListenerJob.php +++ b/app/Jobs/DispatchHookListenerJob.php @@ -1,10 +1,14 @@ context); + // 큐 드라이버가 sync 면 이 Job 은 원 HTTP 요청과 **같은 컨테이너**에서 실행된다. + // restore() 가 재구성 Request 를 바인딩하면 그 뒤로 이어지는 같은 요청의 코드는 + // 헤더·입력·메서드가 전부 지워진 가짜 GET 요청을 보게 된다 — 뒤 순위 훅 리스너가 + // 전송 헤더를 읽지 못해 조용히 건너뛰는 사고가 여기서 난다. + // 워커 프로세스에서는 원래 바인딩이 없어 아래 원복이 아무 일도 하지 않는다. + $previousRequest = app()->bound('request') ? app('request') : null; - $args = HookArgumentSerializer::deserialize($this->serializedArgs); - app($this->listenerClass)->{$this->method}(...$args); + try { + HookContextCapture::restore($this->context); + + $args = HookArgumentSerializer::deserialize($this->serializedArgs); + + try { + $listener = app($this->listenerClass); + } catch (BindingResolutionException $e) { + // 리스너를 만들 수 없다는 것은 그 확장이 이 런타임에 배선되지 않았다는 뜻이다. + // Action 훅은 부가 작업이므로 호출자의 작업까지 함께 실패시키지 않고 기록만 남긴다 + // (큐 드라이버가 sync 인 환경에서 원 요청이 500 이 되는 것을 막는다). + Log::warning('훅 리스너를 해석할 수 없어 건너뜁니다 (확장 미배선)', [ + 'listener' => $this->listenerClass, + 'method' => $this->method, + 'exception' => $e->getMessage(), + ]); + + return; + } + + $listener->{$this->method}(...$args); + } finally { + if ($previousRequest !== null) { + app()->instance('request', $previousRequest); + } + } } /** * Job 실패 시 로그를 기록합니다. * * @param \Throwable $exception 발생한 예외 - * @return void */ public function failed(\Throwable $exception): void { @@ -76,8 +102,6 @@ class DispatchHookListenerJob implements ShouldQueue /** * Job의 표시 이름을 반환합니다. - * - * @return string */ public function displayName(): string { diff --git a/app/Listeners/CoreActivityLogListener.php b/app/Listeners/CoreActivityLogListener.php index 4717b492..ef1fbdcf 100644 --- a/app/Listeners/CoreActivityLogListener.php +++ b/app/Listeners/CoreActivityLogListener.php @@ -8,8 +8,10 @@ use App\Contracts\Extension\HookListenerInterface; use App\Contracts\Repositories\ActivityLogRepositoryInterface; use App\Contracts\Repositories\ScheduleRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; -use App\Models\ActivityLog; +use App\Extension\IdentityVerification\DTO\VerificationChallenge; +use App\Extension\IdentityVerification\DTO\VerificationResult; use App\Models\Attachment; +use App\Models\IdentityVerificationLog; use App\Models\Schedule; use App\Models\User; use Illuminate\Database\Eloquent\Model; @@ -65,6 +67,7 @@ class CoreActivityLogListener implements HookListenerInterface 'core.auth.record_consents' => ['method' => 'handleAuthRecordConsents', 'priority' => 20], 'core.auth.login_failed' => ['method' => 'handleAuthLoginFailed', 'priority' => 20], 'core.auth.account_locked' => ['method' => 'handleAuthAccountLocked', 'priority' => 20], + 'core.auth.account_unlocked' => ['method' => 'handleAuthAccountUnlocked', 'priority' => 20], // ─── IdentityVerification (IDV) ─── // DTO(VerificationChallenge/VerificationResult) 를 인자로 받으므로 sync 실행 필수 @@ -145,7 +148,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 훅 이벤트 처리 (기본 핸들러) * - * @param mixed ...$args 훅에서 전달된 인수들 + * @param mixed ...$args 훅에서 전달된 인수들 */ public function handle(...$args): void { @@ -159,8 +162,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 생성 후 로그 기록 * - * @param User $user 생성된 사용자 - * @param array $originalData 원본 요청 데이터 + * @param User $user 생성된 사용자 + * @param array $originalData 원본 요청 데이터 */ public function handleUserAfterCreate(User $user, array $originalData): void { @@ -175,9 +178,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 수정 후 로그 기록 * - * @param User $user 수정된 사용자 - * @param array $originalData 원본 요청 데이터 - * @param array|null $snapshot 수정 전 스냅샷 + * @param User $user 수정된 사용자 + * @param array $originalData 원본 요청 데이터 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleUserAfterUpdate(User $user, array $originalData, ?array $snapshot = null): void { @@ -194,7 +197,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 삭제 후 로그 기록 * - * @param array $userData 삭제된 사용자 데이터 + * @param array $userData 삭제된 사용자 데이터 */ public function handleUserAfterDelete(array $userData): void { @@ -213,7 +216,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 탈퇴 후 로그 기록 * - * @param User $user 탈퇴한 사용자 + * @param User $user 탈퇴한 사용자 */ public function handleUserAfterWithdraw(User $user): void { @@ -227,7 +230,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 상세 조회 후 로그 기록 * - * @param User $user 조회된 사용자 + * @param User $user 조회된 사용자 */ public function handleUserAfterShow(User $user): void { @@ -241,7 +244,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 목록 조회 후 로그 기록 * - * @param int $count 조회된 총 사용자 수 + * @param int $count 조회된 총 사용자 수 */ public function handleUserAfterList(int $count): void { @@ -254,8 +257,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 검색 후 로그 기록 * - * @param string $query 검색어 - * @param int $count 검색 결과 수 + * @param string $query 검색어 + * @param int $count 검색 결과 수 */ public function handleUserAfterSearch(string $query, int $count): void { @@ -268,10 +271,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 일괄 상태 변경 후 로그 기록 * - * @param array $uuids 대상 UUID 목록 - * @param string $status 변경된 상태 - * @param int $updatedCount 변경된 수 - * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot) + * @param array $uuids 대상 UUID 목록 + * @param string $status 변경된 상태 + * @param int $updatedCount 변경된 수 + * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot) */ public function handleUserAfterBulkUpdate(array $uuids, string $status, int $updatedCount, array $snapshots = []): void { @@ -303,8 +306,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 관리자 로그인 후 로그 기록 * - * @param User $user 로그인한 사용자 - * @param array $loginData 로그인 데이터 + * @param User $user 로그인한 사용자 + * @param array $loginData 로그인 데이터 */ public function handleAuthAfterLogin(User $user, array $loginData): void { @@ -318,7 +321,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 로그아웃 로그 기록 * - * @param User $user 로그아웃한 사용자 + * @param User $user 로그아웃한 사용자 */ public function handleAuthLogout(User $user): void { @@ -332,8 +335,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 회원가입 후 로그 기록 * - * @param User $user 등록된 사용자 - * @param array $registrationData 등록 데이터 + * @param User $user 등록된 사용자 + * @param array $registrationData 등록 데이터 */ public function handleAuthRegister(User $user, array $registrationData): void { @@ -347,7 +350,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 비밀번호 찾기 요청 로그 기록 * - * @param User $user 요청한 사용자 + * @param User $user 요청한 사용자 */ public function handleAuthForgotPassword(User $user): void { @@ -361,7 +364,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 비밀번호 재설정 로그 기록 * - * @param User $user 재설정한 사용자 + * @param User $user 재설정한 사용자 */ public function handleAuthResetPassword(User $user): void { @@ -375,10 +378,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 이용약관 동의 기록 로그 * - * @param User $user 동의한 사용자 - * @param array $data 동의 데이터 - * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) - * @param string|null $ip IP 주소 + * @param User $user 동의한 사용자 + * @param array $data 동의 데이터 + * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) + * @param string|null $ip IP 주소 */ public function handleAuthRecordConsents(User $user, array $data, ?string $agreedAt = null, ?string $ip = null): void { @@ -393,8 +396,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 로그인 실패 로그 기록 (사용자 존재 여부와 무관) * - * @param string $email 시도된 이메일 - * @param array $context IP/UA/시각 등 부가 정보 + * @param string $email 시도된 이메일 + * @param array $context IP/UA/시각 등 부가 정보 */ public function handleAuthLoginFailed(string $email, array $context = []): void { @@ -408,14 +411,19 @@ class CoreActivityLogListener implements HookListenerInterface /** * 계정 잠금 로그 기록 * - * @param User $user 잠긴 사용자 - * @param array $context attempts/locked_until/lockout_minutes/IP + * @param User $user 잠긴 사용자 + * @param array $context attempts/locked_until/lockout_minutes/IP */ public function handleAuthAccountLocked(User $user, array $context = []): void { + // 영구 잠금(무한대)은 차단 분(minutes)이 없으므로 별도 문구를 사용한다. + $permanent = ($context['permanent'] ?? false) || ($context['locked_until'] ?? null) === null; + $this->logActivity('auth.account_locked', [ 'loggable' => $user, - 'description_key' => 'activity_log.description.auth_account_locked', + 'description_key' => $permanent + ? 'activity_log.description.auth_account_locked_permanently' + : 'activity_log.description.auth_account_locked', 'description_params' => [ 'attempts' => $context['attempts'] ?? null, 'minutes' => $context['lockout_minutes'] ?? null, @@ -425,6 +433,22 @@ class CoreActivityLogListener implements HookListenerInterface ]); } + /** + * 계정 잠금 해제 로그 기록 (관리자 수동 해제) + * + * @param User $user 잠금이 해제된 사용자 + * @param array $context IP 등 부가 정보 + */ + public function handleAuthAccountUnlocked(User $user, array $context = []): void + { + $this->logActivity('auth.account_unlocked', [ + 'loggable' => $user, + 'description_key' => 'activity_log.description.auth_account_unlocked', + 'description_params' => ['email' => $user->email], + 'ip_address' => $context['ip_address'] ?? null, + ]); + } + // ═══════════════════════════════════════════ // Role 핸들러 // ═══════════════════════════════════════════ @@ -432,7 +456,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 생성 후 로그 기록 * - * @param Model $role 생성된 역할 + * @param Model $role 생성된 역할 */ public function handleRoleAfterCreate(Model $role): void { @@ -447,8 +471,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 수정 후 로그 기록 * - * @param Model $role 수정된 역할 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $role 수정된 역할 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleRoleAfterUpdate(Model $role, ?array $snapshot = null): void { @@ -465,7 +489,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 삭제 후 로그 기록 * - * @param int $roleId 삭제된 역할 ID + * @param int $roleId 삭제된 역할 ID */ public function handleRoleAfterDelete(int $roleId): void { @@ -479,9 +503,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 권한 동기화 후 로그 기록 * - * @param Model $role 역할 - * @param array $previousPermissions 이전 권한 목록 - * @param array $currentPermissions 현재 권한 목록 + * @param Model $role 역할 + * @param array $previousPermissions 이전 권한 목록 + * @param array $currentPermissions 현재 권한 목록 */ public function handleRoleAfterSyncPermissions(Model $role, array $previousPermissions, array $currentPermissions): void { @@ -499,7 +523,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 상태 전환 후 로그 기록 * - * @param Model $role 역할 + * @param Model $role 역할 */ public function handleRoleAfterToggleStatus(Model $role): void { @@ -517,7 +541,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 생성 후 로그 기록 * - * @param Model $menu 생성된 메뉴 + * @param Model $menu 생성된 메뉴 */ public function handleMenuAfterCreate(Model $menu): void { @@ -532,8 +556,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 수정 후 로그 기록 * - * @param Model $menu 수정된 메뉴 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $menu 수정된 메뉴 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleMenuAfterUpdate(Model $menu, ?array $snapshot = null): void { @@ -550,7 +574,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 삭제 후 로그 기록 * - * @param int $menuId 삭제된 메뉴 ID + * @param int $menuId 삭제된 메뉴 ID */ public function handleMenuAfterDelete(int $menuId): void { @@ -564,7 +588,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 순서 변경 후 로그 기록 * - * @param array $orderData 순서 데이터 + * @param array $orderData 순서 데이터 */ public function handleMenuAfterUpdateOrder(array $orderData): void { @@ -576,7 +600,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 상태 전환 후 로그 기록 * - * @param Model $menu 메뉴 + * @param Model $menu 메뉴 */ public function handleMenuAfterToggleStatus(Model $menu): void { @@ -590,9 +614,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 역할 동기화 후 로그 기록 * - * @param Model $menu 메뉴 - * @param array $previousRoles 이전 역할 목록 - * @param array $currentRoles 현재 역할 목록 + * @param Model $menu 메뉴 + * @param array $previousRoles 이전 역할 목록 + * @param array $currentRoles 현재 역할 목록 */ public function handleMenuAfterSyncRoles(Model $menu, array $previousRoles, array $currentRoles): void { @@ -614,9 +638,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 설정 저장 후 로그 기록 * - * @param string $tab 설정 탭 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $tab 설정 탭 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handleSettingsAfterSave(string $tab, array $settings, bool $result): void { @@ -629,9 +653,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 개별 설정 변경 후 로그 기록 * - * @param string $key 설정 키 - * @param mixed $value 설정 값 - * @param bool $result 저장 결과 + * @param string $key 설정 키 + * @param mixed $value 설정 값 + * @param bool $result 저장 결과 */ public function handleSettingsAfterSet(string $key, mixed $value, bool $result): void { @@ -648,7 +672,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 생성 후 로그 기록 * - * @param Model $schedule 생성된 스케줄 + * @param Model $schedule 생성된 스케줄 */ public function handleScheduleAfterCreate(Model $schedule): void { @@ -662,8 +686,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 수정 후 로그 기록 * - * @param Model $schedule 수정된 스케줄 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $schedule 수정된 스케줄 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleScheduleAfterUpdate(Model $schedule, ?array $snapshot = null): void { @@ -680,7 +704,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 삭제 후 로그 기록 * - * @param int $scheduleId 삭제된 스케줄 ID + * @param int $scheduleId 삭제된 스케줄 ID */ public function handleScheduleAfterDelete(int $scheduleId): void { @@ -694,8 +718,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 수동 실행 후 로그 기록 * - * @param Model $schedule 실행된 스케줄 - * @param Model $history 실행 이력 + * @param Model $schedule 실행된 스케줄 + * @param Model $history 실행 이력 */ public function handleScheduleAfterRun(Model $schedule, Model $history): void { @@ -709,10 +733,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 일괄 상태 변경 후 로그 기록 * - * @param array $ids 대상 ID 목록 - * @param bool $isActive 활성화 여부 - * @param int $updatedCount 변경된 수 - * @param array $snapshots 변경 전 스냅샷 (id => snapshot) + * @param array $ids 대상 ID 목록 + * @param bool $isActive 활성화 여부 + * @param int $updatedCount 변경된 수 + * @param array $snapshots 변경 전 스냅샷 (id => snapshot) */ public function handleScheduleAfterBulkUpdate(array $ids, bool $isActive, int $updatedCount, array $snapshots = []): void { @@ -740,9 +764,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 일괄 삭제 후 로그 기록 * - * @param array $ids 대상 ID 목록 - * @param int $deletedCount 삭제된 수 - * @param array $snapshots 삭제 전 스냅샷 (id => snapshot) + * @param array $ids 대상 ID 목록 + * @param int $deletedCount 삭제된 수 + * @param array $snapshots 삭제 전 스냅샷 (id => snapshot) */ public function handleScheduleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void { @@ -769,7 +793,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 업로드 후 로그 기록 * - * @param Model $attachment 업로드된 첨부파일 + * @param Model $attachment 업로드된 첨부파일 */ public function handleAttachmentAfterUpload(Model $attachment): void { @@ -782,7 +806,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 삭제 후 로그 기록 * - * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) + * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) */ public function handleAttachmentAfterDelete(?Model $attachment = null): void { @@ -794,10 +818,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 일괄 삭제 후 로그 기록 (per-item) * - * @param string $identifier 식별자 - * @param int $count 삭제된 수 - * @param array $ids 삭제된 첨부파일 ID 목록 - * @param array $snapshots 삭제 전 스냅샷 (keyBy id) + * @param string $identifier 식별자 + * @param int $count 삭제된 수 + * @param array $ids 삭제된 첨부파일 ID 목록 + * @param array $snapshots 삭제 전 스냅샷 (keyBy id) */ public function handleAttachmentAfterBulkDelete(string $identifier, int $count, array $ids = [], array $snapshots = []): void { @@ -825,8 +849,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설치 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterInstall(string $moduleName, array $moduleInfo): void { @@ -840,8 +864,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 활성화 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterActivate(string $moduleName, array $moduleInfo): void { @@ -854,7 +878,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 비활성화 후 로그 기록 * - * @param string $moduleName 모듈 식별자 + * @param string $moduleName 모듈 식별자 */ public function handleModuleAfterDeactivate(string $moduleName): void { @@ -867,9 +891,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 제거 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 - * @param bool $deleteData 데이터 삭제 여부 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 + * @param bool $deleteData 데이터 삭제 여부 */ public function handleModuleAfterUninstall(string $moduleName, array $moduleInfo, bool $deleteData): void { @@ -883,9 +907,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 업데이트 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterUpdate(string $moduleName, array $result, array $moduleInfo): void { @@ -899,8 +923,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 레이아웃 갱신 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $result 갱신 결과 + * @param string $moduleName 모듈 식별자 + * @param array $result 갱신 결과 */ public function handleModuleAfterRefreshLayouts(string $moduleName, array $result): void { @@ -917,8 +941,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설치 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterInstall(string $pluginName, array $pluginInfo): void { @@ -932,8 +956,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 활성화 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterActivate(string $pluginName, array $pluginInfo): void { @@ -946,7 +970,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 비활성화 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 + * @param string $pluginName 플러그인 식별자 */ public function handlePluginAfterDeactivate(string $pluginName): void { @@ -959,9 +983,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 제거 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param bool $deleteData 데이터 삭제 여부 - * @param bool $result 제거 결과 + * @param string $pluginName 플러그인 식별자 + * @param bool $deleteData 데이터 삭제 여부 + * @param bool $result 제거 결과 */ public function handlePluginAfterUninstall(string $pluginName, bool $deleteData, bool $result): void { @@ -975,9 +999,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 업데이트 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterUpdate(string $pluginName, array $result, array $pluginInfo): void { @@ -995,8 +1019,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 설치 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $templateInfo 템플릿 정보 + * @param string $identifier 템플릿 식별자 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterInstall(string $identifier, array $templateInfo): void { @@ -1010,7 +1034,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 활성화 후 로그 기록 * - * @param array $templateInfo 템플릿 정보 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterActivate(array $templateInfo): void { @@ -1023,7 +1047,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 비활성화 후 로그 기록 * - * @param string $identifier 템플릿 식별자 + * @param string $identifier 템플릿 식별자 */ public function handleTemplateAfterDeactivate(string $identifier): void { @@ -1036,9 +1060,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 제거 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $templateInfo 템플릿 정보 - * @param bool $deleteData 데이터 삭제 여부 + * @param string $identifier 템플릿 식별자 + * @param array $templateInfo 템플릿 정보 + * @param bool $deleteData 데이터 삭제 여부 */ public function handleTemplateAfterUninstall(string $identifier, array $templateInfo, bool $deleteData): void { @@ -1052,9 +1076,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 버전 업데이트 후 로그 기록 * - * @param string $templateName 템플릿 식별자 - * @param array $result 업데이트 결과 - * @param array $templateInfo 템플릿 정보 + * @param string $templateName 템플릿 식별자 + * @param array $result 업데이트 결과 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterVersionUpdate(string $templateName, array $result, array $templateInfo): void { @@ -1068,8 +1092,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 레이아웃 갱신 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $result 갱신 결과 + * @param string $identifier 템플릿 식별자 + * @param array $result 갱신 결과 */ public function handleTemplateAfterRefreshLayouts(string $identifier, array $result): void { @@ -1086,10 +1110,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 레이아웃 수정 후 로그 기록 * - * @param Model $layout 수정된 레이아웃 - * @param int $templateId 템플릿 ID - * @param string $name 레이아웃 이름 - * @param array $data 수정 데이터 + * @param Model $layout 수정된 레이아웃 + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @param array $data 수정 데이터 */ public function handleLayoutAfterUpdate(Model $layout, int $templateId, string $name, array $data): void { @@ -1103,10 +1127,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 레이아웃 버전 복원 후 로그 기록 * - * @param Model $newVersion 복원된 새 버전 - * @param int $templateId 템플릿 ID - * @param string $name 레이아웃 이름 - * @param int $versionId 복원 대상 버전 ID + * @param Model $newVersion 복원된 새 버전 + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @param int $versionId 복원 대상 버전 ID */ public function handleLayoutAfterVersionRestore(Model $newVersion, int $templateId, string $name, int $versionId): void { @@ -1124,9 +1148,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설정 저장 후 로그 기록 * - * @param string $identifier 모듈 식별자 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $identifier 모듈 식별자 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handleModuleSettingsAfterSave(string $identifier, array $settings, bool $result): void { @@ -1140,7 +1164,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설정 초기화 후 로그 기록 * - * @param string $identifier 모듈 식별자 + * @param string $identifier 모듈 식별자 */ public function handleModuleSettingsAfterReset(string $identifier): void { @@ -1157,9 +1181,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설정 저장 후 로그 기록 * - * @param string $identifier 플러그인 식별자 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $identifier 플러그인 식별자 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handlePluginSettingsAfterSave(string $identifier, array $settings, bool $result): void { @@ -1173,7 +1197,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설정 초기화 후 로그 기록 * - * @param string $identifier 플러그인 식별자 + * @param string $identifier 플러그인 식별자 */ public function handlePluginSettingsAfterReset(string $identifier): void { @@ -1190,10 +1214,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 challenge 요청 후 로그 기록. * - * @param \App\Extension\IdentityVerification\DTO\VerificationChallenge $challenge - * @param string $purpose - * @param \App\Models\User|array $target - * @param array $context + * @param VerificationChallenge $challenge 발급된 본인인증 challenge + * @param string $purpose 인증 목적 (IdentityVerificationPurpose 값) + * @param User|array $target 인증 대상 (회원 모델 또는 이메일/전화 배열) + * @param array $context 부가 컨텍스트 (IP 등) */ public function handleIdentityRequested($challenge, string $purpose, $target, array $context = []): void { @@ -1213,9 +1237,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 검증 후 로그 기록 (성공·실패 공통 디스패치). * - * @param \App\Extension\IdentityVerification\DTO\VerificationResult $result - * @param \App\Models\IdentityVerificationLog|null $log - * @param array $context + * @param VerificationResult $result 검증 결과 DTO + * @param IdentityVerificationLog|null $log 검증 로그 (없을 수 있음) + * @param array $context 부가 컨텍스트 (IP 등) */ public function handleIdentityVerified($result, $log, array $context = []): void { @@ -1238,7 +1262,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 challenge 만료 로그 기록. * - * @param \App\Models\IdentityVerificationLog $log + * @param IdentityVerificationLog $log */ public function handleIdentityExpired($log): void { @@ -1251,5 +1275,4 @@ class CoreActivityLogListener implements HookListenerInterface ], ]); } - } diff --git a/app/Listeners/Identity/EnforceIdentityPolicyListener.php b/app/Listeners/Identity/EnforceIdentityPolicyListener.php index e40317cc..b8adb26f 100644 --- a/app/Listeners/Identity/EnforceIdentityPolicyListener.php +++ b/app/Listeners/Identity/EnforceIdentityPolicyListener.php @@ -90,9 +90,13 @@ class EnforceIdentityPolicyListener implements HookListenerInterface // 2) 코어 hook 은 getSubscribedHooks() 가 소유하므로 동적 등록 대상에서 제외 $coreHooks = array_fill_keys(static::coreHookTargets(), true); - // 3) 현재 hook scope 정책 target 으로 재구독 + // 3) 전담 리스너가 소유하는 target 은 일반 enforce 대상에서 제외 + $delegated = array_fill_keys(static::delegatedHookTargets(), true); + + // 4) 현재 hook scope 정책 target 으로 재구독 foreach (static::loadDynamicHookTargets() as $hookName) { - if (! is_string($hookName) || $hookName === '' || isset($coreHooks[$hookName])) { + if (! is_string($hookName) || $hookName === '' + || isset($coreHooks[$hookName]) || isset($delegated[$hookName])) { continue; } if (isset(self::$dynamicCallbacks[$hookName])) { @@ -145,6 +149,27 @@ class EnforceIdentityPolicyListener implements HookListenerInterface ]; } + /** + * 전담 리스너가 강제를 소유하는 hook target 목록. + * + * 이 리스너는 "행위 직전(before_*)" 훅을 막아 세우는 가드다. 행위가 이미 끝난 뒤 발화하는 + * 훅에서 예외를 던지면 되돌릴 것이 없는 상태에서 응답만 깨진다. + * + * `core.auth.after_register` 가 그 경우다. 이 훅은 `InitiateIdentityChallengeAfterRegister` + * 가 소유하며, `core.auth.signup_after_create` 정책이 켜져 있으면 계정을 '인증 대기' 로 두고 + * 본인확인 challenge 를 발행한다(가입 자체는 성립). 여기서 다시 enforce 하면 가입이 428 로 + * 막혀 '가입 전 본인확인'(`core.auth.signup_before_submit`) 정책과 동작이 같아지고, + * '가입 후' 정책은 켜는 순간 아무도 가입할 수 없게 된다. + * + * @return list 전담 소유 hook target 목록 + */ + protected static function delegatedHookTargets(): array + { + return [ + 'core.auth.after_register', + ]; + } + /** * identity_policies 테이블에서 scope='hook' 정책의 target 목록을 추출합니다. * @@ -175,6 +200,12 @@ class EnforceIdentityPolicyListener implements HookListenerInterface return; } + // 전담 리스너가 소유하는 훅은 구독 단계에서 제외되지만, 다른 경로로 호출되더라도 + // 이중 강제가 생기지 않도록 여기서도 막는다 (delegatedHookTargets 주석 참조). + if (in_array($hookName, static::delegatedHookTargets(), true)) { + return; + } + $policies = $this->policyRepository->resolveByScopeTarget('hook', $hookName); if ($policies->isEmpty()) { return; diff --git a/app/Listeners/UserLogin/HandleFailedLoginListener.php b/app/Listeners/UserLogin/HandleFailedLoginListener.php index d761a733..02a98d2d 100644 --- a/app/Listeners/UserLogin/HandleFailedLoginListener.php +++ b/app/Listeners/UserLogin/HandleFailedLoginListener.php @@ -47,7 +47,7 @@ class HandleFailedLoginListener implements HookListenerInterface * 로그인 실패 시 카운트 증가 및 임계 도달 시 잠금 처리. * * @param string $email 실패한 로그인 이메일 - * @param array $context IP/UA/시각 등 부가 정보 + * @param array $context IP/UA/시각 등 부가 정보 */ public function handleFailed(string $email, array $context = []): void { @@ -79,12 +79,14 @@ class HandleFailedLoginListener implements HookListenerInterface $user ); + // 잠금 시간 0(= 무한대) 은 영구 잠금이라 해제 시각이 null 이다. $lockedUntil = $this->userRepository->lockAccount($user, $lockoutMinutes); HookManager::doAction('core.auth.account_locked', $user, array_merge($context, [ 'attempts' => $newCount, 'locked_until' => $lockedUntil, 'lockout_minutes' => $lockoutMinutes, + 'permanent' => $lockedUntil === null, ])); } } diff --git a/app/Models/User.php b/app/Models/User.php index 0e5832ee..7a7dfd2b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,24 +3,25 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Contracts\UniqueIdServiceInterface; use App\Enums\MenuPermissionType; use App\Enums\PermissionType; use App\Enums\ScopeType; use App\Enums\UserStatus; +use Database\Factories\UserFactory; +use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; -use App\Contracts\UniqueIdServiceInterface; -use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements HasLocalePreference { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable; /** @var array 활동 로그 추적 필드 */ @@ -31,7 +32,7 @@ class User extends Authenticatable implements HasLocalePreference 'language' => ['label_key' => 'activity_log.fields.language', 'type' => 'text'], 'timezone' => ['label_key' => 'activity_log.fields.timezone', 'type' => 'text'], 'country' => ['label_key' => 'activity_log.fields.country', 'type' => 'text'], - 'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => \App\Enums\UserStatus::class], + 'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => UserStatus::class], 'is_super' => ['label_key' => 'activity_log.fields.is_super', 'type' => 'boolean'], 'homepage' => ['label_key' => 'activity_log.fields.homepage', 'type' => 'text'], 'mobile' => ['label_key' => 'activity_log.fields.mobile', 'type' => 'text'], @@ -108,6 +109,7 @@ class User extends Authenticatable implements HasLocalePreference 'mobile_verified_at', 'failed_login_attempts', 'locked_until', + 'locked_permanently', 'last_failed_login_at', ]; @@ -140,6 +142,7 @@ class User extends Authenticatable implements HasLocalePreference 'is_super' => 'boolean', 'failed_login_attempts' => 'integer', 'locked_until' => 'datetime', + 'locked_permanently' => 'boolean', 'last_failed_login_at' => 'datetime', ]; } @@ -196,7 +199,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 모듈들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function modules() { @@ -206,7 +209,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 플러그인들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function plugins() { @@ -388,8 +391,8 @@ class User extends Authenticatable implements HasLocalePreference /** * 슈퍼 관리자만 조회합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @return Builder */ public function scopeSuperAdmins($query) { @@ -445,7 +448,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 메뉴들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function menus() { diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 4a9ac0b4..0fca4ea5 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Repositories\JsonConfigRepository; +use App\Support\AllowedExtensions; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; @@ -652,12 +653,19 @@ class SettingsServiceProvider extends ServiceProvider Config::set('filesystems.default', $uploadSettings['disk']); } - if (! empty($uploadSettings['max_size'])) { - Config::set('g7.upload.max_size', (int) $uploadSettings['max_size']); + // 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다. + // (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다) + if (! empty($uploadSettings['max_file_size'])) { + Config::set('attachment.max_file_size', (int) $uploadSettings['max_file_size'] * 1024); } - if (! empty($uploadSettings['allowed_extensions'])) { - Config::set('g7.upload.allowed_extensions', $uploadSettings['allowed_extensions']); + // 화면은 콤마 문자열을 보내고 API 직접 호출은 배열을 보낸다. 배열만 받아들이면 + // 문자열로 저장된 설정이 조용히 버려져 관리자가 확장자를 바꿔도 반영되지 않는다. + if (isset($uploadSettings['allowed_extensions'])) { + Config::set( + 'attachment.allowed_extensions', + AllowedExtensions::normalize($uploadSettings['allowed_extensions']) + ); } } } diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index cf24e644..c07b2f8b 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; +use Laravel\Sanctum\PersonalAccessToken; class UserRepository implements UserRepositoryInterface { @@ -294,16 +295,22 @@ class UserRepository implements UserRepositoryInterface /** * 사용자의 계정을 지정된 분만큼 잠급니다. * + * `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리합니다. + * 영구 잠금은 해제 시각이 없으므로 `locked_until` 을 NULL 로 두고 + * `locked_permanently` 플래그로 상태를 표현합니다 (NULL 은 이미 "잠금 없음" 을 의미). + * * @param User $user 잠글 사용자 - * @param int $minutes 잠금 유지 시간(분) - * @return Carbon 잠금 해제 시각 + * @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한 + * @return Carbon|null 잠금 해제 시각 (영구 잠금은 null) */ - public function lockAccount(User $user, int $minutes): Carbon + public function lockAccount(User $user, int $minutes): ?Carbon { - $lockedUntil = now()->addMinutes(max(1, $minutes)); + $permanent = $minutes <= 0; + $lockedUntil = $permanent ? null : now()->addMinutes($minutes); $user->forceFill([ 'locked_until' => $lockedUntil, + 'locked_permanently' => $permanent, 'failed_login_attempts' => 0, ])->save(); @@ -321,15 +328,18 @@ class UserRepository implements UserRepositoryInterface { $needsReset = ($user->failed_login_attempts ?? 0) > 0 || $user->locked_until !== null + || (bool) $user->locked_permanently || $user->last_failed_login_at !== null; if (! $needsReset) { return; } + // locked_permanently 를 빠뜨리면 성공 로그인·관리자 해제 후에도 영구 잠금이 잔존한다. $user->forceFill([ 'failed_login_attempts' => 0, 'locked_until' => null, + 'locked_permanently' => false, 'last_failed_login_at' => null, ])->save(); } @@ -342,10 +352,74 @@ class UserRepository implements UserRepositoryInterface */ public function isLocked(User $user): bool { + // 영구 잠금 판정이 먼저 — 해제 시각이 없으므로 NULL 체크에 먼저 걸리면 안 된다. + if ((bool) $user->locked_permanently) { + return true; + } + if ($user->locked_until === null) { return false; } return $user->locked_until->isFuture(); } + + /** + * UUID 로 사용자를 찾습니다. + * + * @param string $uuid 사용자 UUID + * @return User|null 찾은 사용자 모델 또는 null + */ + public function findByUuid(string $uuid): ?User + { + return User::where('uuid', $uuid)->first(); + } + + /** + * UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다. + * + * @param array $uuids 사용자 UUID 배열 + * @return array 사용자 ID 배열 + */ + public function getIdsByUuids(array $uuids): array + { + if (empty($uuids)) { + return []; + } + + return User::whereIn('uuid', $uuids)->pluck('id')->all(); + } + + /** + * 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다. + * + * @param array $ids 사용자 ID 배열 + * @param array $data 갱신할 컬럼 값 + * @return int 갱신된 행 수 + */ + public function updateManyByIds(array $ids, array $data): int + { + if (empty($ids)) { + return 0; + } + + return User::whereIn('id', $ids)->update($data); + } + + /** + * 사용자 ID 목록의 인증 토큰을 모두 삭제합니다. + * + * @param array $ids 사용자 ID 배열 + * @return int 삭제된 토큰 수 + */ + public function deleteTokensByUserIds(array $ids): int + { + if (empty($ids)) { + return 0; + } + + return PersonalAccessToken::where('tokenable_type', User::class) + ->whereIn('tokenable_id', $ids) + ->delete(); + } } diff --git a/app/Rules/PasswordPolicy.php b/app/Rules/PasswordPolicy.php new file mode 100644 index 00000000..a6a9e55f --- /dev/null +++ b/app/Rules/PasswordPolicy.php @@ -0,0 +1,46 @@ + 0 ? $minLength : self::DEFAULT_MIN_LENGTH; + + $rule = Password::min($minLength); + + if ((bool) g7_core_settings('security.require_password_special_char', false)) { + $rule = $rule->symbols(); + } + + return $rule; + } +} diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php index 3ac265c0..430e4759 100644 --- a/app/Services/AttachmentService.php +++ b/app/Services/AttachmentService.php @@ -7,6 +7,7 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface; use App\Enums\AttachmentSourceType; use App\Extension\HookManager; use App\Models\Attachment; +use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; @@ -58,8 +59,11 @@ class AttachmentService // 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트) $file = HookManager::applyFilters('core.attachment.filter_upload_file', $file); - // 저장 경로 생성 (날짜별 디렉토리) - $storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension(); + // 저장 경로 생성 (날짜별 디렉토리). + // 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 — + // 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다. + $extension = strtolower($file->extension() ?: $file->getClientOriginalExtension()); + $storedFilename = Str::uuid().($extension !== '' ? '.'.$extension : ''); $datePath = date('Y/m/d'); $path = "{$datePath}/{$storedFilename}"; @@ -217,7 +221,7 @@ class AttachmentService * 기능 레벨 권한 체크 (permission_hooks) - 다운로드 기능 자체에 대한 접근 권한 * * @param string $hash 첨부파일 해시 - * @param \App\Models\User|null $user 요청한 사용자 (null이면 비로그인) + * @param User|null $user 요청한 사용자 (null이면 비로그인) * @return StreamedResponse|null 다운로드 응답 또는 null * * @throws AuthorizationException 기능 레벨 권한이 없는 경우 @@ -263,7 +267,7 @@ class AttachmentService * 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있습니다. * * @param string $hash 첨부파일 해시 - * @param \App\Models\User|null $user 요청한 사용자 (null이면 비로그인) + * @param User|null $user 요청한 사용자 (null이면 비로그인) * @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null * * @throws AuthorizationException 기능 레벨 권한이 없는 경우 diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 0bedfaed..5ffde989 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -99,7 +99,11 @@ class AuthService if ((bool) g7_core_settings('security.login_attempt_enabled', true)) { $candidate = $this->userRepository->findByEmail($email); if ($candidate !== null && $this->userRepository->isLocked($candidate)) { - $remaining = max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60)); + // 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기. + $remaining = $candidate->locked_until === null + ? null + : max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60)); + throw new AccountLockedException( lockedUntil: $candidate->locked_until, remainingMinutes: $remaining, diff --git a/app/Services/IdentityVerificationService.php b/app/Services/IdentityVerificationService.php index 0b25714b..881a7965 100644 --- a/app/Services/IdentityVerificationService.php +++ b/app/Services/IdentityVerificationService.php @@ -182,6 +182,7 @@ class IdentityVerificationService * * @param string $challengeId Challenge UUID * @return array|null 공개 상태 또는 null (없는 경우) + * * @since engine-v1.46.0 */ public function getStatus(string $challengeId): ?array @@ -205,7 +206,9 @@ class IdentityVerificationService 'purpose' => $log->purpose, 'render_hint' => $log->render_hint, 'expires_at' => optional($log->expires_at)->toIso8601String(), - 'attempts' => (int) $log->attempts, + // 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나 + // 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 그대로 드러난다. + // 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다. 'max_attempts' => (int) $log->max_attempts, 'public_payload' => $publicPayload, ]; @@ -222,6 +225,7 @@ class IdentityVerificationService * @param array $input provider 가 보낸 페이로드 (code/token/state 등) * @param array $context origin 정보 (origin_type=callback) * @return VerificationResult provider 의 검증 결과 (challenge mismatch 시 failure) + * * @since engine-v1.46.0 */ public function handleProviderCallback(string $providerId, string $challengeId, array $input, array $context = []): VerificationResult diff --git a/app/Services/TemplateService.php b/app/Services/TemplateService.php index eb52e067..0e73bd19 100644 --- a/app/Services/TemplateService.php +++ b/app/Services/TemplateService.php @@ -24,6 +24,11 @@ class TemplateService { use ResolvesLanguageFragments; + /** + * 직전 라우트 병합에서 활성 모듈 디렉토리가 비어 라우트가 누락됐는지 여부 + */ + private bool $routeMergeDegraded = false; + public function __construct( private TemplateRepositoryInterface $templateRepository, private TemplateManagerInterface $templateManager, @@ -1074,6 +1079,8 @@ class TemplateService */ public function getRoutesDataWithModules(string $identifier): array { + $this->routeMergeDegraded = false; + // 1. 템플릿 DB 조회 및 활성화 여부 확인 $template = $this->templateRepository->findByIdentifier($identifier); if (! $template || $template->status !== ExtensionStatus::Active->value) { @@ -1172,9 +1179,22 @@ class TemplateService 'success' => true, 'data' => $resultData, 'error' => null, + 'degraded' => $this->routeMergeDegraded, ]; } + /** + * 직전 라우트 병합이 열화 상태였는지 여부를 반환합니다. + * + * 활성 모듈의 디렉토리가 없어 그 모듈 라우트가 통째로 빠진 경우 true. + * + * @return bool 열화 여부 + */ + public function lastRouteMergeWasDegraded(): bool + { + return $this->routeMergeDegraded; + } + /** * 레이아웃 편집기용 routes 데이터를 source 태깅과 함께 반환합니다. * @@ -1653,6 +1673,15 @@ class TemplateService } if ($routesFilePath === null) { + // 활성 모듈의 디렉토리 자체가 없다면 업데이트 중 활성 디렉토리가 잠시 비운 + // 상태다(정상적으로 admin 라우트가 없는 모듈과 구분된다). 이때의 병합 결과는 + // 그 모듈의 화면이 통째로 빠진 열화 스냅샷이므로 호출자가 캐시하지 않도록 + // 표시한다. 표시 없이 캐시되면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 + // 해당 모듈의 모든 화면이 404 로 남는다. + if (! is_dir(base_path("modules/{$moduleIdentifier}"))) { + $this->routeMergeDegraded = true; + } + continue; } diff --git a/app/Services/UserService.php b/app/Services/UserService.php index f0624912..4075254e 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -2,18 +2,17 @@ namespace App\Services; +use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; use App\Enums\UserStatus; use App\Exceptions\CannotDeleteSuperAdminException; use App\Extension\HookManager; use App\Helpers\PermissionHelper; -use App\Models\ActivityLog; use App\Helpers\TimezoneHelper; -use App\Models\Role; +use App\Models\ActivityLog; use App\Models\User; use Exception; use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Laravel\Sanctum\PersonalAccessToken; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -24,6 +23,7 @@ class UserService { public function __construct( private UserRepositoryInterface $userRepository, + private RoleRepositoryInterface $roleRepository, private AttachmentService $attachmentService ) {} @@ -79,7 +79,7 @@ class UserService // 역할 할당 권한 체크: core.permissions.update 권한 없으면 기본 역할 자동 할당 if (! PermissionHelper::check('core.permissions.update')) { - $defaultRoleId = Role::where('identifier', 'user')->value('id'); + $defaultRoleId = $this->roleRepository->findByIdentifier('user')?->id; $roleIds = $defaultRoleId ? [$defaultRoleId] : null; } @@ -156,7 +156,7 @@ class UserService // 자기잠금 방지: 마지막 admin 역할 사용자가 자기 admin 역할을 제거하려는 경우 차단 if ($roleIds !== null && $authUser && $authUser->id === $user->id) { - $adminRole = Role::where('identifier', 'admin')->first(); + $adminRole = $this->roleRepository->findByIdentifier('admin'); if ($adminRole && $user->roles->contains('id', $adminRole->id) && ! in_array($adminRole->id, $roleIds)) { // admin 역할을 가진 다른 사용자가 있는지 확인 $otherAdminCount = $adminRole->users()->where('users.id', '!=', $user->id)->count(); @@ -372,7 +372,7 @@ class UserService */ public function getUserByUuid(string $uuid): ?User { - $user = User::where('uuid', $uuid)->first(); + $user = $this->userRepository->findByUuid($uuid); if ($user) { HookManager::doAction('core.user.after_show', $user); @@ -486,9 +486,9 @@ class UserService /** * 사용자의 활동 로그를 조회합니다. * - * @param int $userId 사용자 ID - * @param int $limit 조회 건수 - * @return \Illuminate\Support\Collection + * @param int $userId 사용자 ID + * @param int $limit 조회 건수 + * @return \Illuminate\Support\Collection 활동 로그 요약 컬렉션 */ public function getUserActivityLogs(int $userId, int $limit = 50): \Illuminate\Support\Collection { @@ -506,6 +506,31 @@ class UserService ]); } + /** + * 사용자의 계정 잠금을 해제합니다 (관리자 수동 해제). + * + * 로그인 시도 추적 컬럼을 모두 초기화합니다. 영구 잠금(`locked_permanently`)도 함께 + * 해제되며, 이 경로가 없으면 잠금 시간 0(무한대) 으로 잠긴 계정은 성공 로그인 자체가 + * 불가하므로 DB 직접 조작 외에 복구 수단이 없습니다. + * + * @param User $user 잠금을 해제할 사용자 + * @return User 갱신된 사용자 모델 + */ + public function unlockAccount(User $user): User + { + HookManager::doAction('core.user.before_unlock', $user); + + $this->userRepository->resetLoginAttempts($user); + + $user = $user->fresh(); + + HookManager::doAction('core.auth.account_unlocked', $user, [ + 'ip_address' => request()->ip(), + ]); + + return $user; + } + /** * 사용자의 마지막 로그인 시간을 현재 시간으로 업데이트합니다. * @@ -552,7 +577,7 @@ class UserService $statusEnum = UserStatus::from($status); // UUID → 정수 ID 변환 (내부 쿼리용) - $userIds = User::whereIn('uuid', $uuids)->pluck('id')->toArray(); + $userIds = $this->userRepository->getIdsByUuids($uuids); // DB 트랜잭션으로 일괄 업데이트 $updatedCount = DB::transaction(function () use ($userIds, $statusEnum) { @@ -565,13 +590,11 @@ class UserService UserStatus::Inactive => $updateData, }; - $count = User::whereIn('id', $userIds)->update($updateData); + $count = $this->userRepository->updateManyByIds($userIds, $updateData); // Active 외 상태로 변경 시 해당 사용자들의 토큰 전체 삭제 if ($statusEnum !== UserStatus::Active) { - PersonalAccessToken::where('tokenable_type', User::class) - ->whereIn('tokenable_id', $userIds) - ->delete(); + $this->userRepository->deleteTokensByUserIds($userIds); } return $count; diff --git a/app/Support/AllowedExtensions.php b/app/Support/AllowedExtensions.php new file mode 100644 index 00000000..7fdd9708 --- /dev/null +++ b/app/Support/AllowedExtensions.php @@ -0,0 +1,49 @@ + 정규화된 확장자 목록 (빈 배열 = 제한 없음) + */ + public static function normalize(mixed $value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + if (! is_array($value)) { + return []; + } + + $normalized = []; + + foreach ($value as $extension) { + if (! is_string($extension) && ! is_numeric($extension)) { + continue; + } + + $extension = strtolower(ltrim(trim((string) $extension), '.')); + + if ($extension === '' || in_array($extension, $normalized, true)) { + continue; + } + + $normalized[] = $extension; + } + + return $normalized; + } +} diff --git a/config/attachment.php b/config/attachment.php index c59ba559..5e5c936b 100644 --- a/config/attachment.php +++ b/config/attachment.php @@ -32,8 +32,30 @@ return [ | 최대 파일 크기 |-------------------------------------------------------------------------- | - | 업로드 가능한 최대 파일 크기 (KB 단위) + | 업로드 가능한 최대 파일 크기 (KB 단위 — Laravel `max:` 규칙과 동일 단위). + | + | 단위 규약: config/attachment.* 는 KB, 관리자 환경설정 upload.* 는 MB 다. + | MB → KB 변환은 SettingsServiceProvider::applyUploadConfig() 단 한 곳에서만 수행하며, + | 소비처(FormRequest 등)는 이 값을 변환 없이 그대로 사용한다. | */ 'max_file_size' => env('ATTACHMENT_MAX_FILE_SIZE', 10240), + + /* + |-------------------------------------------------------------------------- + | 허용 확장자 + |-------------------------------------------------------------------------- + | + | 업로드를 허용할 파일 확장자 목록 (소문자, 점 없이). + | 관리자 환경설정의 `upload.allowed_extensions` 가 저장되면 그 값으로 대체된다. + | + | 빈 배열은 "확장자 제한 없음" 을 의미한다 — 운영 중 예상치 못한 확장자로 업로드가 + | 막히는 상황의 탈출구다. 확장은 `core.attachment.allowed_extensions` 필터 훅으로 + | 목록을 조정할 수 있다. + | + */ + 'allowed_extensions' => [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', + ], ]; diff --git a/config/core.php b/config/core.php index 561bff61..79349ccb 100644 --- a/config/core.php +++ b/config/core.php @@ -1,6 +1,82 @@ [ + // 업로드 + 'upload_max_file_size_min' => 1, + 'upload_max_file_size_max' => 1024, + 'upload_image_max_width_min' => 100, + 'upload_image_max_width_max' => 10000, + 'upload_image_max_height_min' => 100, + 'upload_image_max_height_max' => 10000, + 'upload_image_quality_min' => 1, + 'upload_image_quality_max' => 100, + + // SEO + 'seo_og_image_default_width_min' => 0, + 'seo_og_image_default_width_max' => 8000, + 'seo_og_image_default_height_min' => 0, + 'seo_og_image_default_height_max' => 8000, + 'seo_cache_ttl_min' => 60, + 'seo_cache_ttl_max' => 86400, + 'seo_sitemap_cache_ttl_min' => 3600, + 'seo_sitemap_cache_ttl_max' => 604800, + 'seo_sitemap_urls_per_file_min' => 1000, + 'seo_sitemap_urls_per_file_max' => 50000, + + // 보안 + 'security_password_min_length_min' => 6, + 'security_password_min_length_max' => 64, + 'security_auth_token_lifetime_min' => 0, + 'security_auth_token_lifetime_max' => 3600, + 'security_max_login_attempts_min' => 0, + 'security_max_login_attempts_max' => 100, + 'security_login_lockout_time_min' => 0, + 'security_login_lockout_time_max' => 1440, + + // 고급 (캐시 TTL) + 'advanced_cache_ttl_min' => 0, + 'advanced_cache_ttl_max' => 14400, + 'advanced_seo_sitemap_cache_ttl_min' => 3600, + 'advanced_seo_sitemap_cache_ttl_max' => 604800, + + // 드라이버 + 'drivers_redis_port_min' => 1, + 'drivers_redis_port_max' => 65535, + 'drivers_redis_database_min' => 0, + 'drivers_redis_database_max' => 15, + 'drivers_memcached_port_min' => 1, + 'drivers_memcached_port_max' => 65535, + 'drivers_session_lifetime_min' => 1, + 'drivers_session_lifetime_max' => 43200, + 'drivers_websocket_port_min' => 1, + 'drivers_websocket_port_max' => 65535, + 'drivers_websocket_server_port_min' => 1, + 'drivers_websocket_server_port_max' => 65535, + 'drivers_log_days_min' => 1, + 'drivers_log_days_max' => 365, + + // 본인인증 + 'identity_challenge_ttl_minutes_min' => 1, + 'identity_challenge_ttl_minutes_max' => 1440, + 'identity_max_attempts_min' => 1, + 'identity_max_attempts_max' => 20, + ], + /* |-------------------------------------------------------------------------- | 코어 권한 정의 diff --git a/database/migrations/2026_07_27_000002_add_locked_permanently_to_users_table.php b/database/migrations/2026_07_27_000002_add_locked_permanently_to_users_table.php new file mode 100644 index 00000000..8781fdf0 --- /dev/null +++ b/database/migrations/2026_07_27_000002_add_locked_permanently_to_users_table.php @@ -0,0 +1,46 @@ +boolean('locked_permanently') + ->default(false) + ->after('locked_until') + ->comment('영구 잠금 여부 (잠금 시간 0 = 무한대 설정으로 잠긴 계정)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasColumn('users', 'locked_permanently')) { + return; + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locked_permanently'); + }); + } +}; diff --git a/docs/backend/api/attachments.md b/docs/backend/api/attachments.md index 279ef002..771100b6 100644 --- a/docs/backend/api/attachments.md +++ b/docs/backend/api/attachments.md @@ -35,6 +35,8 @@ | source_identifier | body | string | 아니오 | max 255 | 출처 식별자 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_validation_rules`). +> +> 업로드 가능한 확장자는 환경설정의 허용 형식 목록을 따르며, 확장은 `core.attachment.allowed_extensions` 필터로 목록을 조정할 수 있습니다 (허용 목록이 비어 있으면 확장자를 제한하지 않습니다). **요청 예시** @@ -112,6 +114,8 @@ example-key | source_identifier | body | string | 아니오 | max 255 | 출처 식별자 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_batch_validation_rules`). +> +> 업로드 가능한 확장자는 환경설정의 허용 형식 목록을 따르며, 확장은 `core.attachment.allowed_extensions` 필터로 목록을 조정할 수 있습니다 (허용 목록이 비어 있으면 확장자를 제한하지 않습니다). **요청 예시** diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index 9d824d43..a9d1a3ae 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -1432,7 +1432,7 @@ Content-Type: application/json **설명** -일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보와 함께 반환한다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다. +일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보(`errors.locked_until`, `errors.retry_after_seconds`)와 함께 반환한다. 보안 환경설정의 잠금 시간이 `0`(무한대)이면 무기한 잠금이 되어 `423 auth.account_locked_permanently` 와 함께 `errors.permanent=true`, `errors.locked_until=null`, `errors.retry_after_seconds=null` 을 반환하며 `Retry-After` 헤더도 붙지 않는다. 이 경우 해제 수단은 관리자 해제 API(`POST /api/admin/users/{user}/unlock`) 뿐이다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다. ### POST /api/auth/logout diff --git a/docs/backend/api/users.md b/docs/backend/api/users.md index 0a9afe28..e74b099b 100644 --- a/docs/backend/api/users.md +++ b/docs/backend/api/users.md @@ -853,6 +853,78 @@ HTTP/1.1 200 지정한 사용자(경로 파라미터는 UUID 로 바인딩)를 삭제합니다. 슈퍼관리자 계정은 삭제할 수 없으며 시도 시 422(`exceptions.cannot_delete_super_admin`)를 반환한다. 그 외 삭제 실패 시에는 실패 상세 사유가 담긴 422 를, 나머지 오류는 500 을 반환한다. 삭제는 Service 계층에서 관련 데이터 정리와 훅을 거쳐 처리된다. +### POST /api/admin/users/{user}/unlock + +- **라우트명**: `api.admin.users.unlock` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@unlock` +- **인증/권한**: `auth:sanctum` + `permission:core.users.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user 의 UUID | + +본문 파라미터는 없습니다. + +**요청 예시** + +```http +POST /api/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d/unlock HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +갱신된 사용자 리소스(`UserResource`)를 반환합니다. 관리자 응답에는 잠금 상태 필드가 포함됩니다. + +| 이름 | 타입 | 용도 | +| --- | --- | --- | +| is_locked | boolean | 현재 잠금 여부 (영구 잠금이거나 `locked_until` 이 미래) | +| locked_permanently | boolean | 무기한 잠금 여부 | +| locked_until | string\|null | 잠금 해제 예정 시각 (영구 잠금이면 null) | +| failed_login_attempts | integer | 연속 로그인 실패 횟수 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "계정 잠금이 해제되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "is_locked": false, + "locked_permanently": false, + "locked_until": null, + "failed_login_attempts": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 | +| 404 | Not Found | 해당 UUID 의 사용자가 없는 경우 | + +**설명** + +로그인 실패 누적으로 잠긴 계정의 잠금을 관리자가 수동으로 해제합니다. 로그인 시도 추적 값 +(`failed_login_attempts` / `locked_until` / `locked_permanently`)을 모두 초기화합니다. + +보안 환경설정의 잠금 시간(`security.login_lockout_time`)을 `0`(무한대)으로 두면 계정이 무기한 +잠기며, 이 경우 자동 해제도 성공 로그인도 불가능하므로 **이 엔드포인트가 유일한 복구 경로**입니다. +해제는 활동 로그(`auth.account_unlocked`)에 기록됩니다. + + ### GET /api/admin/users/{user} - **라우트명**: `api.admin.users.show` diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index dc72c475..63f7e1d9 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -8,10 +8,13 @@ ### Added +- 계정이 무기한 잠겼을 때의 로그인 안내와 관리자 잠금 해제 안내, 그리고 해당 활동 기록 문구의 일본어 번역 추가. +- 허용되지 않는 파일 형식을 업로드했을 때의 안내 문구 일본어 번역 추가. - 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가. - 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 환경설정 저장 시 오류 안내에 나오는 항목 이름의 일본어 번역 추가 — 보안·업로드·SEO·고급·드라이버 등 각 설정 항목이 내부 식별자 대신 일본어 항목명으로 표시됩니다. ## [1.0.6] - 2026-07-22 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php index abe57b8f..31a53df8 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php @@ -31,6 +31,7 @@ return [ 'logout' => 'ログアウト', 'login_failed' => 'ログイン失敗', 'account_locked' => 'アカウントロック', + 'account_unlocked' => 'アカウントロック解除', 'request' => 'リクエスト', 'expired' => '有効期限切れ', 'verify' => '本人認証', @@ -125,6 +126,8 @@ return [ 'auth_record_consents' => '利用規約同意記録', 'auth_login_failed' => 'ログイン失敗 (:email)', 'auth_account_locked' => 'アカウントロック (:attempts回失敗、:minutes分ブロック)', + 'auth_account_locked_permanently' => 'アカウント無期限ロック (:attempts回失敗、管理者による解除が必要)', + 'auth_account_unlocked' => 'アカウントロック解除 (:email)', 'schedule_index' => 'スケジュール一覧の閲覧', 'schedule_show' => 'スケジュール詳細の閲覧 (ID: :schedule_id)', 'schedule_create' => 'スケジュールの作成 (ID: :schedule_id)', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php index 1b63d4fc..2a9c5a0b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php @@ -20,6 +20,7 @@ return [ 'file_required' => 'ファイルを選択してください。', 'file_invalid' => '有効なファイルではありません。', 'file_max' => 'ファイルサイズは :maxMB を超えることができません。', + 'file_extension_invalid' => '許可されていないファイル形式です。許可形式: :extensions', 'files_required' => 'ファイルを選択してください。', 'files_array' => 'ファイル形式が正しくありません。', 'files_min' => '最低1つ以上のファイルを選択してください。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php index f942a5dc..d6d5733e 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php @@ -32,6 +32,8 @@ return [ 'account_withdrawn' => '退会したアカウントです。', 'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。', 'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。', + 'account_locked_permanently' => 'ログイン試行回数の超過によりアカウントがロックされました。管理者にお問い合わせください。', + 'account_unlocked' => 'アカウントのロックを解除しました。', 'reset_token_invalid' => '無効なパスワードリセットトークンです。', 'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。', 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index e7c1a759..400adc6e 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -1074,5 +1074,65 @@ return [ 'websocket_host' => 'WebSocket ホスト', 'websocket_port' => 'WebSocket ポート', 'websocket_scheme' => 'WebSocket プロトコル', + 'mailgun_endpoint' => 'Mailgun エンドポイント', + 'channels' => '通知チャンネル', + 'currency' => 'デフォルト通貨', + 'maintenance_mode' => 'メンテナンスモード', + 'asset_url_mode' => 'アセットURL方式', + 'site_logo' => 'サイトロゴ', + 'bot_user_agents' => 'ボット User-Agent 一覧', + 'bot_detection_enabled' => 'ボット検出の使用', + 'bot_detection_library_enabled' => 'ボット検出ライブラリの使用', + 'og_default_site_name' => 'OG デフォルトサイト名', + 'og_image_default_width' => 'OG 画像デフォルト幅', + 'og_image_default_height' => 'OG 画像デフォルト高さ', + 'twitter_default_card' => 'Twitter デフォルトカード種類', + 'twitter_default_site' => 'Twitter デフォルトアカウント', + 'seo_page_cache_enabled' => 'SEO ページキャッシュの使用', + 'cache_ttl' => 'SEO ページキャッシュ保持時間', + 'sitemap_enabled' => 'サイトマップの使用', + 'sitemap_cache_ttl' => 'サイトマップキャッシュ保持時間', + 'sitemap_urls_per_file' => 'サイトマップ1ファイルあたりのURL数', + 'sitemap_gzip' => 'サイトマップ圧縮', + 'sitemap_serve_stale_on_miss' => '未生成時に既存サイトマップを提供', + 'sitemap_max_urls_per_contributor' => '項目別サイトマップ最大URL数', + 'sitemap_hreflang_enabled' => 'サイトマップ代替言語リンクの使用', + 'sitemap_schedule' => 'サイトマップ自動生成の周期', + 'sitemap_schedule_time' => 'サイトマップ自動生成の時刻', + 'generator_enabled' => 'SEO ページジェネレーターの使用', + 'generator_content' => 'SEO ページジェネレーターのコンテンツ', + 'force_https' => 'HTTPS 強制適用', + 'login_attempt_enabled' => 'ログイン試行制限', + 'auth_token_lifetime' => '認証トークン有効期限', + 'max_login_attempts' => '最大ログイン試行回数', + 'login_lockout_time' => 'ログインブロック時間', + 'password_min_length' => 'パスワード最小長', + 'require_password_special_char' => 'パスワード特殊文字必須', + 'allow_internal_outbound_urls' => '内部ネットワークアドレス呼び出しの許可', + 'advanced_cache_enabled' => 'キャッシュの使用', + 'layout_cache_enabled' => 'レイアウトキャッシュの使用', + 'layout_cache_ttl' => 'レイアウトキャッシュ保持時間', + 'stats_cache_enabled' => '統計キャッシュの使用', + 'stats_cache_ttl' => '統計キャッシュ保持時間', + 'seo_cache_enabled' => 'SEO キャッシュの使用', + 'seo_cache_ttl' => 'SEO キャッシュ保持時間', + 'seo_sitemap_cache_ttl' => 'SEO サイトマップキャッシュ保持時間', + 'debug_mode' => 'デバッグモード', + 'sql_query_log' => 'SQL クエリログ', + 'core_update_github_url' => 'コア更新 GitHub アドレス', + 'core_update_github_token' => 'コア更新 GitHub トークン', + 'geoip_enabled' => 'GeoIP の使用', + 'geoip_license_key' => 'GeoIP ライセンスキー', + 'geoip_auto_update_enabled' => 'GeoIP 自動更新', + 'websocket_app_id' => 'WebSocket アプリ ID', + 'websocket_app_secret' => 'WebSocket アプリ シークレット', + 'websocket_verify_ssl' => 'WebSocket SSL 証明書の検証', + 'websocket_server_host' => 'WebSocket サーバー ホスト', + 'websocket_server_port' => 'WebSocket サーバー ポート', + 'websocket_server_scheme' => 'WebSocket サーバー プロトコル', + 'search_engine_driver' => '検索エンジン ドライバー', + 'log_driver' => 'ログ ドライバー', + 'log_level' => 'ログレベル', + 'log_days' => 'ログ保持日数', ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index 21618688..8437ac34 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 첨부파일 개수 상한 초과 안내 문구 일본어 번역 추가. - 게시판 대시보드 조회 안내 문구 일본어 번역 추가. ## [1.0.3] - 2026-07-22 diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php index 5329bdf4..49e239e9 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php @@ -176,6 +176,7 @@ return [ 'table_creation_failed' => '掲示板テーブルの作成に失敗しました (:table)::error', 'category_in_use' => ':category カテゴリーは :count個の投稿で使用中です。', 'board_not_found' => '掲示板が見つかりません。', + 'attachment_limit_exceeded' => '添付ファイルは最大:limit個まで登録できます。(リクエスト: :attempted個)', 'post_not_found' => '投稿が見つかりません。', 'permission_denied' => '権限がありません。', 'validation_failed' => '入力値が正しくありません。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 9b263342..fc82863e 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -8,12 +8,14 @@ ### Added +- 장바구니 수량 상한 초과, 정률 적립률 상한 초과, 판매가·가격 하한 안내 문구 일본어 번역 추가. - 쇼핑몰 환경설정·카테고리 이미지·결제 설정 안내 문구 일본어 번역 추가 — 저장/조회 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 검색 프리셋·브랜드·배송사·배송정책 목록 안내 문구 일본어 번역 추가. - 카테고리를 자기 자신이나 하위 카테고리 아래로 옮기려 할 때, 주문에 속하지 않은 옵션을 변경하려 할 때, 상품에 속하지 않은 이미지를 대표로 지정하려 할 때 나오는 안내 문구 일본어 번역 추가. ### Fixed +- 마일리지 사용 한도 초과 안내에 사용 가능한 최대 금액이 함께 표시되도록 일본어 문구를 갱신했습니다. - 주문 관리자 메모 글자 수 안내가 실제 입력 한도(2000자)와 다른 "1000자"로 표시되던 문제를 수정했습니다. ## [1.0.5] - 2026-07-19 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php index 088c0de3..771ade99 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php @@ -67,7 +67,7 @@ return [ 'deduct_exceeds_balance' => '差し引くマイレージが残高を超えています。', 'below_min_use_amount' => '最小使用金額は:amountポイントです。', 'invalid_use_unit' => 'マイレージは:unitポイント単位で使用できます。', - 'exceeds_max_use' => '最大使用限度を超えました。', + 'exceeds_max_use' => '最大使用限度を超えました。(使用可能な最大::amountポイント)', 'use_exceeds_balance' => '保有ポイント(:amount点)を超えて使用することはできません。', 'base_currency_rule_missing' => '基本通貨のマイレージ使用単位が設定されていないため、マイレージを使用できません。', ], diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 29628fc0..4717a939 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -26,6 +26,7 @@ return [ 'type_invalid' => '配送業者タイプは国内(domestic)または国際(international)のみ可能です。', ], 'list' => [ + 'per_page_max' => '一度に取得できる最大件数は:max件です。', 'page' => [ 'integer' => 'ページ番号は数字である必要があります。', 'min' => 'ページ番号は1以上である必要があります。', @@ -218,6 +219,9 @@ return [ 'selling_price_lte_list' => '販売価格は定価より大きくすることはできません。', 'option_selling_price_lte_list' => 'オプション販売価格は定価より大きくすることはできません。', 'product' => [ + 'mileage_percent_max' => '定率のマイレージ付与率は100%を超えることはできません。', + 'selling_price_lte_list' => '販売価格は定価より高くすることはできません。', + 'price_min' => '価格は0より大きい必要があります。', 'attributes' => [ 'name' => '商品名', 'product_code' => '商品コード', @@ -848,13 +852,14 @@ return [ ], ], 'cart' => [ + 'quantity_limit_exceeded' => 'カートには商品ごとに最大:limit個まで追加できます。(リクエスト: :attempted個)', 'product_id_required' => '商品を選択してください。', 'product_not_found' => '存在しない商品です。', 'option_id_required' => 'オプションを選択してください。', 'option_not_found' => '存在しないオプションです。', 'quantity_required' => '数量を入力してください。', 'quantity_min' => '数量は1個以上である必要があります。', - 'quantity_max' => '数量は最大9,999個まで可能です。', + 'quantity_max' => '数量は最大:max個まで可能です。', 'ids_required' => '削除する商品を選択してください。', 'ids_array' => '商品IDは配列形式である必要があります。', 'ids_min' => '最低1つ以上の商品を選択してください。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md index 64e4d296..8be76886 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 첨부파일 개수 상한 초과 안내와 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가. - 페이지 활동 로그의 "제목"·"내용" 변경 항목 라벨 일본어 번역을 추가했습니다. 일본어 로케일에서 항목명이 비어 보이던 문제가 해소됩니다. ## [1.0.1] - 2026-07-08 diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php index c7464073..8d5f8ddb 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php @@ -35,5 +35,6 @@ return [ 'version_belongs_to_different_page' => '指定されたバージョンは該当ページに属していません。', 'permission_denied' => '権限がありません。', 'validation_failed' => '入力値が正しくありません。', + 'attachment_limit_exceeded' => '添付ファイルは最大:limit個まで登録できます。(リクエスト: :attempted個)', ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php index a83cc4ed..c2addd8d 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php @@ -56,6 +56,7 @@ return [ 'file' => '正しいファイル形式ではありません。', 'max' => 'ファイルサイズは:maxKBを超えることはできません。', 'mimes' => '許可されていないファイル形式です。', + 'mimetypes' => '許可されていないファイル形式です。', ], 'order' => [ 'required' => '順序情報は必須です。', diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 4c5df692..1119f77c 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 회원 상세의 계정 잠금 상태 표시와 잠금 해제 버튼 문구 일본어 번역 추가 (`users.detail.unlock*`, `users.detail.lock_status`) — 무기한 잠금·기한부 잠금·정상 상태 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 언어팩 관리 목록의 "파일 없음" 배지와 다시 설치 버튼 문구 일본어 번역 추가 (`language_packs.badge.files_missing`, `language_packs.actions.reinstall`). - 일반 설정의 에셋 파일 서빙 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지, 자동 감지 결과(확장자를 가로채는 서버 규칙 안내 포함)가 일본어 로케일에서 자연스럽게 표시됩니다. - 대시보드의 에셋 파일 서빙 방식 불일치 안내 문구 일본어 번역 추가 (`dashboard.asset_url_mode_drift_*`) — 안내 제목과 설명, 환경설정 이동 문구가 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 31b1155a..14f5381a 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -256,7 +256,8 @@ "last_login_at": "最後のログイン", "ip_address": "IP", "country_select": "国を選択", - "country_search": "国を検索..." + "country_search": "国を検索...", + "lock_status": "アカウントロック状態" }, "verification": { "method": "本人確認方法", @@ -338,7 +339,13 @@ "profile_memo": "プロフィール/メモ", "activity_info": "アクティビティ情報", "consent_info": "同意履歴" - } + }, + "unlock": "ロック解除", + "unlock_success": "アカウントのロックを解除しました。", + "lock_status": "アカウントロック状態", + "locked_permanently_value": "ロック中 (無期限 — 管理者による解除が必要)", + "locked_until_value": "ロック中 ({{until}} まで)", + "not_locked_value": "正常" }, "bulk_status_change": "ステータス変更" }, @@ -1479,7 +1486,13 @@ "regenerate_key": "キー 再生成", "app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。", "allow_internal_outbound_urls": "内部ネットワークアドレス呼び出し許可", - "allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。" + "allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。", + "password_min_length": "パスワードの最小文字数", + "password_min_length_hint": "会員登録・パスワード変更・再設定に適用されます。ログインには適用されません(既存のパスワードでログインできなくなるのを防ぐため)。", + "require_password_special_char": "特殊文字必須", + "require_password_special_char_label": "パスワードに特殊文字を必ず含める", + "require_password_special_char_hint": "有効にすると、新しく設定するパスワードに特殊文字が少なくとも1つ必要です。", + "ip_throttle_floor_hint": "IP単位のリクエスト制限はこの値の6倍で適用され、1分あたり最低30回は常に保証されます。" }, "identity": { "sub_tabs": { diff --git a/lang/en/activity_log.php b/lang/en/activity_log.php index 9d322426..7d7afb06 100644 --- a/lang/en/activity_log.php +++ b/lang/en/activity_log.php @@ -38,6 +38,7 @@ return [ 'logout' => 'Logout', 'login_failed' => 'Login failed', 'account_locked' => 'Account locked', + 'account_unlocked' => 'Account unlocked', 'request' => 'Request', 'expired' => 'Expired', 'verify' => 'Identity verified', @@ -148,6 +149,8 @@ return [ 'auth_record_consents' => 'Consent recorded', 'auth_login_failed' => 'Login failed (:email)', 'auth_account_locked' => 'Account locked (:attempts attempts, :minutes min lockout)', + 'auth_account_locked_permanently' => 'Account locked permanently (:attempts attempts, administrator unlock required)', + 'auth_account_unlocked' => 'Account unlocked (:email)', // Schedule management 'schedule_index' => 'Schedule list viewed', diff --git a/lang/en/attachment.php b/lang/en/attachment.php index 0a4caa0e..fe386189 100644 --- a/lang/en/attachment.php +++ b/lang/en/attachment.php @@ -27,6 +27,7 @@ return [ 'file_required' => 'Please select a file.', 'file_invalid' => 'The file is not valid.', 'file_max' => 'The file size cannot exceed :max MB.', + 'file_extension_invalid' => 'This file type is not allowed. Allowed types: :extensions', 'files_required' => 'Please select files.', 'files_array' => 'The files format is invalid.', 'files_min' => 'Please select at least one file.', diff --git a/lang/en/auth.php b/lang/en/auth.php index 63ece7ce..a5e2fcab 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -33,6 +33,8 @@ return [ 'account_withdrawn' => 'This account has been withdrawn.', 'account_pending_verification' => 'Identity verification is not complete. Please complete the email verification.', 'account_locked' => 'Too many failed login attempts. Your account is locked for :minutes minute(s).', + 'account_locked_permanently' => 'Too many failed login attempts. Your account has been locked. Please contact an administrator.', + 'account_unlocked' => 'The account lock has been released.', // Password reset 'reset_token_invalid' => 'Invalid password reset token.', diff --git a/lang/en/validation.php b/lang/en/validation.php index d9fb829e..645b0aeb 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -1200,5 +1200,70 @@ return [ 'ses_key' => 'SES access key', 'ses_secret' => 'SES secret key', 'ses_region' => 'SES region', + 'mailgun_endpoint' => 'Mailgun endpoint', + // General settings (additional) + 'channels' => 'notification channels', + 'currency' => 'default currency', + 'maintenance_mode' => 'maintenance mode', + 'asset_url_mode' => 'asset URL mode', + 'site_logo' => 'site logo', + // SEO settings (additional) + 'bot_user_agents' => 'bot user agents', + 'bot_detection_enabled' => 'bot detection', + 'bot_detection_library_enabled' => 'bot detection library', + 'og_default_site_name' => 'OG default site name', + 'og_image_default_width' => 'OG image default width', + 'og_image_default_height' => 'OG image default height', + 'twitter_default_card' => 'Twitter default card type', + 'twitter_default_site' => 'Twitter default account', + 'seo_page_cache_enabled' => 'SEO page cache', + 'cache_ttl' => 'SEO page cache lifetime', + 'sitemap_enabled' => 'sitemap', + 'sitemap_cache_ttl' => 'sitemap cache lifetime', + 'sitemap_urls_per_file' => 'sitemap URLs per file', + 'sitemap_gzip' => 'sitemap compression', + 'sitemap_serve_stale_on_miss' => 'serve stale sitemap on miss', + 'sitemap_max_urls_per_contributor' => 'sitemap max URLs per contributor', + 'sitemap_hreflang_enabled' => 'sitemap hreflang links', + 'sitemap_schedule' => 'sitemap schedule', + 'sitemap_schedule_time' => 'sitemap schedule time', + 'generator_enabled' => 'SEO page generator', + 'generator_content' => 'SEO page generator content', + // Security settings (additional) + 'force_https' => 'force HTTPS', + 'login_attempt_enabled' => 'login attempt limit', + 'auth_token_lifetime' => 'auth token lifetime', + 'max_login_attempts' => 'max login attempts', + 'login_lockout_time' => 'login lockout time', + 'password_min_length' => 'minimum password length', + 'require_password_special_char' => 'require special character in password', + 'allow_internal_outbound_urls' => 'allow internal outbound URLs', + // Advanced settings (additional) + 'advanced_cache_enabled' => 'cache', + 'layout_cache_enabled' => 'layout cache', + 'layout_cache_ttl' => 'layout cache lifetime', + 'stats_cache_enabled' => 'statistics cache', + 'stats_cache_ttl' => 'statistics cache lifetime', + 'seo_cache_enabled' => 'SEO cache', + 'seo_cache_ttl' => 'SEO cache lifetime', + 'seo_sitemap_cache_ttl' => 'SEO sitemap cache lifetime', + 'debug_mode' => 'debug mode', + 'sql_query_log' => 'SQL query log', + 'core_update_github_url' => 'core update GitHub URL', + 'core_update_github_token' => 'core update GitHub token', + 'geoip_enabled' => 'GeoIP', + 'geoip_license_key' => 'GeoIP license key', + 'geoip_auto_update_enabled' => 'GeoIP auto update', + // Driver settings (additional) + 'websocket_app_id' => 'WebSocket app ID', + 'websocket_app_secret' => 'WebSocket app secret', + 'websocket_verify_ssl' => 'WebSocket SSL verification', + 'websocket_server_host' => 'WebSocket server host', + 'websocket_server_port' => 'WebSocket server port', + 'websocket_server_scheme' => 'WebSocket server scheme', + 'search_engine_driver' => 'search engine driver', + 'log_driver' => 'log driver', + 'log_level' => 'log level', + 'log_days' => 'log retention days', ], ]; diff --git a/lang/ko/activity_log.php b/lang/ko/activity_log.php index d2acf918..6e7452b0 100644 --- a/lang/ko/activity_log.php +++ b/lang/ko/activity_log.php @@ -38,6 +38,7 @@ return [ 'logout' => '로그아웃', 'login_failed' => '로그인 실패', 'account_locked' => '계정 잠금', + 'account_unlocked' => '계정 잠금 해제', 'request' => '요청', 'expired' => '만료', 'verify' => '본인인증', @@ -148,6 +149,8 @@ return [ 'auth_record_consents' => '이용약관 동의 기록', 'auth_login_failed' => '로그인 실패 (:email)', 'auth_account_locked' => '계정 잠금 (:attempts회 실패, :minutes분 차단)', + 'auth_account_locked_permanently' => '계정 영구 잠금 (:attempts회 실패, 관리자 해제 필요)', + 'auth_account_unlocked' => '계정 잠금 해제 (:email)', // 스케줄 관리 'schedule_index' => '스케줄 목록 조회', diff --git a/lang/ko/attachment.php b/lang/ko/attachment.php index ba92d264..d66f5520 100644 --- a/lang/ko/attachment.php +++ b/lang/ko/attachment.php @@ -27,6 +27,7 @@ return [ 'file_required' => '파일을 선택해주세요.', 'file_invalid' => '유효한 파일이 아닙니다.', 'file_max' => '파일 크기는 :maxMB를 초과할 수 없습니다.', + 'file_extension_invalid' => '허용되지 않는 파일 형식입니다. 허용 형식: :extensions', 'files_required' => '파일을 선택해주세요.', 'files_array' => '파일 형식이 올바르지 않습니다.', 'files_min' => '최소 1개 이상의 파일을 선택해주세요.', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index e8697b60..bc1cffc5 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -33,6 +33,8 @@ return [ 'account_withdrawn' => '탈퇴한 계정입니다.', 'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.', 'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.', + 'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.', + 'account_unlocked' => '계정 잠금이 해제되었습니다.', // 비밀번호 재설정 'reset_token_invalid' => '유효하지 않은 비밀번호 재설정 토큰입니다.', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 549fbe5f..2044e1f6 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -1193,5 +1193,70 @@ return [ 'ses_key' => 'SES 액세스 키', 'ses_secret' => 'SES 시크릿 키', 'ses_region' => 'SES 리전', + 'mailgun_endpoint' => 'Mailgun 엔드포인트', + // 일반 설정 (추가) + 'channels' => '알림 채널', + 'currency' => '기본 통화', + 'maintenance_mode' => '점검 모드', + 'asset_url_mode' => '자산 주소 방식', + 'site_logo' => '사이트 로고', + // SEO 설정 (추가) + 'bot_user_agents' => '봇 User-Agent 목록', + 'bot_detection_enabled' => '봇 감지 사용', + 'bot_detection_library_enabled' => '봇 감지 라이브러리 사용', + 'og_default_site_name' => 'OG 기본 사이트 이름', + 'og_image_default_width' => 'OG 이미지 기본 너비', + 'og_image_default_height' => 'OG 이미지 기본 높이', + 'twitter_default_card' => '트위터 기본 카드 유형', + 'twitter_default_site' => '트위터 기본 계정', + 'seo_page_cache_enabled' => 'SEO 페이지 캐시 사용', + 'cache_ttl' => 'SEO 페이지 캐시 유지시간', + 'sitemap_enabled' => '사이트맵 사용', + 'sitemap_cache_ttl' => '사이트맵 캐시 유지시간', + 'sitemap_urls_per_file' => '사이트맵 파일당 URL 수', + 'sitemap_gzip' => '사이트맵 압축', + 'sitemap_serve_stale_on_miss' => '사이트맵 미생성 시 이전 파일 제공', + 'sitemap_max_urls_per_contributor' => '사이트맵 항목별 최대 URL 수', + 'sitemap_hreflang_enabled' => '사이트맵 대체 언어 링크 사용', + 'sitemap_schedule' => '사이트맵 자동 생성 주기', + 'sitemap_schedule_time' => '사이트맵 자동 생성 시각', + 'generator_enabled' => 'SEO 페이지 생성기 사용', + 'generator_content' => 'SEO 페이지 생성기 콘텐츠', + // 보안 설정 (추가) + 'force_https' => 'HTTPS 강제 적용', + 'login_attempt_enabled' => '로그인 시도 제한', + 'auth_token_lifetime' => '인증 토큰 유지시간', + 'max_login_attempts' => '최대 로그인 시도 횟수', + 'login_lockout_time' => '로그인 차단 시간', + 'password_min_length' => '비밀번호 최소 길이', + 'require_password_special_char' => '비밀번호 특수문자 필수', + 'allow_internal_outbound_urls' => '내부 네트워크 주소 호출 허용', + // 고급 설정 (추가) + 'advanced_cache_enabled' => '캐시 사용', + 'layout_cache_enabled' => '레이아웃 캐시 사용', + 'layout_cache_ttl' => '레이아웃 캐시 유지시간', + 'stats_cache_enabled' => '통계 캐시 사용', + 'stats_cache_ttl' => '통계 캐시 유지시간', + 'seo_cache_enabled' => 'SEO 캐시 사용', + 'seo_cache_ttl' => 'SEO 캐시 유지시간', + 'seo_sitemap_cache_ttl' => 'SEO 사이트맵 캐시 유지시간', + 'debug_mode' => '디버그 모드', + 'sql_query_log' => 'SQL 쿼리 로그', + 'core_update_github_url' => '코어 업데이트 GitHub 주소', + 'core_update_github_token' => '코어 업데이트 GitHub 토큰', + 'geoip_enabled' => 'GeoIP 사용', + 'geoip_license_key' => 'GeoIP 라이선스 키', + 'geoip_auto_update_enabled' => 'GeoIP 자동 업데이트', + // 드라이버 설정 (추가) + 'websocket_app_id' => '웹소켓 앱 ID', + 'websocket_app_secret' => '웹소켓 앱 시크릿', + 'websocket_verify_ssl' => '웹소켓 SSL 인증서 검증', + 'websocket_server_host' => '웹소켓 서버 호스트', + 'websocket_server_port' => '웹소켓 서버 포트', + 'websocket_server_scheme' => '웹소켓 서버 프로토콜', + 'search_engine_driver' => '검색 엔진 드라이버', + 'log_driver' => '로그 드라이버', + 'log_level' => '로그 레벨', + 'log_days' => '로그 보관 일수', ], ]; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 3f7d1f9d..5a697eeb 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -6,6 +6,11 @@ ## [1.0.3] - 2026-07-16 +### Security + +- 게시판에 설정한 대댓글 최대 깊이가 모든 댓글 등록 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면을 거치는 요청에서만 깊이를 확인해, 연동 기능이 댓글을 직접 등록하면 설정한 깊이를 넘겨 달릴 수 있었습니다. 깊이를 넘긴 답글은 다른 위치로 옮겨 붙이지 않고 사유와 함께 거절합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 게시판에 설정한 첨부파일 최대 개수가 모든 첨부 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면에서 파일을 직접 첨부할 때만 개수를 확인해, 파일을 먼저 올려 두고 글을 저장하는 방식으로는 상한을 넘겨 첨부할 수 있었습니다. 이제 직접 첨부·미리 올린 파일 연결·이미 첨부된 파일까지 합산해 판정합니다. 이미 상한을 넘겨 등록된 기존 첨부는 그대로 두고 신규 등록만 차단합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. @@ -19,11 +24,14 @@ ### Fixed +- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.) - 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다. - 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다. +- 댓글 계층 정리 시 서로가 서로의 부모로 얽혀 최상위 댓글에서 닿을 수 없는 댓글이 아무 기록 없이 넘어가던 문제를 수정했습니다. 이런 댓글은 내용과 위치를 그대로 보존하되, 정리되지 않았다는 사실이 업데이트 기록에 남아 확인할 수 있습니다. - 허용 확장자를 지정하지 않은 게시판에서 어떤 파일도 첨부할 수 없던 문제를 수정했습니다. 파일 선택 단계에서 막히던 것과 서버에서 거부되던 것을 함께 고쳐, 확장자를 지정하지 않은 게시판은 기본 확장자 목록을 사용합니다. 첨부 안내에 확장자가 비어 표시되던 것도 함께 해소됩니다. 확장자를 지정한 게시판은 지금처럼 목록에 없는 파일을 계속 거부합니다. - 게시판 알림 설정에서 저장에 실패해도 아무 안내 없이 넘어가던 화면을 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다. - 일부 안내 문구가 실제 문장 대신 내부 식별자(`sirsoft-board::messages....` 같은 문자열)로 표시되던 문제를 수정했습니다. 첨부파일이 있는 게시판을 찾지 못했을 때, 댓글 목록을 불러오지 못했을 때, 비회원 댓글의 비밀번호를 확인할 때, 삭제·블라인드된 글에 댓글을 달려 할 때, 권한이 없어 거절될 때가 해당됩니다. @@ -37,6 +45,7 @@ - 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) - 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) - 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다. +- 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/config/board.php b/modules/_bundled/sirsoft-board/config/board.php index a952dfe0..16f4a55b 100644 --- a/modules/_bundled/sirsoft-board/config/board.php +++ b/modules/_bundled/sirsoft-board/config/board.php @@ -154,6 +154,26 @@ return [ // 대댓글 깊이 제한 'max_comment_depth_min' => 0, 'max_comment_depth_max' => 10, + + // 신고 정책 + 'auto_hide_threshold_min' => 0, + 'auto_hide_threshold_max' => 100, + 'daily_report_limit_min' => 0, + 'daily_report_limit_max' => 100, + 'rejection_limit_count_min' => 0, + 'rejection_limit_count_max' => 50, + 'rejection_limit_days_min' => 1, + 'rejection_limit_days_max' => 365, + + // 스팸/보안 쿨다운 (초) + 'post_cooldown_seconds_min' => 0, + 'post_cooldown_seconds_max' => 3600, + 'comment_cooldown_seconds_min' => 0, + 'comment_cooldown_seconds_max' => 3600, + 'report_cooldown_seconds_min' => 0, + 'report_cooldown_seconds_max' => 3600, + 'view_count_cache_ttl_min' => 60, + 'view_count_cache_ttl_max' => 604800, ], /* diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index c8e44ddb..3e201398 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -85,6 +85,8 @@ Content-Disposition: form-data; name="temp_key" **설명** 게시판 관리자가 게시글 첨부파일 1건을 업로드합니다. `auth:sanctum` + admin + 게시판별 `attachments.upload` 권한이 필요하며, `AttachmentService::upload()`가 게시판별 동적 첨부 테이블에 저장합니다. `post_id`가 있으면 해당 게시글에 즉시 귀속되고, 없으면 `temp_key`로 임시 업로드되어 게시글 작성/수정 저장 시점에 연결됩니다. 응답은 FileUploader 컴포넌트 호환을 위해 `data.data`로 한 번 더 감싸 파일 메타(hash·url·order 등)를 반환합니다. +첨부 개수는 게시판 설정 `max_file_count` 를 기준으로 **직접 업로드 · 미리 올려 둔 파일 연결(`attachment_ids`) · 임시 업로드 연결(`temp_key`) · 이미 연결된 첨부** 를 모두 합산해 판정합니다. 상한을 넘으면 `422`(`errors.code = attachment_limit_exceeded`)를 반환하며, 이 판정은 업로드 엔드포인트뿐 아니라 게시글 생성·수정 저장 시점에도 동일하게 적용됩니다. `max_file_count` 가 0 이면 개수를 제한하지 않습니다. + ### GET /api/modules/sirsoft-board/admin/board/{slug}/attachments/download/{hash} diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx index 682bd846..a92c2ff7 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx @@ -1135,14 +1135,19 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => { expect(manageTagInputs[0].props.options).toContain('roleOptions'); }); + // 허용 범위는 서버가 내려주는 한계값(_meta.limits)을 바인딩한다. 화면이 숫자를 직접 들면 + // 저장 규칙과 갈라진다 — 실제로 화면 min:1 ↔ 서버 min:0 으로 어긋나 "0=비활성" 안내대로 + // 입력할 수 없던 결함이 있었다(#493 B4). it('필드에 유효한 min/max 제약이 있다', () => { const autoHide = findById(tabReportPolicy, 'field_auto_hide_threshold'); const autoHideInput = findByName(autoHide, 'Input').find( (c: any) => c.props?.name === 'report_policy.auto_hide_threshold' ); - expect(autoHideInput.props.min).toBe(1); - expect(autoHideInput.props.max).toBe(100); - + expect(String(autoHideInput.props.min)).toContain('limits?.auto_hide_threshold_min'); + expect(String(autoHideInput.props.max)).toContain('limits?.auto_hide_threshold_max'); + // 0 = 자동 숨김 비활성. 폴백이 1 로 되돌아가면 비활성으로 되돌릴 방법이 사라진다. + expect(String(autoHideInput.props.min)).toContain('?? 0'); + expect(String(autoHideInput.props.max)).toContain('?? 100'); }); it('Select가 composite 타입이다', () => { diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts index db421086..ab8dabe9 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts @@ -18,8 +18,14 @@ * (구 _tab_basic_defaults.json 은 더 이상 사용하지 않음 — 삭제됨) */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, it, expect } from 'vitest'; +import tabReportPolicy from '../../../layouts/admin/partials/admin_board_settings/_tab_report_policy.json'; +import tabSpamSecurity from '../../../layouts/admin/partials/admin_board_settings/_tab_spam_security.json'; import tabList from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_list.json'; import tabPost from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json'; import tabReply from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json'; @@ -88,3 +94,148 @@ describe('게시판 환경설정 — 범위 힌트 동적 바인딩 가드 (#413 }, ); }); + +/** + * `config/board.php` 의 `limits` 블록을 키 → 값으로 읽는다. + * + * @returns 한계값 키 맵 + */ +function configuredLimits(): Record { + const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + const source = fs.readFileSync(path.join(moduleRoot, 'config/board.php'), 'utf-8'); + const block = /'limits'\s*=>\s*\[([\s\S]*?)\n\s*\],/.exec(source); + + expect(block, "config/board.php 에서 'limits' 블록을 찾지 못했습니다.").not.toBeNull(); + + const limits: Record = {}; + + for (const line of block![1].split('\n')) { + const matched = /'([a-z0-9_]+)'\s*=>\s*(-?[\d.]+)\s*,/.exec(line); + if (matched) { + limits[matched[1]] = Number(matched[2]); + } + } + + return limits; +} + +/** + * 레이아웃에서 해당 필드의 min/max prop 표현식을 찾는다. + * + * @param layout 레이아웃 트리 + * @param field 필드 name (예: report_policy.auto_hide_threshold) + * @returns min/max 표현식 + */ +function boundExpressions(layout: unknown, field: string): { min?: string; max?: string } { + let found: { min?: string; max?: string } = {}; + + const visit = (node: any) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(visit); + + return; + } + if (node.props?.name === field) { + found = { min: node.props.min, max: node.props.max }; + } + Object.values(node).forEach(visit); + }; + + visit(layout); + + return found; +} + +/** + * 신고 정책 · 스팸 보안 탭의 숫자 필드 — [필드 name, partial, limits 키 prefix]. + * + * 이 탭들은 위 목록과 달리 description 범위 힌트를 쓰지 않으므로 Input 경계만 검사한다. + */ +const policyFields: Array<[string, unknown, string]> = [ + ['report_policy.auto_hide_threshold', tabReportPolicy, 'auto_hide_threshold'], + ['report_policy.daily_report_limit', tabReportPolicy, 'daily_report_limit'], + ['report_policy.rejection_limit_count', tabReportPolicy, 'rejection_limit_count'], + ['report_policy.rejection_limit_days', tabReportPolicy, 'rejection_limit_days'], + ['spam_security.report_cooldown_seconds', tabReportPolicy, 'report_cooldown_seconds'], + ['spam_security.post_cooldown_seconds', tabSpamSecurity, 'post_cooldown_seconds'], + ['spam_security.comment_cooldown_seconds', tabSpamSecurity, 'comment_cooldown_seconds'], + ['spam_security.view_count_cache_ttl', tabSpamSecurity, 'view_count_cache_ttl'], +]; + +describe('게시판 환경설정 — 신고 정책 · 스팸 보안 탭 경계값 (#493 B4)', () => { + const limits = configuredLimits(); + + it.each(policyFields)('%s — min/max 가 한계값 설정을 읽는다', (field, layout, limitsKey) => { + const bounds = boundExpressions(layout, field); + + for (const bound of ['min', 'max'] as const) { + expect(bounds[bound], `${field} 에 ${bound} 이 없습니다.`).toBeDefined(); + expect(String(bounds[bound])).toContain(`limits?.${limitsKey}_${bound}`); + } + }); + + it.each(policyFields)('%s — 폴백이 설정 파일의 한계값과 같다', (field, layout, limitsKey) => { + const bounds = boundExpressions(layout, field); + + for (const bound of ['min', 'max'] as const) { + const matched = /\?\?\s*(-?[\d.]+)\s*\}\}/.exec(String(bounds[bound])); + + expect(matched, `${field} 의 ${bound} 폴백을 찾지 못했습니다.`).not.toBeNull(); + expect(Number(matched![1]), `${field} 의 ${bound} 폴백이 설정 파일 값과 다릅니다.`).toBe( + limits[`${limitsKey}_${bound}`], + ); + } + }); + + it('자동 숨김 기준은 0(비활성)을 입력할 수 있다', () => { + // 화면이 min=1 이면 "0=비활성" 안내와 어긋나 비활성으로 되돌릴 방법이 없어진다. + expect(limits.auto_hide_threshold_min).toBe(0); + expect(boundExpressions(tabReportPolicy, 'report_policy.auto_hide_threshold').min).toContain('?? 0'); + }); +}); + +/** + * 서버 저장 규칙(`StoreBoardSettingsRequest`) 원문. + */ +function settingsRequestSource(): string { + const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + + return fs.readFileSync( + path.join(moduleRoot, 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'), + 'utf-8', + ); +} + +describe('게시판 환경설정 — 서버 저장 규칙 정합 (#493 B4)', () => { + const limits = configuredLimits(); + const requestSource = settingsRequestSource(); + + /** + * 이 화면에는 네이티브 `
` 이 없다. 저장은 `apiCall` 이 `_local.form` 을 직접 실어 + * 보내므로 Input 의 `min`/`max` 속성은 브라우저 제출을 막지 못한다 — 안내일 뿐이다. + * 그래서 실제 관문은 서버 규칙 하나뿐이고, 화면과 규칙이 어긋나면 화면이 거부하는 + * 것처럼 보이는 값이 200 으로 저장된다(B4 의 최초 증상). + * + * 세 지점(설정 파일 · 서버 규칙 · 화면 바인딩)이 같은 키·같은 폴백을 가리키는지 대조한다. + */ + it.each(policyFields)('%s — 서버 규칙이 화면과 같은 한계값 키를 읽는다', (field, layout, limitsKey) => { + const rule = new RegExp(`'${field.replace('.', '\\.')}'\\s*=>\\s*\\[([^\\]]*)\\]`).exec(requestSource); + + expect(rule, `${field} 의 저장 규칙을 찾지 못했습니다.`).not.toBeNull(); + + for (const bound of ['min', 'max'] as const) { + // 화면이 읽는 키와 규칙이 읽는 키가 같아야 한다 + expect(String(boundExpressions(layout, field)[bound])).toContain(`limits?.${limitsKey}_${bound}`); + expect(rule![1], `${field} 의 ${bound} 규칙이 한계값 설정을 읽지 않습니다.`) + .toContain(`'${limitsKey}_${bound}'`); + + // 규칙의 폴백도 설정 파일 값과 같아야 한다 (설정이 비었을 때 양쪽이 갈라지지 않도록) + const fallback = new RegExp(`'${limitsKey}_${bound}',\\s*(-?[\\d.]+)\\)`).exec(rule![1]); + + expect(fallback, `${field} 의 ${bound} 규칙 폴백을 찾지 못했습니다.`).not.toBeNull(); + expect(Number(fallback![1]), `${field} 의 ${bound} 규칙 폴백이 설정 파일 값과 다릅니다.`) + .toBe(limits[`${limitsKey}_${bound}`]); + } + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json index 04ec936f..cf56f792 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json @@ -578,8 +578,8 @@ "props": { "type": "number", "name": "basic_defaults.new_display_hours", - "min": 1, - "max": 720, + "min": "{{_local?.form?._meta?.limits?.new_display_hours_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.new_display_hours_max ?? 720}}", "className": "{{(_local.errors?.['basic_defaults.new_display_hours']) ? 'w-32 input-error' : 'w-32'}}", "disabled": "{{_computed.isReadOnly}}" } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json index 093f4ac2..dd358a2c 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json @@ -55,8 +55,7 @@ "id": "field_auto_hide_threshold", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -78,8 +77,8 @@ "props": { "type": "number", "name": "report_policy.auto_hide_threshold", - "min": 1, - "max": 100, + "min": "{{_local?.form?._meta?.limits?.auto_hide_threshold_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.auto_hide_threshold_max ?? 100}}", "className": "{{(_local.errors?.['report_policy.auto_hide_threshold']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -107,8 +106,7 @@ "id": "field_auto_hide_target", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -197,8 +195,7 @@ "id": "field_daily_report_limit", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -220,8 +217,8 @@ "props": { "type": "number", "name": "report_policy.daily_report_limit", - "min": 0, - "max": 100, + "min": "{{_local?.form?._meta?.limits?.daily_report_limit_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.daily_report_limit_max ?? 100}}", "className": "{{(_local.errors?.['report_policy.daily_report_limit']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -249,8 +246,7 @@ "id": "field_rejection_limit", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -295,8 +291,8 @@ "props": { "type": "number", "name": "report_policy.rejection_limit_count", - "min": 0, - "max": 50, + "min": "{{_local?.form?._meta?.limits?.rejection_limit_count_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.rejection_limit_count_max ?? 50}}", "className": "{{(_local.errors?.['report_policy.rejection_limit_count']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -336,8 +332,8 @@ "props": { "type": "number", "name": "report_policy.rejection_limit_days", - "min": 1, - "max": 365, + "min": "{{_local?.form?._meta?.limits?.rejection_limit_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.rejection_limit_days_max ?? 365}}", "className": "{{(_local.errors?.['report_policy.rejection_limit_days']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -396,8 +392,8 @@ "props": { "type": "number", "name": "spam_security.report_cooldown_seconds", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.report_cooldown_seconds_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.report_cooldown_seconds_max ?? 3600}}", "className": "{{(_local.errors?.['spam_security.report_cooldown_seconds']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -420,8 +416,7 @@ "text": "$t:sirsoft-board.admin.settings.fields.report_cooldown_seconds_hint" } ], - "props": { - } + "props": {} } ], "props": { @@ -468,8 +463,7 @@ "id": "field_report_view_roles", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -553,8 +547,7 @@ "text": "{{(Array.isArray(_local.errors?.['report_permissions.manage_roles']) ? _local.errors['report_permissions.manage_roles'][0] : _local.errors?.['report_permissions.manage_roles']) ?? ''}}" } ], - "props": { - } + "props": {} } ], "props": { @@ -707,7 +700,9 @@ "id": "report_received_admin_template_status", "type": "basic", "name": "Div", - "props": { "className": "space-y-2" }, + "props": { + "className": "space-y-2" + }, "children": [ { "type": "basic", @@ -717,33 +712,70 @@ "item_var": "tpl", "index_var": "tplIndex" }, - "props": { "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" }, + "props": { + "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-2 min-w-0" }, + "props": { + "className": "flex items-center gap-2 min-w-0" + }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", "size": "sm", "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" } }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", + "size": "sm", + "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" + } + }, { "type": "basic", "name": "Div", - "props": { "className": "min-w-0" }, + "props": { + "className": "min-w-0" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" }, "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" }, - { "type": "basic", "name": "Span", "props": { "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" }, "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" + }, + "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" + } ] } ] }, - { "type": "basic", "name": "Span", "props": { "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" }, "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" + }, + "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" + } ] }, { "type": "basic", "name": "Div", "if": "{{(((boardNotificationDefinitions?.data?.data ?? []).find(d => d.type === 'report_received_admin')?.templates) ?? []).length === 0}}", - "props": { "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" }, + "props": { + "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" + }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_empty" } ] @@ -751,29 +783,66 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-between gap-2 flex-wrap mt-1" }, + "props": { + "className": "flex items-center justify-between gap-2 flex-wrap mt-1" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500" + }, + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" + }, { "id": "go_to_notification_settings_admin", "type": "basic", "name": "Button", - "props": { "type": "button", "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" }, + "props": { + "type": "button", + "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" + }, "actions": [ { "type": "click", "handler": "sequence", "params": { "actions": [ - { "handler": "setState", "params": { "target": "global", "activeBoardSettingsTab": "notification_definitions" } }, - { "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } } + { + "handler": "setState", + "params": { + "target": "global", + "activeBoardSettingsTab": "notification_definitions" + } + }, + { + "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", + "handler": "replaceUrl", + "params": { + "query": { + "tab": "notification_definitions" + } + } + } ] } } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "arrow-up-right-from-square", + "size": "sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" + } ] } ] @@ -856,7 +925,9 @@ "id": "report_action_template_status", "type": "basic", "name": "Div", - "props": { "className": "space-y-2" }, + "props": { + "className": "space-y-2" + }, "children": [ { "type": "basic", @@ -866,33 +937,70 @@ "item_var": "tpl", "index_var": "tplIndex" }, - "props": { "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" }, + "props": { + "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-2 min-w-0" }, + "props": { + "className": "flex items-center gap-2 min-w-0" + }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", "size": "sm", "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" } }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", + "size": "sm", + "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" + } + }, { "type": "basic", "name": "Div", - "props": { "className": "min-w-0" }, + "props": { + "className": "min-w-0" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" }, "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" }, - { "type": "basic", "name": "Span", "props": { "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" }, "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" + }, + "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" + } ] } ] }, - { "type": "basic", "name": "Span", "props": { "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" }, "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" + }, + "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" + } ] }, { "type": "basic", "name": "Div", "if": "{{(((boardNotificationDefinitions?.data?.data ?? []).find(d => d.type === 'report_action')?.templates) ?? []).length === 0}}", - "props": { "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" }, + "props": { + "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" + }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_empty" } ] @@ -900,29 +1008,66 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-between gap-2 flex-wrap mt-1" }, + "props": { + "className": "flex items-center justify-between gap-2 flex-wrap mt-1" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500" + }, + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" + }, { "id": "go_to_notification_settings_author", "type": "basic", "name": "Button", - "props": { "type": "button", "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" }, + "props": { + "type": "button", + "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" + }, "actions": [ { "type": "click", "handler": "sequence", "params": { "actions": [ - { "handler": "setState", "params": { "target": "global", "activeBoardSettingsTab": "notification_definitions" } }, - { "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } } + { + "handler": "setState", + "params": { + "target": "global", + "activeBoardSettingsTab": "notification_definitions" + } + }, + { + "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", + "handler": "replaceUrl", + "params": { + "query": { + "tab": "notification_definitions" + } + } + } ] } } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "arrow-up-right-from-square", + "size": "sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" + } ] } ] @@ -938,8 +1083,7 @@ ] } ], - "props": { - } + "props": {} } ] } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json index 26a3c346..3f749d81 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json @@ -47,8 +47,7 @@ "id": "field_post_cooldown_seconds", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -70,8 +69,8 @@ "props": { "type": "number", "name": "spam_security.post_cooldown_seconds", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.post_cooldown_seconds_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.post_cooldown_seconds_max ?? 3600}}", "className": "{{(_local.errors?.['spam_security.post_cooldown_seconds']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -99,8 +98,7 @@ "id": "field_comment_cooldown_seconds", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -122,8 +120,8 @@ "props": { "type": "number", "name": "spam_security.comment_cooldown_seconds", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.comment_cooldown_seconds_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.comment_cooldown_seconds_max ?? 3600}}", "className": "{{(_local.errors?.['spam_security.comment_cooldown_seconds']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -176,8 +174,8 @@ "props": { "type": "number", "name": "spam_security.view_count_cache_ttl", - "min": 60, - "max": 604800, + "min": "{{_local?.form?._meta?.limits?.view_count_cache_ttl_min ?? 60}}", + "max": "{{_local?.form?._meta?.limits?.view_count_cache_ttl_max ?? 604800}}", "className": "{{(_local.errors?.['spam_security.view_count_cache_ttl']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -200,8 +198,7 @@ "text": "$t:sirsoft-board.admin.settings.fields.view_count_cache_ttl_hint" } ], - "props": { - } + "props": {} } ], "props": { @@ -379,8 +376,7 @@ ] } ], - "props": { - } + "props": {} } ] } diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php new file mode 100644 index 00000000..fea8598d --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php @@ -0,0 +1,64 @@ + $limit, + 'attempted' => $attempted, + ])); + } + + /** + * 허용 최대 개수를 반환합니다. + * + * @return int 최대 첨부 개수 + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * 시도한 총 개수를 반환합니다. + * + * @return int 연결 시도 개수 + */ + public function getAttempted(): int + { + return $this->attempted; + } + + /** + * 컨트롤러 catch 와 동일한 422 응답으로 렌더링합니다. + * + * @return JsonResponse 상한 초과 응답 + */ + public function render(): JsonResponse + { + return ResponseHelper::error($this->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php new file mode 100644 index 00000000..2f2c4046 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php @@ -0,0 +1,51 @@ + $limit, + ])); + } + + /** + * 허용 최대 깊이를 반환합니다. + * + * @return int 최대 깊이 + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * 시도한 깊이를 반환합니다. + * + * @return int 시도 깊이 + */ + public function getAttempted(): int + { + return $this->attempted; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php new file mode 100644 index 00000000..bce1a68f --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php @@ -0,0 +1,59 @@ +reason; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php index 4559089e..db7a56da 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Http\Requests\ReorderAttachmentsRequest; use Modules\Sirsoft\Board\Http\Requests\UploadAttachmentRequest; @@ -81,6 +82,9 @@ class AttachmentController extends AdminBaseController ], 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (BoardNotFoundException $e) { return $this->error('sirsoft-board::messages.boards.not_found', 404); } catch (\Exception $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php index d62978f1..20943d3b 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; use Modules\Sirsoft\Board\Http\Requests\BlindCommentRequest; use Modules\Sirsoft\Board\Http\Requests\RestoreCommentRequest; use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest; @@ -110,6 +112,12 @@ class CommentController extends AdminBaseController ); } catch (ModelNotFoundException) { return $this->notFound('sirsoft-board::messages.boards.not_found'); + } catch (CommentDepthExceededException $e) { + // 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422 + return $this->error($e->getMessage(), 422); + } catch (PostNotCommentableException $e) { + // 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달 + return $this->error($e->getMessage(), 422); } catch (\Exception $e) { return $this->error('sirsoft-board::messages.comment.create_failed', 500, $e->getMessage()); } diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php index dae59479..0e71eb38 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php @@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; use Modules\Sirsoft\Board\Http\Requests\BlindPostRequest; @@ -193,6 +194,9 @@ class PostController extends AdminBaseController new PostResource($post), 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (\Exception $e) { return $this->error('sirsoft-board::messages.posts.create_failed', 500, $e->getMessage()); } @@ -245,6 +249,9 @@ class PostController extends AdminBaseController 'sirsoft-board::messages.posts.update_success', new PostResource($post) ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (ModelNotFoundException $e) { throw new PostNotFoundException($id); } catch (AccessDeniedHttpException $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php index 4048d2f5..f51568c6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Base\PublicBaseController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Http\Requests\ReorderAttachmentsRequest; use Modules\Sirsoft\Board\Http\Requests\UploadAttachmentRequest; @@ -175,6 +176,9 @@ class AttachmentController extends PublicBaseController ], 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (BoardNotFoundException $e) { return $this->notFound(__('sirsoft-board::messages.boards.not_found')); } catch (\Exception $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php index 3ee6d65e..bcd13397 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php @@ -8,6 +8,8 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest; use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest; use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest; @@ -114,6 +116,12 @@ class CommentController extends PublicBaseController ); } catch (ModelNotFoundException) { return $this->error('sirsoft-board::messages.boards.not_found', 404); + } catch (CommentDepthExceededException $e) { + // 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422 + return $this->error($e->getMessage(), 422); + } catch (PostNotCommentableException $e) { + // 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달 + return $this->error($e->getMessage(), 422); } catch (\Exception $e) { return $this->error('sirsoft-board::messages.comment.create_failed', 500); } diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php index 24a06376..2581a388 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Enums\SecretMode; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; use Modules\Sirsoft\Board\Http\Requests\User\StorePostRequest; @@ -320,7 +321,11 @@ class PostController extends PublicBaseController // secret_mode='enabled'인 경우 사용자 선택에 따름 (별도 처리 불필요) // 게시글 생성 - $post = $this->postService->createPost($slug, $data); + // `files[]` 를 Service 로 넘기지 않으면 검증·권한 확인은 통과하고 첨부만 조용히 + // 사라진다 (201 + 첨부 0 건). 위에서 이미 `hasFile('files')` 로 업로드 권한을 + // 확인하고 있으므로 이 경로가 파일을 받는다는 의도는 명확하다. + $files = $request->file('files'); + $post = $this->postService->createPost($slug, $data, is_array($files) ? $files : []); // 쿨다운 캐시 기록 (게시글 생성 성공 후) $spamSecurity = g7_module_settings('sirsoft-board', 'spam_security', []); @@ -338,6 +343,9 @@ class PostController extends PublicBaseController new PostResource($post), 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (BoardNotFoundException $e) { throw $e; } catch (\Exception $e) { @@ -383,6 +391,9 @@ class PostController extends PublicBaseController 'sirsoft-board::messages.posts.update_success', new PostResource($post) ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (AccessDeniedHttpException $e) { return $this->error('auth.scope_denied', 403); } catch (ModelNotFoundException $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php index 85f90f45..15f81a67 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php @@ -211,11 +211,11 @@ class StoreBoardSettingsRequest extends FormRequest // report_policy (신고 정책) 카테고리 // ======================================== 'report_policy' => ['sometimes', 'array'], - 'report_policy.auto_hide_threshold' => ['nullable', 'integer', 'min:0', 'max:100'], + 'report_policy.auto_hide_threshold' => ['nullable', 'integer', 'min:'.$this->boardLimit('auto_hide_threshold_min', 0), 'max:'.$this->boardLimit('auto_hide_threshold_max', 100)], 'report_policy.auto_hide_target' => ['nullable', 'string', 'in:post,comment,both'], - 'report_policy.daily_report_limit' => ['nullable', 'integer', 'min:0', 'max:100'], - 'report_policy.rejection_limit_count' => ['nullable', 'integer', 'min:0', 'max:50'], - 'report_policy.rejection_limit_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'report_policy.daily_report_limit' => ['nullable', 'integer', 'min:'.$this->boardLimit('daily_report_limit_min', 0), 'max:'.$this->boardLimit('daily_report_limit_max', 100)], + 'report_policy.rejection_limit_count' => ['nullable', 'integer', 'min:'.$this->boardLimit('rejection_limit_count_min', 0), 'max:'.$this->boardLimit('rejection_limit_count_max', 50)], + 'report_policy.rejection_limit_days' => ['nullable', 'integer', 'min:'.$this->boardLimit('rejection_limit_days_min', 1), 'max:'.$this->boardLimit('rejection_limit_days_max', 365)], 'report_policy.notify_admin_on_report' => ['nullable', 'boolean'], 'report_policy.notify_admin_on_report_scope' => ['nullable', 'string', 'in:per_case,per_report'], 'report_policy.notify_admin_on_report_channels' => ['nullable', 'array'], @@ -244,10 +244,10 @@ class StoreBoardSettingsRequest extends FormRequest // spam_security (스팸/보안) 카테고리 // ======================================== 'spam_security' => ['sometimes', 'array'], - 'spam_security.post_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'], - 'spam_security.comment_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'], - 'spam_security.report_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'], - 'spam_security.view_count_cache_ttl' => ['nullable', 'integer', 'min:60', 'max:604800'], + 'spam_security.post_cooldown_seconds' => ['nullable', 'integer', 'min:'.$this->boardLimit('post_cooldown_seconds_min', 0), 'max:'.$this->boardLimit('post_cooldown_seconds_max', 3600)], + 'spam_security.comment_cooldown_seconds' => ['nullable', 'integer', 'min:'.$this->boardLimit('comment_cooldown_seconds_min', 0), 'max:'.$this->boardLimit('comment_cooldown_seconds_max', 3600)], + 'spam_security.report_cooldown_seconds' => ['nullable', 'integer', 'min:'.$this->boardLimit('report_cooldown_seconds_min', 0), 'max:'.$this->boardLimit('report_cooldown_seconds_max', 3600)], + 'spam_security.view_count_cache_ttl' => ['nullable', 'integer', 'min:'.$this->boardLimit('view_count_cache_ttl_min', 60), 'max:'.$this->boardLimit('view_count_cache_ttl_max', 604800)], // ======================================== // seo (SEO 설정) 카테고리 diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php index d732f2a6..8323a609 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php @@ -47,6 +47,24 @@ trait ReadsBoardLimits 'max_comment_depth_min' => 0, 'max_comment_depth_max' => 10, + 'auto_hide_threshold_min' => 0, + 'auto_hide_threshold_max' => 100, + 'daily_report_limit_min' => 0, + 'daily_report_limit_max' => 100, + 'rejection_limit_count_min' => 0, + 'rejection_limit_count_max' => 50, + 'rejection_limit_days_min' => 1, + 'rejection_limit_days_max' => 365, + + 'post_cooldown_seconds_min' => 0, + 'post_cooldown_seconds_max' => 3600, + 'comment_cooldown_seconds_min' => 0, + 'comment_cooldown_seconds_max' => 3600, + 'report_cooldown_seconds_min' => 0, + 'report_cooldown_seconds_max' => 3600, + 'view_count_cache_ttl_min' => 60, + 'view_count_cache_ttl_max' => 604800, + 'new_display_hours_min' => 0, 'new_display_hours_max' => 720, ]; @@ -72,10 +90,11 @@ trait ReadsBoardLimits * 특정 제한값을 반환합니다. * * @param string $key 제한값 키 (예: per_page_min) + * @param int|null $default 키가 없을 때 쓸 값 (미지정 시 0) * @return int 제한값 */ - protected function boardLimit(string $key): int + protected function boardLimit(string $key, ?int $default = null): int { - return (int) ($this->boardLimits()[$key] ?? 0); + return (int) ($this->boardLimits()[$key] ?? $default ?? 0); } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ValidatesAttachmentCount.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ValidatesAttachmentCount.php new file mode 100644 index 00000000..f7167059 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ValidatesAttachmentCount.php @@ -0,0 +1,120 @@ +max_file_count ?? 0); + if ($limit <= 0) { + return; + } + + $files = $this->file('files'); + $newCount = is_array($files) ? count($files) : 0; + + $attachmentIds = $this->input('attachment_ids'); + $newCount += is_array($attachmentIds) ? count($attachmentIds) : 0; + + $tempKey = $this->input('temp_key'); + if (is_string($tempKey) && $tempKey !== '') { + $newCount += Attachment::query() + ->where('temp_key', $tempKey) + ->whereNull('post_id') + ->count(); + } + + if ($newCount <= 0) { + return; + } + + $existing = $postId !== null + ? Attachment::query()->where('board_id', $board->id)->where('post_id', $postId)->count() + : 0; + + if ($existing + $newCount > $limit) { + $validator->errors()->add('files', __('sirsoft-board::messages.errors.attachment_limit_exceeded', [ + 'limit' => $limit, + 'attempted' => $existing + $newCount, + ])); + } + } + + /** + * 단일 파일 업로드 1건이 게시판 상한을 넘으면 도메인 예외로 선차단합니다. + * + * 합산 기준은 `AttachmentService::assertAttachmentCountWithin()` 과 동일합니다 — + * `post_id` 가 있으면 그 게시글의 기존 첨부, 없으면 `temp_key` 의 임시첨부를 기준으로 + * 삼습니다(둘을 더하지 않습니다). 두 계층의 판정이 갈리면 화면과 서버가 다른 답을 + * 내놓게 되므로 우선순위까지 같아야 합니다. + * + * 필드 검증 오류가 아니라 `AttachmentLimitExceededException` 을 던지는 이유는 응답 형태 + * 때문입니다 — 이 엔드포인트의 상한 초과 응답은 `errors.code = attachment_limit_exceeded` + * 로 공개 문서화되어 있고, 선차단이 다른 형태를 내놓으면 업로더가 두 응답을 분기해야 + * 합니다. 예외의 `render()` 가 컨트롤러 catch 와 같은 응답을 만듭니다. + * + * @param Board|null $board 대상 게시판 + * @param int|null $postId 대상 게시글 ID (임시 업로드면 null) + * @param string|null $tempKey 임시 업로드 키 + * + * @throws AttachmentLimitExceededException 상한 초과 시 + */ + protected function assertSingleAttachmentSlotAvailable( + ?Board $board, + ?int $postId = null, + ?string $tempKey = null + ): void { + if (! $board) { + return; + } + + $limit = (int) ($board->max_file_count ?? 0); + if ($limit <= 0) { + return; + } + + $existing = 0; + if ($postId !== null) { + $existing = Attachment::query() + ->where('board_id', $board->id) + ->where('post_id', $postId) + ->count(); + } elseif (is_string($tempKey) && $tempKey !== '') { + // 임시첨부는 아직 게시판에 귀속되기 전이라 board_id 로 좁히지 않는다 + // (`validateAttachmentTotal()` 의 temp_key 합산과 동일 기준). + $existing = Attachment::query() + ->where('temp_key', $tempKey) + ->whereNull('post_id') + ->count(); + } + + if ($existing + 1 > $limit) { + throw new AttachmentLimitExceededException($limit, $existing + 1); + } + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php index feaf4c7e..76e7d1b4 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php @@ -3,10 +3,12 @@ namespace Modules\Sirsoft\Board\Http\Requests; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -19,6 +21,7 @@ use Modules\Sirsoft\Board\Rules\ParentPostValidationRule; class StorePostRequest extends FormRequest { use ResolvesAllowedExtensions; + use ValidatesAttachmentCount; /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 @@ -166,4 +169,17 @@ class StorePostRequest extends FormRequest 'files.*' => __('sirsoft-board::validation.attributes.post.file'), ]; } + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $this->validateAttachmentTotal($validator, $board); + }); + } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php index 5b5f4abe..fab2e3d6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php @@ -3,9 +3,11 @@ namespace Modules\Sirsoft\Board\Http\Requests; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -14,8 +16,12 @@ use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; */ class UpdatePostRequest extends FormRequest { + use ValidatesAttachmentCount; + /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -107,4 +113,20 @@ class UpdatePostRequest extends FormRequest 'status' => __('sirsoft-board::validation.attributes.post.status'), ]; } -} \ No newline at end of file + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * 수정은 이미 연결된 첨부와 합산해 판정합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $postId = $this->route('id'); + $this->validateAttachmentTotal($validator, $board, is_numeric($postId) ? (int) $postId : null); + }); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php index 33486c94..551f87ad 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php @@ -3,16 +3,24 @@ namespace Modules\Sirsoft\Board\Http\Requests; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; /** * 게시판 첨부파일 업로드 요청 검증 + * + * 첨부 개수 상한은 게시글 작성 Request 들과 같이 여기서도 선차단한다 — 최종 불변조건은 + * `AttachmentService::assertAttachmentCountWithin()` 이 담당하지만(모든 경로가 지나는 + * SSoT), 요청 계층에서 막으면 다른 검증 오류와 같은 422 필드 오류 형태로 돌아가 업로더가 + * 응답을 분기하지 않아도 된다. 두 계층은 합산 기준·우선순위가 동일하므로 판정이 갈리지 않는다. */ class UploadAttachmentRequest extends FormRequest { use ResolvesAllowedExtensions; + use ValidatesAttachmentCount; /** * 요청 권한 확인 @@ -91,6 +99,30 @@ class UploadAttachmentRequest extends FormRequest ]; } + /** + * 첨부 개수 상한을 선차단합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + // 필드 규칙(용량·확장자)이 이미 걸린 요청에는 개수 판정을 덧붙이지 않는다 — + // 파일 자체가 거부될 요청까지 상한 초과로 보고하면 원인이 뒤바뀐다. + if ($validator->errors()->isNotEmpty()) { + return; + } + + $postId = $this->input('post_id'); + + $this->assertSingleAttachmentSlotAvailable( + Board::where('slug', $this->route('slug'))->first(), + $postId !== null && $postId !== '' ? (int) $postId : null, + $this->input('temp_key') + ); + }); + } + /** * 검증 전 데이터 준비 */ diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php index 9d75dc26..7c53f6a8 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php @@ -3,12 +3,14 @@ namespace Modules\Sirsoft\Board\Http\Requests\User; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -26,6 +28,7 @@ use Modules\Sirsoft\Board\Rules\ParentPostValidationRule; class StorePostRequest extends FormRequest { use ResolvesAllowedExtensions; + use ValidatesAttachmentCount; /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 @@ -213,4 +216,17 @@ class StorePostRequest extends FormRequest return $validated; } + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $this->validateAttachmentTotal($validator, $board); + }); + } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php index d2c209ec..ce75e6ec 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php @@ -3,9 +3,11 @@ namespace Modules\Sirsoft\Board\Http\Requests\User; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -19,8 +21,12 @@ use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; */ class UpdatePostRequest extends FormRequest { + use ValidatesAttachmentCount; + /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -117,4 +123,20 @@ class UpdatePostRequest extends FormRequest 'status' => __('sirsoft-board::validation.attributes.post.status'), ]; } + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * 수정은 이미 연결된 첨부와 합산해 판정합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $postId = $this->route('id'); + $this->validateAttachmentTotal($validator, $board, is_numeric($postId) ? (int) $postId : null); + }); + } } diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php index 582ba756..9028858c 100644 --- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php @@ -4,12 +4,14 @@ namespace Modules\Sirsoft\Board\Services; use App\Contracts\Extension\StorageInterface; use App\Extension\HookManager; +use App\Helpers\PermissionHelper; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use App\Helpers\PermissionHelper; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Models\Attachment; use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; @@ -35,6 +37,56 @@ class AttachmentService private StorageInterface $storage ) {} + /** + * 첨부 개수가 게시판 상한을 넘지 않는지 검증합니다 (도메인 불변조건). + * + * 총합은 **직접 업로드 + `attachment_ids` + `temp_key` 임시첨부 + 기존 첨부** 4소스이고 + * temp_key 쪽은 DB 조회가 필요하므로 FormRequest 만으로는 판정할 수 없습니다. + * 모든 연결 경로가 반드시 지나는 이 지점이 상한의 SSoT 입니다. + * (FormRequest 의 `files` max 규칙은 "글을 다 쓰고 저장 실패" 를 막는 UX 선차단이며 + * 역할이 다릅니다 — 둘 다 필요합니다.) + * + * @param string $slug 게시판 슬러그 + * @param int|null $postId 게시글 ID (신규 작성 중이면 null) + * @param int $additional 이번에 추가하려는 첨부 개수 + * @param string|null $tempKey 임시 업로드 키 (신규 작성 시 기존 임시첨부 합산용) + * + * @throws AttachmentLimitExceededException 상한 초과 시 + */ + public function assertAttachmentCountWithin( + string $slug, + ?int $postId, + int $additional, + ?string $tempKey = null + ): void { + if ($additional <= 0) { + return; + } + + $board = $this->boardRepository->findBySlug($slug); + if (! $board) { + throw new ModelNotFoundException(__('sirsoft-board::messages.errors.board_not_found')); + } + + $limit = (int) ($board->max_file_count ?? 0); + if ($limit <= 0) { + // 상한 미설정 게시판은 제한하지 않는다 (기존 동작 유지) + return; + } + + $existing = 0; + if ($postId !== null) { + $existing = $this->repository->getByPost($slug, $postId, 'attachments')->count(); + } elseif ($tempKey !== null) { + $existing = $this->repository->getByTempKey($slug, $tempKey, 'attachments')->count(); + } + + $total = $existing + $additional; + if ($total > $limit) { + throw new AttachmentLimitExceededException($limit, $total); + } + } + /** * 단일 파일 업로드 * @@ -55,6 +107,9 @@ class AttachmentService string $collection = 'attachments', ?string $tempKey = null ): Attachment { + // 개수 상한 최종 방어선 — 단일 업로드를 N 번 반복하는 경로도 여기서 막힌다 + $this->assertAttachmentCountWithin($slug, $postId, 1, $tempKey); + // Before 훅 HookManager::doAction('sirsoft-board.attachment.before_upload', $slug, $file, $postId); @@ -182,6 +237,11 @@ class AttachmentService } $tempAttachments = $this->repository->getByTempKey($slug, $tempKey); + + // 개수 상한 최종 방어선 — FormRequest 는 files[] 만 보므로 temp_key 경로가 그대로 뚫린다. + // 이미 연결된 첨부와 합산해 판정한다 (수정 시 기존 첨부 + 신규 임시첨부). + $this->assertAttachmentCountWithin($slug, $postId, $tempAttachments->count()); + $linkedCount = 0; foreach ($tempAttachments as $attachment) { @@ -230,7 +290,7 @@ class AttachmentService * @param string $slug 게시판 슬러그 * @param string $tempKey 임시 업로드 키 * @param string|null $collection 컬렉션 필터 - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ public function getTempAttachments(string $slug, string $tempKey, ?string $collection = null) { @@ -258,7 +318,6 @@ class AttachmentService * * @param string $slug 게시판 슬러그 * @param Attachment $attachment 첨부파일 모델 - * @return void * * @throws AccessDeniedHttpException 삭제글 첨부에 권한 없이 접근한 경우 */ @@ -402,7 +461,7 @@ class AttachmentService * @param string $slug 게시판 슬러그 * @param int $postId 게시글 ID * @param string|null $collection 컬렉션 필터 - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ public function getAttachments(string $slug, int $postId, ?string $collection = null) { diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php index aa9c358e..0c343050 100644 --- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Comment; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; @@ -208,18 +210,18 @@ class CommentService * @return bool 댓글 작성 가능 여부 * * @throws ModelNotFoundException 게시글을 찾을 수 없는 경우 - * @throws \Exception 블라인드/삭제된 게시글인 경우 + * @throws PostNotCommentableException 블라인드/삭제된 게시글인 경우 */ public function validatePostForComment(string $slug, int $postId): bool { $post = $this->postRepository->findOrFail($slug, $postId); if ($post->status === PostStatus::Blinded) { - throw new \Exception(__('sirsoft-board::messages.comment.post_blinded')); + throw PostNotCommentableException::blinded(); } if ($post->status === PostStatus::Deleted || $post->deleted_at) { - throw new \Exception(__('sirsoft-board::messages.comment.post_deleted')); + throw PostNotCommentableException::deleted(); } return true; @@ -257,8 +259,17 @@ class CommentService // 같은 게시글에 속한 부모 댓글만 인정 (교차 게시글 부모 차단) $parentComment = $this->commentRepository->find($slug, $data['parent_id'], (int) $data['post_id']); if ($parentComment) { - // 부모 댓글의 depth + 1 — 상한은 CommentValidationRule 이 게시판 설정으로 검증 $data['depth'] = ($parentComment->depth ?? 0) + 1; + + // 최종 불변조건 — `CommentValidationRule` 은 요청 단계 선차단이라 훅이나 + // Service 직접 호출 경로에는 걸리지 않는다. 클램프하지 않고 예외를 던진다: + // 요청한 위치와 다른 자리에 조용히 붙으면 사용자가 알 수 없다. + $board = $this->boardRepository->findBySlug($slug); + $maxDepth = (int) ($board->max_comment_depth ?? 0); + + if ($data['depth'] > $maxDepth) { + throw new CommentDepthExceededException($maxDepth, $data['depth']); + } } else { // 부모 댓글을 찾을 수 없으면 0으로 설정 $data['depth'] = 0; diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php index 3a09ce17..aaa4acac 100644 --- a/modules/_bundled/sirsoft-board/src/Services/PostService.php +++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php @@ -85,6 +85,7 @@ class PostService * @param string $slug 게시판 슬러그 * @param array $filters 필터 조건 * @param bool $withTrashed 삭제된 게시글 포함 여부 + * @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용) * @return int 일반 게시글 수 (답글, 공지 제외) * * @throws ModelNotFoundException 게시판을 찾을 수 없는 경우 @@ -145,6 +146,7 @@ class PostService * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용) * @return Post 게시글 모델 * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 @@ -393,8 +395,9 @@ class PostService * @param int $id 게시글 ID * @param string $reason 블라인드 사유 * @param string|null $triggerType 트리거 유형 (admin, report 등) + * @return Post 블라인드 처리된 게시글 모델 * - * @throws ModelNotFoundException + * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 */ public function blindPost(string $slug, int $id, string $reason, ?string $triggerType = null): Post { @@ -519,6 +522,7 @@ class PostService * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @param int|null $boardId 이미 검증된 게시판 ID (중복 조회 방지용) * @return Post 게시글 모델 (카운트 포함) * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 @@ -629,6 +633,7 @@ class PostService * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID * @param bool $canViewDeleted 삭제된 게시글 조회 가능 여부 + * @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용) * @return Post 상세 정보가 포함된 게시글 모델 * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 @@ -725,6 +730,9 @@ class PostService ): void { // 방안 A: 업로드 완료된 첨부파일 ID로 연결 (신규 방식) if (! empty($attachmentIds)) { + // 개수 상한 최종 방어선 (기존 연결분과 합산) — Request 는 files[] 만 세므로 여기서 다시 판정한다 + $this->attachmentService->assertAttachmentCountWithin($slug, $postId, count($attachmentIds)); + $linkedCount = $this->attachmentRepository->linkAttachmentsByIds($slug, $attachmentIds, $postId); if ($linkedCount > 0) { Log::info("{$context} 시 첨부파일 ID로 연결 완료", [ @@ -1154,6 +1162,7 @@ class PostService * * @param array $boardIds 검색 대상 게시판 ID 목록 * @param string $keyword 검색 키워드 + * @return int 일치하는 게시글 수 */ public function countAcrossBoards(array $boardIds, string $keyword): int { diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php index 7ea248e1..925196f5 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php @@ -220,6 +220,7 @@ return [ 'table_creation_failed' => 'Failed to create board table (:table): :error', 'category_in_use' => 'Category :category is in use by :count post(s).', 'board_not_found' => 'Board not found.', + 'attachment_limit_exceeded' => 'You can attach up to :limit file(s). (requested: :attempted)', 'post_not_found' => 'Post not found.', 'permission_denied' => 'Permission denied.', 'validation_failed' => 'Validation failed.', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php index 1492acb9..10041c1e 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php @@ -220,6 +220,7 @@ return [ 'table_creation_failed' => '게시판 테이블 생성에 실패했습니다 (:table): :error', 'category_in_use' => ':category 카테고리는 :count개의 게시글에서 사용 중입니다.', 'board_not_found' => '게시판을 찾을 수 없습니다.', + 'attachment_limit_exceeded' => '첨부파일은 최대 :limit개까지 등록할 수 있습니다. (요청: :attempted개)', 'post_not_found' => '게시글을 찾을 수 없습니다.', 'permission_denied' => '권한이 없습니다.', 'validation_failed' => '입력값이 올바르지 않습니다.', diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php index ad92f418..5e0504de 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php @@ -553,6 +553,88 @@ class BoardSettingsControllerTest extends ModuleTestCase ->assertJsonValidationErrors(['basic_defaults.per_page_mobile']); } + /** + * basic_defaults.max_file_count 는 config max_file_count_max(20)까지 허용 (#493 B2) + */ + public function test_settings_request_max_file_count_allows_config_max(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 20], + ]); + + $response->assertStatus(200); + } + + /** + * basic_defaults.max_file_count 가 config max(20) 초과 시 422 (#493 B2) + */ + public function test_settings_request_max_file_count_rejects_above_config_max(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 21], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['basic_defaults.max_file_count']); + } + + /** + * 제한값 설정을 바꾸면 저장 규칙이 따라오는지 확인 (#493 B2 — config 가 SSoT) + * + * 규칙이 리터럴로 되돌아가면 설정을 바꿔도 경계가 그대로라 이 테스트가 먼저 깨진다. + */ + public function test_settings_request_bounds_follow_config(): void + { + config(['sirsoft-board.limits.max_file_count_max' => 3]); + + $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 3], + ]) + ->assertStatus(200); + + $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 4], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['basic_defaults.max_file_count']); + } + + /** + * report_policy.auto_hide_threshold 는 0(자동 숨김 비활성)을 저장할 수 있다 (#493 B4) + * + * 화면 안내가 "0=비활성" 이므로 서버가 0 을 거부하면 비활성으로 되돌릴 방법이 없다. + */ + public function test_settings_request_auto_hide_threshold_allows_zero(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'report_policy' => ['auto_hide_threshold' => 0], + ]); + + $response->assertStatus(200); + + $content = json_decode(File::get($this->settingsStoragePath.'/report_policy.json'), true); + $this->assertSame(0, (int) $content['auto_hide_threshold']); + } + + /** + * report_policy.auto_hide_threshold 가 config max(100) 초과 시 422 (#493 B4) + */ + public function test_settings_request_auto_hide_threshold_rejects_above_config_max(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'report_policy' => ['auto_hide_threshold' => 101], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['report_policy.auto_hide_threshold']); + } + // ======================================== // clearCache (캐시 초기화) 테스트 // ======================================== diff --git a/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php b/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php new file mode 100644 index 00000000..6f50ce5b --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php @@ -0,0 +1,464 @@ + $slug, + 'name' => ['ko' => '첨부 상한 게시판', 'en' => 'Attachment Limit Board'], + 'is_active' => true, + 'use_file_upload' => true, + 'max_file_count' => 5, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'attachments.upload']); + + $this->memberUser = User::factory()->create(['email' => 'attachment-limit@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * 임시 첨부(temp_key) 를 N 개 만듭니다. + * + * @param string $tempKey 임시 업로드 키 + * @param int $count 생성 개수 + */ + private function seedTempAttachments(string $tempKey, int $count): void + { + for ($i = 0; $i < $count; $i++) { + Attachment::create([ + 'board_id' => 0, + 'post_id' => null, + 'temp_key' => $tempKey, + 'collection' => 'attachments', + 'original_filename' => "temp-{$i}.png", + 'stored_filename' => "temp-{$i}.png", + 'path' => "{$this->getTestBoardSlug()}/temp/{$tempKey}/temp-{$i}.png", + 'mime_type' => 'image/png', + 'size' => 100, + 'order' => $i, + ]); + } + } + + /** + * 게시글에 연결된 첨부를 N 개 만듭니다. + * + * @param int $postId 게시글 ID + * @param int $count 생성 개수 + */ + private function seedLinkedAttachments(int $postId, int $count): void + { + $board = $this->board; + + for ($i = 0; $i < $count; $i++) { + Attachment::create([ + 'board_id' => $board->id, + 'post_id' => $postId, + 'collection' => 'attachments', + 'original_filename' => "linked-{$i}.png", + 'stored_filename' => "linked-{$i}.png", + 'path' => "{$this->getTestBoardSlug()}/2026/07/27/linked-{$i}.png", + 'mime_type' => 'image/png', + 'size' => 100, + 'order' => $i, + ]); + } + } + + /** + * temp_key 경로로 상한을 초과하면 차단된다 (실측 재현된 우회 경로). + */ + public function test_temp_key_link_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(); + $tempKey = 'tk_'.str_repeat('a', 20); + $this->seedTempAttachments($tempKey, 8); + + $this->expectException(AttachmentLimitExceededException::class); + + app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + } + + /** + * 상한과 정확히 같은 개수는 통과한다 (경계). + */ + public function test_temp_key_link_at_limit_passes(): void + { + $postId = $this->createTestPost(); + $tempKey = 'tk_'.str_repeat('b', 20); + $this->seedTempAttachments($tempKey, 5); + + $linked = app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + + $this->assertSame(5, $linked); + } + + /** + * 기존 첨부와 합산해 상한을 넘으면 차단된다 (수정 경로). + */ + public function test_existing_plus_new_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(); + $this->seedLinkedAttachments($postId, 4); + + $tempKey = 'tk_'.str_repeat('c', 20); + $this->seedTempAttachments($tempKey, 2); + + $this->expectException(AttachmentLimitExceededException::class); + + app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + } + + /** + * 기존 4 + 신규 1 = 5 는 통과한다 (경계). + */ + public function test_existing_plus_new_at_limit_passes(): void + { + $postId = $this->createTestPost(); + $this->seedLinkedAttachments($postId, 4); + + $tempKey = 'tk_'.str_repeat('d', 20); + $this->seedTempAttachments($tempKey, 1); + + $linked = app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + + $this->assertSame(1, $linked); + } + + /** + * attachment_ids 경로도 합산 판정된다. + */ + public function test_attachment_ids_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(); + $this->seedLinkedAttachments($postId, 5); + + $this->expectException(AttachmentLimitExceededException::class); + + app(AttachmentService::class)->assertAttachmentCountWithin( + $this->getTestBoardSlug(), + $postId, + 1 + ); + } + + /** + * 관리자 수정 경로에서 상한 초과가 500 이 아닌 422 로 응답되는지 검증합니다. + * + * FormRequest 선차단은 UX 용이고 최종 불변조건은 Service 예외다. 그런데 관리자 수정 + * 컨트롤러의 catch 가 게시글 *조회* try 에 붙어 있어, 예외를 실제로 던지는 저장 try 는 + * 이 예외를 잡지 못하고 generic 500 으로 떨어졌다. 조회 mock 이 정상 반환하고 저장 mock 이 + * 예외를 던지는 구성으로 그 매핑만 격리해 검증한다. + */ + public function test_admin_update_maps_attachment_limit_to_422(): void + { + $slug = $this->getTestBoardSlug(); + $admin = $this->createAdminUser([ + "sirsoft-board.{$slug}.admin.posts.write", + "sirsoft-board.{$slug}.admin.manage", + ]); + + $postId = $this->createTestPost(['user_id' => $admin->id]); + $post = Post::find($postId); + + $mock = Mockery::mock(PostService::class); + $mock->shouldReceive('getPost')->andReturn($post); + $mock->shouldReceive('updatePost') + ->andThrow(new AttachmentLimitExceededException(5, 6)); + $this->app->instance(PostService::class, $mock); + + $this->actingAs($admin) + ->putJson("/api/modules/sirsoft-board/admin/board/{$slug}/posts/{$postId}", [ + 'title' => '상한 초과 수정 시도', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * 직접 업로드와 `attachment_ids` 를 섞어 보내도 합산 판정된다. + * + * 소스가 나뉘면 각 소스는 상한 이하인데 총합만 초과하는 조합이 만들어진다. 어느 한 소스만 + * 세는 구현은 이 조합을 그대로 통과시킨다. + */ + public function test_mixed_files_and_attachment_ids_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + // 기존 첨부 3 개 — 이 중 3 개를 attachment_ids 로 유지하면서 새 파일 3 개를 더 보낸다 + $this->seedLinkedAttachments($postId, 3); + $keepIds = Attachment::where('post_id', $postId)->pluck('id')->all(); + + $response = $this->actingAs($this->memberUser)->putJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}", + [ + 'title' => '혼합 소스 상한 초과', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'attachment_ids' => $keepIds, + 'files' => [ + UploadedFile::fake()->image('mix-1.png'), + UploadedFile::fake()->image('mix-2.png'), + UploadedFile::fake()->image('mix-3.png'), + ], + ] + ); + + $response->assertStatus(422); + $this->assertArrayHasKey('files', $response->json('errors') ?? []); + } + + /** + * 작성 시 직접 업로드가 상한과 정확히 같은 개수면 통과하고 실제로 저장된다 (경계). + * + * 합산 판정이 들어간 뒤로 `files[]` 경로는 초과 케이스만 고정돼 있었다. 판정식이 + * `>=` 로 어긋나거나 기존 첨부 0 건을 1 건으로 세면 상한 동수가 조용히 막히는데, + * 초과 케이스만 보는 테스트는 그 회귀를 통과시킨다. 저장 건수까지 단언해 "차단은 + * 안 했지만 첨부도 안 됐다" 는 절반짜리 통과를 배제한다. + */ + public function test_files_at_limit_on_create_passes_and_persists(): void + { + $response = $this->actingAs($this->memberUser, 'sanctum')->postJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts", + [ + 'title' => '상한 동수 첨부', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'files' => [ + UploadedFile::fake()->image('at-1.png'), + UploadedFile::fake()->image('at-2.png'), + UploadedFile::fake()->image('at-3.png'), + UploadedFile::fake()->image('at-4.png'), + UploadedFile::fake()->image('at-5.png'), + ], + ] + ); + + $response->assertStatus(201); + + $postId = (int) $response->json('data.id'); + $this->assertSame(5, Attachment::where('post_id', $postId)->count()); + } + + /** + * 작성 시 상한을 1 건 넘기면 차단된다 (위 경계의 대조군). + */ + public function test_files_over_limit_on_create_is_blocked(): void + { + $response = $this->actingAs($this->memberUser, 'sanctum')->postJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts", + [ + 'title' => '상한 초과 첨부', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'files' => [ + UploadedFile::fake()->image('over-1.png'), + UploadedFile::fake()->image('over-2.png'), + UploadedFile::fake()->image('over-3.png'), + UploadedFile::fake()->image('over-4.png'), + UploadedFile::fake()->image('over-5.png'), + UploadedFile::fake()->image('over-6.png'), + ], + ] + ); + + $response->assertStatus(422); + $this->assertArrayHasKey('files', $response->json('errors') ?? []); + } + + /** + * 단일 업로드를 반복해도 상한을 넘는 순간 차단된다. + * + * 한 번에 여러 개를 보내는 경로만 막으면, 한 개씩 N 번 부르는 경로로 그대로 우회된다. + */ + public function test_repeated_single_upload_is_blocked_at_the_limit(): void + { + $postId = $this->createTestPost(); + $service = app(AttachmentService::class); + + for ($i = 0; $i < 5; $i++) { + $service->upload( + $this->getTestBoardSlug(), + UploadedFile::fake()->image("single-{$i}.png"), + $postId + ); + } + + $this->assertSame(5, Attachment::where('post_id', $postId)->count()); + + $this->expectException(AttachmentLimitExceededException::class); + + $service->upload( + $this->getTestBoardSlug(), + UploadedFile::fake()->image('single-6.png'), + $postId + ); + } + + /** + * 단일 업로드 엔드포인트가 상한 초과를 422 로 돌려주는지 검증합니다. + * + * 이 엔드포인트는 게시글 작성 Request 들과 마찬가지로 요청 계층에서 선차단하고, + * `AttachmentService::upload()` 가 최종 불변조건으로 남는다(훅·Service 직접 호출 방어). + * + * 두 계층이 같은 응답을 내야 소비자가 분기하지 않는다 — 선차단은 필드 검증 오류가 아니라 + * `AttachmentLimitExceededException` 을 던지고, 그 `render()` 가 컨트롤러 catch 와 + * 동일한 `errors.code=attachment_limit_exceeded` 를 만든다. 이 테스트가 그 동일성을 고정한다. + */ + public function test_single_upload_endpoint_blocks_over_limit_with_422(): void + { + $slug = $this->getTestBoardSlug(); + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $this->seedLinkedAttachments($postId, 5); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('over-limit.png'), + 'post_id' => $postId, + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * 단일 업로드 상한 초과는 Service 에 닿기 전 요청 계층에서 차단된다. + * + * `AttachmentService` 를 통째로 mock 해 `upload()` 가 아예 호출되지 않음을 단언한다 — + * Service 가 최종 관문으로 남아 있어도 요청 계층이 먼저 막아야 선차단이라 부를 수 있다. + */ + public function test_single_upload_over_limit_is_blocked_before_reaching_service(): void + { + $slug = $this->getTestBoardSlug(); + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $this->seedLinkedAttachments($postId, 5); + + $service = Mockery::mock(AttachmentService::class); + $service->shouldNotReceive('upload'); + $this->app->instance(AttachmentService::class, $service); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('blocked-before-service.png'), + 'post_id' => $postId, + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * temp_key 임시 업로드도 상한을 넘는 순간 요청 계층에서 차단된다. + */ + public function test_single_upload_with_temp_key_over_limit_is_blocked(): void + { + $slug = $this->getTestBoardSlug(); + $tempKey = 'temp-single-upload-limit'; + + $this->seedTempAttachments($tempKey, 5); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('temp-over-limit.png'), + 'temp_key' => $tempKey, + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * 상한 이내 단일 업로드는 그대로 통과한다 (경계). + */ + public function test_single_upload_endpoint_allows_within_limit(): void + { + $slug = $this->getTestBoardSlug(); + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $this->seedLinkedAttachments($postId, 4); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('within-limit.png'), + 'post_id' => $postId, + ]) + ->assertSuccessful(); + } + + /** + * 상한 미설정(0) 게시판은 제한하지 않는다 (기존 동작 유지). + */ + public function test_board_without_limit_is_not_restricted(): void + { + $this->board->update(['max_file_count' => 0]); + + $postId = $this->createTestPost(); + $tempKey = 'tk_'.str_repeat('e', 20); + $this->seedTempAttachments($tempKey, 8); + + $linked = app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + + $this->assertSame(8, $linked); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php index cc85f128..27304a33 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php @@ -6,6 +6,8 @@ require_once __DIR__.'/../ModuleTestCase.php'; use App\Models\Role; use App\Models\User; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Services\CommentService; use Modules\Sirsoft\Board\Tests\BoardTestCase; /** @@ -159,6 +161,50 @@ class CommentDepthLimitTest extends BoardTestCase ]); } + /** + * max_comment_depth=0 게시판에서는 대댓글 자체가 불가능하다 + * + * 0 은 "답글 없음" 설정이다. 상한을 리터럴로 클램프하거나 0 을 무제한으로 오해하면 + * 이 게시판에서 대댓글이 그대로 달린다. + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_beyond_board_max_rejected_422, depth_follows_board_setting_not_literal + */ + public function test_reply_is_rejected_when_max_depth_is_zero(): void + { + $this->updateBoardSettings(['max_comment_depth' => 0]); + + $postId = $this->createTestPost(); + $parentId = $this->createTestComment($postId, ['depth' => 0]); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), [ + 'content' => '대댓글 불가 게시판에서의 답글 시도', + 'parent_id' => $parentId, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['parent_id']); + } + + /** + * max_comment_depth=0 이어도 최상위 댓글은 정상 작성된다 (과잉 차단 방지) + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_follows_board_setting_not_literal + */ + public function test_top_level_comment_is_allowed_when_max_depth_is_zero(): void + { + $this->updateBoardSettings(['max_comment_depth' => 0]); + + $postId = $this->createTestPost(); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '최상위 댓글']) + ->assertStatus(201); + } + /** * 댓글 생성 엔드포인트 URL 을 생성합니다. * @@ -169,4 +215,61 @@ class CommentDepthLimitTest extends BoardTestCase { return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments"; } + + /** + * FormRequest 를 거치지 않는 경로(훅 · Service 직접 호출)에서도 깊이 상한이 지켜진다 + * + * `CommentValidationRule` 은 요청 단계 선차단이라 Service 를 직접 부르는 확장 코드에는 + * 걸리지 않는다. 첨부 개수(B3)가 Service 를 최종 불변조건으로 삼은 것과 같은 이유로, + * 깊이 상한도 Service 가 마지막 관문이어야 한다. + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_beyond_board_max_rejected_422 + */ + public function test_service_direct_call_rejects_reply_beyond_max_depth(): void + { + $this->updateBoardSettings(['max_comment_depth' => 2]); + + $postId = $this->createTestPost(); + $depth0 = $this->createTestComment($postId, ['depth' => 0]); + $depth1 = $this->createTestComment($postId, ['parent_id' => $depth0, 'depth' => 1]); + $depth2 = $this->createTestComment($postId, ['parent_id' => $depth1, 'depth' => 2]); + + $service = app(CommentService::class); + + $this->expectException(CommentDepthExceededException::class); + + // depth 3 = 상한 2 초과 → Service 가 막아야 한다 + $service->createComment($this->board->slug, [ + 'post_id' => $postId, + 'parent_id' => $depth2, + 'content' => 'FormRequest 를 우회한 깊이 초과 답글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * 상한 이내의 Service 직접 호출은 그대로 통과한다 (과잉 차단 방지) + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_follows_board_setting_not_literal + */ + public function test_service_direct_call_allows_reply_within_max_depth(): void + { + $this->updateBoardSettings(['max_comment_depth' => 2]); + + $postId = $this->createTestPost(); + $depth0 = $this->createTestComment($postId, ['depth' => 0]); + + $comment = app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'parent_id' => $depth0, + 'content' => '상한 이내 답글', + 'user_id' => $this->memberUser->id, + ]); + + $this->assertSame(1, (int) $comment->depth); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CommentPostStateTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CommentPostStateTest.php new file mode 100644 index 00000000..e293d32a --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/CommentPostStateTest.php @@ -0,0 +1,197 @@ + $slug, + 'name' => ['ko' => '댓글 상태 게시판', 'en' => 'Comment State Board'], + 'is_active' => true, + 'use_comment' => true, + 'max_comment_depth' => 5, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + + $this->memberUser = User::factory()->create(['email' => 'comment-post-state@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * 요청 경로: 블라인드 게시글 댓글은 422 로 선차단된다 + * + * @scenario entity=board_comment, post_state=blinded + * + * @effects blinded_post_comment_rejected_422 + */ + public function test_request_path_rejects_comment_on_blinded_post(): void + { + $postId = $this->createTestPost(['status' => 'blinded']); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '블라인드 글에 댓글']) + ->assertStatus(422); + + $this->assertDatabaseMissing('board_comments', ['post_id' => $postId]); + } + + /** + * 요청 경로: 삭제된 게시글 댓글은 422 로 선차단된다 + * + * @scenario entity=board_comment, post_state=deleted + * + * @effects deleted_post_comment_rejected_422 + */ + public function test_request_path_rejects_comment_on_deleted_post(): void + { + $postId = $this->createTestPost(['status' => 'deleted']); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '삭제된 글에 댓글']) + ->assertStatus(422); + + $this->assertDatabaseMissing('board_comments', ['post_id' => $postId]); + } + + /** + * Service 직접 경로: 블라인드 게시글은 도메인 예외로 막힌다 + * + * 베이스 `\Exception` 이면 컨트롤러 제네릭 catch 에 흡수되어 사유가 사라진다. + * + * @scenario entity=board_comment, post_state=blinded + * + * @effects blinded_post_comment_rejected_422 + */ + public function test_service_direct_call_rejects_comment_on_blinded_post(): void + { + $postId = $this->createTestPost(['status' => 'blinded']); + + $this->expectException(PostNotCommentableException::class); + $this->expectExceptionMessage(__('sirsoft-board::messages.comment.post_blinded')); + + app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'FormRequest 를 우회한 블라인드 글 댓글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * Service 직접 경로: 삭제된 게시글은 도메인 예외로 막힌다 + * + * @scenario entity=board_comment, post_state=deleted + * + * @effects deleted_post_comment_rejected_422 + */ + public function test_service_direct_call_rejects_comment_on_deleted_post(): void + { + $postId = $this->createTestPost(['status' => 'deleted']); + + $this->expectException(PostNotCommentableException::class); + $this->expectExceptionMessage(__('sirsoft-board::messages.comment.post_deleted')); + + app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'FormRequest 를 우회한 삭제 글 댓글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * Service 직접 경로: soft-delete 된 게시글도 도메인 예외로 막힌다 + * + * @scenario entity=board_comment, post_state=soft_deleted + * + * @effects deleted_post_comment_rejected_422 + */ + public function test_service_direct_call_rejects_comment_on_soft_deleted_post(): void + { + $postId = $this->createTestPost(['deleted_at' => now()]); + + $this->expectException(PostNotCommentableException::class); + $this->expectExceptionMessage(__('sirsoft-board::messages.comment.post_deleted')); + + app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'FormRequest 를 우회한 soft-delete 글 댓글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * 정상 게시글의 댓글 작성은 요청·Service 양 경로에서 그대로 통과한다 (과잉 차단 방지) + * + * @scenario entity=board_comment, post_state=published + * + * @effects published_post_comment_allowed + */ + public function test_comment_on_published_post_still_passes(): void + { + $postId = $this->createTestPost(); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '정상 댓글']) + ->assertStatus(201); + + $comment = app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'Service 직접 호출 댓글', + 'user_id' => $this->memberUser->id, + ]); + + $this->assertNotNull($comment->id); + } + + /** + * 댓글 생성 엔드포인트 URL 을 생성합니다. + * + * @param int $postId 게시글 ID + * @return string 댓글 생성 URL + */ + private function storeUrl(int $postId): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php index fff2ade1..aeb0286d 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php @@ -6,8 +6,10 @@ require_once __DIR__.'/../../ModuleTestCase.php'; use App\Extension\UpgradeContext; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Tests\BoardTestCase; use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3; +use Psr\Log\LoggerInterface; /** * 1.0.3 댓글 계층 오염 데이터 정리 업그레이드 스텝 테스트 @@ -128,6 +130,124 @@ class CommentHierarchyCleanupTest extends BoardTestCase $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.'); } + /** + * 부모 행이 삭제된 고아 댓글은 값이 보존되고 경고 로그가 남는다 + * + * 정리 1단계는 부모와 INNER JOIN 하므로 부모 행 자체가 없는 댓글은 매칭되지 않고, + * 2단계 BFS 는 `parent_id IS NULL` 루트에서 출발하므로 이 댓글에 도달하지 못한다. + * 값을 임의로 고치거나 지우면 데이터 손실이므로 보존하되, 정정되지 않았다는 사실은 + * 경고로 남겨야 한다. + * + * @scenario entity=board_comment, parent_target=missing + * + * @effects dangling_parent_skipped_with_warning + */ + public function test_dangling_parent_is_skipped_with_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $parent = $this->createTestComment($post, ['depth' => 0]); + $dangling = $this->createTestComment($post, ['parent_id' => $parent, 'depth' => 3]); + + // 부모 행만 물리 삭제하여 dangling parent_id 상태를 만든다. + DB::table('board_comments')->where('id', $parent)->delete(); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + $this->assertDatabaseHas('board_comments', [ + 'id' => $dangling, + 'parent_id' => $parent, + 'depth' => 3, + ]); + + $logger->shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '부모 행이 없는 댓글') + && str_contains($message, (string) $dangling)) + ->once(); + } + + /** + * 루트에서 도달할 수 없는 순환 참조 댓글은 값이 보존되고 경고 로그가 남는다 + * + * 부모 행이 실재하므로 고아 판정(`부모 행이 없는 댓글`)에 걸리지 않고, + * BFS 는 루트에서만 출발하므로 순환 컴포넌트에 진입조차 못 해 세대 상한에도 + * 걸리지 않는다. 두 그물을 모두 빠져나가므로 전용 경고가 없으면 + * "정정되지 않았다" 는 사실이 조용히 묻힌다. + * + * @scenario entity=board_comment, parent_target=cycle + * + * @effects unreachable_cycle_skipped_with_warning + */ + public function test_unreachable_cycle_is_skipped_with_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $first = $this->createTestComment($post, ['depth' => 0]); + $second = $this->createTestComment($post, ['parent_id' => $first, 'depth' => 1]); + + // 첫 댓글의 부모를 두 번째로 돌려 루트 없는 순환(A→B→A)을 만든다. + DB::table('board_comments')->where('id', $first)->update([ + 'parent_id' => $second, + 'depth' => 4, + ]); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + // 값 보존 — 순환의 어느 지점을 끊을지는 운영자 판단이므로 건드리지 않는다. + $this->assertDatabaseHas('board_comments', [ + 'id' => $first, + 'parent_id' => $second, + 'depth' => 4, + ]); + $this->assertDatabaseHas('board_comments', [ + 'id' => $second, + 'parent_id' => $first, + 'depth' => 1, + ]); + + $logger->shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글') + && str_contains($message, (string) $first) + && str_contains($message, (string) $second)) + ->once(); + } + + /** + * 정합한 계층만 있으면 순환 경고가 발생하지 않는다 (과잉 경고 방지) + * + * @scenario entity=board_comment, parent_target=self + * + * @effects healthy_hierarchy_emits_no_cycle_warning + */ + public function test_healthy_hierarchy_emits_no_cycle_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $root = $this->createTestComment($post, ['depth' => 0]); + $this->createTestComment($post, ['parent_id' => $root, 'depth' => 1]); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + $logger->shouldNotHaveReceived('warning', [ + \Mockery::on(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글')), + ]); + } + /** * 1.0.3 정리 마이그레이션을 실행합니다. */ diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml new file mode 100644 index 00000000..9613dfd7 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml @@ -0,0 +1,49 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(첨부 개수 불변조건) +# 이며 PHPUnit Feature 테스트가 경로·경계 조합을 커버하고 green 이다. + +feature: 게시판 첨부파일 개수 상한 서버측 강제 (B3) + +description: | + 게시판 설정 `max_file_count` 를 모든 첨부 연결 경로에서 강제한다. + + 배경: + - `files[]` 직접 업로드만 FormRequest 가 막고 `attachment_ids` · `temp_key` · + 단일 업로드 반복 경로에는 개수 검사가 없었다. 같은 8개 파일을 `temp_key` 로 보내면 + 201 로 전부 연결되어 사용자 페이지에 그대로 노출됐다. + + 계층 결정: + - 총합은 직접 파일 + `attachment_ids` + `temp_key` 임시첨부 + 기존 첨부 4소스이고 + temp_key 쪽은 DB 조회가 필요하므로 FormRequest 만으로 판정 불가 + - `AttachmentService::assertAttachmentCountWithin()` 이 불변조건의 SSoT + - FormRequest 선차단은 "글 다 쓰고 저장 실패" 를 막는 UX 역할 (중복이지만 역할이 다름) + +axes: + link_path: [direct_files, attachment_ids, temp_key, single_upload_repeat] + existing_state: [none, below_limit, at_limit] + request_count: [under, exact, over] + board_limit: [configured, unlimited] + +exclusions: + - { board_limit: unlimited, request_count: over, reason: "상한 미설정 게시판은 제한하지 않음 (기존 동작)" } + +effects: + - temp_key_link_over_limit_blocked + - temp_key_link_at_limit_passes + - existing_plus_new_over_limit_blocked + - existing_plus_new_at_limit_passes + - attachment_ids_path_counted_in_total + - single_upload_repeat_blocked_at_limit + - unlimited_board_not_restricted + - limit_violation_returns_422_not_500 + - existing_over_limit_attachments_are_not_deleted + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: AttachmentLimitExceededException 메시지는 sirsoft-board::messages.errors.attachment_limit_exceeded + - rule: service-direct-data-access + coverage: AttachmentService 는 AttachmentRepositoryInterface / BoardRepositoryInterface 만 사용 diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml new file mode 100644 index 00000000..1fa816b8 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml @@ -0,0 +1,63 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(댓글 깊이 불변조건 + +# 업그레이드 데이터 정정)이며 PHPUnit Feature 테스트가 경로·경계 조합을 커버하고 green 이다. + +feature: 대댓글 깊이 상한 강제 + 포화 depth 데이터 정정 (B1) + +description: | + 게시판 설정 `max_comment_depth` 를 저장 시점에도 강제하고, 과거에 포화 저장된 depth 를 + 업그레이드에서 재계산한다. + + 배경: + - 검증(`CommentValidationRule`)은 부모의 *저장된* depth 로 판정하는데 저장 시 + `min(parent.depth + 1, 5)` 로 포화시켰다. 부모 depth 가 5 를 넘지 못하니 + `max_comment_depth=10` 게시판에서 판정식이 영원히 참이 되지 않아 **무한 중첩**이 가능했다. + - 실측: 20단 중첩이 전부 201 로 저장되고 depth 가 `0,1,2,3,4,5,5,5,…` 로 5 에서 포화. + + 계층 결정: + - 저장부의 리터럴 클램프를 제거하고 `부모 depth + 1` 을 그대로 쓴다 + (검증을 통과한 값이므로 클램프가 불필요). + - 방어적 상한은 두되 **초과 시 클램프가 아니라 예외** — 클램프가 이 버그의 원인이었다. + - 코드 수정만으로는 결함이 닫히지 않는다. 기존 스레드의 부모 depth 가 5 로 고정되어 있어 + 그 스레드에서는 계속 우회가 가능하므로 업그레이드 데이터 정정이 함께 필요하다. + + 데이터 정정 규율: + - 루트(`parent_id IS NULL`)부터 **레벨 단위 청크 UPDATE**(재귀 금지), 방문 집합으로 유한 종료 + - 재계산 결과가 상한을 넘어도 **값만 정정, 삭제 금지** + - 루트에서 도달하지 못한 행(고아 부모 / 순환 컴포넌트)은 값 보존 + 경고 로그 + +axes: + entity: [board_comment] + parent_target: [unrelated, foreign, missing, cycle, self] + board_max_depth: [zero, lowered, default] + request_depth: [under, exact, over] + entry_path: [http_user, http_admin, service_direct] + +exclusions: + - { board_max_depth: zero, request_depth: under, reason: "상한 0 은 대댓글 자체가 불가 — 하위 조합 없음" } + - { parent_target: missing, entry_path: http_user, reason: "고아 부모는 업그레이드 정정 축이며 등록 경로 조합이 아님" } + - { parent_target: cycle, entry_path: http_user, reason: "순환은 앱 경로로 생성 불가 — 오염 데이터 정정 축" } + +effects: + - depth_equals_parent_depth_plus_one + - depth_follows_board_setting_not_literal + - depth_beyond_board_max_rejected_422 + - existing_clamped_depth_recalculated + - existing_cross_post_parent_detached_content_preserved + - dangling_parent_skipped_with_warning + - unreachable_cycle_skipped_with_warning + - healthy_hierarchy_emits_no_cycle_warning + - cleanup_is_idempotent + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: CommentDepthExceededException 메시지는 sirsoft-board::validation.comment.depth.exceeded + - rule: service-config-value-clamp + coverage: CommentService 의 리터럴 클램프 제거 — 이 룰이 재유입을 검출한다 + - rule: upgrade-step-path-a + coverage: 정정 스텝은 Facades\DB/Schema 만 사용하고 로직을 private 헬퍼로 로컬 완결 diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php index 11d01d34..6885ccdb 100644 --- a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php @@ -17,7 +17,8 @@ use Illuminate\Support\Facades\Schema; * 콘텐츠는 건드리지 않으며 depth 컬럼만 갱신하므로 결정론적입니다. * * idempotent: 이미 정합한 행은 갱신 대상에서 제외되어 재실행해도 결과가 같습니다. - * 순환 참조가 남아 있어도 방문 ID 집합으로 유한 종료합니다. + * 순환 참조가 남아 있어도 방문 ID 집합으로 유한 종료하며, 루트에서 도달하지 못한 + * 행(고아 부모 / 순환 컴포넌트)은 값을 보존한 채 경고 로그로 남깁니다. * V-1 안전: Facades\DB/Schema 만 사용. */ class RecalculateCommentDepth implements DataMigration @@ -84,6 +85,9 @@ class RecalculateCommentDepth implements DataMigration )); } + $this->logOrphanParents($context); + $this->logUnreachableComments($context, $visited); + $context->logger->info(sprintf( '[board:1.0.3] depth 재계산 완료 — %d 건 갱신 (최대 세대 %d)', $updated, @@ -180,6 +184,94 @@ class RecalculateCommentDepth implements DataMigration return $updated; } + /** + * 부모 행이 존재하지 않는 고아 댓글을 경고 로그로 남깁니다. + * + * BFS 는 `parent_id IS NULL` 인 루트에서만 출발하므로, 부모가 이미 삭제된 댓글은 + * 어느 세대에도 도달하지 못해 depth 가 옛 값 그대로 남는다. 값을 임의로 고치거나 + * 행을 지우면 데이터 손실이므로 건드리지 않되, 정정되지 않았다는 사실은 남긴다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + private function logOrphanParents(UpgradeContext $context): void + { + $orphanIds = DB::table(self::TABLE.' as c') + ->whereNotNull('c.parent_id') + ->whereNotExists(function ($query) { + $query->select('p.id') + ->from(self::TABLE.' as p') + ->whereColumn('p.id', 'c.parent_id'); + }) + ->pluck('c.id') + ->all(); + + if ($orphanIds === []) { + return; + } + + $context->logger->warning(sprintf( + '[board:1.0.3] 부모 행이 없는 댓글 %d 건 — depth 재계산에서 스킵(값 보존). 대상 id: %s', + count($orphanIds), + json_encode(array_map('intval', $orphanIds), JSON_UNESCAPED_UNICODE) + )); + } + + /** + * 루트에서 도달하지 못한 댓글(순환 참조 컴포넌트)을 경고 로그로 남깁니다. + * + * 부모 행이 존재하는데도 BFS 가 방문하지 못했다면 그 부모 역시 도달 불가라는 뜻이고, + * 거슬러 올라가면 루트가 없는 순환(A→B→A)에 닿는다. 이런 행은 세대 상한에 걸리지도 + * (BFS 가 진입 자체를 못 함) 고아 판정에 걸리지도(부모 행은 실재함) 않아, + * 경고가 없으면 정정되지 않은 사실이 조용히 묻힌다. + * + * 값은 건드리지 않는다 — 순환의 어느 지점을 끊을지는 운영자 판단 영역이다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @param array $visited BFS 가 방문한 댓글 ID 집합 + */ + private function logUnreachableComments(UpgradeContext $context, array $visited): void + { + $cycleIds = []; + $lastId = 0; + + while (true) { + $ids = DB::table(self::TABLE.' as c') + ->whereNotNull('c.parent_id') + ->where('c.id', '>', $lastId) + ->whereExists(function ($query) { + $query->select('p.id') + ->from(self::TABLE.' as p') + ->whereColumn('p.id', 'c.parent_id'); + }) + ->orderBy('c.id') + ->limit(self::CHUNK) + ->pluck('c.id') + ->all(); + + if ($ids === []) { + break; + } + + foreach ($ids as $id) { + $lastId = (int) $id; + + if (! isset($visited[$lastId])) { + $cycleIds[] = $lastId; + } + } + } + + if ($cycleIds === []) { + return; + } + + $context->logger->warning(sprintf( + '[board:1.0.3] 루트에서 도달하지 못한 댓글 %d 건 — 순환 참조로 보이며 depth 재계산에서 스킵(값 보존). 대상 id: %s', + count($cycleIds), + json_encode($cycleIds, JSON_UNESCAPED_UNICODE) + )); + } + /** * 갱신 대상의 변경 전 상태를 로그로 남깁니다 (복구 근거). * diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 2e0b7590..2bf8c30a 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -6,6 +6,16 @@ ## [1.0.5] - 2026-07-16 +### Security + +- 관리자가 설정한 마일리지 사용 정책(최소 사용액·사용 단위·최대 사용 한도)이 주문서 작성과 주문 확정 양쪽에서 실제로 적용됩니다. 이전에는 보유 잔액만 확인해, 사용 한도를 30%로 제한해 두어도 결제금액 전액을 마일리지로 결제할 수 있었습니다. 한도를 넘으면 사용 가능한 최대 금액을 함께 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문에는 주문 시점의 마일리지 사용 정책이 함께 보관됩니다. 이후 관리자가 한도를 바꿔도 과거 주문이 어떤 기준으로 성립했는지 확인할 수 있습니다. (기존 주문은 당시 설정을 알 수 없어 비어 있습니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 무통장입금 기한이 주문 요청에 실린 값으로 정해지던 문제를 수정했습니다. 이제 가상계좌와 동일하게 관리자 환경설정의 미입금 자동취소 기한만 사용합니다. 이전에는 안내된 입금기한과 자동취소 시점이 어긋날 수 있었습니다. 이미 생성된 주문의 기한은 그대로 유지됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 장바구니 담기 수량 제한이 실제로 적용되도록 수정했습니다. 설정값(상품당 99개)이 화면·저장 어디에도 반영되지 않아 한 번에 9,999개까지 담을 수 있었고, 이미 담긴 수량과 합산되는 경우에는 제한 자체가 없었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 장바구니 수량 제한 안내 문구가 실제 설정값을 알려 주도록 수정했습니다. 이전에는 설정과 무관하게 "최대 9,999개까지 가능"으로 고정 표시돼, 안내받은 수량보다 적은 수량에서 저장이 막혔습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품의 최대 구매수량 제한이 장바구니 담기와 옵션 변경에서도 적용되도록 수정했습니다. 이전에는 수량 변경 화면에서만 검사해, 담기나 옵션 변경으로는 한도를 넘길 수 있었습니다. 비회원 장바구니가 로그인 시 합쳐질 때는 로그인이 실패하지 않도록 한도까지 자동 조정하고 조정 내역을 함께 알려 줍니다. 이때 같은 상품을 옵션별로 나눠 담아 한도를 넘기는 것도 함께 막습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 재주문으로 장바구니에 담을 때도 상품의 최대 구매수량 제한이 적용됩니다. 한도를 넘는 항목은 품절 상품과 같이 사유와 함께 제외되고, 나머지 항목은 정상적으로 담깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. @@ -14,11 +24,20 @@ ### Changed +- 실제로 사용되지 않던 설정 항목(상품·주문 목록의 페이지당 개수, 결제 자동취소 기한 중복 항목)을 정리했습니다. 미입금 자동취소 기한은 관리자 환경설정의 값 하나만 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ### Fixed +- 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 고시정보 템플릿 목록에서 한 번에 가져올 개수에 상한(100개)을 두었습니다. 이전에는 임의의 큰 값을 넘겨 한 요청으로 전체 데이터를 가져갈 수 있었습니다. '전체' 조회는 종전대로 동작합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 옵션의 정률 적립률을 100%를 넘겨 저장할 수 있던 문제를 수정했습니다. 전체 기본 적립률은 100%까지만 허용되는데 옵션별 적립률에는 상한이 없어, 결제금액보다 많은 마일리지가 적립되도록 설정할 수 있었습니다. 정액(원 단위) 적립은 종전처럼 상한이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품·옵션 일괄 수정에서 가격 검증이 등록/수정 화면보다 느슨하던 문제를 수정했습니다. 원화처럼 소수점을 쓰지 않는 통화에서 소수 가격이 저장되거나, 판매가가 정가보다 크게 저장되거나, 0원으로 저장되던 경우가 막힙니다. 정가·판매가 중 한쪽만 수정하는 경우도 저장된 나머지 값과 함께 판정하므로, 판매가만 정가 위로 올려 저장할 수 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 부분취소 후에도 주문의 부가세가 취소 전 금액으로 남아 과세표준과 어긋나던 문제를 수정했습니다. 취소로 과세표준이 줄면 부가세도 함께 다시 계산되며, 계산에는 주문 당시의 세율이 사용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문의 부가세가 항상 0으로 표시되던 문제를 수정했습니다. 부가세는 부분취소를 거친 주문에서만 채워져 같은 목록에서도 주문마다 세금 표기가 달랐습니다. 이제 주문을 만들 때 계산해 기록하며, 상품별 세율이 다르면 상품별로 계산해 합산합니다. 기존 주문의 부가세는 업데이트 시 자동으로 채워지며, 채운 주문 건수가 업그레이드 로그에 남습니다(이미 값이 있는 주문은 건드리지 않습니다). (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문서의 마일리지 '사용 가능 최대' 안내가 실제 결제 기준 금액과 최소 사용액을 반영하도록 수정했습니다. 이전에는 할인·배송비 반영 전 상품합계를 기준으로 계산해 실제로 쓸 수 있는 금액보다 크게 안내되었고, 최소 사용액에 못 미치는 경우에도 사용 가능한 것처럼 보였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다. - 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php index 7cd8e56b..4aef2a12 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php +++ b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php @@ -17,7 +17,6 @@ return [ // 상품 설정 'product' => [ - 'per_page' => 12, 'max_images' => 10, 'max_image_size' => 5 * 1024 * 1024, // 5MB 'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'], @@ -25,14 +24,13 @@ return [ // 주문 설정 'order' => [ - 'per_page' => 20, 'order_number_prefix' => 'ORD', ], // 결제 설정 'payment' => [ - // 미입금 주문 자동취소 기한 (일) — 결제수단 무관 단일 SSoT - 'auto_cancel_days' => 3, + // 미입금 주문 자동취소 기한은 관리자 환경설정 order_settings.auto_cancel_days 가 SSoT 다. + // 여기에 같은 이름의 값을 두면 두 번째 SSoT 가 되어 어느 쪽이 적용되는지 알 수 없어진다. // 입금 기한 만료 시 자동 취소 여부 'auto_cancel_expired' => true, @@ -85,6 +83,53 @@ return [ 'max_image_size_mb' => 10, // 리뷰 이미지 최대 크기 (MB) ], + /* + |-------------------------------------------------------------------------- + | 환경설정 입력 한계값 + |-------------------------------------------------------------------------- + | 관리자 환경설정 화면의 숫자 입력 경계값(min/max)과 저장 검증 규칙이 공유하는 단일 출처. + | 규칙(StoreEcommerceSettingsRequest)이 이 값을 읽어 규칙 문자열을 만들고, + | 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. + */ + 'limits' => [ + // 주문 + 'auto_cancel_days_min' => 1, + 'auto_cancel_days_max' => 30, + 'cart_expiry_days_min' => 1, + 'cart_expiry_days_max' => 365, + + // 마일리지 (전역) + 'default_earn_rate_min' => 0, + 'default_earn_rate_max' => 100, + 'earn_delay_days_min' => 0, + 'earn_delay_days_max' => 365, + 'expiry_days_min' => 1, + 'expiry_days_max' => 3650, + 'expiry_notification_days_before_min' => 1, + 'expiry_notification_days_before_max' => 365, + + // 마일리지 (통화별 규칙 행) + 'point_value_min' => 0.001, + 'min_use_amount_min' => 0, + 'use_unit_min' => 1, + 'max_use_percent_min' => 0, + 'max_use_percent_max' => 100, + 'max_use_value_min' => 0, + 'max_use_value_max' => 1000000000, + + // 통화 행 + 'decimal_places_min' => 0, + 'decimal_places_max' => 8, + + // 리뷰 + 'write_deadline_days_min' => 1, + 'write_deadline_days_max' => 365, + 'max_images_min' => 0, + 'max_images_max' => 20, + 'max_image_size_mb_min' => 1, + 'max_image_size_mb_max' => 50, + ], + // 관리자 대시보드 이커머스 영역 설정 'dashboard' => [ 'scheduler_enabled' => true, // 판매 현황 집계 스케줄러(hourly) 활성화 여부 diff --git a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php index d718ba80..87782a82 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php +++ b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php @@ -35,7 +35,11 @@ class OrderOptionFactory extends Factory 'order_id' => Order::factory(), 'parent_option_id' => null, 'product_id' => Product::factory(), - 'product_option_id' => ProductOption::factory(), + // 연결 옵션의 재고는 아래 option_snapshot 이 적는 값(100)과 맞춘다. + // ProductOption 기본 정의는 재고를 0~100 난수로 뽑는데, 주문 수량(1~5)보다 작게 + // 뽑히면 결제 완료 시 재고 차감이 실패해 주문 상태를 다루는 테스트가 재고 부족으로 + // 무작위 실패한다. 재고 부족 시나리오는 그 상태를 명시하는 테스트가 따로 지정한다. + 'product_option_id' => ProductOption::factory()->state(['stock_quantity' => 100]), 'option_status' => OrderStatusEnum::PENDING_ORDER, 'source_type' => 'order', 'source_option_id' => null, diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php new file mode 100644 index 00000000..b41d28aa --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php @@ -0,0 +1,53 @@ +mediumText('mileage_policy_snapshot') + ->nullable() + ->after('shipping_policy_applied_snapshot') + ->comment('주문 시점 마일리지 사용 정책 스냅샷 (최소 사용액/사용 단위/한도 재현용)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_orders')) { + return; + } + + if (! Schema::hasColumn('ecommerce_orders', 'mileage_policy_snapshot')) { + return; + } + + Schema::table('ecommerce_orders', function (Blueprint $table) { + $table->dropColumn('mileage_policy_snapshot'); + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md index bbfd7d86..6a39b6e7 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md @@ -415,15 +415,48 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| merged_count | integer | `2` | 회원 장바구니로 옮겨진 비회원 아이템 수 | +| adjustments | array | `[]` | 병합 과정에서 수량이 상한으로 줄어든 항목 목록 (조정이 없으면 빈 배열) | +| adjustments[].product_id | integer | `12` | 조정된 상품 ID | +| adjustments[].product_option_id | integer | `34` | 조정된 상품 옵션 ID | +| adjustments[].requested | integer | `10` | 병합 시 합산된 수량 | +| adjustments[].applied | integer | `5` | 상한 적용 후 실제 저장된 수량 | + +> 병합은 로그인 흐름에서 자동 수행되므로 수량 상한을 넘겨도 예외를 던지지 않고 상한까지 줄입니다. +> 줄어든 사실을 사용자에게 알릴 수 있도록 `adjustments` 를 확인해 안내하세요. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니를 병합했습니다.", + "data": { + "merged_count": 2, + "adjustments": [ + { + "product_id": 12, + "product_option_id": 34, + "requested": 10, + "applied": 5 + } + ] + } +} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태 | 조건 | 응답 | +| --- | --- | --- | +| 422 | 미로그인 상태이거나 `X-Cart-Key` 헤더가 없음/형식 불일치 | `errors.auth` 또는 `errors.cart_key` | +| 500 | 병합 처리 실패 | `messages.cart.merge_failed` | @@ -611,7 +644,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | --- | --- | --- | --- | --- | --- | | id | path | string | 예 | — | 대상 리소스의 식별자 | | product_option_id | body | integer | 예 | — | product option 식별자 | -| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| quantity | body | integer | 예 | min 1, max: 장바구니 수량 상한 설정 | 변경할 구매 수량 (1 ~ 관리자 설정 상한, 기본 99) | | additional_option_selections | body | array | 아니오 | — | 추가 옵션 재선택 목록 (항목별 additional_option_id/value_id, 직접입력 custom_text — 미전달 시 기존 선택 유지) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.change_option_validation_rules`). @@ -665,7 +698,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | id | path | string | 예 | — | 대상 리소스의 식별자 | -| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| quantity | body | integer | 예 | min 1, max: 장바구니 수량 상한 설정 | 변경할 구매 수량 (1 ~ 관리자 설정 상한, 기본 99) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.update_quantity_validation_rules`). @@ -700,6 +733,6 @@ Content-Type: application/json -**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량은 1~9999 범위이며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. +**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량 상한은 관리자 환경설정의 장바구니 수량 상한(기본 99)을 따르며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index e2141763..f04e1189 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -96,6 +96,8 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **설명** 현재 유효한 임시 주문을 조회하면서 최신 가격으로 실시간 재계산해 주문서 페이지 데이터를 반환합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@show`가 `TempOrderService::getTempOrderWithCalculation()`으로 재계산하고 `CheckoutDataService::buildResponseData()`가 쿠폰·마일리지·상품·구매불가 상품 정보를 포함해 응답을 구성합니다. 쿼리로 `country_code`/`zipcode`/`region` 등 배송 주소를 전달하면 해당 주소 기준 배송비가 계산되며, 우편번호 없이 배송국가만으로도 미리보기 배송비를 산출합니다. 임시 주문이 만료·미존재면 404를 반환합니다. +응답의 `mileage_info` 에는 잔액(`available`)·사용 가능 최대(`max_usable`)·사용 가능 여부(`usable`)와 함께 사용 정책 `usage_policy`(`min_use_amount`, `use_unit`, `max_use_amount`)가 포함됩니다. `max_usable` 과 `usage_policy.max_use_amount` 는 저장 API 가 판정하는 기준(마일리지 차감 전 결제금액)과 동일한 기준으로 산출되므로, 입력 UI 는 이 값을 그대로 상·하한으로 사용하면 됩니다. 조회는 사용 한도를 초과한 임시 주문이 있어도 200 을 반환합니다(차단은 저장 시점에서만). + ### POST /api/modules/sirsoft-ecommerce/checkout @@ -153,7 +155,9 @@ Content-Type: application/json -**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 보유 잔액을 넘는 마일리지 사용은 422, 빈 장바구니는 400을 반환합니다. +**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 빈 장바구니는 400을 반환합니다. + +마일리지 사용은 보유 잔액뿐 아니라 관리자 설정 사용 정책(최소 사용액 `min_use_amount`, 사용 단위 `use_unit`, 최대 한도 `max_use_percent`/`max_use_value`)까지 서버에서 강제하며, 위반 시 422 를 반환합니다. 한도 초과 응답 메시지에는 사용 가능한 최대 금액이 함께 안내됩니다. 판정 기준 금액은 마일리지 차감 전 결제금액이며, 비회원의 `use_points` 는 예외 없이 0 으로 처리됩니다. ### PUT /api/modules/sirsoft-ecommerce/checkout @@ -214,7 +218,7 @@ Content-Type: application/json -**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 마일리지는 422를 반환합니다. +**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 또는 사용 정책(최소 사용액·사용 단위·최대 한도) 위반 마일리지는 422를 반환합니다. ### POST /api/modules/sirsoft-ecommerce/checkout/extend diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 0a875faa..a5292864 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -1394,6 +1394,8 @@ Content-Type: application/json **설명** 주문서 작성을 마치고 실제 주문을 생성(결제하기)하는 회원/비회원 공용 엔드포인트입니다. PG 플러그인의 fetch 인터셉터가 이 한 경로만 매칭하므로 회원/비회원이 동일 URL로 진입하고, `Public\OrderController@store`가 `Auth::id()`로 분기합니다(회원은 `OrderResource`, 비회원은 민감 필드를 가린 `GuestOrderResource`). `optional.sanctum` + `sirsoft-ecommerce.user-orders.create` 권한이 필요하며, `expected_total_amount`로 금액 위변조를 검증하고 비회원은 `guest_lookup_password`로 이후 조회 비밀번호를 설정합니다. 회원이 `save_shipping_address`를 켜면 배송지가 자동 저장(PG 결제는 결제완료 시점) 됩니다. +주문 확정 시점에는 재고·구매대상제한·배송국가·쿠폰 유효성과 함께 **마일리지 사용 정책**도 현재 설정 기준으로 재검증합니다. 임시 주문을 만든 뒤 관리자가 한도를 강화했거나 임시 주문이 조작된 경우 `422`(`errors.code = mileage_usage_not_allowed`)로 차단되며 주문은 생성되지 않습니다. 반대로 정상 생성된 주문에는 그 시점의 사용 정책이 `mileage_policy_snapshot` 으로 고정되어, 이후 설정이 바뀌어도 해당 주문의 판정 근거를 재현할 수 있습니다(통화·프로모션·배송정책 스냅샷과 동일 취지). + ### GET /api/modules/sirsoft-ecommerce/user/orders/{id} @@ -1851,7 +1853,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | added_count | integer | `0` | added 개수 (집계) | -| skipped | array | `[]` | 장바구니에 담지 못한 항목 목록 (품절·단종 등으로 재주문에서 건너뛴 옵션) | +| skipped | array | `[]` | 장바구니에 담지 못한 항목 목록 (품절·단종·구매수량 한도 초과 등으로 재주문에서 건너뛴 옵션) | | cart_count | integer | `0` | cart 개수 (집계) | **응답 예시** @@ -1881,7 +1883,7 @@ HTTP/1.1 200 -**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. +**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종·상품별 구매수량 한도 초과 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 한 항목이 담기지 못해도 나머지 항목은 그대로 담기며, 응답은 200 입니다(항목 하나 때문에 재주문 전체를 실패시키지 않습니다). 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. ### PUT /api/modules/sirsoft-ecommerce/user/orders/{id}/shipping-address diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md index 3f578d88..5ed274e5 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md @@ -29,7 +29,7 @@ | --- | --- | --- | --- | --- | --- | | search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | | active_only | query | boolean | 아니오 | — | true 시 활성(is_active) 템플릿만 조회 | -| per_page | query | string | 아니오 | — | 페이지당 항목 수 | +| per_page | query | string | 아니오 | 정수 1~100 또는 `all` | 페이지당 항목 수 (`all` 은 전체 조회, 숫자는 최대 100) | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | **요청 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index ac8b4adf..2ce1b968 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -2003,6 +2003,17 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | product | path | string | 예 | — | 대상 product의 식별자 | +| copy_images | query | boolean | 아니오 | true/false (기본 true) | 상품 이미지 복사 여부 | +| copy_options | query | boolean | 아니오 | true/false (기본 true) | 옵션 복사 여부 | +| copy_categories | query | boolean | 아니오 | true/false (기본 true) | 카테고리 연결 복사 여부 | +| copy_sales_info | query | boolean | 아니오 | true/false (기본 true) | 판매 정보(가격·재고·상태) 복사 여부 | +| copy_description | query | boolean | 아니오 | true/false (기본 true) | 상세 설명 복사 여부 | +| copy_notice | query | boolean | 아니오 | true/false (기본 true) | 상품 공지 복사 여부 | +| copy_common_info | query | boolean | 아니오 | true/false (기본 true) | 상품 공통정보 복사 여부 | +| copy_other_info | query | boolean | 아니오 | true/false (기본 true) | 기타 정보 복사 여부 | +| copy_shipping | query | boolean | 아니오 | true/false (기본 true) | 배송 정책 복사 여부 | +| copy_seo | query | boolean | 아니오 | true/false (기본 **false**) | SEO 메타 복사 여부 — 메타 제목·설명은 상품마다 달라야 검색 결과에서 구분되므로 기본 제외 | +| copy_identification | query | boolean | 아니오 | true/false (기본 true) | 식별 정보(SKU·상품코드 등) 복사 여부 | **요청 예시** @@ -2280,6 +2291,8 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | product | path | string | 예 | — | 대상 product의 식별자 | +| per_page | query | integer | 아니오 | 1~100 (기본 10) | 페이지당 로그 수 | +| sort_order | query | string | 아니오 | `asc` / `desc` (기본 `desc`) | 작성 시각 정렬 방향 | **요청 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx index 6aa2cfeb..93bc1346 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx @@ -245,13 +245,17 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { expect(daysSection.if).toContain('auto_cancel_expired'); }); - it('자동취소일 Input 이 min=1, max=30 으로 정의되어야 한다 (위치 의존 제거)', () => { + // 경계값은 서버가 내려주는 한계값(_meta.limits)을 바인딩한다 — 화면이 리터럴을 들면 + // 저장 규칙이 바뀔 때 따라오지 못해 "화면은 받는데 저장에서 422" 가 된다. + it('자동취소일 Input 이 서버 한계값을 바인딩해야 한다 (위치 의존 제거)', () => { const input = findFirst(card, (n: any) => n?.name === 'Input' && n?.props?.name === 'order_settings.auto_cancel_days', ); expect(input).not.toBeNull(); - expect(input.props.min).toBe(1); - expect(input.props.max).toBe(30); + expect(String(input.props.min)).toContain('_meta?.limits?.auto_cancel_days_min'); + expect(String(input.props.max)).toContain('_meta?.limits?.auto_cancel_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 30'); }); it('입금기한 단일화: 구 vbank_due_days Input 이 더 이상 존재하지 않는다', () => { @@ -272,13 +276,15 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { describe('장바구니 유효기간 카드 구조', () => { const card = findById(tab, 'cart_expiry_card'); - it('cart_expiry_days Input이 min=1, max=365이어야 한다', () => { + it('cart_expiry_days Input 이 서버 한계값을 바인딩해야 한다', () => { const input = findFirst(card, (n: any) => n?.name === 'Input' && n?.props?.name === 'order_settings.cart_expiry_days', ); expect(input).not.toBeNull(); - expect(input.props.min).toBe(1); - expect(input.props.max).toBe(365); + expect(String(input.props.min)).toContain('_meta?.limits?.cart_expiry_days_min'); + expect(String(input.props.max)).toContain('_meta?.limits?.cart_expiry_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx index c300a70a..7f4fb022 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx @@ -164,12 +164,14 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { ); }); - it('Input이 type=number, min=1, max=365여야 한다', () => { + it('Input이 type=number 이고 허용 범위가 서버 한계값을 바인딩해야 한다', () => { const input = writeDeadlineSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('365'); + expect(String(input.props.min)).toContain('limits?.write_deadline_days_min'); + expect(String(input.props.max)).toContain('limits?.write_deadline_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); it('Input 기본값이 30을 참조해야 한다', () => { @@ -224,12 +226,16 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { // 구분선(Div, index 1) 다음 max_images 항목(index 2) const maxImagesSection = settingsArea.children[2]; - it('Input이 type=number, min=0, max=10여야 한다', () => { + // 경계값은 서버 검증(StoreEcommerceSettingsRequest)이 SSoT — 화면이 더 좁으면 + // 사용자가 저장 가능한 값을 입력조차 못 하고, 더 넓으면 저장 단계에서만 실패한다. + it('Input이 type=number 이고 허용 범위가 서버 한계값을 바인딩해야 한다 (max_images)', () => { const input = maxImagesSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('0'); - expect(String(input.props.max)).toBe('10'); + expect(String(input.props.min)).toContain('limits?.max_images_min'); + expect(String(input.props.max)).toContain('limits?.max_images_max'); + expect(String(input.props.min)).toContain('?? 0'); + expect(String(input.props.max)).toContain('?? 20'); }); it('Input 기본값이 5를 참조해야 한다', () => { @@ -264,13 +270,17 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { // 구분선(index 3) 다음 max_image_size_mb 항목(index 4) const maxSizeSection = settingsArea.children[4]; - it('Input이 type=number, min=1, max=20, step=0.5여야 한다', () => { + // 서버 규칙이 integer 이므로 step 은 1 이어야 한다 — 0.5 는 화면에서만 허용되고 + // 저장 단계에서 422 가 되는 값이다. + it('Input이 type=number, step=1 이고 허용 범위가 서버 한계값을 바인딩해야 한다', () => { const input = maxSizeSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('20'); - expect(String(input.props.step)).toBe('0.5'); + expect(String(input.props.min)).toContain('limits?.max_image_size_mb_min'); + expect(String(input.props.max)).toContain('limits?.max_image_size_mb_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 50'); + expect(String(input.props.step)).toBe('1'); }); it('Input 기본값이 5를 참조해야 한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx index a0b389cd..fb8e5827 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx @@ -161,31 +161,39 @@ describe('리뷰 설정 탭 (_tab_review_settings.json) 렌더링', () => { expect(cardBody.children.length).toBeGreaterThanOrEqual(3); }); - it('write_deadline_days Input이 min=1, max=365다', () => { + it('write_deadline_days Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const deadlineSection = cardBody.children[0]; const input = deadlineSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('365'); + expect(String(input.props.min)).toContain('limits?.write_deadline_days_min'); + expect(String(input.props.max)).toContain('limits?.write_deadline_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); - it('max_images Input이 min=0, max=10이다', () => { + // 경계값은 서버 검증(StoreEcommerceSettingsRequest)이 SSoT — 화면이 더 좁으면 저장 가능한 + // 값을 입력조차 못 하고, 더 넓으면 저장 단계에서만 실패한다. + it('max_images Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const maxImagesSection = cardBody.children[2]; // 구분선(index 1) 건너뜀 const input = maxImagesSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('0'); - expect(String(input.props.max)).toBe('10'); + expect(String(input.props.min)).toContain('limits?.max_images_min'); + expect(String(input.props.max)).toContain('limits?.max_images_max'); + expect(String(input.props.min)).toContain('?? 0'); + expect(String(input.props.max)).toContain('?? 20'); }); - it('max_image_size_mb Input이 min=1, max=20이다', () => { + it('max_image_size_mb Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const maxSizeSection = cardBody.children[4]; // 구분선(index 3) 건너뜀 const input = maxSizeSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('20'); + expect(String(input.props.min)).toContain('limits?.max_image_size_mb_min'); + expect(String(input.props.max)).toContain('limits?.max_image_size_mb_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 50'); }); it('모든 Input의 onChange가 setState + hasChanges: true를 사용한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts new file mode 100644 index 00000000..60b548a5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts @@ -0,0 +1,237 @@ +/** + * 설정 화면 ↔ 서버 검증 규칙 경계값 계약 테스트 (E10) + * + * @description + * 설정 입력 UI 의 min/max/step 은 서버 검증 규칙(StoreEcommerceSettingsRequest)과 같은 값을 + * 말해야 한다. 화면이 더 좁으면 저장 가능한 값을 입력조차 못 하고, 더 넓으면 입력은 되는데 + * 저장 단계에서만 422 가 난다. 어느 쪽도 화면만 보고는 알 수 없어 조용히 어긋난 채로 남는다. + * + * 양쪽 모두 `config('sirsoft-ecommerce.limits')` 한 곳을 읽도록 배선했으므로, 세 지점 + * (설정 파일 · 서버 규칙 · 화면 바인딩)이 같은 한계값 키와 같은 폴백을 가리키는지 대조한다. + * 어느 한 쪽만 고쳐도 실패한다. + * + * @vitest-environment node + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import tabMileageBasic from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json'; +import tabOrderSettings from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json'; +import tabReviewSettings from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json'; + +const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const requestSource = fs.readFileSync( + path.join(moduleRoot, 'src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php'), + 'utf-8', +); +const configSource = fs.readFileSync(path.join(moduleRoot, 'config/ecommerce.php'), 'utf-8'); + +/** 한 경계(min 또는 max)의 출처: 어떤 한계값 키를 읽고, 그 키가 없을 때 무엇으로 떨어지는가. */ +interface Bound { + limitKey: string | null; + fallback: number; +} + +/** + * `config/ecommerce.php` 의 `limits` 블록을 키 → 값으로 읽는다. + * + * @returns 한계값 키 맵 + */ +function configuredLimits(): Record { + const block = /'limits'\s*=>\s*\[([\s\S]*?)\n\s*\],/.exec(configSource); + expect(block, "config/ecommerce.php 에서 'limits' 블록을 찾지 못했습니다.").not.toBeNull(); + + const limits: Record = {}; + + for (const line of block![1].split('\n')) { + const matched = /'([a-z0-9_]+)'\s*=>\s*(-?[\d.]+)\s*,/.exec(line); + if (matched) { + limits[matched[1]] = Number(matched[2]); + } + } + + expect(Object.keys(limits).length).toBeGreaterThan(0); + + return limits; +} + +const LIMITS = configuredLimits(); + +/** + * 규칙/바인딩 문자열에서 한 경계의 출처를 뽑는다. + * + * 서버 규칙: `'min:'.config('sirsoft-ecommerce.limits.KEY', N)` 또는 `'min:N'` + * 화면 바인딩: `{{_local?.form?._meta?.limits?.KEY ?? N}}` 또는 `"N"` + * + * @param source 규칙 배열 본문 또는 prop 값 + * @param bound 'min' | 'max' + * @param isRule 서버 규칙 문자열이면 true + * @returns 경계 출처 (해당 경계가 없으면 null) + */ +function extractBound(source: string, bound: 'min' | 'max', isRule: boolean): Bound | null { + if (isRule) { + const viaConfig = new RegExp( + `'${bound}:'\\s*\\.\\s*config\\(\\s*'sirsoft-ecommerce\\.limits\\.([a-z0-9_]+)'\\s*,\\s*(-?[\\d.]+)\\s*\\)`, + ).exec(source); + + if (viaConfig) { + return { limitKey: viaConfig[1], fallback: Number(viaConfig[2]) }; + } + + const literal = new RegExp(`'${bound}:(-?[\\d.]+)'`).exec(source); + + return literal ? { limitKey: null, fallback: Number(literal[1]) } : null; + } + + if (source === undefined || source === null || source === '') { + return null; + } + + const viaBinding = /limits\?\.([a-z0-9_]+)\s*\?\?\s*(-?[\d.]+)/.exec(source); + + if (viaBinding) { + return { limitKey: viaBinding[1], fallback: Number(viaBinding[2]) }; + } + + return Number.isNaN(Number(source)) ? null : { limitKey: null, fallback: Number(source) }; +} + +/** + * FormRequest 원본에서 해당 설정 키의 규칙 배열 본문을 읽는다. + * + * @param key 설정 키 + * @returns 규칙 배열 본문 문자열 + */ +function ruleBody(key: string): string { + const escaped = key.replace(/[.]/g, '\\.'); + const matched = new RegExp(`'${escaped}'\\s*=>\\s*\\[(.*)\\],\\n`).exec(requestSource); + + expect(matched, `${key} 규칙을 FormRequest 에서 찾지 못했습니다.`).not.toBeNull(); + + return matched![1]; +} + +/** + * 레이아웃 트리에서 해당 설정 키를 다루는 Input 노드를 찾는다. + * + * 자동 바인딩을 쓰는 화면은 `props.name`, 수동 바인딩 화면은 `props.value` 의 표현식 경로로 + * 필드를 식별한다 — 두 형태가 같은 탭 안에 섞여 있으므로 둘 다 본다. + * + * @param node 탐색 시작 노드 + * @param key 설정 키 + * @returns 매칭된 Input 노드 (없으면 null) + */ +function findInput(node: any, key: string): any { + if (!node || typeof node !== 'object') return null; + + const boundPath = key.split('.').join('?.'); + const value = node.props?.value; + + if ( + node.name === 'Input' && + (node.props?.name === key || (typeof value === 'string' && value.includes(boundPath))) + ) { + return node; + } + + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + const found = findInput(item, key); + if (found) return found; + } + } else if (child && typeof child === 'object') { + const found = findInput(child, key); + if (found) return found; + } + } + + return null; +} + +const FIELDS: Array<{ key: string; layout: any }> = [ + { key: 'review_settings.write_deadline_days', layout: tabReviewSettings }, + { key: 'review_settings.max_images', layout: tabReviewSettings }, + { key: 'review_settings.max_image_size_mb', layout: tabReviewSettings }, + { key: 'order_settings.auto_cancel_days', layout: tabOrderSettings }, + { key: 'order_settings.cart_expiry_days', layout: tabOrderSettings }, + { key: 'mileage.default_earn_rate', layout: tabMileageBasic }, +]; + +describe('설정 화면 경계값 ↔ 서버 규칙 계약', () => { + it.each(FIELDS)('$key 의 min/max 가 서버 규칙과 같은 한계값을 가리킨다', ({ key, layout }) => { + const input = findInput(layout, key); + expect(input, `${key} Input 을 레이아웃에서 찾지 못했습니다.`).not.toBeNull(); + + const body = ruleBody(key); + let compared = 0; + + for (const bound of ['min', 'max'] as const) { + const server = extractBound(body, bound, true); + + if (server === null) { + continue; + } + + const screen = extractBound(String(input.props[bound] ?? ''), bound, false); + + expect(screen, `${key} 화면에 ${bound} 이 없습니다 — 서버는 ${bound} 을 강제합니다.`).not.toBeNull(); + expect(screen!.limitKey, `${key} 의 ${bound} 이 서버 규칙과 다른 한계값을 읽습니다.`).toBe( + server.limitKey, + ); + expect(screen!.fallback, `${key} 의 ${bound} 폴백이 서버 규칙과 다릅니다.`).toBe(server.fallback); + + compared++; + } + + // 규칙 형태가 바뀌어 아무 경계도 못 읽으면 이 테스트는 아무것도 검증하지 못한 채 통과한다. + expect(compared, `${key} 에서 서버 경계를 하나도 읽지 못했습니다 — 규칙 추출이 깨졌습니다.`).toBeGreaterThan(0); + }); + + it.each(FIELDS)('$key 의 폴백이 설정 파일의 한계값과 같다', ({ key }) => { + const body = ruleBody(key); + let checked = 0; + + for (const bound of ['min', 'max'] as const) { + const server = extractBound(body, bound, true); + + if (server === null || server.limitKey === null) { + continue; + } + + expect(LIMITS, `${server.limitKey} 가 config/ecommerce.php 의 limits 에 없습니다.`).toHaveProperty( + server.limitKey, + ); + expect(LIMITS[server.limitKey], `${key} 의 ${bound} 폴백이 설정 파일 값과 다릅니다.`).toBe( + server.fallback, + ); + + checked++; + } + + expect(checked).toBeGreaterThan(0); + }); + + it.each(FIELDS)('$key 의 step 이 서버 규칙의 정수 여부와 어긋나지 않는다', ({ key, layout }) => { + const input = findInput(layout, key); + const body = ruleBody(key); + + if (!/'integer'/.test(body)) { + return; + } + + // step 자체가 사라지면 브라우저 기본값 1 로 동작해 당장은 문제가 없지만, + // 다음 사람이 소수 step 을 다시 넣어도 막을 근거가 없어진다. + // 부재를 통과시키면 이 가드가 그 순간 무력화되므로 명시 실패시킨다. + expect( + input.props.step, + `${key} 는 서버가 integer 를 요구하므로 step 을 명시해야 합니다 — 부재는 소수 step 회귀를 막지 못합니다.`, + ).toBeDefined(); + + // 서버가 integer 를 요구하는데 소수 step 을 주면, 화면이 권하는 값이 곧 422 가 된다. + expect(Number(input.props.step)).toBe(1); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_cards.json index be5b927e..e66b69c7 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_cards.json @@ -8,7 +8,7 @@ "name": "Div", "children": [ { - "id": "currency_card", + "id": "currency_card_{{currencyIndex}}", "type": "basic", "name": "Div", "iteration": { @@ -1026,8 +1026,8 @@ "type": "number", "name": "newCurrency.decimal_places", "value": "{{_local.form?.newCurrency?.decimal_places ?? 2}}", - "min": 0, - "max": 8, + "min": "{{_local?.form?._meta?.limits?.decimal_places_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.decimal_places_max ?? 8}}", "className": "w-full", "disabled": "{{_computed.isReadOnly}}" } @@ -1039,6 +1039,5 @@ ] } ], - "props": { - } + "props": {} } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_table.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_table.json index 9b17e143..2c442780 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_table.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_currency_exchange_table.json @@ -83,7 +83,7 @@ "name": "Tbody", "children": [ { - "id": "currency_row", + "id": "currency_row_{{currencyIndex}}", "type": "basic", "name": "Tr", "iteration": { @@ -765,8 +765,8 @@ "type": "number", "name": "newCurrency.decimal_places", "value": "{{_local.form?.newCurrency?.decimal_places ?? 2}}", - "min": 0, - "max": 8, + "min": "{{_local?.form?._meta?.limits?.decimal_places_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.decimal_places_max ?? 8}}", "className": "input w-20 text-center", "disabled": "{{_computed.isReadOnly}}" } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json index 69620655..346cf9a3 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json @@ -137,7 +137,8 @@ "type": "number", "name": "mileage.default_earn_rate", "value": "{{_local.form?.mileage?.default_earn_rate ?? 1}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.default_earn_rate_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.default_earn_rate_max ?? 100}}", "step": "0.1", "className": "input w-24 text-center", "disabled": "{{_computed.isReadOnly}}" @@ -229,7 +230,7 @@ "type": "number", "name": "mileage.earn_delay_days", "value": "{{_local.form?.mileage?.earn_delay_days ?? 0}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.earn_delay_days_min ?? 0}}", "className": "input w-20 text-center", "disabled": "{{_computed.isReadOnly}}" } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json index 0de753a8..03aeb913 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json @@ -11,7 +11,7 @@ }, "children": [ { - "id": "currency_rule_card", + "id": "currency_rule_card_{{ruleIndex}}", "type": "basic", "name": "Div", "iteration": { @@ -333,7 +333,9 @@ { "type": "basic", "name": "Label", - "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.code" }, { @@ -350,7 +352,10 @@ { "type": "change", "handler": "setState", - "params": { "target": "local", "form.newMileageCurrency.hasError": false } + "params": { + "target": "local", + "form.newMileageCurrency.hasError": false + } } ] } @@ -363,7 +368,9 @@ { "type": "basic", "name": "Label", - "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.point_value" }, { @@ -373,7 +380,7 @@ "type": "number", "name": "newMileageCurrency.point_value", "value": "{{_local.form?.newMileageCurrency?.point_value ?? 1}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.point_value_min ?? 0}}", "step": "0.001", "className": "input w-full", "disabled": "{{_computed.isReadOnly}}" @@ -388,7 +395,9 @@ { "type": "basic", "name": "Label", - "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.min_use" }, { @@ -398,7 +407,7 @@ "type": "number", "name": "newMileageCurrency.min_use_amount", "value": "{{_local.form?.newMileageCurrency?.min_use_amount ?? 0}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.min_use_amount_min ?? 0}}", "className": "input w-full", "disabled": "{{_computed.isReadOnly}}" } @@ -412,7 +421,9 @@ { "type": "basic", "name": "Label", - "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.use_unit" }, { @@ -422,7 +433,7 @@ "type": "number", "name": "newMileageCurrency.use_unit", "value": "{{_local.form?.newMileageCurrency?.use_unit ?? 1}}", - "min": "1", + "min": "{{_local?.form?._meta?.limits?.use_unit_min ?? 1}}", "className": "input w-full", "disabled": "{{_computed.isReadOnly}}" } @@ -436,18 +447,24 @@ { "type": "basic", "name": "Label", - "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2" }, + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use" }, { "type": "basic", "name": "Div", - "props": { "className": "space-y-2" }, + "props": { + "className": "space-y-2" + }, "children": [ { "type": "basic", "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer" }, + "props": { + "className": "flex items-center gap-2 cursor-pointer" + }, "actions": [ { "type": "click", @@ -475,7 +492,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-600 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use_percent" }, { @@ -485,8 +504,8 @@ "type": "number", "name": "newMileageCurrency.max_use_percent", "value": "{{_local.form?.newMileageCurrency?.max_use_percent ?? 30}}", - "min": "0", - "max": "100", + "min": "{{_local?.form?._meta?.limits?.max_use_percent_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.max_use_percent_max ?? 100}}", "className": "input w-16 text-center", "disabled": "{{_computed.isReadOnly || (_local.form?.newMileageCurrency?.max_use_type ?? 'percent') !== 'percent'}}" } @@ -494,7 +513,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use_percent_suffix" } ] @@ -502,7 +523,9 @@ { "type": "basic", "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer" }, + "props": { + "className": "flex items-center gap-2 cursor-pointer" + }, "actions": [ { "type": "click", @@ -530,7 +553,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-600 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use_fixed" }, { @@ -540,7 +565,7 @@ "type": "number", "name": "newMileageCurrency.max_use_value", "value": "{{_local.form?.newMileageCurrency?.max_use_value ?? 0}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.max_use_value_min ?? 0}}", "className": "input w-28 text-center", "disabled": "{{_computed.isReadOnly || (_local.form?.newMileageCurrency?.max_use_type ?? 'percent') !== 'fixed'}}" } @@ -554,7 +579,9 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-end gap-2 pt-1" }, + "props": { + "className": "flex items-center justify-end gap-2 pt-1" + }, "children": [ { "type": "basic", @@ -568,11 +595,19 @@ { "type": "click", "handler": "setState", - "params": { "target": "local", "isAddingMileageCurrency": false, "form.newMileageCurrency": null } + "params": { + "target": "local", + "isAddingMileageCurrency": false, + "form.newMileageCurrency": null + } } ], "children": [ - { "type": "basic", "name": "Span", "text": "$t:common.cancel" } + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } ] }, { @@ -599,7 +634,10 @@ { "handler": "setState", "if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && (_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim())}}", - "params": { "target": "local", "form.newMileageCurrency.hasError": true } + "params": { + "target": "local", + "form.newMileageCurrency.hasError": true + } }, { "handler": "setState", @@ -616,8 +654,19 @@ } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "check", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.add" } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "check", + "size": "sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.add" + } ] } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json index baabce48..660ca48c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json @@ -67,7 +67,7 @@ "name": "Tbody", "children": [ { - "id": "currency_rule_row", + "id": "currency_rule_row_{{ruleIndex}}", "type": "basic", "name": "Tr", "iteration": { @@ -409,7 +409,9 @@ { "type": "basic", "name": "Td", - "props": { "className": "align-top" }, + "props": { + "className": "align-top" + }, "children": [ { "type": "basic", @@ -425,7 +427,10 @@ { "type": "change", "handler": "setState", - "params": { "target": "local", "form.newMileageCurrency.hasError": false } + "params": { + "target": "local", + "form.newMileageCurrency.hasError": false + } } ] } @@ -434,7 +439,9 @@ { "type": "basic", "name": "Td", - "props": { "className": "align-top" }, + "props": { + "className": "align-top" + }, "children": [ { "type": "basic", @@ -443,7 +450,7 @@ "type": "number", "name": "newMileageCurrency.point_value", "value": "{{_local.form?.newMileageCurrency?.point_value ?? 1}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.point_value_min ?? 0}}", "step": "0.001", "className": "input w-24 text-center", "disabled": "{{_computed.isReadOnly}}" @@ -454,12 +461,16 @@ { "type": "basic", "name": "Td", - "props": { "className": "align-top" }, + "props": { + "className": "align-top" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-1" }, + "props": { + "className": "flex items-center gap-1" + }, "children": [ { "type": "basic", @@ -468,7 +479,7 @@ "type": "number", "name": "newMileageCurrency.min_use_amount", "value": "{{_local.form?.newMileageCurrency?.min_use_amount ?? 0}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.min_use_amount_min ?? 0}}", "className": "input w-24 text-center", "disabled": "{{_computed.isReadOnly}}" } @@ -476,7 +487,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.min_use_suffix" } ] @@ -486,12 +499,16 @@ { "type": "basic", "name": "Td", - "props": { "className": "align-top" }, + "props": { + "className": "align-top" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-1" }, + "props": { + "className": "flex items-center gap-1" + }, "children": [ { "type": "basic", @@ -500,7 +517,7 @@ "type": "number", "name": "newMileageCurrency.use_unit", "value": "{{_local.form?.newMileageCurrency?.use_unit ?? 1}}", - "min": "1", + "min": "{{_local?.form?._meta?.limits?.use_unit_min ?? 1}}", "className": "input w-20 text-center", "disabled": "{{_computed.isReadOnly}}" } @@ -508,7 +525,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.use_unit_suffix" } ] @@ -518,17 +537,23 @@ { "type": "basic", "name": "Td", - "props": { "className": "align-top" }, + "props": { + "className": "align-top" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "space-y-2" }, + "props": { + "className": "space-y-2" + }, "children": [ { "type": "basic", "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer" }, + "props": { + "className": "flex items-center gap-2 cursor-pointer" + }, "actions": [ { "type": "click", @@ -556,7 +581,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-600 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use_percent" }, { @@ -566,8 +593,8 @@ "type": "number", "name": "newMileageCurrency.max_use_percent", "value": "{{_local.form?.newMileageCurrency?.max_use_percent ?? 30}}", - "min": "0", - "max": "100", + "min": "{{_local?.form?._meta?.limits?.max_use_percent_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.max_use_percent_max ?? 100}}", "className": "input w-16 text-center", "disabled": "{{_computed.isReadOnly || (_local.form?.newMileageCurrency?.max_use_type ?? 'percent') !== 'percent'}}" } @@ -575,7 +602,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use_percent_suffix" } ] @@ -583,7 +612,9 @@ { "type": "basic", "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer" }, + "props": { + "className": "flex items-center gap-2 cursor-pointer" + }, "actions": [ { "type": "click", @@ -611,7 +642,9 @@ { "type": "basic", "name": "Span", - "props": { "className": "text-xs text-gray-600 dark:text-gray-400" }, + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400" + }, "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use_fixed" }, { @@ -621,7 +654,7 @@ "type": "number", "name": "newMileageCurrency.max_use_value", "value": "{{_local.form?.newMileageCurrency?.max_use_value ?? 0}}", - "min": "0", + "min": "{{_local?.form?._meta?.limits?.max_use_value_min ?? 0}}", "className": "input w-24 text-center", "disabled": "{{_computed.isReadOnly || (_local.form?.newMileageCurrency?.max_use_type ?? 'percent') !== 'fixed'}}" } @@ -635,12 +668,16 @@ { "type": "basic", "name": "Td", - "props": { "className": "align-top text-center" }, + "props": { + "className": "align-top text-center" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-center gap-1" }, + "props": { + "className": "flex items-center justify-center gap-1" + }, "children": [ { "type": "basic", @@ -666,7 +703,10 @@ { "handler": "setState", "if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && (_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim())}}", - "params": { "target": "local", "form.newMileageCurrency.hasError": true } + "params": { + "target": "local", + "form.newMileageCurrency.hasError": true + } }, { "handler": "setState", @@ -683,7 +723,14 @@ } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "check", "size": "sm" } } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "check", + "size": "sm" + } + } ] }, { @@ -698,11 +745,22 @@ { "type": "click", "handler": "setState", - "params": { "target": "local", "isAddingMileageCurrency": false, "form.newMileageCurrency": null } + "params": { + "target": "local", + "isAddingMileageCurrency": false, + "form.newMileageCurrency": null + } } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "xmark", "size": "sm" } } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "xmark", + "size": "sm" + } + } ] } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_expiry_card.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_expiry_card.json index 511cef55..3c509dd7 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_expiry_card.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_expiry_card.json @@ -88,7 +88,7 @@ "type": "number", "name": "mileage.expiry_days", "value": "{{_local.form?.mileage?.expiry_days ?? 365}}", - "min": "1", + "min": "{{_local?.form?._meta?.limits?.expiry_days_min ?? 1}}", "className": "input w-28 text-center", "disabled": "{{_computed.isReadOnly}}" } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_notification_card.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_notification_card.json index aaf3e3dc..cd530355 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_notification_card.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_notification_card.json @@ -100,7 +100,7 @@ "type": "number", "name": "mileage.expiry_notification_days_before", "value": "{{_local.form?.mileage?.expiry_notification_days_before ?? 7}}", - "min": "1", + "min": "{{_local?.form?._meta?.limits?.expiry_notification_days_before_min ?? 1}}", "className": "input w-20 text-center", "disabled": "{{_computed.isReadOnly}}" } @@ -142,7 +142,7 @@ }, "children": [ { - "id": "template_status_row", + "id": "template_status_row_{{tplIndex}}", "type": "basic", "name": "Div", "iteration": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json index 90b60a12..6ddfa2a0 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json @@ -387,8 +387,9 @@ "type": "number", "name": "order_settings.auto_cancel_days", "value": "{{_local.form?.order_settings?.auto_cancel_days ?? 3}}", - "min": 1, - "max": 30, + "min": "{{_local?.form?._meta?.limits?.auto_cancel_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.auto_cancel_days_max ?? 30}}", + "step": "1", "className": "{{_local.errors?.['order_settings.auto_cancel_days'] ? 'input input-error w-20' : 'input w-20'}}", "disabled": "{{_computed.isReadOnly}}" } @@ -679,8 +680,9 @@ "type": "number", "name": "order_settings.cart_expiry_days", "value": "{{_local.form?.order_settings?.cart_expiry_days ?? 30}}", - "min": 1, - "max": 365, + "min": "{{_local?.form?._meta?.limits?.cart_expiry_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.cart_expiry_days_max ?? 365}}", + "step": "1", "className": "{{_local.errors?.['order_settings.cart_expiry_days'] ? 'input input-error w-20' : 'input w-20'}}", "disabled": "{{_computed.isReadOnly}}" } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json index 88103693..7a2e931d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json @@ -57,8 +57,9 @@ "name": "Input", "props": { "type": "number", - "min": "1", - "max": "365", + "min": "{{_local?.form?._meta?.limits?.write_deadline_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.write_deadline_days_max ?? 365}}", + "step": "1", "className": "input w-20", "disabled": "{{_computed.isReadOnly}}", "value": "{{_local.form?.review_settings?.write_deadline_days ?? 30}}" @@ -121,8 +122,9 @@ "name": "Input", "props": { "type": "number", - "min": "0", - "max": "10", + "min": "{{_local?.form?._meta?.limits?.max_images_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.max_images_max ?? 20}}", + "step": "1", "className": "input w-20", "disabled": "{{_computed.isReadOnly}}", "value": "{{_local.form?.review_settings?.max_images ?? 5}}" @@ -185,9 +187,9 @@ "name": "Input", "props": { "type": "number", - "min": "1", - "max": "20", - "step": "0.5", + "min": "{{_local?.form?._meta?.limits?.max_image_size_mb_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_image_size_mb_max ?? 50}}", + "step": "1", "className": "input w-20", "disabled": "{{_computed.isReadOnly}}", "value": "{{_local.form?.review_settings?.max_image_size_mb ?? 5}}" diff --git a/modules/_bundled/sirsoft-ecommerce/src/DTO/Summary.php b/modules/_bundled/sirsoft-ecommerce/src/DTO/Summary.php index 8bcbb0a5..02878b22 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/DTO/Summary.php +++ b/modules/_bundled/sirsoft-ecommerce/src/DTO/Summary.php @@ -21,6 +21,7 @@ class Summary * @param int $shippingDiscount 배송비 할인 * @param int $taxableAmount 과세 금액 합계 * @param int $taxFreeAmount 면세 금액 합계 + * @param int $vatAmount 부가세 합계 (옵션별 세율 반영, 과세표준에 내재된 금액) * @param int $pointsEarning 적립 예정 마일리지 합계 * @param int $pointsUsed 사용 마일리지 합계 * @param int $paymentAmount 결제금액 (마일리지 사용 전) @@ -28,9 +29,9 @@ class Summary * @param MultiCurrencyPrices|null $multiCurrency 다중 통화 변환 금액 * @param string|null $selectedPaymentCurrency 선택된 결제 통화 * @param array $metadata 플러그인 확장용 메타데이터 - * - deposit_used: 예치금 사용 합계 (예치금 플러그인) - * - gift_card_used: 상품권 사용 합계 (상품권 플러그인) - * - grade_discount: 회원등급 할인 합계 (회원등급 플러그인) + * - deposit_used: 예치금 사용 합계 (예치금 플러그인) + * - gift_card_used: 상품권 사용 합계 (상품권 플러그인) + * - grade_discount: 회원등급 할인 합계 (회원등급 플러그인) */ public function __construct( public int $subtotal = 0, @@ -44,6 +45,7 @@ class Summary public int $shippingDiscount = 0, public int $taxableAmount = 0, public int $taxFreeAmount = 0, + public int $vatAmount = 0, public int $pointsEarning = 0, public int $pointsUsed = 0, public int $paymentAmount = 0, @@ -122,6 +124,7 @@ class Summary 'shipping_discount_formatted' => ecommerce_format_price($this->shippingDiscount), 'taxable_amount' => $this->taxableAmount, 'tax_free_amount' => $this->taxFreeAmount, + 'vat_amount' => $this->vatAmount, 'points_earning' => $this->pointsEarning, 'total_mileage' => $this->pointsEarning, 'mileage_formatted' => number_format($this->pointsEarning).'P', @@ -175,6 +178,7 @@ class Summary shippingDiscount: $data['shipping_discount'] ?? 0, taxableAmount: $data['taxable_amount'] ?? 0, taxFreeAmount: $data['tax_free_amount'] ?? 0, + vatAmount: $data['vat_amount'] ?? 0, pointsEarning: $data['points_earning'] ?? 0, pointsUsed: $data['points_used'] ?? 0, paymentAmount: $data['payment_amount'] ?? 0, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CartQuantityLimitException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CartQuantityLimitException.php new file mode 100644 index 00000000..91c0d1c1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CartQuantityLimitException.php @@ -0,0 +1,48 @@ + $limit, + 'attempted' => $attempted, + ])); + } + + /** + * 허용 최대 수량을 반환합니다. + * + * @return int 최대 수량 + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * 합산 후 수량을 반환합니다. + * + * @return int 시도 수량 + */ + public function getAttempted(): int + { + return $this->attempted; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductPriceRelationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductPriceRelationException.php new file mode 100644 index 00000000..52a8d307 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductPriceRelationException.php @@ -0,0 +1,27 @@ + PermissionHelper::check('sirsoft-ecommerce.settings.update', request()->user()), ]; + // 화면의 숫자 입력 경계값 — 저장 규칙과 같은 출처를 내려준다. + // 화면이 리터럴을 들면 규칙이 바뀔 때 따라오지 못해 "화면은 받는데 저장은 422" 가 된다. + $settings['_meta'] = ['limits' => config('sirsoft-ecommerce.limits', [])]; + return ResponseHelper::moduleSuccess( 'sirsoft-ecommerce', 'messages.settings.fetch_success', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php index a4f86e2c..644e97c0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php @@ -5,20 +5,21 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Resources\ActivityLogResource; -use App\Models\ActivityLog; use Exception; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Modules\Sirsoft\Ecommerce\Exceptions\ProductHasOrderHistoryException; use Modules\Sirsoft\Ecommerce\Exceptions\ProductImageUploadLimitException; +use Modules\Sirsoft\Ecommerce\Exceptions\ProductPriceRelationException; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdatePriceRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateProductsRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateStatusRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateStockRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductListRequest; +use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductLogsRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ReorderProductImagesRequest; +use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ShowProductForCopyRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreProductRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateProductRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UploadProductImageRequest; @@ -26,7 +27,6 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\ProductCollection; use Modules\Sirsoft\Ecommerce\Http\Resources\ProductOptionResource; use Modules\Sirsoft\Ecommerce\Http\Resources\ProductResource; use Modules\Sirsoft\Ecommerce\Models\Product; -use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Services\ProductImageService; use Modules\Sirsoft\Ecommerce\Services\ProductService; @@ -407,6 +407,15 @@ class ProductController extends AdminBaseController 200, ['count' => ($result['products_updated'] ?? 0) + ($result['options_updated'] ?? 0)] ); + } catch (ProductPriceRelationException $e) { + // 판매가 > 정가 조합은 도메인 검증 실패 → generic 500 이 아닌 422 + return ResponseHelper::error( + 'validation.product.selling_price_lte_list', + 422, + null, + [], + 'sirsoft-ecommerce' + ); } catch (ValidationException $e) { return ResponseHelper::moduleError( 'sirsoft-ecommerce', @@ -557,28 +566,14 @@ class ProductController extends AdminBaseController /** * 상품 복사용 데이터를 조회합니다. * - * @param Request $request HTTP 요청 (복사 옵션 포함) + * @param ShowProductForCopyRequest $request 복사 옵션 요청 데이터 * @param Product $product 복사할 상품 모델 * @return JsonResponse 복사용 데이터 JSON 응답 */ - public function showForCopy(Request $request, Product $product): JsonResponse + public function showForCopy(ShowProductForCopyRequest $request, Product $product): JsonResponse { try { - $copyOptions = [ - 'images' => $request->boolean('copy_images', true), - 'options' => $request->boolean('copy_options', true), - 'categories' => $request->boolean('copy_categories', true), - 'sales_info' => $request->boolean('copy_sales_info', true), - 'description' => $request->boolean('copy_description', true), - 'notice' => $request->boolean('copy_notice', true), - 'common_info' => $request->boolean('copy_common_info', true), - 'other_info' => $request->boolean('copy_other_info', true), - 'shipping' => $request->boolean('copy_shipping', true), - 'seo' => $request->boolean('copy_seo', false), - 'identification' => $request->boolean('copy_identification', true), - ]; - - $copyData = $this->productService->getDetailForCopy($product->id, $copyOptions); + $copyData = $this->productService->getDetailForCopy($product->id, $request->getCopyOptions()); if (! $copyData) { return ResponseHelper::notFound( @@ -802,36 +797,15 @@ class ProductController extends AdminBaseController /** * 상품 처리로그(활동 로그) 목록을 조회합니다. * - * @param Request $request 요청 + * @param ProductLogsRequest $request 활동 로그 조회 요청 데이터 * @param Product $product 상품 * @return JsonResponse 활동 로그 목록 */ - public function logs(Request $request, Product $product): JsonResponse + public function logs(ProductLogsRequest $request, Product $product): JsonResponse { try { - $perPage = (int) ($request->query('per_page', 10)); - $sortOrder = $request->query('sort_order', 'desc'); - - // 상품 + 상품옵션 로그를 합쳐서 조회 - $optionIds = $product->options()->pluck('id')->toArray(); - - $query = ActivityLog::where(function ($q) use ($product, $optionIds) { - // 상품 자체 로그 - $q->where(function ($sub) use ($product) { - $sub->where('loggable_type', $product->getMorphClass()) - ->where('loggable_id', $product->getKey()); - }); - - // 해당 상품의 옵션 로그 - if (! empty($optionIds)) { - $q->orWhere(function ($sub) use ($optionIds) { - $sub->where('loggable_type', (new ProductOption)->getMorphClass()) - ->whereIn('loggable_id', $optionIds); - }); - } - })->orderBy('created_at', $sortOrder); - - $logs = $query->paginate($perPage); + // 상품 + 상품옵션 로그 합산 조회는 Repository 위임 (Service 경유) + $logs = $this->productService->getActivityLogs($product, $request->getFilters()); return ResponseHelper::moduleSuccess( 'sirsoft-ecommerce', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php index 321b43de..60ffeb2c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CartController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Exceptions\CartOperationException; +use Modules\Sirsoft\Ecommerce\Exceptions\CartQuantityLimitException; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; use Modules\Sirsoft\Ecommerce\Http\Requests\Public\BulkAddToCartRequest; @@ -153,6 +154,14 @@ class CartController extends PublicBaseController } catch (CartUnavailableException $e) { // 판매불가/재고/구매수량 한도 위반 — generic 500 이 아닌 사유별 422 매핑 return $this->cartUnavailableResponse($e); + } catch (CartQuantityLimitException $e) { + // 장바구니 수량 상한 초과 — generic 500 이 아닌 422 명시 차단 + return ResponseHelper::error( + 'sirsoft-ecommerce::validation.cart.quantity_limit_exceeded', + 422, + ['code' => 'cart_quantity_limit_exceeded', 'detail' => $e->getMessage()], + ['limit' => $e->getLimit(), 'attempted' => $e->getAttempted()] + ); } catch (CartOperationException $e) { // 항목없음/권한없음/옵션없음 — 사유별 404/403/422 매핑 return $this->cartOperationResponse($e); @@ -214,6 +223,14 @@ class CartController extends PublicBaseController } catch (CartUnavailableException $e) { // 판매불가/재고/구매수량 한도 위반 — generic 500 이 아닌 사유별 422 매핑 return $this->cartUnavailableResponse($e); + } catch (CartQuantityLimitException $e) { + // 장바구니 수량 상한 초과 — generic 500 이 아닌 422 명시 차단 + return ResponseHelper::error( + 'sirsoft-ecommerce::validation.cart.quantity_limit_exceeded', + 422, + ['code' => 'cart_quantity_limit_exceeded', 'detail' => $e->getMessage()], + ['limit' => $e->getLimit(), 'attempted' => $e->getAttempted()] + ); } catch (CartOperationException $e) { // 항목없음/권한없음/옵션없음 — 사유별 404/403/422 매핑 return $this->cartOperationResponse($e); @@ -259,6 +276,14 @@ class CartController extends PublicBaseController } catch (CartUnavailableException $e) { // 판매불가/재고/구매수량 한도 위반 — generic 500 이 아닌 사유별 422 매핑 return $this->cartUnavailableResponse($e); + } catch (CartQuantityLimitException $e) { + // 장바구니 수량 상한 초과 — generic 500 이 아닌 422 명시 차단 + return ResponseHelper::error( + 'sirsoft-ecommerce::validation.cart.quantity_limit_exceeded', + 422, + ['code' => 'cart_quantity_limit_exceeded', 'detail' => $e->getMessage()], + ['limit' => $e->getLimit(), 'attempted' => $e->getAttempted()] + ); } catch (CartOperationException $e) { // 항목없음/권한없음/옵션없음/타상품옵션 — 사유별 404/403/422 매핑 return $this->cartOperationResponse($e); @@ -291,6 +316,14 @@ class CartController extends PublicBaseController $this->cartService->deleteItem($id, $userId, $cartKey); return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.deleted'); + } catch (CartQuantityLimitException $e) { + // 장바구니 수량 상한 초과 — generic 500 이 아닌 422 명시 차단 + return ResponseHelper::error( + 'sirsoft-ecommerce::validation.cart.quantity_limit_exceeded', + 422, + ['code' => 'cart_quantity_limit_exceeded', 'detail' => $e->getMessage()], + ['limit' => $e->getLimit(), 'attempted' => $e->getAttempted()] + ); } catch (CartOperationException $e) { // 없는 항목(404)/타인 항목(403) 삭제 시도 — generic 500 이 아닌 사유별 4xx return $this->cartOperationResponse($e); @@ -393,8 +426,11 @@ class CartController extends PublicBaseController $mergedCount = $this->cartService->mergeGuestCartToUser($cartKey, $userId); + // 병합은 수량을 상한까지 줄일 수 있다. 줄인 사실을 응답에 담지 않으면 사용자는 + // 자기가 담아 둔 수량이 조용히 바뀐 것을 알 방법이 없다. return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.merged', [ 'merged_count' => $mergedCount, + 'adjustments' => $this->cartService->getLastMergeAdjustments(), ]); } catch (Exception $e) { Log::error('cart.merge failed', ['error' => $e->getMessage()]); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php index a279a326..b7c0fd9c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php @@ -10,6 +10,7 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException; +use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException; use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException; use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException; use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException; @@ -164,6 +165,19 @@ trait HandlesOrderCreation 'has_restriction_issue' => $e->hasRestrictionIssue(), ]); + } catch (MileageValidationException $e) { + // 마일리지 사용 정책 위반(한도/단위/최소사용액/잔액) — generic 500 이 아닌 422 명시 차단. + // 임시주문 생성 이후 설정이 바뀌었거나 임시주문이 조작된 경우 여기로 떨어진다. + Log::warning('Order create: mileage usage policy violation', [ + 'message' => $e->getMessage(), + ]); + + return ResponseHelper::error( + 'sirsoft-ecommerce::exceptions.order_create_failed', + 422, + ['code' => 'mileage_usage_not_allowed', 'detail' => $e->getMessage()] + ); + } catch (OrderProcessingException $e) { // 주문 확정 재계산 검증 실패(쿠폰 만료/min_amount/per_user_limit/not_combinable 등) // generic 500 이 아닌 422 로 하드 차단 — 서버 우회 방지 (U14/MP06) diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateOptionsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateOptionsRequest.php index 44519d64..914d0b03 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateOptionsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateOptionsRequest.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; use App\Extension\HookManager; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ResolvesBaseCurrencyPrecision; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductOption; @@ -16,6 +17,8 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption; */ class BulkUpdateOptionsRequest extends FormRequest { + use ResolvesBaseCurrencyPrecision; + /** * 권한 확인 * @@ -33,6 +36,8 @@ class BulkUpdateOptionsRequest extends FormRequest */ public function rules(): array { + $priceRule = $this->basePriceRule(); + $rules = [ // 처리 대상 옵션 ID 배열 (필수) - "productId-optionId" 형식 'ids' => ['required', 'array', 'min:1'], @@ -54,8 +59,9 @@ class BulkUpdateOptionsRequest extends FormRequest 'items.*.option_name' => ['nullable', 'array'], 'items.*.option_name.*' => ['nullable', 'string', 'max:255'], 'items.*.sku' => ['nullable', 'string', 'max:100'], - 'items.*.list_price' => ['nullable', 'numeric', 'min:0'], - 'items.*.price_adjustment' => ['nullable', 'numeric'], + // 단건 등록/수정과 동일한 통화 정밀도 규칙 + 'items.*.list_price' => ['nullable', 'numeric', $priceRule, 'min:0'], + 'items.*.price_adjustment' => ['nullable', 'numeric', $priceRule], 'items.*.stock_quantity' => ['nullable', 'integer', 'min:0'], 'items.*.safe_stock_quantity' => ['nullable', 'integer', 'min:0'], 'items.*.is_default' => ['nullable', 'boolean'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateProductsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateProductsRequest.php index 78d9de4c..066b8a9c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateProductsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkUpdateProductsRequest.php @@ -3,10 +3,12 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus; +use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ResolvesBaseCurrencyPrecision; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductOption; @@ -17,6 +19,8 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption; */ class BulkUpdateProductsRequest extends FormRequest { + use ResolvesBaseCurrencyPrecision; + /** * 권한 확인 * @@ -34,6 +38,8 @@ class BulkUpdateProductsRequest extends FormRequest */ public function rules(): array { + $priceRule = $this->basePriceRule(); + $rules = [ // 처리 대상 상품 ID 배열 (필수) 'ids' => ['required', 'array', 'min:1'], @@ -57,8 +63,9 @@ class BulkUpdateProductsRequest extends FormRequest 'items.*.id' => ['required', 'integer', Rule::exists(Product::class, 'id')], 'items.*.name' => ['nullable', 'array'], 'items.*.name.*' => ['nullable', 'string', 'max:255'], - 'items.*.list_price' => ['nullable', 'numeric', 'min:0'], - 'items.*.selling_price' => ['nullable', 'numeric', 'min:0'], + // 단건 등록/수정과 동일한 통화 정밀도·하한 규칙 (일괄 수정만 느슨하면 우회로가 된다) + 'items.*.list_price' => ['nullable', 'numeric', $priceRule, 'min:0.01'], + 'items.*.selling_price' => ['nullable', 'numeric', $priceRule, 'min:0.01'], 'items.*.sales_status' => ['nullable', 'string', function ($attribute, $value, $fail) { if ($value !== null && ! in_array($value, ProductSalesStatus::values())) { $fail(__('sirsoft-ecommerce::validation.product.invalid_sales_status')); @@ -86,8 +93,8 @@ class BulkUpdateProductsRequest extends FormRequest 'option_items.*.option_name' => ['nullable', 'array'], 'option_items.*.option_name.*' => ['nullable', 'string', 'max:255'], 'option_items.*.sku' => ['nullable', 'string', 'max:100'], - 'option_items.*.list_price' => ['nullable', 'numeric', 'min:0'], - 'option_items.*.price_adjustment' => ['nullable', 'numeric'], + 'option_items.*.list_price' => ['nullable', 'numeric', $priceRule, 'min:0'], + 'option_items.*.price_adjustment' => ['nullable', 'numeric', $priceRule], 'option_items.*.stock_quantity' => ['nullable', 'integer', 'min:0'], 'option_items.*.safe_stock_quantity' => ['nullable', 'integer', 'min:0'], 'option_items.*.is_default' => ['nullable', 'boolean'], @@ -112,4 +119,37 @@ class BulkUpdateProductsRequest extends FormRequest 'option_items.*.option_id.exists' => __('sirsoft-ecommerce::validation.product.bulk.option_not_found'), ]; } + + /** + * 단일 필드 규칙으로 표현할 수 없는 가격 관계를 검증합니다. + * + * 일괄 수정은 정가/판매가 중 한쪽만 보내는 부분 전송이 흔하므로, **둘 다 전송된 경우에만** + * 비교합니다. 한쪽만 온 경우의 정합성은 Service 가 DB 기존값과 대조해 판정합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + foreach ((array) $this->input('items', []) as $index => $item) { + if (! is_array($item)) { + continue; + } + + $list = $item['list_price'] ?? null; + $selling = $item['selling_price'] ?? null; + + if ($list === null || $selling === null || $list === '' || $selling === '') { + continue; + } + + if ((float) $selling > (float) $list) { + $validator->errors()->add( + "items.{$index}.selling_price", + __('sirsoft-ecommerce::validation.product.selling_price_lte_list') + ); + } + } + }); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php new file mode 100644 index 00000000..d643e0ba --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductLogsRequest.php @@ -0,0 +1,51 @@ +> 검증 규칙 배열 + */ + public function rules(): array + { + return [ + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort_order' => ['sometimes', 'string', Rule::in(['asc', 'desc'])], + ]; + } + + /** + * 활동 로그 조회 필터(페이지 크기·정렬)를 반환합니다. + * + * @return array{per_page: int, sort_order: string} 조회 필터 + */ + public function getFilters(): array + { + return [ + 'per_page' => (int) ($this->query('per_page', 10)), + 'sort_order' => $this->query('sort_order', 'desc'), + ]; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductNoticeTemplateListRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductNoticeTemplateListRequest.php index ef972f6d..975fd2a5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductNoticeTemplateListRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ProductNoticeTemplateListRequest.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; /** @@ -9,6 +10,9 @@ use Illuminate\Foundation\Http\FormRequest; */ class ProductNoticeTemplateListRequest extends FormRequest { + /** @var int 숫자 per_page 의 상한 */ + private const MAX_PER_PAGE = 100; + /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 * @@ -69,4 +73,30 @@ class ProductNoticeTemplateListRequest extends FormRequest 'page.min' => __('sirsoft-ecommerce::validation.list.page.min'), ]; } + + /** + * per_page 가 숫자인 경우 상한을 강제합니다. + * + * `all` 은 그대로 허용하되(기존 계약 유지), 숫자로 전달되면 상한 없이 임의 값이 들어와 + * 한 요청으로 전체 데이터를 끌어갈 수 있으므로 여기서 막습니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $perPage = $this->input('per_page'); + + if ($perPage === null || $perPage === '' || $perPage === 'all') { + return; + } + + if (is_numeric($perPage) && (int) $perPage > self::MAX_PER_PAGE) { + $validator->errors()->add( + 'per_page', + __('sirsoft-ecommerce::validation.list.per_page_max', ['max' => self::MAX_PER_PAGE]) + ); + } + }); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShowProductForCopyRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShowProductForCopyRequest.php new file mode 100644 index 00000000..f34c2189 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ShowProductForCopyRequest.php @@ -0,0 +1,75 @@ + true, + 'options' => true, + 'categories' => true, + 'sales_info' => true, + 'description' => true, + 'notice' => true, + 'common_info' => true, + 'other_info' => true, + 'shipping' => true, + 'seo' => false, + 'identification' => true, + ]; + + /** + * 사용자가 이 요청을 수행할 권한이 있는지 확인 (권한은 미들웨어에서 처리) + * + * @return bool 항상 true + */ + public function authorize(): bool + { + return true; + } + + /** + * 요청에 적용할 검증 규칙 + * + * @return array> 검증 규칙 배열 + */ + public function rules(): array + { + $rules = []; + + foreach (array_keys(self::COPY_TARGETS) as $target) { + $rules["copy_{$target}"] = ['sometimes', 'boolean']; + } + + return $rules; + } + + /** + * 복사 옵션을 반환합니다. + * + * @return array 항목별 복사 여부 + */ + public function getCopyOptions(): array + { + $options = []; + + foreach (self::COPY_TARGETS as $target => $default) { + $options[$target] = $this->boolean("copy_{$target}", $default); + } + + return $options; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index adf5bc8b..12f4cd57 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -85,7 +85,7 @@ class StoreEcommerceSettingsRequest extends FormRequest 'language_currency.currencies.*.base_unit' => ['nullable', 'integer', 'min:1'], 'language_currency.currencies.*.rounding_unit' => ['nullable', 'string'], 'language_currency.currencies.*.rounding_method' => ['nullable', 'string', 'in:floor,round,ceil'], - 'language_currency.currencies.*.decimal_places' => ['nullable', 'integer', 'min:0', 'max:8'], + 'language_currency.currencies.*.decimal_places' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.decimal_places_min', 0), 'max:'.config('sirsoft-ecommerce.limits.decimal_places_max', 8)], 'language_currency.currencies.*.is_default' => ['nullable', 'boolean'], // 통화별 언어(가입 시 통화 추정용 매핑, A4). 후보 = 시스템 지원 로케일. 'language_currency.currencies.*.locales' => ['nullable', 'array'], @@ -132,8 +132,9 @@ class StoreEcommerceSettingsRequest extends FormRequest 'order_settings.bank_accounts.*.is_active' => ['nullable', 'boolean'], 'order_settings.bank_accounts.*.is_default' => ['nullable', 'boolean'], 'order_settings.auto_cancel_expired' => ['nullable', 'boolean'], - 'order_settings.auto_cancel_days' => ['nullable', 'integer', 'min:0', 'max:30'], - 'order_settings.cart_expiry_days' => ['nullable', 'integer', 'min:1', 'max:365'], + // 0 은 now()->addDays(0) = 즉시 만료라 실질적으로 사용할 수 없는 값이다 → UI(min:1)가 옳다 + 'order_settings.auto_cancel_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)], + 'order_settings.cart_expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.cart_expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.cart_expiry_days_max', 365)], 'order_settings.stock_restore_on_cancel' => ['nullable', 'boolean'], 'order_settings.confirmable_statuses' => ['nullable', 'array'], 'order_settings.confirmable_statuses.*' => ['string', 'in:payment_complete,shipping_hold,preparing,shipping_ready,shipping,delivered'], @@ -151,32 +152,32 @@ class StoreEcommerceSettingsRequest extends FormRequest // review_settings 섹션 'review_settings' => ['sometimes', 'array'], - 'review_settings.write_deadline_days' => ['nullable', 'integer', 'min:1', 'max:365'], - 'review_settings.max_images' => ['nullable', 'integer', 'min:0', 'max:20'], - 'review_settings.max_image_size_mb' => ['nullable', 'integer', 'min:1', 'max:50'], + 'review_settings.write_deadline_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.write_deadline_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.write_deadline_days_max', 365)], + 'review_settings.max_images' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_images_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_images_max', 20)], + 'review_settings.max_image_size_mb' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_image_size_mb_min', 1), 'max:'.config('sirsoft-ecommerce.limits.max_image_size_mb_max', 50)], // mileage 섹션 'mileage' => ['sometimes', 'array'], 'mileage.enabled' => ['required_with:mileage', 'boolean'], - 'mileage.default_earn_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'mileage.default_earn_rate' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.default_earn_rate_min', 0), 'max:'.config('sirsoft-ecommerce.limits.default_earn_rate_max', 100)], // 마일리지 차감 시점은 결제수단별(order_settings.payment_methods.*.mileage_deduction_timing)로 관리한다. 'mileage.earn_trigger' => ['nullable', 'string', 'in:delivered,confirmed'], - 'mileage.earn_delay_days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'mileage.earn_delay_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.earn_delay_days_min', 0), 'max:'.config('sirsoft-ecommerce.limits.earn_delay_days_max', 365)], 'mileage.currency_rules' => ['nullable', 'array'], // 통화 코드: ISO 4217 형식(3자리 영문 대문자) 강제 'mileage.currency_rules.*.currency_code' => ['required_with:mileage.currency_rules', 'string', 'regex:/^[A-Z]{3}$/'], // 1점당 금액: 0이면 마일리지가 금전 가치 0 → 사용 불가. 최소 0 초과(0.001 단위까지 허용) - 'mileage.currency_rules.*.point_value' => ['nullable', 'numeric', 'min:0.001'], - 'mileage.currency_rules.*.min_use_amount' => ['nullable', 'integer', 'min:0'], - 'mileage.currency_rules.*.use_unit' => ['nullable', 'integer', 'min:1'], + 'mileage.currency_rules.*.point_value' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.point_value_min', 0.001)], + 'mileage.currency_rules.*.min_use_amount' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.min_use_amount_min', 0)], + 'mileage.currency_rules.*.use_unit' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.use_unit_min', 1)], 'mileage.currency_rules.*.max_use_type' => ['nullable', 'string', 'in:percent,fixed'], - 'mileage.currency_rules.*.max_use_percent' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'mileage.currency_rules.*.max_use_percent' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.max_use_percent_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_percent_max', 100)], // 최대 사용한도(고정금액): 비합리적 거액 방지를 위해 10억 상한 - 'mileage.currency_rules.*.max_use_value' => ['nullable', 'integer', 'min:0', 'max:1000000000'], + 'mileage.currency_rules.*.max_use_value' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_use_value_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_value_max', 1000000000)], 'mileage.expiry_enabled' => ['nullable', 'boolean'], - 'mileage.expiry_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'mileage.expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.expiry_days_max', 3650)], 'mileage.expiry_notification_enabled' => ['nullable', 'boolean'], - 'mileage.expiry_notification_days_before' => ['nullable', 'integer', 'min:1', 'max:365'], + 'mileage.expiry_notification_days_before' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.expiry_notification_days_before_min', 1), 'max:'.config('sirsoft-ecommerce.limits.expiry_notification_days_before_max', 365)], // shipping 섹션 'shipping' => ['sometimes', 'array'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreProductRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreProductRequest.php index 0c9dbe7a..13f18631 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreProductRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreProductRequest.php @@ -13,6 +13,8 @@ use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; use Modules\Sirsoft\Ecommerce\Enums\ProductImageCollection; use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus; use Modules\Sirsoft\Ecommerce\Enums\ProductTaxStatus; +use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ResolvesBaseCurrencyPrecision; +use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesMileageRate; use Modules\Sirsoft\Ecommerce\Models\Brand; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -25,6 +27,9 @@ use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy; */ class StoreProductRequest extends FormRequest { + use ResolvesBaseCurrencyPrecision; + use ValidatesMileageRate; + /** * 권한 확인 * @@ -44,8 +49,7 @@ class StoreProductRequest extends FormRequest { // 기본통화의 소수 자릿수 — 가격 입력 허용 자릿수 상한. // KRW(0) 이면 정수만, USD/EUR(2) 이면 소수 2자리까지 허용. - $decimalPlaces = $this->baseCurrencyDecimalPlaces(); - $priceRule = $decimalPlaces > 0 ? 'decimal:0,'.$decimalPlaces : 'integer'; + $priceRule = $this->basePriceRule(); $rules = [ // 기본 정보 @@ -258,6 +262,8 @@ class StoreProductRequest extends FormRequest { $validator->after(function (Validator $validator) { $this->validatePurchaseRestrictionRoles($validator); + // 정률 적립률 상한 — mileage_type 에 따라 의미가 달라 필드 규칙으로 표현 불가 + $this->validateMileageRates($validator, 'options'); }); } @@ -319,28 +325,6 @@ class StoreProductRequest extends FormRequest ]; } - /** - * 기본통화의 소수 자릿수를 반환합니다. - * - * 환경설정 language_currency 의 default_currency 에 매칭되는 통화의 - * decimal_places 값(예: KRW=0, USD=2)을 반환합니다. - * - * @return int 소수 자릿수 (미설정 시 0) - */ - protected function baseCurrencyDecimalPlaces(): int - { - $settings = g7_module_settings('sirsoft-ecommerce', 'language_currency'); - $default = $settings['default_currency'] ?? 'KRW'; - - foreach ($settings['currencies'] ?? [] as $currency) { - if (($currency['code'] ?? null) === $default) { - return (int) ($currency['decimal_places'] ?? 0); - } - } - - return 0; - } - /** * 데이터 전처리 */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ResolvesBaseCurrencyPrecision.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ResolvesBaseCurrencyPrecision.php new file mode 100644 index 00000000..d1079a0b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ResolvesBaseCurrencyPrecision.php @@ -0,0 +1,47 @@ +baseCurrencyDecimalPlaces(); + + return $decimalPlaces > 0 ? 'decimal:0,'.$decimalPlaces : 'integer'; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ValidatesMileageRate.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ValidatesMileageRate.php new file mode 100644 index 00000000..df12713b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Concerns/ValidatesMileageRate.php @@ -0,0 +1,53 @@ +input($arrayKey); + + if (! is_array($rows)) { + return; + } + + foreach ($rows as $index => $row) { + if (! is_array($row)) { + continue; + } + + $type = $row['mileage_type'] ?? null; + $value = $row['mileage_value'] ?? null; + + if ($type !== 'percent' || $value === null || $value === '') { + continue; + } + + if ((float) $value > 100) { + $validator->errors()->add( + "{$arrayKey}.{$index}.mileage_value", + __('sirsoft-ecommerce::validation.product.mileage_percent_max') + ); + } + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php index aaf85218..d16f9911 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php @@ -17,7 +17,7 @@ class BulkAddToCartRequest extends FormRequest /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -27,16 +27,19 @@ class BulkAddToCartRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array + * @return array 검증 규칙 */ public function rules(): array { + $maxQuantity = (int) config('sirsoft-ecommerce.cart.max_quantity', 99); + $rules = [ 'product_id' => ['required', 'integer', Rule::exists(Product::class, 'id')], 'items' => 'required|array|min:1', // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석 'items.*.product_option_id' => ['nullable', 'integer'], - 'items.*.quantity' => 'required|integer|min:1|max:9999', + // 장바구니 수량 상한은 config('sirsoft-ecommerce.cart.max_quantity') 가 SSoT + 'items.*.quantity' => ['required', 'integer', 'min:1', 'max:'.$maxQuantity], // 추가옵션 선택 (서버에서 value_id 기준 가격 재조회·검증 — 클라 가격 신뢰 금지) 'items.*.additional_option_selections' => 'nullable|array', 'items.*.additional_option_selections.*.additional_option_id' => 'required_with:items.*.additional_option_selections|integer', @@ -51,7 +54,7 @@ class BulkAddToCartRequest extends FormRequest /** * 검증 오류 메시지 커스터마이징 * - * @return array + * @return array 검증 메시지 */ public function messages(): array { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/ChangeCartOptionRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/ChangeCartOptionRequest.php index 6c10d0b9..70277041 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/ChangeCartOptionRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/ChangeCartOptionRequest.php @@ -15,7 +15,7 @@ class ChangeCartOptionRequest extends FormRequest /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -25,13 +25,16 @@ class ChangeCartOptionRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array + * @return array 검증 규칙 */ public function rules(): array { + $maxQuantity = (int) config('sirsoft-ecommerce.cart.max_quantity', 99); + $rules = [ 'product_option_id' => ['required', 'integer', Rule::exists(ProductOption::class, 'id')], - 'quantity' => 'required|integer|min:1|max:9999', + // 장바구니 수량 상한은 config('sirsoft-ecommerce.cart.max_quantity') 가 SSoT + 'quantity' => ['required', 'integer', 'min:1', 'max:'.$maxQuantity], // 추가옵션 재선택 (미전달 시 기존 선택 유지 — 서버에서 value_id 검증/가격 재조회) 'additional_option_selections' => 'nullable|array', 'additional_option_selections.*.additional_option_id' => 'required_with:additional_option_selections|integer', @@ -46,7 +49,7 @@ class ChangeCartOptionRequest extends FormRequest /** * 검증 오류 메시지 커스터마이징 * - * @return array + * @return array 검증 메시지 */ public function messages(): array { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php index d58fdcce..69274a16 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php @@ -78,7 +78,6 @@ class CreateOrderRequest extends FormRequest 'dbank.bank_name' => 'nullable|string|max:50', 'dbank.account_number' => 'required_if:payment_method,dbank|nullable|string|max:50', 'dbank.account_holder' => 'required_if:payment_method,dbank|nullable|string|max:50', - 'dbank.due_days' => 'nullable|integer|min:1|max:30', // 배송지 저장 'save_shipping_address' => 'nullable|boolean', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/UpdateCartQuantityRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/UpdateCartQuantityRequest.php index b664b895..9833c112 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/UpdateCartQuantityRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/UpdateCartQuantityRequest.php @@ -12,6 +12,8 @@ class UpdateCartQuantityRequest extends FormRequest { /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -21,12 +23,15 @@ class UpdateCartQuantityRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array + * @return array 검증 규칙 */ public function rules(): array { + $maxQuantity = (int) config('sirsoft-ecommerce.cart.max_quantity', 99); + $rules = [ - 'quantity' => 'required|integer|min:1|max:9999', + // 장바구니 수량 상한은 config('sirsoft-ecommerce.cart.max_quantity') 가 SSoT + 'quantity' => ['required', 'integer', 'min:1', 'max:'.$maxQuantity], ]; return HookManager::applyFilters('sirsoft-ecommerce.cart.update_quantity_validation_rules', $rules, $this); @@ -35,7 +40,7 @@ class UpdateCartQuantityRequest extends FormRequest /** * 검증 오류 메시지 커스터마이징 * - * @return array + * @return array 검증 메시지 */ public function messages(): array { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MergeCartOnLoginListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MergeCartOnLoginListener.php index ae76524a..652b7877 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MergeCartOnLoginListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MergeCartOnLoginListener.php @@ -19,7 +19,7 @@ class MergeCartOnLoginListener implements HookListenerInterface /** * 구독할 훅 목록 반환 * - * @return array + * @return array 훅 이름 => 실행 설정(method/priority/sync) 매핑 */ public static function getSubscribedHooks(): array { @@ -27,6 +27,12 @@ class MergeCartOnLoginListener implements HookListenerInterface 'core.auth.after_login' => [ 'method' => 'handle', 'priority' => 20, + // 비회원 장바구니 식별자는 전송 헤더로만 온다. 큐 컨텍스트(HookContextCapture)는 + // IP·UA·path 만 복원하고 임의 헤더는 나르지 않으므로, 큐로 넘기면 워커에서 + // 헤더가 null 이 되어 병합이 조용히 건너뛰어진다 — 기본 큐 드라이버가 database 인 + // 설치에서는 로그인해도 담아둔 장바구니가 사라진다. 로그인 응답 안에서 끝나야 하는 + // 작업이므로 동기 실행한다(실패는 아래 try/catch 가 흡수해 로그인은 지키다). + 'sync' => true, ], ]; } @@ -34,8 +40,7 @@ class MergeCartOnLoginListener implements HookListenerInterface /** * 로그인 성공 시 비회원 장바구니를 회원 계정으로 병합합니다. * - * @param mixed ...$args 첫 번째: User 객체, 두 번째: 로그인 컨텍스트 배열 - * @return void + * @param mixed ...$args 첫 번째: User 객체, 두 번째: 로그인 컨텍스트 배열 */ public function handle(...$args): void { @@ -45,14 +50,19 @@ class MergeCartOnLoginListener implements HookListenerInterface return; } + // 코어 로그인 훅 페이로드는 이커머스를 모르므로(모듈 키를 코어 계약에 넣지 않는다) + // 비회원 장바구니 식별자는 전송 헤더가 유일한 출처다. 형식은 병합 엔드포인트 + // (MergeGuestCartRequest)와 같은 규칙으로 아래에서 검증한다. + // audit:allow listener-formrequest-bypass reason: 코어 훅이 전달하지 않는 전송 계층 식별자 — 아래에서 형식 검증 $cartKey = request()->header('X-Cart-Key'); - if (! $cartKey) { + if (! is_string($cartKey) || ! preg_match('/^ck_[a-zA-Z0-9]{32}$/', $cartKey)) { return; } try { $mergedCount = $this->cartService->mergeGuestCartToUser($cartKey, $user->id); + $adjustments = $this->cartService->getLastMergeAdjustments(); if ($mergedCount > 0) { Log::info('장바구니 병합 완료', [ @@ -61,6 +71,16 @@ class MergeCartOnLoginListener implements HookListenerInterface 'merged_count' => $mergedCount, ]); } + + // 로그인 경로의 병합은 응답에 결과를 실을 자리가 없다. 수량이 상한까지 줄었다면 + // 최소한 운영자가 추적할 수 있도록 조정 내역을 남긴다 (조용한 클램프 금지). + if ($adjustments !== []) { + Log::warning('장바구니 병합 중 수량이 상한으로 조정됨', [ + 'user_id' => $user->uuid, + 'cart_key' => $cartKey, + 'adjustments' => $adjustments, + ]); + } } catch (\Exception $e) { Log::error('장바구니 병합 실패', [ 'user_id' => $user->uuid, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php index fdcbe6ac..cd2be24e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php @@ -113,6 +113,7 @@ class Order extends Model 'promotions_applied_snapshot', 'shipping_policy_applied_snapshot', 'promotions_available_snapshot', + 'mileage_policy_snapshot', 'order_meta', // 다중 통화 컬럼 (JSON) 'mc_subtotal_amount', @@ -180,6 +181,7 @@ class Order extends Model 'promotions_applied_snapshot' => 'array', 'shipping_policy_applied_snapshot' => 'array', 'promotions_available_snapshot' => 'array', + 'mileage_policy_snapshot' => 'array', 'order_meta' => 'array', 'order_status' => OrderStatusEnum::class, 'order_device' => DeviceTypeEnum::class, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php index 3c9526ff..07831061 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php @@ -253,4 +253,13 @@ interface ProductRepositoryInterface * @return bool 성공 여부 */ public function syncStockFromOptions(int $productId): bool; + + /** + * 상품과 그 옵션의 활동 로그를 합쳐 페이지네이션으로 조회 + * + * @param Product $product 대상 상품 + * @param array $filters 조회 필터 (per_page, sort_order) + * @return LengthAwarePaginator 활동 로그 페이지네이터 + */ + public function getActivityLogsForProduct(Product $product, array $filters = []): LengthAwarePaginator; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php index 28480573..6bddf8c0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use App\Helpers\PermissionHelper; +use App\Models\ActivityLog; use App\Search\Engines\DatabaseFulltextEngine; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -12,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Models\Product; +use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; /** @@ -742,4 +744,31 @@ class ProductRepository implements ProductRepositoryInterface { return $this->model->where('id', $productId)->update(['stock_quantity' => $stock]); } + + /** + * {@inheritDoc} + */ + public function getActivityLogsForProduct(Product $product, array $filters = []): LengthAwarePaginator + { + $perPage = (int) ($filters['per_page'] ?? 10); + $sortOrder = ($filters['sort_order'] ?? 'desc') === 'asc' ? 'asc' : 'desc'; + + $optionIds = $product->options()->pluck('id')->toArray(); + + return ActivityLog::where(function (Builder $q) use ($product, $optionIds) { + // 상품 자체 로그 + $q->where(function (Builder $sub) use ($product) { + $sub->where('loggable_type', $product->getMorphClass()) + ->where('loggable_id', $product->getKey()); + }); + + // 해당 상품의 옵션 로그 + if (! empty($optionIds)) { + $q->orWhere(function (Builder $sub) use ($optionIds) { + $sub->where('loggable_type', (new ProductOption)->getMorphClass()) + ->whereIn('loggable_id', $optionIds); + }); + } + })->orderBy('created_at', $sortOrder)->paginate($perPage); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php index f8935ce1..07b36981 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php @@ -12,8 +12,10 @@ use Modules\Sirsoft\Ecommerce\DTO\CartWithCalculationResult; use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult; use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress; use Modules\Sirsoft\Ecommerce\Exceptions\CartOperationException; -use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; +use Modules\Sirsoft\Ecommerce\Exceptions\CartQuantityLimitException; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; +use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException; +use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; use Modules\Sirsoft\Ecommerce\Models\Cart; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CartRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; @@ -219,6 +221,20 @@ class CartService $currentQuantity = $existingItem ? $existingItem->quantity : 0; $this->validateStock($data['product_option_id'], $data['quantity'] ?? 1, $currentQuantity); + // 장바구니 수량 상한 — 필드 규칙은 이번 요청분만 보므로 누적 합산은 여기서 판정한다 + $this->validateCartQuantityLimit($currentQuantity + (int) ($data['quantity'] ?? 1)); + + // 구매수량 한도 — 담기 경로에서도 상품 정책(min/max_purchase_qty)을 강제한다 + $this->validatePurchaseQuantityLimit( + (int) $data['product_id'], + $this->cartRepository->sumQuantityByProduct( + (int) $data['product_id'], + $data['user_id'] ?? null, + $data['cart_key'] ?? null, + $existingItem?->id + ) + $currentQuantity + (int) ($data['quantity'] ?? 1) + ); + $cart = DB::transaction(function () use ($data, $existingItem, $additionalSelections) { if ($existingItem) { // 동일 옵션 + 동일 추가옵션 조합 존재: 수량 증가 @@ -280,7 +296,7 @@ class CartService $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); if (! $matchedOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); + throw new CartOperationException('option_not_found'); } $productOptionId = $matchedOption->id; @@ -288,7 +304,7 @@ class CartService // 옵션 없는 상품: 첫 번째(기본) 옵션 사용 $defaultOption = $productOptions->first(); if (! $defaultOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); + throw new CartOperationException('option_not_found'); } $productOptionId = $defaultOption->id; } @@ -419,6 +435,17 @@ class CartService $this->validateStock($newProductOptionId, $quantity, 0); } + // 장바구니 수량 상한 (합산 기준) + $this->validateCartQuantityLimit($quantity + ($existingItem?->quantity ?? 0)); + + // 구매수량 한도 — 사용자가 명시적으로 조작하는 경로이므로 예외로 차단한다 + $this->validatePurchaseQuantityLimit( + (int) $cart->product_id, + $this->cartRepository->sumQuantityByProduct((int) $cart->product_id, $userId, $cartKey, $cart->id) + - ($existingItem?->quantity ?? 0) + + $quantity + ($existingItem?->quantity ?? 0) + ); + HookManager::doAction('sirsoft-ecommerce.cart.before_change_option', $cart, $newProductOptionId, $quantity); $cart = DB::transaction(function () use ($cart, $newProductOptionId, $quantity, $existingItem, $newSelections) { @@ -521,7 +548,9 @@ class CartService { HookManager::doAction('sirsoft-ecommerce.cart.before_merge', $cartKey, $userId); - $mergedCount = DB::transaction(function () use ($cartKey, $userId) { + $adjustments = []; + + $mergedCount = DB::transaction(function () use ($cartKey, $userId, &$adjustments) { $guestItems = $this->cartRepository->findByCartKeyWithoutUser($cartKey); $count = 0; @@ -541,18 +570,74 @@ class CartService $totalQuantity = $existingItem->quantity + $guestItem->quantity; $finalQuantity = $availableStock > 0 ? min($totalQuantity, $availableStock) : $totalQuantity; + // 로그인 시 자동 병합이라 예외를 던지면 로그인 자체가 실패한다 → + // 상한까지 클램프하고 조정 내역을 반환값에 담아 화면이 안내하게 한다. + // 같은 상품의 다른 라인 수량까지 합산해 판정한다 (옵션 분할 우회 차단). + $otherLinesTotal = $this->cartRepository->sumQuantityByProduct( + $guestItem->product_id, + $userId, + null, + $existingItem->id, + ); + + $clamped = $this->clampToPurchaseQuantityLimit( + $guestItem->product_id, + $finalQuantity, + $otherLinesTotal, + ); + + // 이미 한도를 넘겨 저장돼 있던 라인을 병합이 깎지는 않는다 — + // 병합이 막아야 하는 것은 '이번 병합으로 늘어나는 분' 이다. + $clamped = max($clamped, (int) $existingItem->quantity); + + if ($clamped !== $finalQuantity) { + $adjustments[] = [ + 'product_id' => $guestItem->product_id, + 'product_option_id' => $guestItem->product_option_id, + 'requested' => $finalQuantity, + 'applied' => $clamped, + ]; + } + $this->cartRepository->update($existingItem, [ - 'quantity' => $finalQuantity, + 'quantity' => $clamped, ]); $this->cartRepository->delete($guestItem); } else { // 동일 옵션 없음: 재고 초과 시 조정 후 user_id 업데이트 (cart_key 유지) $finalQuantity = $availableStock > 0 ? min($guestItem->quantity, $availableStock) : $guestItem->quantity; - $this->cartRepository->update($guestItem, [ - 'user_id' => $userId, - 'quantity' => $finalQuantity, - ]); + $otherLinesTotal = $this->cartRepository->sumQuantityByProduct( + $guestItem->product_id, + $userId, + null, + ); + + $clamped = $this->clampToPurchaseQuantityLimit( + $guestItem->product_id, + $finalQuantity, + $otherLinesTotal, + ); + + if ($clamped !== $finalQuantity) { + $adjustments[] = [ + 'product_id' => $guestItem->product_id, + 'product_option_id' => $guestItem->product_option_id, + 'requested' => $finalQuantity, + 'applied' => $clamped, + ]; + } + + if ($clamped <= 0) { + // 회원 장바구니가 이미 한도를 채웠다 — 0 수량 라인을 만들지 않고 버린다. + // 버린 사실은 위 조정 내역(applied = 0)으로 사용자에게 전달된다. + $this->cartRepository->delete($guestItem); + } else { + $this->cartRepository->update($guestItem, [ + 'user_id' => $userId, + 'quantity' => $clamped, + ]); + } } $count++; @@ -561,7 +646,10 @@ class CartService return $count; }); - HookManager::doAction('sirsoft-ecommerce.cart.after_merge', $cartKey, $userId, $mergedCount); + HookManager::doAction('sirsoft-ecommerce.cart.after_merge', $cartKey, $userId, $mergedCount, $adjustments); + + // 조용한 클램프 금지 — 조정이 있었으면 호출 측이 사용자에게 알릴 수 있도록 노출한다 + $this->lastMergeAdjustments = $adjustments; return $mergedCount; } @@ -584,11 +672,11 @@ class CartService $order = $this->orderRepository->findWithRelations($orderId); if (! $order) { - throw new \Exception(__('sirsoft-ecommerce::exceptions.order_not_found')); + throw new OrderProcessingException(__('sirsoft-ecommerce::exceptions.order_not_found')); } if ((int) $order->user_id !== $userId) { - throw new \Exception(__('sirsoft-ecommerce::exceptions.unauthorized')); + throw new OrderProcessingException(__('sirsoft-ecommerce::exceptions.unauthorized')); } HookManager::doAction('sirsoft-ecommerce.cart.before_reorder', $order, $userId); @@ -835,4 +923,85 @@ class CartService throw new CartOperationException('invalid_option'); } } + + /** + * 병합 과정에서 수량이 조정된 내역 (조용한 클램프 방지). + * + * @var array + */ + protected array $lastMergeAdjustments = []; + + /** + * 직전 병합에서 수량이 조정된 내역을 반환합니다. + * + * @return array 조정 내역 + */ + public function getLastMergeAdjustments(): array + { + return $this->lastMergeAdjustments; + } + + /** + * 장바구니 수량을 상한까지 클램프합니다. + * + * 예외를 던질 수 없는 경로(로그인 시 자동 병합)에서만 사용합니다. + * + * @param int $quantity 요청 수량 + * @return int 상한 이내로 조정된 수량 + */ + protected function clampToCartQuantityLimit(int $quantity): int + { + $max = (int) config('sirsoft-ecommerce.cart.max_quantity', 99); + + return $max > 0 ? min($quantity, $max) : $quantity; + } + + /** + * 장바구니 전역 상한과 상품별 구매수량 한도를 함께 적용해 클램프합니다. + * + * 예외를 던질 수 없는 경로(로그인 시 자동 병합) 전용입니다. 담기/수량변경 경로가 + * `validatePurchaseQuantityLimit()` 로 422 를 내는 것과 같은 한도를, 던질 수 없는 + * 자리에서는 클램프로 강제합니다. 두 곳 중 한 곳만 한도를 보면 비회원으로 담고 + * 로그인하는 것만으로 상품 정책이 무력화됩니다. + * + * 상품 총수량 기준으로 판정하므로 옵션을 나눠 담는 우회도 함께 막힙니다. + * 최소 구매수량(`min_purchase_qty`)은 여기서 강제하지 않습니다 — 요청하지 않은 수량을 + * 늘리는 것은 클램프가 아니라 사용자 의사에 반하는 변경입니다. + * + * @param int $productId 상품 ID + * @param int $lineQuantity 이 라인에 적용하려는 수량 + * @param int $otherLinesTotal 같은 상품의 다른 라인 수량 합계 + * @return int 두 한도 이내로 조정된 수량 (여유가 없으면 0) + */ + protected function clampToPurchaseQuantityLimit(int $productId, int $lineQuantity, int $otherLinesTotal = 0): int + { + $allowed = $this->clampToCartQuantityLimit($lineQuantity); + + $product = $this->productRepository->find($productId); + $productMax = (int) ($product->max_purchase_qty ?? 0); + + if ($product && $productMax > 0) { + $allowed = min($allowed, max(0, $productMax - $otherLinesTotal)); + } + + return $allowed; + } + + /** + * 장바구니 라인 수량이 상한을 넘지 않는지 검증합니다. + * + * 필드 규칙(`max:`)은 이번 요청분만 보므로, 기존 수량과 합산되는 경로는 여기서 판정합니다. + * + * @param int $totalQuantity 합산 후 라인 수량 + * + * @throws CartOperationException 상한 초과 시 + */ + protected function validateCartQuantityLimit(int $totalQuantity): void + { + $max = (int) config('sirsoft-ecommerce.cart.max_quantity', 99); + + if ($max > 0 && $totalQuantity > $max) { + throw new CartQuantityLimitException($max, $totalQuantity); + } + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CheckoutDataService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CheckoutDataService.php index 931b10ea..321f742c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CheckoutDataService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CheckoutDataService.php @@ -165,11 +165,17 @@ class CheckoutDataService $itemSubtotals ); - // 마일리지 조회 + // 마일리지 조회. + // 안내 기준 금액은 서버 검증(validateUsage)과 동일하게 "마일리지 차감 전 결제금액"이다. + // subtotal 로 안내하면 쿠폰·배송비 반영 후 실제 허용액보다 크게 안내되어 + // "화면이 허용한 값인데 결제에서 422" 가 된다. + $paymentAmount = (int) ($calculationArray['summary']['payment_amount'] ?? $subtotal); $mileageInfo = $this->userMileageService->getBalance($userId); - $mileageInfo['max_usable'] = $this->userMileageService->getMaxUsable($userId, $subtotal); + $mileageInfo['max_usable'] = $this->userMileageService->getMaxUsable($userId, $paymentAmount); // 기본 통화 사용 규칙 미설정 시 사용 불가(M2) — 적립(enabled)과 분리된 "사용 가능" 플래그 $mileageInfo['usable'] = $this->userMileageService->isMileageUsable(); + // 사용 정책(최소 사용액/사용 단위/정책상 최대) — 입력 UI 가 서버 판정과 같은 기준을 쓰도록 노출 + $mileageInfo['usage_policy'] = $this->userMileageService->getUsagePolicy($paymentAmount); return [ 'available_coupons' => $availableCoupons, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php index 4321afe7..dd3c0173 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php @@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** * 주문 변경 금액 계산 서비스 @@ -646,7 +647,8 @@ class OrderAdjustmentService 'total_deposit_used_amount' => 0, 'total_tax_amount' => $taxableAmount, 'total_tax_free_amount' => (float) $result->summary->taxFreeAmount, - 'total_vat_amount' => $taxableAmount > 0 ? round($taxableAmount * 10 / 110) : 0, + // 재계산 결과의 옵션별 세율 반영 부가세를 그대로 사용 (주문 생성 시점과 동일 산식) + 'total_vat_amount' => (float) ($result->summary->vatAmount ?? VatCalculator::fromTaxableAmount((int) $taxableAmount)), 'total_earned_points_amount' => (float) ($result->summary->pointsEarning ?? 0), 'item_count' => $itemCount, ]; @@ -791,6 +793,12 @@ class OrderAdjustmentService 'total_points_used_amount' => (float) $recalcResult->summary->pointsUsed, 'total_tax_amount' => (float) $recalcResult->summary->taxableAmount, 'total_tax_free_amount' => (float) $recalcResult->summary->taxFreeAmount, + // 과세표준을 갱신하면서 부가세를 그대로 두면 둘이 어긋나, 주문 화면의 공급가액 + // (과세표준 - 부가세)이 음수까지 갈 수 있다. 같은 재계산 결과에서 함께 쓴다. + 'total_vat_amount' => (float) ( + $recalcResult->summary->vatAmount + ?? VatCalculator::fromTaxableAmount((int) $recalcResult->summary->taxableAmount) + ), 'promotions_applied_snapshot' => $promotionsSnapshot, ]; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php index 1b8b9153..893fdb92 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php @@ -37,6 +37,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** * 주문 계산 서비스 @@ -924,9 +925,18 @@ class OrderCalculationService $isTaxable = $product->tax_status === ProductTaxStatus::TAXABLE; + // 세율은 상품별로 다를 수 있다. 스냅샷 모드에서는 주문 시점 세율이 그대로 쓰여 + // 관리자가 이후 상품 세율을 바꿔도 기존 주문의 부가세가 소급 변경되지 않는다. + $taxRate = $isTaxable + ? (float) ($product->tax_rate ?? VatCalculator::DEFAULT_RATE) + : null; + $taxableAmount = $isTaxable ? $amount : 0; + $classification[$optionId] = [ - 'taxable_amount' => $isTaxable ? $amount : 0, + 'taxable_amount' => $taxableAmount, 'tax_free_amount' => $isTaxable ? 0 : $amount, + 'tax_rate' => $taxRate, + 'vat_amount' => VatCalculator::fromTaxableAmount((int) $taxableAmount, $taxRate), ]; } @@ -1444,6 +1454,10 @@ class OrderCalculationService $totalTaxFree += $tax['tax_free_amount']; } + // 부가세는 옵션별 부가세의 합 — 세율이 섞인 주문에서는 이것만이 정확하다 + // (전체 과세표준에 단일 세율을 적용하면 면세·영세·다른 세율 상품이 섞였을 때 어긋난다) + $totalVat = VatCalculator::sumFromClassification($taxClassification); + $summary = new Summary( subtotal: $paymentCalculation['subtotal'], productCouponDiscount: $paymentCalculation['coupon_discount'], @@ -1456,6 +1470,7 @@ class OrderCalculationService shippingDiscount: $paymentCalculation['shipping_discount'], taxableAmount: $totalTaxable, taxFreeAmount: $totalTaxFree, + vatAmount: $totalVat, pointsEarning: $totalPointsEarning, pointsUsed: $pointsUsageResult['actual_points'], paymentAmount: $paymentCalculation['payment_amount'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index b64395bd..9bd03aff 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -20,6 +20,7 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\SequenceType; use Modules\Sirsoft\Ecommerce\Enums\ShippingStatusEnum; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; +use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException; use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException; use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException; use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException; @@ -35,6 +36,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** * 주문 처리 서비스 @@ -124,6 +126,10 @@ class OrderProcessingService // ⚠️ 프론트엔드 전달 금액과 서버 재계산 금액 검증 $this->validateOrderAmount($calculationResult, $expectedTotalAmount); + // 마일리지 사용 정책 최종 재검증 (임시주문 생성 이후의 설정 변경·임시주문 조작 차단). + // 임시주문 단계에서 이미 통과한 값이라도 결제 확정 직전 한 번 더 판정한다. + $this->validateMileageUsagePolicy($tempOrder, $calculationResult); + // 통화 스냅샷 생성 $currencySnapshot = $this->buildCurrencySnapshot(); @@ -529,6 +535,9 @@ class OrderProcessingService 'total_amount' => $summary->finalAmount ?? 0, 'total_tax_amount' => $summary->taxableAmount ?? 0, 'total_tax_free_amount' => $summary->taxFreeAmount ?? 0, + // 부가세는 주문 생성 시점에 기록한다. 기존에는 부분취소 재계산에서만 갱신되어 + // 부분취소를 겪은 주문만 값이 정상화되고 나머지는 영구 0 이었다. + 'total_vat_amount' => $this->resolveVatAmount($calculationResult), 'total_points_used_amount' => $summary->pointsUsed ?? 0, 'total_deposit_used_amount' => 0, 'total_paid_amount' => 0, @@ -546,6 +555,11 @@ class OrderProcessingService 'ordered_at' => Carbon::now(), 'promotions_applied_snapshot' => $this->buildPromotionsAppliedSnapshot($calculationResult), 'shipping_policy_applied_snapshot' => $this->buildShippingPolicyAppliedSnapshot($calculationResult, $shippingInfo), + // 주문 시점 마일리지 사용 정책 고정 — 이후 관리자가 한도를 바꿔도 이 주문의 판정 근거는 불변 + 'mileage_policy_snapshot' => $this->userMileageService->buildUsagePolicySnapshot( + (int) ($summary->paymentAmount ?? 0), + (int) ($summary->pointsUsed ?? 0) + ), 'order_meta' => $this->buildOrderMeta($tempOrder), // 다중 통화 필드 'mc_subtotal_amount' => $mcAmounts['mc_subtotal_amount'], @@ -878,9 +892,7 @@ class OrderProcessingService // 결제 디바이스/UA 가 영구 NULL 로 남지 않는다. PG 결제 수단은 콜백이 더 정확한 값으로 덮어쓴다. $userAgent = request()->userAgent() ?? ''; - // 부가세(VAT): 과세표준(taxableAmount)에 내재된 부가세 = 과세표준 / 11 (공급가액의 10%). - $taxableAmount = (int) ($calculationResult->summary->taxableAmount ?? 0); - $vatAmount = $taxableAmount > 0 ? (int) round($taxableAmount / 11) : 0; + $vatAmount = $this->resolveVatAmount($calculationResult); $paymentData = [ 'payment_method' => $paymentMethod, @@ -939,9 +951,11 @@ class OrderProcessingService $paymentData['dbank_account'] = $dbankInfo['account_number'] ?? null; $paymentData['dbank_holder'] = $dbankInfo['account_holder'] ?? null; $paymentData['depositor_name'] = $depositorName ?? $dbankInfo['depositor_name'] ?? null; - // 주문별 명시 due_days(클라이언트 입력) 우선, 미지정 시 단일 SSoT auto_cancel_days + // 입금기한 단일 SSoT: auto_cancel_days (VBANK 와 동일 기준). + // 클라이언트가 보낸 due_days 는 무시한다 — 기한은 서버 정책이며, 이를 받아들이면 + // 미입금 자동취소 스케줄러와 안내 기한이 어긋난다. $paymentData['deposit_due_at'] = Carbon::now()->addDays( - $dbankInfo['due_days'] ?? module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', 3) + module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', 3) ); } @@ -1250,6 +1264,63 @@ class OrderProcessingService return ! $this->orderRepository->hasOrderByUser($userId); } + /** + * 주문/결제 레코드에 기록할 부가세를 결정합니다. + * + * 계산 단계에서 옵션별 세율로 산출한 합계를 그대로 사용합니다 — 여기서 다시 단일 세율로 + * 계산하면 세율이 섞인 주문에서 주문과 결제 값이 어긋납니다. + * + * 다만 부가세 합산 도입 이전에 만들어진 임시주문이 도입 이후에 결제되면 합계가 비어 있어 + * 부가세가 0 으로 굳습니다. 이 경우에 한해 과세표준에 기본 세율을 적용해 채웁니다 + * (기존 주문 백필과 동일한 기준). + * + * @param OrderCalculationResult $calculationResult 주문 계산 결과 + * @return int 부가세 금액 + */ + protected function resolveVatAmount(OrderCalculationResult $calculationResult): int + { + $summary = $calculationResult->summary; + $vatAmount = (int) ($summary->vatAmount ?? 0); + + if ($vatAmount > 0) { + return $vatAmount; + } + + $taxableAmount = (int) ($summary->taxableAmount ?? 0); + + return $taxableAmount > 0 + ? VatCalculator::fromTaxableAmount($taxableAmount, VatCalculator::DEFAULT_RATE) + : 0; + } + + /** + * 마일리지 사용 정책을 주문 확정 직전에 재검증합니다. + * + * 임시주문 단계 검증 이후에 관리자가 한도 설정을 바꾸거나 임시주문이 조작된 경우를 + * 차단하는 2차 방어선입니다. 판정 기준 금액은 마일리지 차감 전 결제금액입니다. + * + * @param TempOrder $tempOrder 임시 주문 + * @param OrderCalculationResult $calculationResult 재계산 결과 + * + * @throws MileageValidationException 정책 위반 시 + */ + protected function validateMileageUsagePolicy(TempOrder $tempOrder, OrderCalculationResult $calculationResult): void + { + $userId = $tempOrder->user_id; + $usePoints = (int) $tempOrder->getUsedPoints(); + + // 비회원은 마일리지 사용 불가 (임시주문 단계에서 이미 0) + if ($userId === null || $usePoints <= 0) { + return; + } + + $this->userMileageService->validateUsage( + $userId, + $usePoints, + (int) ($calculationResult->summary->paymentAmount ?? 0) + ); + } + /** * 임시 주문에서 계산 입력 DTO를 구성합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php index b49ca091..a8c2b583 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Ecommerce\Enums\SequenceType; use Modules\Sirsoft\Ecommerce\Exceptions\OptionHasOrderHistoryException; use Modules\Sirsoft\Ecommerce\Exceptions\ProductHasOrderHistoryException; +use Modules\Sirsoft\Ecommerce\Exceptions\ProductPriceRelationException; use Modules\Sirsoft\Ecommerce\Exceptions\StockMismatchException; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOption; @@ -587,6 +588,7 @@ class ProductService if (! empty($updateData)) { $product = $this->repository->find($productId); if ($product) { + $this->assertPriceRelation($product, $updateData); $updateData['updated_by'] = Auth::id(); $this->repository->update($product, $updateData); $productsUpdated++; @@ -618,6 +620,54 @@ class ProductService return $result; } + /** + * 상품과 그 옵션의 활동 로그를 합쳐 조회합니다. + * + * @param Product $product 대상 상품 + * @param array $filters 조회 필터 (per_page, sort_order) + * @return LengthAwarePaginator 활동 로그 페이지네이터 + */ + public function getActivityLogs(Product $product, array $filters = []): LengthAwarePaginator + { + return $this->repository->getActivityLogsForProduct($product, $filters); + } + + /** + * 실제로 적용될 가격 조합이 판매가 ≤ 정가를 지키는지 확인합니다. + * + * 일괄 수정은 정가/판매가 중 한쪽만 보내는 부분 전송이 흔해 FormRequest 는 두 값이 모두 + * 전송된 경우에만 비교합니다. 한쪽만 온 경우 나머지 한쪽은 DB 에 남아 있던 값이 그대로 + * 적용되므로, 단일 필드 전송으로 판매가를 정가 위로 올리는 우회로가 열립니다. + * 저장 직전에 DB 기존값과 합쳐 확정된 조합으로 다시 판정합니다. + * + * @param Product $product 대상 상품 (DB 기존값) + * @param array $updateData 적용될 수정 데이터 + * + * @throws ProductPriceRelationException 판매가가 정가를 초과하는 경우 + */ + protected function assertPriceRelation(Product $product, array $updateData): void + { + $incoming = static fn (string $key) => array_key_exists($key, $updateData) + && $updateData[$key] !== null + && $updateData[$key] !== ''; + + if (! $incoming('list_price') && ! $incoming('selling_price')) { + return; + } + + $listPrice = $incoming('list_price') + ? (float) $updateData['list_price'] + : (float) $product->list_price; + + $sellingPrice = $incoming('selling_price') + ? (float) $updateData['selling_price'] + : (float) $product->selling_price; + + if ($sellingPrice > $listPrice) { + throw new ProductPriceRelationException((int) $product->id); + } + } + /** * bulk_changes에 설정된 필드를 제외한 데이터 반환 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php index b9b94ccf..d9279ea6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php @@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\DTO\CalculationItem; use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult; use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress; use Modules\Sirsoft\Ecommerce\Exceptions\CartEmptyException; +use Modules\Sirsoft\Ecommerce\Exceptions\CartOperationException; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException; use Modules\Sirsoft\Ecommerce\Exceptions\TempOrderNotFoundException; @@ -90,6 +91,10 @@ class TempOrderService $calculationResult = $this->orderCalculationService->calculate($calculationInput); + // 마일리지 사용 정책(최소사용액/사용단위/최대한도) 최종 검증. + // 결제금액이 확정된 뒤라야 정률 한도를 판정할 수 있으므로 계산 직후에 둔다. + $this->assertPointsWithinUsagePolicy($userId, $validatedPoints, $calculationResult); + // 임시 주문 생성/수정 $tempOrder = DB::transaction(function () use ( $cartItems, @@ -222,14 +227,14 @@ class TempOrderService $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); if (! $matchedOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); + throw new CartOperationException('option_not_found'); } $optionId = $matchedOption->id; } else { $defaultOption = $productOptions->first(); if (! $defaultOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); + throw new CartOperationException('option_not_found'); } $optionId = $defaultOption->id; @@ -238,7 +243,7 @@ class TempOrderService // product/images 관계를 포함해 옵션 재조회 (검증·계산에 필요) $option = $this->productOptionRepository->findByIdsWithProduct([$optionId])->first(); if (! $option || ! $option->product) { - throw new \Exception(__('sirsoft-ecommerce::exceptions.option_not_found')); + throw new CartOperationException('option_not_found'); } // 추가옵션 선택 검증·정규화 (담기와 동일 서버 SSoT — D9/D12) @@ -361,6 +366,9 @@ class TempOrderService $calculationResult = $this->orderCalculationService->calculate($calculationInput); + // 마일리지 사용 정책(최소사용액/사용단위/최대한도) 최종 검증 (생성 경로와 동일 기준) + $this->assertPointsWithinUsagePolicy($userId, $validatedPoints, $calculationResult); + // 검증된 프로모션 정보 구성 (프론트 필드명 그대로 저장) $validatedPromotions = [ 'item_coupons' => $validatedItemCoupons, @@ -721,6 +729,37 @@ class TempOrderService return $usePoints; } + /** + * 마일리지 사용 정책 검증 (최소 사용액 / 사용 단위 / 최대 한도) + * + * validatePointsUsage() 는 계산 전 단계라 결제금액을 알 수 없어 잔액만 판정합니다. + * 정률 한도(max_use_percent)는 결제금액이 있어야 판정 가능하므로, 계산이 끝난 + * 직후(영속화 전)에 이 검증을 수행합니다. 판정 기준 금액은 마일리지 차감 전 + * 결제금액(summary.paymentAmount)입니다. + * + * @param int|null $userId 사용자 ID (비회원은 null) + * @param int $usePoints 사용 요청 마일리지 + * @param OrderCalculationResult $calculationResult 주문 계산 결과 + * + * @throws MileageValidationException 정책 위반 시 + */ + protected function assertPointsWithinUsagePolicy( + ?int $userId, + int $usePoints, + OrderCalculationResult $calculationResult + ): void { + // 비회원은 validatePointsUsage 에서 이미 0 으로 정규화됨 + if ($userId === null || $usePoints <= 0) { + return; + } + + $this->userMileageService->validateUsage( + $userId, + $usePoints, + (int) ($calculationResult->summary->paymentAmount ?? 0) + ); + } + /** * 단일 쿠폰 소유권 검증 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php index 55ece9d9..64fdd9a3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php @@ -211,21 +211,90 @@ class UserMileageService $cap = intdiv($cap, $unit) * $unit; } + // 최소 사용 금액 미만이면 애초에 사용이 불가하므로 안내값도 0 이어야 한다. + // (안내값이 하한을 모르면 "화면이 허용한 값인데 결제에서 거부" 가 된다) + $minUse = (int) ($rule['min_use_amount'] ?? 0); + if ($cap < $minUse) { + return 0; + } + return max(0, $cap); } + /** + * 마일리지 사용 정책(하한/단위/상한)을 반환합니다 (주문서 안내용). + * + * 검증(validateUsage)이 실제로 적용하는 값과 동일한 출처(통화 규칙)를 사용해, + * 화면 안내와 서버 판정이 어긋나지 않도록 합니다. + * + * @param int $paymentAmount 결제 금액 (정률 상한 계산 기준, 마일리지 차감 전) + * @param string|null $currency 통화 코드 (null 시 기본통화) + * @return array{min_use_amount: int, use_unit: int, max_use_amount: int} 사용 정책 + */ + public function getUsagePolicy(int $paymentAmount, ?string $currency = null): array + { + if (! $this->isMileageUsable()) { + return ['min_use_amount' => 0, 'use_unit' => 1, 'max_use_amount' => 0]; + } + + $rule = $this->currencyRule($currency ?? $this->defaultCurrency()); + + return [ + 'min_use_amount' => (int) ($rule['min_use_amount'] ?? 0), + 'use_unit' => max(1, (int) ($rule['use_unit'] ?? 1)), + 'max_use_amount' => $this->resolveAllowedUseAmount($rule, $paymentAmount), + ]; + } + + /** + * 주문 시점 마일리지 사용 정책 스냅샷을 생성합니다. + * + * 통화/프로모션/배송정책과 동일하게, 주문을 지배한 정책을 주문 행에 고정합니다. + * 관리자가 이후 한도를 바꿔도 그 주문이 어떤 정책 아래 성립했는지 재현 가능해야 + * 합니다(정산·감사·분쟁 대응). 사용액 자체는 원장(MileageTransaction)이 SSoT 이며, + * 이 스냅샷은 판정 근거를 보존하는 용도입니다. + * + * @param int $paymentAmount 판정 기준 결제금액 (마일리지 차감 전) + * @param int $usedPoints 실제 사용된 마일리지 + * @param string|null $currency 정산 통화 (null 시 기본통화) + * @return array 사용 정책 스냅샷 + */ + public function buildUsagePolicySnapshot(int $paymentAmount, int $usedPoints, ?string $currency = null): array + { + $currency = $currency ?? $this->defaultCurrency(); + $usable = $this->isMileageUsable(); + $rule = $usable ? $this->currencyRule($currency) : []; + + return [ + 'usable' => $usable, + 'currency' => $currency, + 'rule' => [ + 'point_value' => (float) ($rule['point_value'] ?? 1), + 'min_use_amount' => (int) ($rule['min_use_amount'] ?? 0), + 'use_unit' => max(1, (int) ($rule['use_unit'] ?? 1)), + 'max_use_type' => (string) ($rule['max_use_type'] ?? 'percent'), + 'max_use_percent' => (float) ($rule['max_use_percent'] ?? 100), + 'max_use_value' => (int) ($rule['max_use_value'] ?? 0), + ], + // 판정 근거 — 이 결제금액에 이 정책을 적용해 아래 상한이 나왔고, 그 안에서 사용됐다. + 'payment_amount_basis' => $paymentAmount, + 'max_use_amount' => $usable ? $this->resolveAllowedUseAmount($rule, $paymentAmount) : 0, + 'used_points' => $usedPoints, + ]; + } + /** * 마일리지 사용 검증 (min/use_unit/max/잔액) → 보정값 반환 또는 예외 * * @param int $userId 사용자 ID * @param int $usePoints 사용 요청 마일리지 - * @param int $paymentAmount 결제 금액 - * @param string $currency 통화 코드 + * @param int $paymentAmount 결제 금액 (마일리지 차감 전) + * @param string|null $currency 통화 코드 (null 시 기본통화 — canUse/getMaxUsable 과 동일 기준) * @return int 검증/보정된 사용 마일리지 * * @throws MileageValidationException 검증 실패 시 */ - public function validateUsage(int $userId, int $usePoints, int $paymentAmount, string $currency): int + public function validateUsage(int $userId, int $usePoints, int $paymentAmount, ?string $currency = null): int { if ($usePoints <= 0) { return 0; @@ -238,6 +307,7 @@ class UserMileageService ); } + $currency = $currency ?? $this->defaultCurrency(); $rule = $this->currencyRule($currency); $minUse = (int) ($rule['min_use_amount'] ?? 0); @@ -256,8 +326,12 @@ class UserMileageService $maxByLimit = $this->resolveMaxUseLimit($rule, $paymentAmount); if ($usePoints > $maxByLimit || $usePoints > $paymentAmount) { + // 사용 가능한 최대 금액을 함께 안내 — 얼마로 줄여야 결제되는지 알 수 없으면 + // 사용자는 값을 낮춰가며 재시도할 수밖에 없다. throw new MileageValidationException( - __('sirsoft-ecommerce::exceptions.mileage.exceeds_max_use') + __('sirsoft-ecommerce::exceptions.mileage.exceeds_max_use', [ + 'amount' => $this->resolveAllowedUseAmount($rule, $paymentAmount), + ]) ); } @@ -971,6 +1045,27 @@ class UserMileageService return (int) ($rule['max_use_value'] ?? PHP_INT_MAX); } + /** + * 실제로 사용 가능한 최대 금액을 계산합니다 (한도 · 결제금액 · 사용단위 반영). + * + * 잔액은 반영하지 않습니다 — 정책상 허용 한도이며, 잔액 부족은 별도 사유로 구분됩니다. + * + * @param array $rule 통화 규칙 + * @param int $paymentAmount 결제 금액 (마일리지 차감 전) + * @return int 정책상 사용 가능한 최대 금액 + */ + private function resolveAllowedUseAmount(array $rule, int $paymentAmount): int + { + $allowed = min($this->resolveMaxUseLimit($rule, $paymentAmount), $paymentAmount); + + $unit = (int) ($rule['use_unit'] ?? 1); + if ($unit > 1) { + $allowed = intdiv($allowed, $unit) * $unit; + } + + return max(0, $allowed); + } + /** * 통화 규칙을 조회합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/VatCalculator.php b/modules/_bundled/sirsoft-ecommerce/src/Support/VatCalculator.php new file mode 100644 index 00000000..93c9a2f9 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/VatCalculator.php @@ -0,0 +1,67 @@ + $taxClassification 옵션별 과세 분류 + * @return int 부가세 합계 + */ + public static function sumFromClassification(array $taxClassification): int + { + $total = 0; + + foreach ($taxClassification as $tax) { + if (isset($tax['vat_amount'])) { + $total += (int) $tax['vat_amount']; + + continue; + } + + $total += self::fromTaxableAmount( + (int) ($tax['taxable_amount'] ?? 0), + $tax['tax_rate'] ?? null + ); + } + + return $total; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php index b8d9beb6..fa15010d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php @@ -13,7 +13,7 @@ return [ 'deduct_exceeds_balance' => 'The mileage to deduct exceeds the available balance.', 'below_min_use_amount' => 'The minimum usable amount is :amount points.', 'invalid_use_unit' => 'Mileage can only be used in units of :unit points.', - 'exceeds_max_use' => 'Exceeds the maximum usable limit.', + 'exceeds_max_use' => 'Exceeds the maximum usable limit. (up to :amount points)', 'base_currency_rule_missing' => 'Mileage cannot be used because the mileage usage unit for the base currency is not configured.', ], diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index 49aea08b..43481635 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -38,6 +38,7 @@ return [ // List query common validation messages 'list' => [ + 'per_page_max' => 'You can retrieve up to :max items at a time.', // Pagination 'page' => [ 'integer' => 'Page number must be an integer.', @@ -243,6 +244,9 @@ return [ // Product validation messages 'product' => [ + 'mileage_percent_max' => 'The percentage earn rate cannot exceed 100%.', + 'selling_price_lte_list' => 'The selling price cannot be greater than the list price.', + 'price_min' => 'The price must be greater than 0.', // Human-readable field names shown in error messages (StoreProductRequest::attributes()) 'attributes' => [ 'name' => 'product name', @@ -937,6 +941,7 @@ return [ // Cart validation messages 'cart' => [ + 'quantity_limit_exceeded' => 'You can add up to :limit item(s) per product to the cart. (requested: :attempted)', 'ids_required' => 'Please select items to delete.', 'ids_array' => 'Item IDs must be an array.', 'ids_min' => 'Please select at least one item.', @@ -947,7 +952,7 @@ return [ 'option_not_found' => 'Option not found.', 'quantity_required' => 'Please enter a quantity.', 'quantity_min' => 'Quantity must be at least 1.', - 'quantity_max' => 'Quantity cannot exceed 9,999.', + 'quantity_max' => 'Quantity cannot exceed :max.', 'items_required' => 'Please select items to add to cart.', 'items_min' => 'Please select at least one item.', 'selected_ids_array' => 'Selected cart item IDs must be an array.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php index f33088d5..60f02532 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php @@ -13,7 +13,7 @@ return [ 'deduct_exceeds_balance' => '차감하려는 마일리지가 잔액을 초과합니다.', 'below_min_use_amount' => '최소 사용 금액은 :amount점입니다.', 'invalid_use_unit' => '마일리지는 :unit점 단위로 사용할 수 있습니다.', - 'exceeds_max_use' => '최대 사용 한도를 초과했습니다.', + 'exceeds_max_use' => '최대 사용 한도를 초과했습니다. (사용 가능 최대 :amount점)', 'base_currency_rule_missing' => '기본 통화에 대한 마일리지 사용 단위가 설정되지 않아 마일리지를 사용할 수 없습니다.', ], diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 921470f6..4ea94e3d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -38,6 +38,7 @@ return [ // 목록 조회 공통 검증 메시지 'list' => [ + 'per_page_max' => '한 번에 조회할 수 있는 최대 개수는 :max개입니다.', // 페이지네이션 'page' => [ 'integer' => '페이지 번호는 숫자여야 합니다.', @@ -243,6 +244,9 @@ return [ // 상품 검증 메시지 'product' => [ + 'mileage_percent_max' => '정률 적립률은 100% 를 초과할 수 없습니다.', + 'selling_price_lte_list' => '판매가는 정가보다 클 수 없습니다.', + 'price_min' => '가격은 0보다 커야 합니다.', // 에러 메시지에 노출되는 한글 필드명 (StoreProductRequest::attributes()) 'attributes' => [ 'name' => '상품명', @@ -937,6 +941,7 @@ return [ // 장바구니 검증 메시지 'cart' => [ + 'quantity_limit_exceeded' => '장바구니에는 상품당 최대 :limit개까지 담을 수 있습니다. (요청: :attempted개)', 'ids_required' => '삭제할 상품을 선택해주세요.', 'ids_array' => '상품 ID는 배열 형태여야 합니다.', 'ids_min' => '최소 1개 이상의 상품을 선택해주세요.', @@ -947,7 +952,7 @@ return [ 'option_not_found' => '존재하지 않는 옵션입니다.', 'quantity_required' => '수량을 입력해주세요.', 'quantity_min' => '수량은 1개 이상이어야 합니다.', - 'quantity_max' => '수량은 최대 9,999개까지 가능합니다.', + 'quantity_max' => '수량은 최대 :max개까지 가능합니다.', 'items_required' => '장바구니에 담을 상품을 선택해주세요.', 'items_min' => '최소 1개 이상의 상품을 선택해주세요.', 'selected_ids_array' => '선택된 장바구니 상품 ID는 배열 형태여야 합니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Cart/CartQuantityAndPurchaseLimitTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Cart/CartQuantityAndPurchaseLimitTest.php new file mode 100644 index 00000000..59ca9665 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Cart/CartQuantityAndPurchaseLimitTest.php @@ -0,0 +1,564 @@ +create([ + 'shipping_policy_id' => null, + 'list_price' => 10000, + 'selling_price' => 10000, + 'max_purchase_qty' => $maxPurchaseQty, + 'stock_quantity' => $stock, + ]); + + return ProductOptionFactory::new()->forProduct($product)->create([ + 'selling_price' => 10000, + 'price_adjustment' => 0, + 'stock_quantity' => $stock, + ]); + } + + // ======================================== + // E7 — 장바구니 수량 상한 + // ======================================== + + /** + * 설정 상한을 넘는 담기는 차단되는지 테스트합니다. + */ + public function test_add_over_cart_quantity_limit_is_blocked(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(); + + $this->expectException(CartQuantityLimitException::class); + + app(CartService::class)->bulkAddToCart([ + 'user_id' => $user->id, + 'product_id' => $option->product_id, + 'items' => [ + ['product_option_id' => $option->id, 'quantity' => 100], + ], + ]); + } + + /** + * 기존 수량과 합산해 상한을 넘으면 차단되는지 테스트합니다. + * + * 필드 규칙(`max:`)은 이번 요청분만 보므로 누적 경로는 서버가 따로 판정해야 한다. + */ + public function test_accumulated_quantity_over_limit_is_blocked(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(); + + CartFactory::new()->forUser($user)->forOption($option)->create(['quantity' => 90]); + + $this->expectException(CartQuantityLimitException::class); + + app(CartService::class)->bulkAddToCart([ + 'user_id' => $user->id, + 'product_id' => $option->product_id, + 'items' => [ + ['product_option_id' => $option->id, 'quantity' => 20], + ], + ]); + } + + /** + * 상한과 정확히 같은 합산 수량은 통과하는지 테스트합니다 (경계). + */ + public function test_accumulated_quantity_at_limit_passes(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(); + + CartFactory::new()->forUser($user)->forOption($option)->create(['quantity' => 90]); + + app(CartService::class)->bulkAddToCart([ + 'user_id' => $user->id, + 'product_id' => $option->product_id, + 'items' => [ + ['product_option_id' => $option->id, 'quantity' => 9], + ], + ]); + + $this->assertSame(99, (int) Cart::where('user_id', $user->id)->sum('quantity')); + } + + // ======================================== + // E8 — 구매수량 한도 + // ======================================== + + /** + * 담기 경로에서 구매수량 한도가 강제되는지 테스트합니다. + */ + public function test_add_to_cart_enforces_purchase_quantity_limit(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(maxPurchaseQty: 5); + + $this->expectException(CartUnavailableException::class); + + app(CartService::class)->bulkAddToCart([ + 'user_id' => $user->id, + 'product_id' => $option->product_id, + 'items' => [ + ['product_option_id' => $option->id, 'quantity' => 8], + ], + ]); + } + + /** + * 옵션 변경 경로에서 구매수량 한도가 강제되는지 테스트합니다. + * + * @scenario entry=change_option + * + * @effects change_option_blocks_above_max_qty + */ + public function test_change_option_enforces_purchase_quantity_limit(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(maxPurchaseQty: 5); + $other = ProductOptionFactory::new() + ->forProduct($option->product) + ->create(['selling_price' => 10000, 'price_adjustment' => 0, 'stock_quantity' => 1000]); + + $cart = CartFactory::new()->forUser($user)->forOption($option)->create(['quantity' => 1]); + + $this->expectException(CartUnavailableException::class); + + app(CartService::class)->changeOption($cart->id, $other->id, 8, $user->id, null); + } + + /** + * 한도 이내 옵션 변경은 통과하는지 테스트합니다. + */ + public function test_change_option_within_limit_passes(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(maxPurchaseQty: 5); + $other = ProductOptionFactory::new() + ->forProduct($option->product) + ->create(['selling_price' => 10000, 'price_adjustment' => 0, 'stock_quantity' => 1000]); + + $cart = CartFactory::new()->forUser($user)->forOption($option)->create(['quantity' => 1]); + + $updated = app(CartService::class)->changeOption($cart->id, $other->id, 5, $user->id, null); + + $this->assertSame(5, $updated->quantity); + } + + /** + * 수량 상한 초과 안내 문구가 실제 상한값을 말하는지 테스트합니다. + * + * 규칙은 `config('sirsoft-ecommerce.cart.max_quantity')` 를 읽는데 안내 문구가 다른 숫자를 + * 고정하고 있으면, 사용자는 "최대 N개까지 가능" 이라는 안내를 보면서 그보다 적은 수량에서 + * 차단당한다 — 차단 사유와 안내가 서로 다른 말을 하는 상태다. + */ + public function test_quantity_limit_message_states_the_configured_limit(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(); + + $cart = CartFactory::new()->forOption($option)->create([ + 'user_id' => $user->id, + 'quantity' => 1, + ]); + + $response = $this->actingAs($user, 'sanctum') + ->patchJson("/api/modules/sirsoft-ecommerce/cart/{$cart->id}/quantity", ['quantity' => 100]) + ->assertStatus(422); + + $message = json_encode($response->json('errors'), JSON_UNESCAPED_UNICODE); + + $this->assertStringContainsString('99', $message, '안내 문구가 설정 상한을 말하지 않습니다.'); + $this->assertStringNotContainsString( + '9,999', + $message, + '안내 문구가 실제 상한(99)이 아닌 옛 하드코딩 값(9,999)을 말하고 있습니다.' + ); + } + + /** + * 비회원 병합은 예외 대신 상한까지 클램프하고 조정 내역을 남기는지 테스트합니다. + * + * 로그인 시 자동 병합이라 예외를 던지면 로그인 자체가 실패한다. + */ + public function test_guest_merge_clamps_instead_of_throwing(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 5]); + + $user = User::factory()->create(); + $option = $this->makeOption(); + $cartKey = 'ck_'.str_repeat('m', 32); + + CartFactory::new()->forOption($option)->create([ + 'user_id' => null, + 'cart_key' => $cartKey, + 'quantity' => 10, + ]); + + $service = app(CartService::class); + $merged = $service->mergeGuestCartToUser($cartKey, $user->id); + + $this->assertSame(1, $merged); + + // 조용한 클램프 금지 — 조정 내역이 반환되어야 한다 + $adjustments = $service->getLastMergeAdjustments(); + $this->assertCount(1, $adjustments); + $this->assertSame(10, $adjustments[0]['requested']); + $this->assertSame(5, $adjustments[0]['applied']); + } + + /** + * 병합 응답이 조정 내역을 실제로 사용자에게 전달하는지 테스트합니다. + * + * 서비스가 내역을 들고 있어도 응답에 실리지 않으면 사용자 관점에서는 여전히 조용한 + * 클램프다 — 계약의 양 끝을 모두 고정한다. + * + * @scenario entry=guest_merge + * + * @effects guest_merge_reports_clamp_adjustments + */ + public function test_merge_endpoint_exposes_clamp_adjustments(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 5]); + + $user = $this->createUser(); + $option = $this->makeOption(); + $cartKey = 'ck_'.str_repeat('n', 32); + + CartFactory::new()->forOption($option)->create([ + 'user_id' => null, + 'cart_key' => $cartKey, + 'quantity' => 10, + ]); + + $response = $this->actingAs($user, 'sanctum') + ->withHeaders(['X-Cart-Key' => $cartKey]) + ->postJson('/api/modules/sirsoft-ecommerce/cart/merge') + ->assertStatus(200); + + $adjustments = $response->json('data.adjustments'); + + $this->assertIsArray($adjustments, '병합 응답에 조정 내역이 없습니다 — 사용자는 수량이 줄어든 것을 알 수 없습니다.'); + $this->assertCount(1, $adjustments); + $this->assertSame(10, $adjustments[0]['requested']); + $this->assertSame(5, $adjustments[0]['applied']); + } + + /** + * 비회원 병합이 상품별 구매수량 한도까지 클램프하는지 테스트합니다. + * + * 병합은 장바구니 전역 상한(`cart.max_quantity`)만 보고 상품 정책(`max_purchase_qty`)을 + * 보지 않으면, 한도 5짜리 상품을 비회원으로 10개 담고 로그인하는 것만으로 한도를 + * 통과시킬 수 있다 — 담기·수량변경·옵션변경 경로를 모두 막아도 남는 우회로다. + * + * @scenario entry=guest_merge + * + * @effects guest_merge_clamps_to_product_limit + */ + public function test_guest_merge_clamps_to_product_purchase_limit(): void + { + // 전역 상한은 넉넉히 — 상품 한도만으로 클램프되어야 한다 + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = User::factory()->create(); + $option = $this->makeOption(maxPurchaseQty: 5); + $cartKey = 'ck_'.str_repeat('p', 32); + + CartFactory::new()->forOption($option)->create([ + 'user_id' => null, + 'cart_key' => $cartKey, + 'quantity' => 10, + ]); + + $service = app(CartService::class); + $service->mergeGuestCartToUser($cartKey, $user->id); + + $merged = Cart::where('user_id', $user->id)->first(); + $this->assertNotNull($merged); + $this->assertSame( + 5, + (int) $merged->quantity, + '병합이 상품 구매수량 한도를 넘겼습니다 — 비회원으로 담고 로그인하면 한도가 무력화됩니다.' + ); + + $adjustments = $service->getLastMergeAdjustments(); + $this->assertCount(1, $adjustments); + $this->assertSame(10, $adjustments[0]['requested']); + $this->assertSame(5, $adjustments[0]['applied']); + } + + /** + * 병합의 한도 판정이 같은 상품의 기존 회원 장바구니 수량까지 합산하는지 테스트합니다. + * + * 담기 경로는 옵션을 나눠 담는 우회를 막으려고 상품 총수량으로 판정한다. 병합만 라인 + * 단위로 판정하면 "회원 장바구니에 4개 + 비회원 장바구니에 다른 옵션 4개" 로 한도 5를 + * 넘길 수 있다. + * + * @scenario entry=guest_merge + * + * @effects guest_merge_limit_counts_existing_lines + */ + public function test_guest_merge_limit_counts_existing_member_cart_lines(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = User::factory()->create(); + $optionA = $this->makeOption(maxPurchaseQty: 5); + $optionB = ProductOptionFactory::new()->forProduct($optionA->product)->create([ + 'selling_price' => 10000, + 'price_adjustment' => 0, + 'stock_quantity' => 1000, + ]); + $cartKey = 'ck_'.str_repeat('q', 32); + + CartFactory::new()->forOption($optionA)->create([ + 'user_id' => $user->id, + 'cart_key' => 'ck_'.str_repeat('r', 32), + 'quantity' => 4, + ]); + CartFactory::new()->forOption($optionB)->create([ + 'user_id' => null, + 'cart_key' => $cartKey, + 'quantity' => 4, + ]); + + $service = app(CartService::class); + $service->mergeGuestCartToUser($cartKey, $user->id); + + $total = (int) Cart::where('user_id', $user->id) + ->where('product_id', $optionA->product_id) + ->sum('quantity'); + + $this->assertSame( + 5, + $total, + '옵션을 나눠 담으면 병합에서 상품 한도를 넘길 수 있습니다.' + ); + } + + /** + * 재주문에서 구매수량 한도를 넘는 항목이 사유와 함께 제외되는지 테스트합니다. + * + * 재주문은 여러 항목을 한 번에 담는 조작이라, 한 항목 때문에 전체를 실패시키면 나머지 + * 정상 항목까지 담기지 않는다. 품절·단종과 동일하게 항목 단위로 제외하고 사유를 + * 돌려주되, 한도를 넘긴 수량이 장바구니에 들어가서는 안 된다. + * + * @scenario entry=reorder + * + * @effects reorder_skips_items_over_limit_with_reason + */ + public function test_reorder_skips_items_over_purchase_limit_with_reason(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(maxPurchaseQty: 5); + + $order = OrderFactory::new()->create(['user_id' => $user->id]); + OrderOptionFactory::new()->forOrder($order)->create([ + 'product_id' => $option->product_id, + 'product_option_id' => $option->id, + 'parent_option_id' => null, + 'quantity' => 8, + ]); + + $result = app(CartService::class)->reorderFromOrder($order->id, $user->id); + + $this->assertSame(0, $result['added_count']); + $this->assertCount(1, $result['skipped']); + $this->assertNotSame('', (string) $result['skipped'][0]['reason'], '제외 사유가 비어 있으면 사용자가 이유를 알 수 없습니다.'); + $this->assertSame(0, Cart::where('user_id', $user->id)->count(), '한도를 넘는 수량이 장바구니에 들어갔습니다.'); + } + + /** + * 실제 로그인 경로에서 한도 초과 비회원 장바구니가 로그인을 깨뜨리지 않는지 테스트합니다. + * + * 병합이 클램프를 택한 유일한 근거가 "로그인 시 자동 병합이라 예외를 던지면 로그인 자체가 + * 실패한다" 였다. 그 근거를 서비스 직접 호출로만 검증하면 실제 로그인 왕복은 무가드다. + * + * 동시에 병합이 **실제로 일어났는지**까지 단언한다. 로그인이 200 이어도 장바구니가 + * 넘어오지 않으면 사용자는 담아둔 것을 잃은 채 로그인만 된다 — 조용한 소실이다. + * + * @scenario entry=guest_merge + * + * @effects guest_merge_clamps_to_product_limit + */ + public function test_login_with_over_limit_guest_cart_succeeds_and_merges_clamped(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = User::factory()->create([ + 'email' => 'merge-on-login@example.com', + 'password' => Hash::make('correct-password'), + 'status' => 'active', + ]); + $option = $this->makeOption(maxPurchaseQty: 5); + $cartKey = 'ck_'.str_repeat('s', 32); + + CartFactory::new()->forOption($option)->create([ + 'user_id' => null, + 'cart_key' => $cartKey, + 'quantity' => 10, + ]); + + $this->withHeaders(['X-Cart-Key' => $cartKey]) + ->postJson('/api/auth/login', [ + 'email' => 'merge-on-login@example.com', + 'password' => 'correct-password', + ]) + ->assertStatus(200); + + $merged = Cart::where('user_id', $user->id)->first(); + + $this->assertNotNull( + $merged, + '로그인은 성공했으나 비회원 장바구니가 회원 계정으로 넘어오지 않았습니다 — 담아둔 항목이 조용히 사라집니다.' + ); + $this->assertSame( + 5, + (int) $merged->quantity, + '병합이 상품 구매수량 한도를 넘겼습니다.' + ); + $this->assertSame(0, Cart::whereNull('user_id')->where('cart_key', $cartKey)->count()); + } + + /** + * 한도 이내 항목은 재주문으로 정상 담기는지 테스트합니다 (제외 로직이 과잉 차단하지 않음). + */ + public function test_reorder_adds_items_within_purchase_limit(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $user = $this->createUser(); + $option = $this->makeOption(maxPurchaseQty: 5); + + $order = OrderFactory::new()->create(['user_id' => $user->id]); + OrderOptionFactory::new()->forOrder($order)->create([ + 'product_id' => $option->product_id, + 'product_option_id' => $option->id, + 'parent_option_id' => null, + 'quantity' => 3, + ]); + + $result = app(CartService::class)->reorderFromOrder($order->id, $user->id); + + $this->assertSame(1, $result['added_count']); + $this->assertSame([], $result['skipped']); + $this->assertSame(3, (int) Cart::where('user_id', $user->id)->sum('quantity')); + } + + /** + * 무통장 입금기한이 클라이언트 입력이 아니라 서버 설정을 따르는지 테스트합니다 (E6). + * + * 수정 전: `dbank.due_days` 를 클라이언트가 보내면 그대로 반영되어 자동취소 스케줄러와 + * 안내 기한이 어긋났다. VBANK 는 이미 서버 설정 단일 SSoT 였다. + */ + public function test_dbank_due_date_uses_server_setting_not_client_input(): void + { + config(['sirsoft-ecommerce.cart.max_quantity' => 99]); + + $settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + if (! is_dir($settingsDir)) { + mkdir($settingsDir, 0755, true); + } + file_put_contents($settingsDir.'/order_settings.json', json_encode(['auto_cancel_days' => 1])); + + try { + $option = $this->makeOption(); + $user = $this->createUser(); + + $cart = CartFactory::new()->forUser($user)->forOption($option)->create(['quantity' => 1]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', ['item_ids' => [$cart->id]]) + ->assertStatus(201); + + $this->actingAs($user, 'sanctum')->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + [ + 'orderer' => ['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'dbank@test.com'], + 'shipping' => [ + 'recipient_name' => '김철수', + 'recipient_phone' => '010-9876-5432', + 'country_code' => 'KR', + 'zipcode' => '12345', + 'address' => '서울시 강남구 테헤란로 123', + 'address_detail' => '101동 1001호', + ], + 'payment_method' => 'dbank', + 'expected_total_amount' => 10000, + 'depositor_name' => '홍길동', + 'dbank' => [ + 'bank_code' => 'KB', + 'bank_name' => '국민은행', + 'account_number' => '123-456-789012', + 'account_holder' => '주식회사 테스트', + // 클라이언트가 30일을 보내도 서버 설정(1일)이 이겨야 한다 + 'due_days' => 30, + ], + ] + )->assertStatus(201); + + $order = Order::where('user_id', $user->id)->latest('id')->first(); + $dueAt = $order->payment->deposit_due_at; + + $this->assertNotNull($dueAt); + $this->assertSame(1, (int) round(now()->diffInDays($dueAt, false))); + } finally { + $path = $settingsDir.'/order_settings.json'; + if (file_exists($path)) { + unlink($path); + } + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductControllerCopyTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductControllerCopyTest.php index 108383dc..94256ead 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductControllerCopyTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductControllerCopyTest.php @@ -296,4 +296,21 @@ class ProductControllerCopyTest extends ModuleTestCase $this->assertArrayNotHasKey('created_at', $response->json('data')); $this->assertArrayNotHasKey('updated_at', $response->json('data')); } + + /** + * 불리언이 아닌 복사 플래그는 거절된다 + * + * 이전에는 검증 없이 boolean() 캐스팅만 해서, 오타나 잘못된 값이 조용히 false 로 해석돼 + * "복사했는데 특정 항목만 빠져 있다" 는 결과가 나왔다. + */ + public function test_show_for_copy_rejects_non_boolean_flag(): void + { + $user = $this->createAdminUser(['sirsoft-ecommerce.products.read']); + $product = Product::factory()->create(); + + $this->actingAs($user) + ->getJson("/api/modules/sirsoft-ecommerce/admin/products/{$product->id}/copy?copy_images=maybe") + ->assertStatus(422) + ->assertJsonValidationErrors(['copy_images']); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductLogsControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductLogsControllerTest.php new file mode 100644 index 00000000..8e652189 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductLogsControllerTest.php @@ -0,0 +1,72 @@ +id}/logs"; + + return $query === '' ? $url : "{$url}?{$query}"; + } + + /** + * 기본 조회가 정상 응답한다 + */ + public function test_logs_returns_paginated_response(): void + { + $user = $this->createAdminUser(['sirsoft-ecommerce.products.read']); + $product = Product::factory()->create(); + + $this->actingAs($user) + ->getJson($this->url($product)) + ->assertOk() + ->assertJsonStructure(['data' => ['data', 'meta']]); + } + + /** + * 상한을 넘는 per_page 는 거절된다 + */ + public function test_logs_rejects_per_page_over_limit(): void + { + $user = $this->createAdminUser(['sirsoft-ecommerce.products.read']); + $product = Product::factory()->create(); + + $this->actingAs($user) + ->getJson($this->url($product, 'per_page=99999')) + ->assertStatus(422) + ->assertJsonValidationErrors(['per_page']); + } + + /** + * 허용되지 않은 정렬 방향은 거절된다 + */ + public function test_logs_rejects_unknown_sort_order(): void + { + $user = $this->createAdminUser(['sirsoft-ecommerce.products.read']); + $product = Product::factory()->create(); + + $this->actingAs($user) + ->getJson($this->url($product, 'sort_order=sideways')) + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_order']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Mileage/MileageUsageLimitTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Mileage/MileageUsageLimitTest.php new file mode 100644 index 00000000..d4edd85f --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Mileage/MileageUsageLimitTest.php @@ -0,0 +1,492 @@ +settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + if (! is_dir($this->settingsDir)) { + mkdir($this->settingsDir, 0755, true); + } + } + + protected function tearDown(): void + { + foreach (['mileage.json', 'language_currency.json'] as $file) { + $path = $this->settingsDir.'/'.$file; + if (file_exists($path)) { + unlink($path); + } + } + + parent::tearDown(); + } + + /** + * 마일리지 통화 규칙을 설정 파일로 기록합니다. + * + * @param array $ruleOverrides 통화 규칙 오버라이드 + */ + private function writeMileageRule(array $ruleOverrides = []): void + { + $rule = array_merge([ + 'currency_code' => 'KRW', + 'point_value' => 1, + 'min_use_amount' => 0, + 'use_unit' => 1, + 'max_use_type' => 'percent', + 'max_use_percent' => 100, + 'max_use_value' => 0, + ], $ruleOverrides); + + file_put_contents($this->settingsDir.'/mileage.json', json_encode([ + 'enabled' => true, + 'default_earn_rate' => 0, + 'earn_trigger' => 'confirmed', + 'earn_delay_days' => 0, + 'currency_rules' => [$rule], + 'expiry_enabled' => false, + 'expiry_days' => 365, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + file_put_contents($this->settingsDir.'/language_currency.json', json_encode([ + 'default_currency' => 'KRW', + ], JSON_PRETTY_PRINT)); + } + + /** + * 회원에게 마일리지를 지급합니다. + * + * @param int $userId 회원 ID + * @param int $amount 지급 금액 + */ + private function grantMileage(int $userId, int $amount): MileageTransaction + { + return MileageTransaction::create([ + 'user_id' => $userId, + 'currency' => 'KRW', + 'type' => MileageTransactionTypeEnum::ADMIN_EARN, + 'amount' => $amount, + 'remaining_amount' => $amount, + 'balance_after' => $amount, + 'description' => 'test grant', + 'expires_at' => null, + ]); + } + + /** + * 판매중 상품 + 장바구니를 구성합니다. + * + * 결제금액을 정확히 통제해야 정률 한도를 검증할 수 있으므로 상품가를 명시 지정하고 + * 배송정책은 붙이지 않습니다(정책 없음 = KR 배송 가능 + 배송비 0). + * + * @param User|null $user 회원 (null 이면 비회원 장바구니) + * @param int $sellingPrice 판매가 + * @param string|null $cartKey 비회원 장바구니 키 + */ + private function setupCart(?User $user, int $sellingPrice = 10000, ?string $cartKey = null): Cart + { + $product = ProductFactory::new()->create([ + 'shipping_policy_id' => null, + 'list_price' => $sellingPrice, + 'selling_price' => $sellingPrice, + ]); + $option = ProductOptionFactory::new()->forProduct($product)->create([ + 'selling_price' => $sellingPrice, + 'price_adjustment' => 0, + 'stock_quantity' => 100, + ]); + + $factory = CartFactory::new()->forOption($option); + + if ($user !== null) { + return $factory->forUser($user)->create(['quantity' => 1]); + } + + return $factory->create([ + 'user_id' => null, + 'cart_key' => $cartKey, + 'quantity' => 1, + ]); + } + + /** + * 회원 주문 생성 페이로드를 구성합니다. + * + * @param int $expectedTotalAmount 결제예정금액 + */ + private function memberOrderPayload(int $expectedTotalAmount): array + { + return [ + 'orderer' => [ + 'name' => '홍길동', + 'phone' => '010-1234-5678', + 'email' => 'member@test.com', + ], + 'shipping' => [ + 'recipient_name' => '김철수', + 'recipient_phone' => '010-9876-5432', + 'country_code' => 'KR', + 'zipcode' => '12345', + 'address' => '서울시 강남구 테헤란로 123', + 'address_detail' => '101동 1001호', + ], + 'payment_method' => PaymentMethodEnum::DBANK->value, + 'expected_total_amount' => $expectedTotalAmount, + 'depositor_name' => '홍길동', + 'dbank' => [ + 'bank_code' => 'KB', + 'bank_name' => '국민은행', + 'account_number' => '123-456-789012', + 'account_holder' => '주식회사 테스트', + ], + ]; + } + + // ======================================== + // 임시주문 경로 — 정률 상한 / 사용 단위 / 최소 사용액 + // ======================================== + + /** + * 정률 상한(max_use_percent)을 초과하는 사용 요청은 422 로 차단됩니다. + * + * 수정 전: 잔액만 확인해 201 로 통과 → 결제금액 전액을 마일리지로 충당 가능. + */ + public function test_use_points_exceeding_max_use_percent_returns_422(): void + { + // Given: 결제금액의 30% 까지만 사용 가능한 설정 + 잔액 10,000 회원 + 결제금액 10,000 + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 30]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + // When: 결제금액 전액(10,000)을 마일리지로 사용 시도 + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 10000, + ]); + + // Then: 422 + 사용 가능 최대 금액(3,000) 안내 + $response->assertStatus(422); + $this->assertStringContainsString('3000', $response->json('errors.message') ?? ''); + } + + /** + * 정률 상한과 정확히 같은 금액은 통과합니다 (경계). + */ + public function test_use_points_exactly_max_use_percent_passes(): void + { + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 30]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 3000, + ]); + + $response->assertStatus(201); + } + + /** + * 정액 상한(max_use_value)을 초과하는 사용 요청은 422 로 차단됩니다. + */ + public function test_use_points_exceeding_max_use_value_returns_422(): void + { + $this->writeMileageRule(['max_use_type' => 'fixed', 'max_use_value' => 2000]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 5000, + ]); + + $response->assertStatus(422); + } + + /** + * 사용 단위(use_unit)에 맞지 않는 사용 요청은 422 로 차단됩니다. + */ + public function test_use_points_violating_use_unit_returns_422(): void + { + $this->writeMileageRule(['use_unit' => 1000]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 1500, + ]); + + $response->assertStatus(422); + } + + /** + * 최소 사용액(min_use_amount) 미만 사용 요청은 422 로 차단됩니다. + */ + public function test_use_points_below_min_use_amount_returns_422(): void + { + $this->writeMileageRule(['min_use_amount' => 5000]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 1000, + ]); + + $response->assertStatus(422); + } + + /** + * 임시주문 수정(PUT) 경로에서도 동일하게 차단됩니다. + */ + public function test_use_points_exceeding_limit_on_update_returns_422(): void + { + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 30]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + ]) + ->assertStatus(201); + + $response = $this->actingAs($user, 'sanctum') + ->putJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'use_points' => 10000, + ]); + + $response->assertStatus(422); + } + + // ======================================== + // 주문 확정 경로 — 2단 방어 + // ======================================== + + /** + * 임시주문 생성 후 한도 설정이 강화되면 주문 확정 시점에 차단됩니다. + * + * 임시주문 단계 검증만 두면 "먼저 담아둔 임시주문"으로 정책을 우회할 수 있습니다. + */ + public function test_order_create_blocked_when_limit_tightened_after_temp_order(): void + { + // Given: 한도 100% 인 상태에서 결제금액 50,000 중 10,000 마일리지 사용 임시주문 생성 + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 100]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 50000); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 10000, + ]) + ->assertStatus(201); + + // When: 관리자가 한도를 10%(=5,000) 로 낮춘 뒤 주문 확정 시도 + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 10]); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/user/orders', $this->memberOrderPayload(40000)); + + // Then: 422 로 차단되고 주문이 생성되지 않는다 + $response->assertStatus(422); + $response->assertJsonPath('errors.code', 'mileage_usage_not_allowed'); + $this->assertDatabaseCount('ecommerce_orders', 0); + } + + /** + * 한도 내 마일리지 사용 주문은 정상 생성되고, 주문 시점 정책이 스냅샷으로 고정됩니다. + * + * 통화·프로모션·배송정책과 동일하게, 이후 관리자가 한도를 바꿔도 그 주문이 어떤 정책 + * 아래 성립했는지 재현 가능해야 합니다. + */ + public function test_order_create_passes_and_snapshots_usage_policy(): void + { + $this->writeMileageRule([ + 'max_use_type' => 'percent', + 'max_use_percent' => 100, + 'min_use_amount' => 1000, + 'use_unit' => 10, + ]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 50000); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 10000, + ]) + ->assertStatus(201); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/user/orders', $this->memberOrderPayload(40000)); + + $response->assertStatus(201); + + $order = Order::where('user_id', $user->id)->latest('id')->first(); + $snapshot = $order->mileage_policy_snapshot; + + $this->assertTrue($snapshot['usable']); + $this->assertSame('KRW', $snapshot['currency']); + $this->assertSame(1000, $snapshot['rule']['min_use_amount']); + $this->assertSame(10, $snapshot['rule']['use_unit']); + $this->assertSame('percent', $snapshot['rule']['max_use_type']); + $this->assertSame(50000, $snapshot['payment_amount_basis']); + $this->assertSame(50000, $snapshot['max_use_amount']); + $this->assertSame(10000, $snapshot['used_points']); + + // 주문 이후 관리자가 한도를 낮춰도 이미 생성된 주문의 스냅샷은 불변 + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 10]); + // JSON 왕복에서 100.0 이 100 으로 표현될 수 있어 값 비교로 단언 + $this->assertEquals(100, $order->fresh()->mileage_policy_snapshot['rule']['max_use_percent']); + } + + // ======================================== + // 무회귀 — 조회 경로 / 비회원 / 기본 설정 + // ======================================== + + /** + * 한도를 초과하게 된 임시주문이 있어도 체크아웃 조회는 200 을 유지합니다. + * + * 조회 경로에서 던지면 설정 변경 직후 사용자의 주문서 화면이 통째로 붕괴합니다. + */ + public function test_checkout_preview_still_returns_200_when_limit_tightened(): void + { + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 100]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 50000); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 10000, + ]) + ->assertStatus(201); + + // When: 한도를 10% 로 낮춘 뒤 주문서 조회 + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 10]); + + $response = $this->actingAs($user, 'sanctum') + ->getJson('/api/modules/sirsoft-ecommerce/checkout'); + + // Then: 조회는 정상 (쓰기 경로에서만 차단) + $response->assertStatus(200); + } + + /** + * 비회원의 마일리지 사용 요청은 예외 없이 조용히 0 으로 처리됩니다. + */ + public function test_guest_use_points_is_silently_ignored(): void + { + $this->writeMileageRule(['max_use_type' => 'percent', 'max_use_percent' => 10]); + $cartKey = 'ck_'.str_repeat('e', 32); + $cart = $this->setupCart(null, 10000, $cartKey); + + $response = $this->postJson( + '/api/modules/sirsoft-ecommerce/checkout', + ['item_ids' => [$cart->id], 'use_points' => 9000], + ['X-Cart-Key' => $cartKey] + ); + + $response->assertStatus(201); + $this->assertSame(0, (int) $response->json('data.calculation.summary.points_used')); + } + + /** + * 한도 미설정(기본 통화 규칙: 하한 0 / 단위 1 / 100%) 사이트는 기존과 동일하게 통과합니다. + */ + public function test_unrestricted_settings_keep_previous_behavior(): void + { + $this->writeMileageRule(); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + $cart = $this->setupCart($user, 10000); + + $response = $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', [ + 'item_ids' => [$cart->id], + 'use_points' => 7777, + ]); + + $response->assertStatus(201); + $this->assertSame(7777, (int) $response->json('data.calculation.summary.points_used')); + } + + // ======================================== + // 안내값(getMaxUsable) 이 하한을 반영 + // ======================================== + + /** + * 사용 가능 최대가 최소 사용액에 못 미치면 안내값은 0 이어야 합니다. + * + * 안내값이 하한을 모르면 "화면이 허용한 값인데 결제에서 거부" 가 됩니다. + */ + public function test_max_usable_is_zero_when_cap_below_min_use_amount(): void + { + // 결제금액 10,000 의 10% = 1,000 인데 최소 사용액이 5,000 + $this->writeMileageRule([ + 'min_use_amount' => 5000, + 'max_use_type' => 'percent', + 'max_use_percent' => 10, + ]); + $user = $this->createUser(); + $this->grantMileage($user->id, 10000); + + $service = app(UserMileageService::class); + + $this->assertSame(0, $service->getMaxUsable($user->id, 10000)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/OrderVatTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/OrderVatTest.php new file mode 100644 index 00000000..f44a298f --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/OrderVatTest.php @@ -0,0 +1,287 @@ +create([ + 'shipping_policy_id' => null, + 'list_price' => $price, + 'selling_price' => $price, + 'tax_status' => $taxStatus, + // tax_rate 컬럼은 NOT NULL — 면세 상품도 값은 존재하며 과세 분류에서 무시된다 + 'tax_rate' => $taxRate ?? 10.0, + ]); + + $option = ProductOptionFactory::new()->forProduct($product)->create([ + 'selling_price' => $price, + 'price_adjustment' => 0, + 'stock_quantity' => 100, + ]); + + return $option->id; + } + + /** + * 계산 결과를 반환합니다. + * + * @param array $items 계산 대상 + */ + private function calculate(array $items): OrderCalculationResult + { + $calculationItems = array_map( + fn (array $item) => new CalculationItem( + productId: $item['product_id'], + productOptionId: $item['option_id'], + quantity: $item['quantity'], + ), + $items + ); + + return app(OrderCalculationService::class)->calculate(new CalculationInput(items: $calculationItems)); + } + + /** + * 과세 상품의 부가세가 계산 단계에서 산출되는지 테스트합니다. + */ + public function test_vat_is_calculated_for_taxable_items(): void + { + $optionId = $this->makeOption(11000, ProductTaxStatus::TAXABLE, 10.0); + $option = ProductOption::find($optionId); + + $result = $this->calculate([ + ['option_id' => $optionId, 'product_id' => $option->product_id, 'quantity' => 1], + ]); + + $this->assertSame(11000, $result->summary->taxableAmount); + // 수정 전: summary 에 부가세 자체가 없어 주문에 0 이 기록됐다 + $this->assertSame(1000, $result->summary->vatAmount); + } + + /** + * 면세 상품은 부가세가 0 인지 테스트합니다. + */ + public function test_tax_free_item_has_no_vat(): void + { + $optionId = $this->makeOption(11000, ProductTaxStatus::TAX_FREE, 10.0); + $option = ProductOption::find($optionId); + + $result = $this->calculate([ + ['option_id' => $optionId, 'product_id' => $option->product_id, 'quantity' => 1], + ]); + + $this->assertSame(0, $result->summary->taxableAmount); + $this->assertSame(11000, $result->summary->taxFreeAmount); + $this->assertSame(0, $result->summary->vatAmount); + } + + /** + * 세율이 섞인 주문에서 옵션별로 합산되는지 테스트합니다. + * + * 전체 과세표준에 단일 세율을 적용하면 값이 어긋난다. + */ + public function test_mixed_tax_rates_are_summed_per_option(): void + { + $standardId = $this->makeOption(11000, ProductTaxStatus::TAXABLE, 10.0); + $reducedId = $this->makeOption(10500, ProductTaxStatus::TAXABLE, 5.0); + + $standard = ProductOption::find($standardId); + $reduced = ProductOption::find($reducedId); + + $result = $this->calculate([ + ['option_id' => $standardId, 'product_id' => $standard->product_id, 'quantity' => 1], + ['option_id' => $reducedId, 'product_id' => $reduced->product_id, 'quantity' => 1], + ]); + + $this->assertSame(21500, $result->summary->taxableAmount); + // 1000(10%) + 500(5%) = 1500. 단일 세율 계산이었다면 1955. + $this->assertSame(1500, $result->summary->vatAmount); + } + + /** + * 면세 상품의 세율 값은 부가세에 반영되지 않는지 테스트합니다. + * + * (세율 미지정 시 기본 세율 폴백은 VatCalculatorTest 가 커버 — DB 컬럼이 NOT NULL 이라 + * Product 레벨에서는 null 세율이 존재할 수 없고, 스냅샷 모드에서만 가능하다.) + */ + public function test_tax_free_item_ignores_its_rate(): void + { + $taxableId = $this->makeOption(11000, ProductTaxStatus::TAXABLE, 10.0); + $freeId = $this->makeOption(50000, ProductTaxStatus::TAX_FREE, 10.0); + + $taxable = ProductOption::find($taxableId); + $free = ProductOption::find($freeId); + + $result = $this->calculate([ + ['option_id' => $taxableId, 'product_id' => $taxable->product_id, 'quantity' => 1], + ['option_id' => $freeId, 'product_id' => $free->product_id, 'quantity' => 1], + ]); + + // 면세 50,000 은 과세표준에 들어가지 않으므로 부가세는 과세 11,000 분만 + $this->assertSame(11000, $result->summary->taxableAmount); + $this->assertSame(50000, $result->summary->taxFreeAmount); + $this->assertSame(1000, $result->summary->vatAmount); + } + + /** + * 주문 생성 시점에 total_vat_amount 가 기록되는지 테스트합니다. + * + * 수정 전: 쓰기 지점이 부분취소 재계산뿐이라 신규 주문은 영구 0. + */ + public function test_order_row_records_vat_at_creation(): void + { + $optionId = $this->makeOption(11000, ProductTaxStatus::TAXABLE, 10.0); + $option = ProductOption::find($optionId); + + $user = $this->createUser(); + $cart = CartFactory::new() + ->forUser($user) + ->forOption($option) + ->create(['quantity' => 1]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/modules/sirsoft-ecommerce/checkout', ['item_ids' => [$cart->id]]) + ->assertStatus(201); + + $response = $this->actingAs($user, 'sanctum')->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + [ + 'orderer' => ['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'vat@test.com'], + 'shipping' => [ + 'recipient_name' => '김철수', + 'recipient_phone' => '010-9876-5432', + 'country_code' => 'KR', + 'zipcode' => '12345', + 'address' => '서울시 강남구 테헤란로 123', + 'address_detail' => '101동 1001호', + ], + 'payment_method' => PaymentMethodEnum::DBANK->value, + 'expected_total_amount' => 11000, + 'depositor_name' => '홍길동', + 'dbank' => [ + 'bank_code' => 'KB', + 'bank_name' => '국민은행', + 'account_number' => '123-456-789012', + 'account_holder' => '주식회사 테스트', + ], + ] + ); + + $response->assertStatus(201); + + $order = Order::where('user_id', $user->id)->latest('id')->first(); + $this->assertSame(1000, (int) $order->total_vat_amount); + $this->assertSame(1000, (int) $order->payment->vat_amount); + } + + /** + * 주문 이후 상품 세율을 바꿔도 기존 주문의 부가세가 소급 변경되지 않는지 테스트합니다. + * + * 부분취소·재계산은 스냅샷 모드로 돈다. 이때 세율을 DB 현재값에서 읽으면 관리자가 + * 세율을 조정한 순간 과거 주문의 세액이 통째로 뒤바뀐다 — 이미 발행한 증빙과 어긋나는 + * 회계 사고다. 스냅샷의 주문 시점 세율만 쓰는지 고정한다. + */ + public function test_snapshot_rate_freezes_order_time_value(): void + { + // 주문 시점 세율 10% → 이후 관리자가 5% 로 변경한 상태를 DB 로 표현 + $product = ProductFactory::new()->create([ + 'shipping_policy_id' => null, + 'list_price' => 11000, + 'selling_price' => 11000, + 'tax_status' => ProductTaxStatus::TAXABLE, + 'tax_rate' => 5.0, + ]); + $option = ProductOptionFactory::new()->forProduct($product)->create([ + 'selling_price' => 11000, + 'price_adjustment' => 0, + 'stock_quantity' => 100, + ]); + + $input = new CalculationInput( + items: [ + new CalculationItem( + productId: $product->id, + productOptionId: $option->id, + quantity: 1, + productSnapshot: [ + 'id' => $product->id, + 'name' => '주문 시 이름', + 'selling_price' => 11000, + 'tax_status' => ProductTaxStatus::TAXABLE->value, + 'tax_rate' => 10.0, + ], + optionSnapshot: [ + 'id' => $option->id, + 'selling_price' => 11000, + 'price_adjustment' => 0, + 'weight' => null, + 'volume' => null, + ], + ), + ], + metadata: ['snapshot_mode' => true], + ); + + $result = app(OrderCalculationService::class)->calculate($input); + + // 주문 시점 10% → 1,000. 현재 DB 세율 5% 를 읽었다면 524 가 된다. + $this->assertSame(11000, $result->summary->taxableAmount); + $this->assertSame(1000, $result->summary->vatAmount); + } + + /** + * 계산 결과 직렬화/역직렬화에서 부가세가 보존되는지 테스트합니다. + * + * 임시주문은 calculation_result 를 JSON 으로 저장했다가 주문 확정 시 다시 읽는다. + */ + public function test_vat_survives_array_round_trip(): void + { + $optionId = $this->makeOption(11000, ProductTaxStatus::TAXABLE, 10.0); + $option = ProductOption::find($optionId); + + $result = $this->calculate([ + ['option_id' => $optionId, 'product_id' => $option->product_id, 'quantity' => 1], + ]); + + $array = $result->summary->toArray(); + $this->assertSame(1000, $array['vat_amount']); + + $restored = Summary::fromArray($array); + $this->assertSame(1000, $restored->vatAmount); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/BulkPricePartialUpdateTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/BulkPricePartialUpdateTest.php new file mode 100644 index 00000000..8d75d8f8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/BulkPricePartialUpdateTest.php @@ -0,0 +1,117 @@ +adminUser = $this->createAdminUser([ + 'sirsoft-ecommerce.products.update', + ]); + } + + /** + * 일괄 수정 엔드포인트 URL + * + * @return string 엔드포인트 URL + */ + private function url(): string + { + return '/api/modules/sirsoft-ecommerce/admin/products/bulk-update'; + } + + /** + * 판매가만 전송해 DB 정가를 넘기면 차단되는지 테스트합니다. + * + * @scenario entity=ec_product, payload=selling_only, relation=exceeds_stored_list + * + * @effects partial_price_rejected_422, price_unchanged_on_rejection + */ + public function test_selling_price_only_cannot_exceed_stored_list_price(): void + { + $product = Product::factory()->create([ + 'list_price' => 10000, + 'selling_price' => 9000, + ]); + + $this->actingAs($this->adminUser) + ->patchJson($this->url(), [ + 'ids' => [$product->id], + 'items' => [ + ['id' => $product->id, 'selling_price' => 20000], + ], + ]) + ->assertStatus(422); + + $this->assertSame(9000, (int) $product->fresh()->selling_price); + } + + /** + * 정가만 전송해 DB 판매가 아래로 내리면 차단되는지 테스트합니다. + * + * @scenario entity=ec_product, payload=list_only, relation=below_stored_selling + * + * @effects partial_price_rejected_422, price_unchanged_on_rejection + */ + public function test_list_price_only_cannot_drop_below_stored_selling_price(): void + { + $product = Product::factory()->create([ + 'list_price' => 10000, + 'selling_price' => 9000, + ]); + + $this->actingAs($this->adminUser) + ->patchJson($this->url(), [ + 'ids' => [$product->id], + 'items' => [ + ['id' => $product->id, 'list_price' => 5000], + ], + ]) + ->assertStatus(422); + + $this->assertSame(10000, (int) $product->fresh()->list_price); + } + + /** + * 관계를 지키는 부분 전송은 정상 반영되는지 테스트합니다. + * + * @scenario entity=ec_product, payload=selling_only, relation=within_stored_list + * + * @effects partial_price_applied_200 + */ + public function test_valid_partial_price_update_is_applied(): void + { + $product = Product::factory()->create([ + 'list_price' => 10000, + 'selling_price' => 9000, + ]); + + $this->actingAs($this->adminUser) + ->patchJson($this->url(), [ + 'ids' => [$product->id], + 'items' => [ + ['id' => $product->id, 'selling_price' => 8000], + ], + ]) + ->assertStatus(200); + + $this->assertSame(8000, (int) $product->fresh()->selling_price); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/ProductPriceAndMileageRuleTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/ProductPriceAndMileageRuleTest.php new file mode 100644 index 00000000..ae028bec --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/ProductPriceAndMileageRuleTest.php @@ -0,0 +1,252 @@ +setContainer($this->app)->setRedirector($this->app['redirect']); + + $validator = $this->app['validator']->make($data, $request->rules()); + $request->setValidator($validator); + + if (method_exists($request, 'withValidator')) { + $request->withValidator($validator); + } + + $validator->passes(); + + return $validator; + } + + // ======================================== + // E2 — 옵션별 정률 적립률 상한 + // ======================================== + + /** + * 정률 적립률 100 초과는 차단되는지 테스트합니다. + * + * @scenario surface=product_options, field=mileage_value, mileage_type=percent + * + * @effects percent_mileage_over_100_rejected + */ + public function test_percent_mileage_over_100_is_rejected(): void + { + $validator = $this->validate(StoreProductRequest::class, [ + 'options' => [ + ['mileage_type' => 'percent', 'mileage_value' => 1000], + ], + ]); + + $this->assertTrue($validator->errors()->has('options.0.mileage_value')); + } + + /** + * 정률 100 은 통과하는지 테스트합니다 (경계). + * + * @scenario surface=product_options, field=mileage_value, mileage_type=percent + * + * @effects percent_mileage_at_bound_allowed + */ + public function test_percent_mileage_exactly_100_is_allowed(): void + { + $validator = $this->validate(StoreProductRequest::class, [ + 'options' => [ + ['mileage_type' => 'percent', 'mileage_value' => 100], + ], + ]); + + $this->assertFalse($validator->errors()->has('options.0.mileage_value')); + } + + /** + * 정액(fixed) 적립은 금액이므로 상한을 두지 않는지 테스트합니다. + * + * @scenario surface=product_options, field=mileage_value, mileage_type=fixed + * + * @effects fixed_mileage_has_no_upper_bound + */ + public function test_fixed_mileage_has_no_upper_bound(): void + { + $validator = $this->validate(StoreProductRequest::class, [ + 'options' => [ + ['mileage_type' => 'fixed', 'mileage_value' => 5000], + ], + ]); + + $this->assertFalse($validator->errors()->has('options.0.mileage_value')); + } + + // ======================================== + // E4 — 일괄 수정 가격 규칙 + // ======================================== + + /** + * KRW(소수 0자리)에서 소수 가격이 차단되는지 테스트합니다. + * + * @scenario surface=bulk_products, field=price, mileage_type=none + * + * @effects bulk_price_respects_currency_precision + */ + public function test_bulk_product_price_rejects_decimals_in_zero_precision_currency(): void + { + $validator = $this->validate(BulkUpdateProductsRequest::class, [ + 'items' => [ + ['id' => 1, 'list_price' => 1000.5], + ], + ]); + + $this->assertTrue($validator->errors()->has('items.0.list_price')); + } + + /** + * 판매가가 정가보다 크면 차단되는지 테스트합니다 (둘 다 전송된 경우). + * + * @scenario surface=bulk_products, field=price_relation, mileage_type=none + * + * @effects bulk_selling_price_cannot_exceed_list_price + */ + public function test_bulk_product_selling_price_cannot_exceed_list_price(): void + { + $validator = $this->validate(BulkUpdateProductsRequest::class, [ + 'items' => [ + ['id' => 1, 'list_price' => 10000, 'selling_price' => 20000], + ], + ]); + + $this->assertTrue($validator->errors()->has('items.0.selling_price')); + } + + /** + * 한쪽만 전송된 부분 수정은 비교하지 않는지 테스트합니다. + * + * 일괄 수정은 부분 전송이 흔하다 — 여기서 막으면 정상 사용이 불가능해진다. + * + * @scenario surface=bulk_products, field=price_relation, mileage_type=none + * + * @effects bulk_partial_send_deferred_to_service + */ + public function test_bulk_product_partial_price_update_is_not_compared(): void + { + $validator = $this->validate(BulkUpdateProductsRequest::class, [ + 'items' => [ + ['id' => 1, 'selling_price' => 20000], + ], + ]); + + $this->assertFalse($validator->errors()->has('items.0.selling_price')); + } + + /** + * 0원 가격이 차단되는지 테스트합니다. + * + * @scenario surface=bulk_products, field=price, mileage_type=none + * + * @effects bulk_product_price_zero_rejected + */ + public function test_bulk_product_price_zero_is_rejected(): void + { + $validator = $this->validate(BulkUpdateProductsRequest::class, [ + 'items' => [ + ['id' => 1, 'list_price' => 0], + ], + ]); + + $this->assertTrue($validator->errors()->has('items.0.list_price')); + } + + /** + * 정상 가격은 통과하는지 테스트합니다. + * + * @scenario surface=bulk_products, field=price, mileage_type=none + * + * @effects bulk_valid_price_passes + */ + public function test_bulk_product_valid_price_passes(): void + { + $validator = $this->validate(BulkUpdateProductsRequest::class, [ + 'items' => [ + ['id' => 1, 'list_price' => 20000, 'selling_price' => 15000], + ], + ]); + + $this->assertFalse($validator->errors()->has('items.0.list_price')); + $this->assertFalse($validator->errors()->has('items.0.selling_price')); + } + + /** + * 옵션 일괄 수정도 통화 정밀도 규칙을 적용하는지 테스트합니다. + * + * @scenario surface=bulk_options, field=price, mileage_type=none + * + * @effects bulk_price_respects_currency_precision + */ + public function test_bulk_option_price_rejects_decimals(): void + { + $validator = $this->validate(BulkUpdateOptionsRequest::class, [ + 'items' => [ + ['product_id' => 1, 'option_id' => 1, 'list_price' => 1000.5], + ], + ]); + + $this->assertTrue($validator->errors()->has('items.0.list_price')); + } + + /** + * 옵션 가격 0 은 상품 가격과 달리 허용되는지 테스트합니다. + * + * 상품 자신의 가격은 `min:0.01`(0원 상품 금지)이지만, 옵션 가격 0 은 "이 옵션은 별도 + * 가격이 없다" 는 정상 입력이다 — 단건 등록(`StoreProductRequest` 의 `options.*.list_price`) + * 도 `min:0` 이다. 일괄 수정만 `min:0.01` 로 좁히면 단건에서 저장한 옵션을 일괄 화면에서는 + * 수정할 수 없게 된다. 두 경로의 경계가 같아야 한다는 것을 여기서 고정한다. + * + * @scenario surface=bulk_options, field=price, mileage_type=none + * + * @effects bulk_option_price_zero_allowed + */ + public function test_bulk_option_price_zero_is_allowed_unlike_product_price(): void + { + $optionValidator = $this->validate(BulkUpdateOptionsRequest::class, [ + 'items' => [ + ['product_id' => 1, 'option_id' => 1, 'list_price' => 0], + ], + ]); + + $this->assertFalse( + $optionValidator->errors()->has('items.0.list_price'), + '옵션 가격 0(별도 가격 없음)이 거부되면 단건 등록 경로와 어긋납니다.' + ); + + // 대조군 — 상품 자신의 가격 0 은 여전히 거부되어야 한다 + $productValidator = $this->validate(BulkUpdateProductsRequest::class, [ + 'items' => [ + ['id' => 1, 'list_price' => 0], + ], + ]); + + $this->assertTrue($productValidator->errors()->has('items.0.list_price')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Settings/SettingsContractBoundaryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Settings/SettingsContractBoundaryTest.php new file mode 100644 index 00000000..f2d25cc0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Settings/SettingsContractBoundaryTest.php @@ -0,0 +1,115 @@ +setContainer($this->app)->setRedirector($this->app['redirect']); + + $validator = $this->app['validator']->make($data, $request->rules()); + $request->setValidator($validator); + + if (method_exists($request, 'withValidator')) { + $request->withValidator($validator); + } + + $validator->passes(); + + return $validator; + } + + /** + * 리뷰 이미지 크기는 정수만 허용되는지 테스트합니다 (JSON step 0.5 가 오류였다). + */ + public function test_review_image_size_rejects_fractional_value(): void + { + $validator = $this->validate(StoreEcommerceSettingsRequest::class, [ + 'review_settings' => ['max_image_size_mb' => 5.5], + ]); + + $this->assertTrue($validator->errors()->has('review_settings.max_image_size_mb')); + } + + /** + * 자동취소 기한 0 이 차단되는지 테스트합니다. + * + * 0 은 `now()->addDays(0)` = 즉시 만료라 실질적으로 쓸 수 없는 값 → UI(min:1)가 옳다. + */ + public function test_auto_cancel_days_zero_is_rejected(): void + { + $validator = $this->validate(StoreEcommerceSettingsRequest::class, [ + 'order_settings' => ['auto_cancel_days' => 0], + ]); + + $this->assertTrue($validator->errors()->has('order_settings.auto_cancel_days')); + } + + /** + * 자동취소 기한 1 은 통과하는지 테스트합니다 (경계). + */ + public function test_auto_cancel_days_one_is_allowed(): void + { + $validator = $this->validate(StoreEcommerceSettingsRequest::class, [ + 'order_settings' => ['auto_cancel_days' => 1], + ]); + + $this->assertFalse($validator->errors()->has('order_settings.auto_cancel_days')); + } + + /** + * per_page 숫자 값에 상한이 적용되는지 테스트합니다 (E11). + */ + public function test_per_page_numeric_upper_bound_is_enforced(): void + { + $validator = $this->validate(ProductNoticeTemplateListRequest::class, [ + 'per_page' => '100000', + ]); + + $this->assertTrue($validator->errors()->has('per_page')); + } + + /** + * per_page = all 은 기존 계약대로 허용되는지 테스트합니다. + */ + public function test_per_page_all_is_still_allowed(): void + { + $validator = $this->validate(ProductNoticeTemplateListRequest::class, [ + 'per_page' => 'all', + ]); + + $this->assertFalse($validator->errors()->has('per_page')); + } + + /** + * per_page 상한 이내는 통과하는지 테스트합니다. + */ + public function test_per_page_within_bound_passes(): void + { + $validator = $this->validate(ProductNoticeTemplateListRequest::class, [ + 'per_page' => '100', + ]); + + $this->assertFalse($validator->errors()->has('per_page')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/OrderVatBackfillTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/OrderVatBackfillTest.php new file mode 100644 index 00000000..3f1536e0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/OrderVatBackfillTest.php @@ -0,0 +1,150 @@ + $options 옵션별 과세표준·세율 + * @return int 생성된 주문 ID + */ + private function makeOrder(int $totalTaxAmount, int $totalVatAmount, array $options): int + { + $order = OrderFactory::new()->create(); + + DB::table('ecommerce_orders')->where('id', $order->id)->update([ + 'total_tax_amount' => $totalTaxAmount, + 'total_vat_amount' => $totalVatAmount, + ]); + + foreach ($options as $spec) { + $orderOption = OrderOptionFactory::new()->forOrder($order)->create([ + 'parent_option_id' => null, + ]); + + $snapshot = $orderOption->product_snapshot; + $snapshot['tax_rate'] = $spec['rate']; + + DB::table('ecommerce_order_options')->where('id', $orderOption->id)->update([ + 'subtotal_tax_amount' => $spec['taxable'], + 'product_snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE), + ]); + } + + return $order->id; + } + + /** + * 1.0.5 업그레이드 스텝을 실행합니다. + */ + private function runUpgrade(): void + { + (new Upgrade_1_0_5)->run(new UpgradeContext('1.0.4', '1.0.5', '1.0.5', 'extension-upgrade')); + } + + /** + * 부가세를 반환합니다. + * + * @param int $orderId 주문 ID + * @return int 부가세 + */ + private function vatOf(int $orderId): int + { + return (int) Order::find($orderId)->total_vat_amount; + } + + /** + * 부가세가 0 인 주문만 스냅샷 세율로 백필되는지 테스트합니다. + */ + public function test_zero_vat_order_is_backfilled_with_snapshot_rate(): void + { + $orderId = $this->makeOrder(11000, 0, [['taxable' => 11000, 'rate' => 10.0]]); + + $this->runUpgrade(); + + $this->assertSame(1000, $this->vatOf($orderId)); + } + + /** + * 이미 부가세가 기록된 주문은 백필이 건드리지 않는지 테스트합니다. + * + * 부분취소로 정상화된 주문을 재계산으로 덮어쓰면 회계 수치가 조용히 바뀐다. + */ + public function test_order_with_existing_vat_is_left_untouched(): void + { + $orderId = $this->makeOrder(11000, 777, [['taxable' => 11000, 'rate' => 10.0]]); + + $this->runUpgrade(); + + $this->assertSame(777, $this->vatOf($orderId)); + } + + /** + * 과세표준이 없는 주문(전액 면세)은 0 으로 남는지 테스트합니다. + */ + public function test_tax_free_order_stays_zero(): void + { + $orderId = $this->makeOrder(0, 0, [['taxable' => 0, 'rate' => 10.0]]); + + $this->runUpgrade(); + + $this->assertSame(0, $this->vatOf($orderId)); + } + + /** + * 세율이 섞인 주문은 옵션별 세율로 합산되는지 테스트합니다. + * + * 주문 전체 과세표준에 단일 세율을 적용하면 값이 어긋난다. + */ + public function test_mixed_rate_order_is_summed_per_option(): void + { + $orderId = $this->makeOrder(21500, 0, [ + ['taxable' => 11000, 'rate' => 10.0], + ['taxable' => 10500, 'rate' => 5.0], + ]); + + $this->runUpgrade(); + + // 1000(10%) + 500(5%) = 1500. 단일 세율이었다면 1955. + $this->assertSame(1500, $this->vatOf($orderId)); + } + + /** + * 재실행해도 결과가 같은지 테스트합니다 (멱등). + */ + public function test_backfill_is_idempotent(): void + { + $orderId = $this->makeOrder(11000, 0, [['taxable' => 11000, 'rate' => 10.0]]); + + $this->runUpgrade(); + $first = $this->vatOf($orderId); + + $this->runUpgrade(); + + $this->assertSame($first, $this->vatOf($orderId)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/OrderOptionFactoryStockTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/OrderOptionFactoryStockTest.php new file mode 100644 index 00000000..1a107564 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/OrderOptionFactoryStockTest.php @@ -0,0 +1,53 @@ +create(); + $productOption = ProductOption::find($orderOption->product_option_id); + + $this->assertNotNull($productOption); + $this->assertGreaterThanOrEqual( + $orderOption->quantity, + $productOption->stock_quantity, + '기본 팩토리의 연결 옵션 재고가 주문 수량보다 적습니다 — 재고 차감을 거치는 ' + .'테스트가 무작위로 실패하게 됩니다.' + ); + } + } + + /** + * 스냅샷이 적은 재고와 실제 연결 옵션의 재고가 일치한다. + */ + public function test_snapshot_stock_matches_linked_option_stock(): void + { + $orderOption = OrderOption::factory()->create(); + $productOption = ProductOption::find($orderOption->product_option_id); + + $this->assertSame( + (int) ($orderOption->option_snapshot['stock_quantity'] ?? -1), + (int) $productOption->stock_quantity + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php index 75e0bceb..6dac1941 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php @@ -7318,4 +7318,68 @@ class OrderAdjustmentServiceTest extends ModuleTestCase $resultPoints->refundAmount + $resultPoints->refundPointsAmount, ); } + + // ================================================================ + // 부분취소 부가세 재산출 (#493 E5) + // ================================================================ + + /** + * 부분취소 재계산이 잔여 금액 기준 부가세를 다시 계산하는지 테스트합니다. + * + * 부가세를 재산출하지 않으면 취소 후에도 취소 전 부가세가 남아 세금 표기가 실제 결제 + * 금액과 어긋난다. + */ + public function test_partial_cancel_recalculates_vat(): void + { + [$productA, $optionA] = $this->createProductWithOption(price: 11000); + [$productB, $optionB] = $this->createProductWithOption(price: 22000); + + $input = new CalculationInput(items: [ + new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1), + new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1), + ]); + + $order = $this->createOrderFromCalculation($input); + $cancelTarget = $order->options->firstWhere('product_option_id', $optionB->id); + + $result = $this->adjustmentService->calculate( + $order, + $this->buildCancellation([['order_option_id' => $cancelTarget->id, 'cancel_quantity' => 1]]) + ); + + // 잔여 11,000 (10%) → 1,000. 취소 전 전체(33,000)의 3,000 이 남으면 안 된다. + $this->assertSame(1000, (int) $result->orderUpdates['total_vat_amount']); + } + + /** + * 부분취소 재계산이 주문 당시 스냅샷 세율을 쓰는지 테스트합니다. + * + * 상품의 현재 세율을 읽으면, 주문 이후 세율이 바뀐 상품에서 과거 주문의 세금 표기가 + * 소급해 바뀐다. + */ + public function test_partial_cancel_uses_snapshot_tax_rate_not_current_product_rate(): void + { + [$productA, $optionA] = $this->createProductWithOption(price: 11000); + [$productB, $optionB] = $this->createProductWithOption(price: 22000); + + $input = new CalculationInput(items: [ + new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1), + new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1), + ]); + + $order = $this->createOrderFromCalculation($input); + + // 주문 이후 상품 세율 변경 — 과거 주문의 재계산에 영향을 주면 안 된다 + $productA->update(['tax_rate' => 25.0]); + + $cancelTarget = $order->options->firstWhere('product_option_id', $optionB->id); + + $result = $this->adjustmentService->calculate( + $order, + $this->buildCancellation([['order_option_id' => $cancelTarget->id, 'cancel_quantity' => 1]]) + ); + + // 스냅샷 세율 10% 기준 1,000 (현재 세율 25% 였다면 2,200) + $this->assertSame(1000, (int) $result->orderUpdates['total_vat_amount']); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingDueAtTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingDueAtTest.php index 975ab691..ef507553 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingDueAtTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingDueAtTest.php @@ -25,7 +25,8 @@ abstract class OrderProcessingDueAtModuleStub implements ModuleInterface, Module * 입금 대기 due_at 산정 단일화 테스트 (A3) * * vbank_due_at / deposit_due_at 산정 출처가 단일 SSoT auto_cancel_days 로 통일되었는지, - * dbank 의 per-order due_days 명시 전달분이 우선순위를 유지하는지 검증. + * 요청에 실린 due_days 는 무시되는지 검증 (E6 — 기한은 서버 정책이므로 클라이언트가 + * 늘릴 수 있으면 자동취소 배치를 우회할 수 있다). */ class OrderProcessingDueAtTest extends ModuleTestCase { @@ -103,6 +104,11 @@ class OrderProcessingDueAtTest extends ModuleTestCase $m->invoke($this->service, $order, $method, '홍길동', $dbankInfo, $this->makeCalculationResult(), []); } + /** + * @scenario payment_method=vbank + * + * @effects vbank_due_at_uses_auto_cancel_days + */ public function test_vbank_due_at_uses_auto_cancel_days(): void { Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0)); @@ -120,6 +126,11 @@ class OrderProcessingDueAtTest extends ModuleTestCase Carbon::setTestNow(); } + /** + * @scenario payment_method=dbank_default + * + * @effects dbank_due_at_uses_auto_cancel_days + */ public function test_dbank_due_at_uses_auto_cancel_days_when_no_per_order_override(): void { Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0)); @@ -138,19 +149,25 @@ class OrderProcessingDueAtTest extends ModuleTestCase Carbon::setTestNow(); } - public function test_dbank_per_order_due_days_takes_priority(): void + /** + * @scenario payment_method=dbank_client_due_days + * + * @effects client_due_days_is_ignored + */ + public function test_dbank_ignores_client_supplied_due_days(): void { Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0)); $order = Order::factory()->create(); - // per-order due_days=10 명시 → auto_cancel_days(5)보다 우선 + // 요청에 due_days=10 이 실려도 무시하고 서버 설정(auto_cancel_days=5)을 쓴다. + // 기한은 서버 정책이므로 클라이언트가 늘리면 자동취소 배치를 우회할 수 있다. $this->invokeCreatePayment($order, 'dbank', [ 'bank_code' => '004', 'account_number' => '123', 'account_holder' => '홍길동', 'due_days' => 10, ]); $payment = $order->payment()->first(); $this->assertSame( - Carbon::now()->addDays(10)->toDateString(), + Carbon::now()->addDays(5)->toDateString(), Carbon::parse($payment->deposit_due_at)->toDateString(), ); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php index 6112fa41..5cb75113 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php @@ -71,6 +71,40 @@ class OrderProcessingServiceTest extends ModuleTestCase } } + /** + * 마일리지 사용 정책을 무제한으로 완화합니다. + * + * 주문 확정 시점에 사용 정책(min_use_amount / use_unit / max_use_*)이 강제되므로(#493 E1), + * 마일리지가 주제가 아닌 테스트는 배포 기본값(최소 1,000 / 상한 50,000)에 걸린다. + * 이 테스트들의 관심사는 주문 메타·결제 상태이므로 정책을 열어 두고 본래 대상만 검증한다. + * 정책 강제 자체는 MileageUsageLimitTest 가 다룬다. + */ + private function allowAnyMileageUsage(): void + { + $dir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + + file_put_contents($dir.'/mileage.json', json_encode([ + 'enabled' => true, + 'default_earn_rate' => 0, + 'earn_trigger' => 'confirmed', + 'earn_delay_days' => 0, + 'currency_rules' => [[ + 'currency_code' => 'KRW', + 'point_value' => 1, + 'min_use_amount' => 0, + 'use_unit' => 1, + 'max_use_type' => 'percent', + 'max_use_percent' => 100, + 'max_use_value' => 0, + ]], + 'expiry_enabled' => false, + 'expiry_days' => 365, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + /** * 카드(card) 결제수단의 마일리지 차감 시점을 설정합니다. (마일리지/MP06) * @@ -604,6 +638,8 @@ class OrderProcessingServiceTest extends ModuleTestCase public function test_create_from_temp_order_saves_order_meta_with_calculation_input(): void { + $this->allowAnyMileageUsage(); + $user = User::factory()->create(); // 마일리지 사용 훅이 실제 FIFO 차감을 수행하므로 사용액(500) 이상 잔액 시드 @@ -2648,6 +2684,8 @@ class OrderProcessingServiceTest extends ModuleTestCase public function test_create_from_temp_order_full_mileage_payment_marks_payment_complete(): void { + $this->allowAnyMileageUsage(); + $user = User::factory()->create(); // 결제액 전액(100,000)을 마일리지로 충당 — FIFO 차감용 잔액 시드 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/TempOrderServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/TempOrderServiceTest.php index 2e8d87ba..4f3372d6 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/TempOrderServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/TempOrderServiceTest.php @@ -65,6 +65,11 @@ class TempOrderServiceTest extends ModuleTestCase $mockUserMileageService = Mockery::mock(UserMileageService::class); $mockUserMileageService->shouldReceive('canUse')->andReturn(true)->byDefault(); $mockUserMileageService->shouldReceive('getBalance')->andReturn(['available' => 0])->byDefault(); + // 사용 한도 정책 강제(#493 E1): 임시주문 계산 직후 정책 위반 여부를 검증한다. + // 이 테스트군은 프로모션 보존만 다루므로 정책 통과(요청값 그대로)를 기본 동작으로 둔다. + $mockUserMileageService->shouldReceive('validateUsage') + ->andReturnUsing(fn (int $userId, int $usePoints) => $usePoints) + ->byDefault(); $this->service = new TempOrderService( $this->mockTempOrderRepository, diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Settings/CatalogLangPackTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Settings/CatalogLangPackTest.php index 1231980b..506efc63 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Settings/CatalogLangPackTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Settings/CatalogLangPackTest.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Settings; +use App\Services\LanguagePack\LanguagePackTranslator; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; /** @@ -80,16 +81,23 @@ class CatalogLangPackTest extends ModuleTestCase /** * ja lang pack 활성 환경 통합 — 카탈로그 라벨이 일본어로 보강되는지 end-to-end 검증. - * lang pack 미설치 환경에서는 skip. + * + * 활성 언어팩 등록은 `LanguagePackServiceProvider` 가 부팅 시 DB 의 활성 행을 읽어 + * 수행하는데, 테스트 DB 에는 그 행이 없다. 예전에는 "ja 가 우연히 로드됐는지" 를 보고 + * 아니면 skip 했는데, 그 조건은 표준 테스트 환경에서 **항상** 참이라 이 케이스는 한 번도 + * 실행되지 않는 죽은 커버리지였다. 그래서 provider 와 동일한 방식으로 번들 언어팩 경로를 + * 직접 등록해 결정적으로 실행되게 한다. */ public function test_catalog_labels_resolve_to_japanese_when_lang_pack_active(): void { - $jaCountry = __('sirsoft-ecommerce::settings.countries.KR.name', [], 'ja'); - $koCountry = __('sirsoft-ecommerce::settings.countries.KR.name', [], 'ko'); + $this->registerBundledJapaneseLanguagePack(); - if ($jaCountry === 'sirsoft-ecommerce::settings.countries.KR.name' || $jaCountry === $koCountry) { - $this->markTestSkipped('g7-module-sirsoft-ecommerce-ja lang pack 의 settings.* 키 미로드'); - } + // 전제 확인 — 등록이 실제로 먹었는지 (미등록이면 아래 단언이 무의미해진다) + $this->assertSame( + '大韓民国', + __('sirsoft-ecommerce::settings.countries.KR.name', [], 'ja'), + '번들 ja 언어팩의 settings.* 키가 등록되지 않았습니다.' + ); app()->setLocale('ja'); @@ -113,4 +121,30 @@ class CatalogLangPackTest extends ModuleTestCase fallbackKey: "sirsoft-ecommerce::settings.payment_methods.{$methodEntry['id']}.name", )); } + + /** + * 번들 ja 언어팩을 Translator 에 등록합니다. + * + * `LanguagePackServiceProvider::registerActivePack()` 과 같은 경로를 씁니다 — + * `addNamespace` 는 hint 를 덮어써 모듈 자체 ko/en 등록을 깨뜨리므로, + * namespace fallback 으로 ja 만 보완합니다. + */ + private function registerBundledJapaneseLanguagePack(): void + { + $path = base_path('lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja'); + + $this->assertDirectoryExists($path, '번들 ja 언어팩 디렉토리가 없습니다.'); + + $translator = $this->app->make('translator'); + + if (! $translator instanceof LanguagePackTranslator) { + $this->fail('Translator 가 LanguagePackTranslator 가 아닙니다 — 언어팩 보강 경로가 배선되지 않았습니다.'); + } + + $translator->addNamespaceFallbackPath( + namespace: 'sirsoft-ecommerce', + locale: 'ja', + path: $path, + ); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/VatCalculatorTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/VatCalculatorTest.php new file mode 100644 index 00000000..f1df8ed5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/VatCalculatorTest.php @@ -0,0 +1,95 @@ +assertSame( + (int) round($taxable / 11), + VatCalculator::fromTaxableAmount($taxable), + "과세표준 {$taxable}" + ); + } + } + + /** + * 세율 0(영세율) 과 면세는 부가세 0 인지 테스트합니다. + */ + public function test_zero_rate_yields_zero_vat(): void + { + $this->assertSame(0, VatCalculator::fromTaxableAmount(50000, 0.0)); + $this->assertSame(0, VatCalculator::fromTaxableAmount(0, 10.0)); + $this->assertSame(0, VatCalculator::fromTaxableAmount(-100, 10.0)); + } + + /** + * 상품별 세율이 반영되는지 테스트합니다. + */ + public function test_custom_rate_is_applied(): void + { + // 세율 5% → 10500 / 21 = 500 + $this->assertSame(500, VatCalculator::fromTaxableAmount(10500, 5.0)); + } + + /** + * 세율이 섞인 분류에서 옵션별 합산이 이뤄지는지 테스트합니다. + */ + public function test_sum_from_classification_handles_mixed_rates(): void + { + $classification = [ + 1 => ['taxable_amount' => 11000, 'tax_rate' => 10.0], + 2 => ['taxable_amount' => 10500, 'tax_rate' => 5.0], + 3 => ['taxable_amount' => 0, 'tax_rate' => null], // 면세 + ]; + + // 1000 + 500 + 0 + $this->assertSame(1500, VatCalculator::sumFromClassification($classification)); + } + + /** + * 이미 계산된 vat_amount 가 있으면 그대로 합산하는지 테스트합니다. + */ + public function test_sum_uses_precomputed_vat_amount(): void + { + $classification = [ + 1 => ['taxable_amount' => 11000, 'tax_rate' => 10.0, 'vat_amount' => 1000], + 2 => ['taxable_amount' => 22000, 'tax_rate' => 10.0, 'vat_amount' => 2000], + ]; + + $this->assertSame(3000, VatCalculator::sumFromClassification($classification)); + } + + /** + * 전체 과세표준에 단일 세율을 적용하는 방식과 결과가 다름을 고정합니다. + * + * 세율이 섞인 주문에서 단일 세율 계산은 틀린 값을 낸다 — 이것이 옵션별 합산이 필요한 이유. + */ + public function test_mixed_rate_differs_from_single_rate_calculation(): void + { + $classification = [ + 1 => ['taxable_amount' => 11000, 'tax_rate' => 10.0], + 2 => ['taxable_amount' => 10500, 'tax_rate' => 5.0], + ]; + + $perOption = VatCalculator::sumFromClassification($classification); + $singleRate = VatCalculator::fromTaxableAmount(21500, 10.0); + + $this->assertSame(1500, $perOption); + $this->assertNotSame($perOption, $singleRate); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-mileage-use-points.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-mileage-use-points.yaml index b92ded4a..4de748e5 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-mileage-use-points.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-mileage-use-points.yaml @@ -23,7 +23,14 @@ axes: entry_point: - member_store # 회원 체크아웃 생성(store) - member_update # 회원 체크아웃 수정(update) + - member_order # 회원 주문 확정(POST user/orders) — 2단 방어선 - guest # 비회원 → 항상 0 처리(에러 아님) + usage_policy: + - unrestricted # 하한 0 / 단위 1 / 100% → 잔액 검사와 동일 (무회귀) + - percent_limited # max_use_percent 상한 + - fixed_limited # max_use_value 상한 + - unit_restricted # use_unit 배수 강제 + - min_amount_required # min_use_amount 하한 effects: - member_exceed_balance_returns_422 # 회원 초과 use_points → 422 + code=mileage_exceeds_balance @@ -32,6 +39,18 @@ effects: - member_below_balance_passes # 잔액 미만 통과 - guest_use_points_ignored_zero # 비회원 use_points>0 → 200/points_used=0 - available_balance_ledger_floor_integer # availableBalance() 원장 기준 floor 정수 + - percent_limit_exceeded_returns_422 # max_use_percent 초과 → 422 + 사용 가능 최대 금액 안내 + - percent_limit_boundary_passes # 상한과 동일 금액 통과(경계) + - fixed_limit_exceeded_returns_422 # max_use_value 초과 → 422 + - use_unit_violation_returns_422 # 사용 단위 미배수 → 422 + - below_min_use_amount_returns_422 # 최소 사용액 미만 → 422 + - order_create_blocked_when_limit_tightened # 임시주문 후 한도 강화 → 주문 확정 422 (2단 방어) + - order_create_passes_within_limit # 한도 내 사용 주문은 정상 생성 (무회귀) + - order_snapshots_mileage_usage_policy # 주문 시점 사용 정책 스냅샷 고정 (사후 설정 변경에 불변) + - checkout_preview_unaffected_by_limit # 조회 경로는 한도 위반이어도 200 (화면 붕괴 방지) + - unrestricted_policy_keeps_previous_behavior # 한도 미설정 사이트 기존 동작 유지 + - max_usable_zero_when_cap_below_min_use # 안내값(getMaxUsable)이 최소 사용액 하한 반영 test_files: - modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/TempOrderServiceSecurityTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Mileage/MileageUsageLimitTest.php diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-purchase-quantity-limit.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-purchase-quantity-limit.yaml index 73d930b1..98d62f94 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-purchase-quantity-limit.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp05-purchase-quantity-limit.yaml @@ -29,6 +29,9 @@ axes: - bulk_add # 담기(bulkAddToCart, 기존+신규 합산) - update_qty # 수량 변경(자기 라인 제외 합산) - checkout # 체크아웃 최종 검증(옵션 합산) + - change_option # 옵션 변경(E8) — 다른 라인 합산 후 판정, 초과 시 422 + - guest_merge # 로그인 시 비회원 장바구니 병합(E8) — 예외 불가 경로라 상한까지 클램프 + - reorder # 재주문(E8) — 초과 항목만 skip + 사유, 나머지는 담기고 200 option_lines: - single # 단일 옵션 라인 - multi # 다중 옵션 라인 합산 @@ -42,7 +45,13 @@ effects: - checkout_passes_within_qty_bounds # 범위 내 통과(경계) - option_card_max_clamps_to_product_limit # 옵션 상품 라인 max = min(stock, max_purchase_qty) - total_buttons_disabled_on_limit_violation # 합계 min 미달/max 초과 시 버튼 disabled + 안내 + - change_option_blocks_above_max_qty # 옵션 변경도 상품 총수량으로 판정(E8) + - guest_merge_clamps_to_product_limit # 병합은 예외 대신 상품 한도까지 클램프(E8) + - guest_merge_limit_counts_existing_lines # 병합 판정에 회원 기존 라인 합산(옵션 분할 우회 차단) + - guest_merge_reports_clamp_adjustments # 조용한 클램프 금지 — 조정 내역을 응답으로 전달 + - reorder_skips_items_over_limit_with_reason # 재주문 초과 항목 skip + 사유, 장바구니 진입 0건 test_files: - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CartServiceTest.php - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/TempOrderServiceTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Cart/CartQuantityAndPurchaseLimitTest.php diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-auto-cancel-days.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-auto-cancel-days.yaml index ccc47121..a587b0da 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-auto-cancel-days.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-auto-cancel-days.yaml @@ -8,15 +8,16 @@ feature: 입금 대기 주문 자동취소 기한 auto_cancel_days 단일화 (A3 description: | vbank/dbank 주문 생성 시 due_at 을 결제수단 무관 단일 입금기한 설정 auto_cancel_days 로 산정. - dbank 의 per-order $dbankInfo['due_days'] 명시 전달분은 우선순위 유지(클라이언트 입력). + 요청에 due_days 가 실려도 무시하고 서버 설정을 쓴다 — 기한은 서버 정책이며, 클라이언트가 + 늘릴 수 있으면 자동취소 배치를 우회할 수 있다(E6). vbank_due_days/dbank_due_days 설정 표면(레이아웃/검증/migrate/lang)을 제거하고 잔여 참조 0. 업그레이드 스텝이 기존 환경 구 키를 max(vbank,dbank) 로 auto_cancel_days 미설정 시 1회 흡수. axes: payment_method: - vbank # vbank_due_at = now()+auto_cancel_days - - dbank_default # deposit_due_at = now()+auto_cancel_days (per-order 미지정) - - dbank_per_order # $dbankInfo['due_days'] 명시 → 그 값 우선 + - dbank_default # deposit_due_at = now()+auto_cancel_days (요청에 due_days 없음) + - dbank_client_due_days # 요청에 due_days 명시 → 무시하고 서버 설정 적용 auto_cancel_days: - three # 기본 3 - five @@ -30,7 +31,7 @@ axes: effects: - vbank_due_at_uses_auto_cancel_days - dbank_due_at_uses_auto_cancel_days - - per_order_due_days_takes_priority # 클라이언트 due_days 우선 보존 + - client_due_days_is_ignored # 요청 due_days 무시, 서버 설정 적용(E6) - legacy_keys_removed_from_rules # 검증 규칙/페이로드에 구 키 없음(negative) - upgrade_absorbs_max_legacy # max(vbank,dbank) 흡수 - upgrade_skips_when_already_set diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-product-notice.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-product-notice.yaml index cc68f850..91e4acc8 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-product-notice.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-product-notice.yaml @@ -23,6 +23,10 @@ axes: permission: - can_update - no_permission + per_page: # 목록 요청 크기 경계 (#493 E11) + - all # 'all' 키워드는 계속 허용 + - within_bound # 숫자 ≤ 100 + - over_bound # 숫자 > 100 → 422 effects: - infinite_scroll_appends_page2 # response.data.data 정정 → 21~ 항목 추가 @@ -31,7 +35,12 @@ effects: - toggle_active_logs_activity # 전용 toggle_active 활동로그 - dropdown_excludes_inactive # active_only=true → 비활성 미노출 - common_info_scroll_fixed # A10 동반 정정 회귀 가드 + # 목록 요청 크기 상한 (#493 E11) + - per_page_over_bound_rejected_422 # 상한 없던 요청에 100 상한 부여 + - per_page_all_keyword_still_allowed # 'all' 은 기존 계약 유지 + - per_page_at_bound_passes # 경계 100 통과 test_files: - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductNoticeTemplateControllerTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Settings/SettingsContractBoundaryTest.php - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/product-notice-list-toggle.test.tsx diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-review-image-policy.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-review-image-policy.yaml index 25c352fa..186064da 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-review-image-policy.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-review-image-policy.yaml @@ -22,10 +22,12 @@ axes: - five - ten # 기본 entry: - - service_read # Service 가 review_settings.* 읽음 - - upload_validation # UploadReviewImageRequest max 동적 - - public_api # GET settings/review 200 - - front_render # 리뷰 모달 조건부/props + - service_read # Service 가 review_settings.* 읽음 + - upload_validation # UploadReviewImageRequest max 동적 + - public_api # GET settings/review 200 + - front_render # 리뷰 모달 조건부/props + - settings_save # 관리자 설정 저장 규칙 경계 (#493 E10) + - settings_screen # 설정 화면 JSON 의 min/max/step 계약 (#493 E10) locale: - ko - en @@ -39,9 +41,15 @@ effects: - front_props_reflect_settings # maxFiles/maxSize 동적 바인딩 - attribute_label_not_raw_key # 음수 입력 시 한글/영문 라벨 - review_settings_save_logs_activity # 리뷰설정 저장 활동로그 + # 설정 화면 JSON ↔ 저장 규칙 경계 정합 (#493 E10) + - non_integer_image_size_rejected_422 # step:0.5 가 오류 — 규칙은 integer + - settings_screen_bounds_match_rules # 화면 min/max/step 이 서버 경계와 동일 + - settings_screen_bounds_bind_to_meta_limits # 리터럴이 아닌 _meta.limits 바인딩 test_files: - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceTest.php - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewServiceTest.php - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/ReviewSettingsApiTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Settings/SettingsContractBoundaryTest.php + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts - templates/_bundled/sirsoft-basic/__tests__/layouts/review-modal-image-policy.test.tsx diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-vat-amount.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-vat-amount.yaml new file mode 100644 index 00000000..9ee035ce --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-vat-amount.yaml @@ -0,0 +1,52 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(부가세 산출·백필)이며 +# PHPUnit Unit/Feature 테스트가 세율 조합과 쓰기 지점을 커버하고 green 이다. + +feature: 주문 부가세(total_vat_amount) 산출 및 상품별 세율 연동 (E5) + +description: | + 주문 생성 시점에 부가세를 계산해 기록하고, 상품별 세율을 반영한다. + + 배경: + - `total_vat_amount` 쓰기 지점이 부분취소 재계산 1곳뿐이라 부분취소를 겪은 주문만 + 값이 정상화되고 나머지는 영구 0 이었다. 같은 화면에서 주문마다 세금 표기가 달랐다. + - `× 10/110` 과 `/ 11` 두 표기가 섞여 있었다 (수학적으로 동일 — 헬퍼로 단일화). + + 설계: + - `Support\VatCalculator` 가 산식 SSoT + - `classifyTaxStatus()` 가 옵션별 `tax_rate` / `vat_amount` 를 산출 (스냅샷 모드는 주문 시점 세율 고정) + - 합산은 **옵션별 부가세의 합** — 전체 과세표준에 단일 세율을 적용하면 세율 혼합 주문에서 어긋난다 + +axes: + tax_status: [taxable, tax_free] + tax_rate: [standard_10, reduced_5, missing] + order_composition: [single_rate, mixed_rate, taxable_plus_free] + write_point: [order_create, payment_record, partial_cancel_recalc, upgrade_backfill] + +exclusions: + - { tax_status: tax_free, tax_rate: reduced_5, reason: "면세 상품의 세율은 부가세에 반영되지 않음" } + +effects: + - vat_calculated_for_taxable_items + - tax_free_item_has_no_vat + - mixed_tax_rates_summed_per_option + - tax_free_item_rate_ignored + - order_row_records_vat_at_creation + - payment_record_vat_matches_order + - vat_survives_calculation_result_round_trip + - default_rate_matches_divide_by_eleven + - zero_rate_yields_zero_vat + - snapshot_rate_freezes_order_time_value + - upgrade_backfills_only_zero_vat_orders + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/VatCalculatorTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/OrderVatTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/OrderVatBackfillTest.php + +rules_layer_coverage: + - rule: service-direct-data-access + coverage: VatCalculator 는 순수 계산 헬퍼 (DB 접근 없음) + - rule: upgrade-step-local-helper + coverage: 백필 마이그레이션은 VatCalculator 를 호출하지 않고 산식을 로컬 private 메서드로 복제 (경로 A 규율) diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-price-and-mileage-rules.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-price-and-mileage-rules.yaml new file mode 100644 index 00000000..4f167cf9 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-price-and-mileage-rules.yaml @@ -0,0 +1,68 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(검증 규칙 계층) +# 이며 PHPUnit Feature 테스트가 화면·필드·경계 조합을 커버하고 green 이다. + +feature: 상품 가격·적립률 검증 규칙 통일 (E2 · E4) + +description: | + 단건 상품 등록에만 걸려 있던 가격 규칙을 일괄 수정 경로에도 동일하게 적용하고, + 옵션별 정률 적립률에 상한을 부여한다. + + 배경 (E2 — 적립률): + - 전역 `default_earn_rate` 는 `max:100` 인데, 동일한 `/100` 정률 계산에 쓰이는 + `options.*.mileage_value` 에는 상한이 없었다. 1000% 적립이 저장 가능했다. + - `fixed` 는 금액이므로 상한을 두면 안 된다 → `mileage_type` 조건부 상한이 필요하고, + 인덱스별 분기가 필요하므로 `withValidator()` trait 로 구현한다. + + 배경 (E4 — 벌크 가격): + - 단건은 통화 소수자릿수 기반 규칙 + `min:0.01` + `lte:list_price` 인데 + 벌크는 `['nullable','numeric','min:0']` 뿐이었다. 일괄 수정이 우회로가 된다. + - 벌크는 부분 전송이 흔하므로 `lte` 는 **둘 다 존재할 때만** 비교하고, + 단일 필드 전송은 Service 가 DB 기존값과 대조한다 (역할 분담). + + 경계 소유: + - 통화 정밀도: `ResolvesBaseCurrencyPrecision` trait 이 단건·벌크 공용 SSoT + - 적립률 상한: `ValidatesMileageRate` trait + - 옵션 가격 하한은 `min:0` — 단건 옵션 경로와 동일 경계여야 한다 + (상품 본가는 `min:0.01`, 옵션은 0 허용이 기존 계약) + +axes: + surface: [single_product, bulk_products, bulk_options, product_options] + field: [price, price_relation, mileage_value] + mileage_type: [percent, fixed, none] + send_shape: [both_fields, single_field] + +exclusions: + - { surface: bulk_options, field: price_relation, reason: "이 경로의 items.* 에 selling_price 가 없어 비교 대상이 없음" } + - { field: price, mileage_type: percent, reason: "가격 축과 적립률 타입 축은 교차하지 않음" } + - { field: price, mileage_type: fixed, reason: "동일" } + - { field: mileage_value, mileage_type: none, reason: "적립률 필드에는 타입이 반드시 있음" } + - { field: mileage_value, send_shape: single_field, reason: "적립률은 타입·값이 함께 전송됨" } + +effects: + # E2 — 적립률 + - percent_mileage_over_100_rejected + - percent_mileage_at_bound_allowed + - fixed_mileage_has_no_upper_bound + # E4 — 벌크 가격 + - bulk_price_respects_currency_precision + - bulk_selling_price_cannot_exceed_list_price + - bulk_product_price_zero_rejected + - bulk_option_price_zero_allowed + - bulk_valid_price_passes + - bulk_partial_send_deferred_to_service + - partial_send_compared_against_stored_value + - price_relation_violation_returns_422_not_500 + - rejected_bulk_update_leaves_prices_unchanged + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/ProductPriceAndMileageRuleTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Product/BulkPricePartialUpdateTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Requests/StoreProductRequestTest.php + +rules_layer_coverage: + - rule: formrequest-numeric-boundary-drift + coverage: 단건과 벌크의 같은 필드 경계가 갈라지면 cross-file 불일치로 검출된다 + - rule: i18n-throw-hardcoded-korean + coverage: ProductPriceRelationException 메시지는 sirsoft-ecommerce::validation.product.selling_price_lte_list diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php index 6d66bfad..8445954f 100644 --- a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php @@ -7,10 +7,12 @@ use App\Extension\AbstractUpgradeStep; /** * Ecommerce 모듈 1.0.5 업그레이드 스텝 * - * 카테고리 계층의 순환 참조를 절단하고 path 를 전면 재구축한다. - * - * 순환이 남아 있으면 카테고리 수정·순서 변경 요청이 path 재계산 재귀에서 종료되지 않아 - * 실패하고, 해당 노드가 트리에서 사라져 관리자 화면으로 복구할 수 없다. + * 1. 카테고리 계층의 순환 참조를 절단하고 path 를 전면 재구축한다. + * 순환이 남아 있으면 카테고리 수정·순서 변경 요청이 path 재계산 재귀에서 종료되지 않아 + * 실패하고, 해당 노드가 트리에서 사라져 관리자 화면으로 복구할 수 없다. + * 2. 주문 부가세(total_vat_amount)를 백필한다. + * 쓰기 지점이 부분취소 재계산 1곳뿐이라 부분취소를 겪은 주문만 값이 정상화되고 + * 나머지는 영구 0 이었다. 0 인 주문만 채우고 이미 정상화된 값은 건드리지 않는다. * * 모든 비즈니스 로직은 data/1.0.5/migrations/ 로 격리(AbstractUpgradeStep 규약). * diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillOrderVatAmount.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillOrderVatAmount.php new file mode 100644 index 00000000..23763e27 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.0.5/migrations/02_BackfillOrderVatAmount.php @@ -0,0 +1,189 @@ + 0 인 주문. + * 부분취소로 이미 정상화된 주문(값이 0 이 아님)은 건드리지 않습니다. + * + * 세율: 주문옵션 스냅샷(`product_snapshot.tax_rate`)이 있으면 그 값을, 없으면 10% 를 사용합니다. + * 기존 주문은 스냅샷에 세율이 없을 수 있어 폴백이 필요합니다. + * + * idempotent: 이미 값이 있는 주문은 스킵. V-1 안전: Facades\DB/Schema 만 사용. + */ +class BackfillOrderVatAmount implements DataMigration +{ + private const ORDERS_TABLE = 'ecommerce_orders'; + + private const OPTIONS_TABLE = 'ecommerce_order_options'; + + /** @var int 청크 크기 */ + private const CHUNK = 500; + + /** @var float 세율 폴백 (%) */ + private const DEFAULT_RATE = 10.0; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'BackfillOrderVatAmount'; + } + + /** + * 부가세가 비어 있는 주문을 백필합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::ORDERS_TABLE) + || ! Schema::hasColumn(self::ORDERS_TABLE, 'total_vat_amount') + || ! Schema::hasColumn(self::ORDERS_TABLE, 'total_tax_amount')) { + $context->logger->info('[ecommerce:1.0.5] 주문 부가세 백필 — 대상 스키마 부재로 스킵'); + + return; + } + + $hasOptionSnapshot = Schema::hasTable(self::OPTIONS_TABLE) + && Schema::hasColumn(self::OPTIONS_TABLE, 'product_snapshot'); + + $updated = 0; + $lastId = 0; + + while (true) { + $orders = DB::table(self::ORDERS_TABLE) + ->where('id', '>', $lastId) + ->where('total_tax_amount', '>', 0) + ->where(function ($query) { + $query->where('total_vat_amount', 0)->orWhereNull('total_vat_amount'); + }) + ->orderBy('id') + ->limit(self::CHUNK) + ->get(['id', 'total_tax_amount']); + + if ($orders->isEmpty()) { + break; + } + + foreach ($orders as $order) { + $lastId = (int) $order->id; + + $vat = $this->resolveVatForOrder( + (int) $order->id, + (float) $order->total_tax_amount, + $hasOptionSnapshot + ); + + if ($vat <= 0) { + continue; + } + + DB::table(self::ORDERS_TABLE) + ->where('id', $order->id) + ->update(['total_vat_amount' => $vat]); + + $updated++; + } + + $context->logger->info("[ecommerce:1.0.5] 주문 부가세 백필 진행 — 누적 {$updated}건 (마지막 주문 ID {$lastId})"); + } + + $context->logger->info("[ecommerce:1.0.5] 주문 부가세 백필 완료 — 총 {$updated}건"); + } + + /** + * 주문 하나의 부가세를 산출합니다. + * + * 주문옵션 스냅샷의 세율별로 과세표준을 나눠 합산하고, 스냅샷이 없으면 주문 전체 과세표준에 + * 기본 세율을 적용합니다. + * + * @param int $orderId 주문 ID + * @param float $totalTaxable 주문 과세표준 + * @param bool $hasOptionSnapshot 주문옵션 스냅샷 사용 가능 여부 + * @return int 부가세 + */ + private function resolveVatForOrder(int $orderId, float $totalTaxable, bool $hasOptionSnapshot): int + { + if (! $hasOptionSnapshot) { + return $this->vatFromTaxable($totalTaxable, self::DEFAULT_RATE); + } + + $columns = ['product_snapshot']; + $hasSubtotal = Schema::hasColumn(self::OPTIONS_TABLE, 'subtotal_tax_amount'); + if ($hasSubtotal) { + $columns[] = 'subtotal_tax_amount'; + } + + $options = DB::table(self::OPTIONS_TABLE) + ->where('order_id', $orderId) + ->get($columns); + + if ($options->isEmpty() || ! $hasSubtotal) { + return $this->vatFromTaxable($totalTaxable, self::DEFAULT_RATE); + } + + // 세율별 과세표준 합산 → 세율별 부가세의 합 (세율이 섞인 주문에서 이것만 정확) + $byRate = []; + $sumSubtotal = 0.0; + + foreach ($options as $option) { + $snapshot = json_decode((string) $option->product_snapshot, true); + $rate = is_array($snapshot) && isset($snapshot['tax_rate']) && $snapshot['tax_rate'] !== null + ? (float) $snapshot['tax_rate'] + : self::DEFAULT_RATE; + + $amount = (float) ($option->subtotal_tax_amount ?? 0); + if ($amount <= 0) { + continue; + } + + $byRate[(string) $rate] = ($byRate[(string) $rate] ?? 0) + $amount; + $sumSubtotal += $amount; + } + + // 옵션 과세 소계 합이 주문 과세표준과 크게 어긋나면(집계 누락 등) 주문 과세표준 + 기본 세율로 폴백 + if ($sumSubtotal <= 0 || abs($sumSubtotal - $totalTaxable) > max(1.0, $totalTaxable * 0.01)) { + return $this->vatFromTaxable($totalTaxable, self::DEFAULT_RATE); + } + + $vat = 0; + foreach ($byRate as $rate => $amount) { + $vat += $this->vatFromTaxable($amount, (float) $rate); + } + + return $vat; + } + + /** + * 과세표준에 내재된 부가세를 계산합니다. + * + * 경로 A 규율: 기존 클래스(Support\VatCalculator)를 호출하지 않고 로컬 복제한다 — + * 업그레이드 스텝은 과거 버전의 코드베이스에서도 실행될 수 있다. + * + * @param float $taxableAmount 과세표준 (부가세 포함) + * @param float $rate 세율 (%) + * @return int 부가세 + */ + private function vatFromTaxable(float $taxableAmount, float $rate): int + { + if ($taxableAmount <= 0 || $rate <= 0) { + return 0; + } + + return (int) round($taxableAmount * $rate / (100 + $rate)); + } +} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 9081b877..8feaecb1 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -6,6 +6,10 @@ ## [1.0.2] - 2026-07-16 +### Security + +- 페이지 첨부파일의 최대 개수·용량·허용 형식 설정이 실제 업로드에 적용되도록 수정했습니다. 이전에는 설정을 바꿔도 반영되지 않고 화면에 고정된 값(10MB, 확장자 16종)이 쓰였으며, 개수 제한은 어디에서도 적용되지 않아 설정값을 넘겨 첨부할 수 있었습니다. 허용 형식은 설정의 파일 형식 목록을 그대로 따르며, 목록을 비우면 형식을 제한하지 않습니다. 이미 상한을 넘겨 등록된 기존 첨부는 그대로 두고 신규 등록만 차단합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. @@ -15,6 +19,7 @@ - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. +- 첨부파일 저장 디스크 설정 항목을 제거했습니다. 설정 파일 자체가 그 디스크에 저장되는 구조라 값을 바꿔도 반영될 수 없는 항목이었습니다(기존에도 항상 기본값으로 동작). 저장 위치는 서버 파일 저장소 설정으로 관리합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) ### Fixed diff --git a/modules/_bundled/sirsoft-page/config/settings/defaults.json b/modules/_bundled/sirsoft-page/config/settings/defaults.json index fde464e9..3d4ae768 100644 --- a/modules/_bundled/sirsoft-page/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-page/config/settings/defaults.json @@ -1,8 +1,14 @@ { - "attachment": { - "disk": "modules", - "max_count": 5, - "max_size_mb": 10, - "allowed_types": ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "application/zip"] - } + "attachment": { + "max_count": 5, + "max_size_mb": 10, + "allowed_types": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "application/zip" + ] + } } diff --git a/modules/_bundled/sirsoft-page/docs/api/attachments.md b/modules/_bundled/sirsoft-page/docs/api/attachments.md index 982f3db6..102837d4 100644 --- a/modules/_bundled/sirsoft-page/docs/api/attachments.md +++ b/modules/_bundled/sirsoft-page/docs/api/attachments.md @@ -27,7 +27,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| file | body | file | 예 | max 10240 | 업로드 파일 | +| file | body | file | 예 | max: 첨부 최대 용량 설정(MB→KB 환산, 기본 10MB) · mimetypes: 허용 형식 설정 | 업로드 파일 (용량·형식 모두 페이지 모듈 환경설정이 SSoT) | | page_id | body | integer | 아니오 | min 1 | page 식별자 | | collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | temp_key | body | string | 아니오 | max 64 | 저장 전 임시 귀속 키. 아직 저장되지 않은 페이지의 첨부를 임시로 묶어 두고, 이후 페이지 저장 시 이 키로 확정 귀속합니다 (`page_id` 미지정 시 사용) | @@ -79,7 +79,9 @@ Content-Disposition: form-data; name="temp_key" -**설명** 페이지 첨부파일을 업로드합니다(최대 10MB). `file`(필수)과 함께 이미 저장된 페이지에 귀속시키려면 `page_id`, 아직 저장 전이면 `temp_key`(임시 귀속 후 페이지 저장 시 `store`/`update` 의 `temp_key` 로 확정)를 보냅니다. `collection` 으로 첨부 그룹을 구분합니다. 응답은 FileUploader 컴포넌트 규약(`data.data`)에 맞춰 `PageAttachmentResource` 를 201 로 반환합니다. 권한은 `pages.create` 를 요구합니다. +**설명** 페이지 첨부파일을 업로드합니다. `file`(필수)과 함께 이미 저장된 페이지에 귀속시키려면 `page_id`, 아직 저장 전이면 `temp_key`(임시 귀속 후 페이지 저장 시 `store`/`update` 의 `temp_key` 로 확정)를 보냅니다. `collection` 으로 첨부 그룹을 구분합니다. 응답은 FileUploader 컴포넌트 규약(`data.data`)에 맞춰 `PageAttachmentResource` 를 201 로 반환합니다. 권한은 `pages.create` 를 요구합니다. + +업로드 정책(최대 개수 · 최대 용량 · 허용 형식)은 모듈 환경설정 `attachment.*` 가 SSoT 입니다. 용량은 `attachment.max_size_mb`(MB), 형식은 `attachment.allowed_types`(MIME 목록)로 판정하며, 형식 목록을 비우면 형식을 제한하지 않습니다(설정 키 자체가 없으면 모듈 기본 목록으로 폴백). 개수는 `attachment.max_count` 를 기준으로 **직접 업로드 · 임시 업로드(`temp_key`) 연결 · 이미 연결된 첨부** 를 합산해 판정하며, 초과 시 `422`(`errors.code = attachment_limit_exceeded`)를 반환합니다. 이 판정은 업로드 엔드포인트뿐 아니라 페이지 생성·수정 저장 시점에도 동일하게 적용됩니다. `max_count` 가 0 이면 개수를 제한하지 않습니다. ### PATCH /api/modules/sirsoft-page/admin/attachments/reorder diff --git a/modules/_bundled/sirsoft-page/module.php b/modules/_bundled/sirsoft-page/module.php index b9f99fe5..311148d8 100644 --- a/modules/_bundled/sirsoft-page/module.php +++ b/modules/_bundled/sirsoft-page/module.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Page; use App\Extension\AbstractModule; +use Illuminate\Database\Seeder; use Modules\Sirsoft\Page\Database\Seeders\PageSeeder; use Modules\Sirsoft\Page\Listeners\ActivityLogDescriptionResolver; use Modules\Sirsoft\Page\Listeners\PageActivityLogListener; @@ -110,7 +111,7 @@ class Module extends AbstractModule /** * 모듈 설치 시 실행할 시더 목록 반환 * - * @return array> + * @return array> */ public function getSeeders(): array { @@ -139,7 +140,11 @@ class Module extends AbstractModule */ public function getStorageDisk(): string { - return config('sirsoft-page.attachment.disk', 'modules'); + // 스토리지 디스크는 모듈 환경설정으로 정할 수 없다 — 설정 파일 자체가 이 디스크에 저장되므로 + // module_setting() 을 여기서 호출하면 설정 로드 → getStorage() → getStorageDisk() → 설정 로드 + // 무한 재귀가 된다. 기존의 config('sirsoft-page.attachment.disk') 참조도 해당 config 파일이 + // 존재하지 않아 항상 폴백만 반환했다. 디스크는 코어 filesystems 설정으로 관리한다. + return 'modules'; } /** diff --git a/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php b/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php new file mode 100644 index 00000000..b8e99363 --- /dev/null +++ b/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php @@ -0,0 +1,49 @@ + $limit, + 'attempted' => $attempted, + ])); + } + + /** + * 허용 최대 개수를 반환합니다. + * + * @return int 최대 첨부 개수 + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * 시도한 총 개수를 반환합니다. + * + * @return int 연결 시도 개수 + */ + public function getAttempted(): int + { + return $this->attempted; + } +} diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php index 472a177d..7278af46 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Page\Http\Controllers\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; +use Modules\Sirsoft\Page\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Page\Http\Requests\ReorderPageAttachmentsRequest; use Modules\Sirsoft\Page\Http\Requests\UploadPageAttachmentRequest; use Modules\Sirsoft\Page\Http\Resources\PageAttachmentResource; @@ -50,6 +51,9 @@ class PageAttachmentController extends AdminBaseController ], 201 ); + } catch (AttachmentLimitExceededException $e) { + // 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (\Exception $e) { return $this->error('sirsoft-page::messages.attachment.upload_failed', 500, $e->getMessage()); } diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php index a49a123e..1fe8879f 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Page\Http\Controllers\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; +use Modules\Sirsoft\Page\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Page\Http\Requests\BulkChangePageStatusRequest; use Modules\Sirsoft\Page\Http\Requests\ChangePageStatusRequest; use Modules\Sirsoft\Page\Http\Requests\CheckSlugRequest; @@ -77,6 +78,9 @@ class PageController extends AdminBaseController new PageResource($page), 201 ); + } catch (AttachmentLimitExceededException $e) { + // 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (\Exception $e) { return $this->error('sirsoft-page::messages.page.create_failed', 500, $e->getMessage()); } @@ -125,6 +129,9 @@ class PageController extends AdminBaseController 'sirsoft-page::messages.page.update_success', new PageResource($page) ); + } catch (AttachmentLimitExceededException $e) { + // 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (ModelNotFoundException) { return $this->notFound('sirsoft-page::messages.page.not_found'); } catch (AccessDeniedHttpException) { diff --git a/modules/_bundled/sirsoft-page/src/Http/Requests/UploadPageAttachmentRequest.php b/modules/_bundled/sirsoft-page/src/Http/Requests/UploadPageAttachmentRequest.php index 0cb16a5d..2666d0d6 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Requests/UploadPageAttachmentRequest.php +++ b/modules/_bundled/sirsoft-page/src/Http/Requests/UploadPageAttachmentRequest.php @@ -9,8 +9,26 @@ use Illuminate\Foundation\Http\FormRequest; */ class UploadPageAttachmentRequest extends FormRequest { + /** + * 허용 형식 폴백 (config/settings/defaults.json 의 attachment.allowed_types 와 동일) + * + * 설정이 로드되지 않은 상태에서도 형식 제한이 유지되도록 하는 안전값입니다. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) + */ + private const FALLBACK_ALLOWED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'application/zip', + ]; + /** * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -36,8 +54,27 @@ class UploadPageAttachmentRequest extends FormRequest */ public function rules(): array { + // 모듈 환경설정(attachment.*)이 SSoT — 하드코딩된 상한/형식과 이원화되지 않도록 동적 생성한다. + // 설정이 MIME 배열이므로 mimes: 가 아니라 mimetypes: 를 사용한다(의미 정합). + $maxSizeMb = (int) g7_module_settings('sirsoft-page', 'attachment.max_size_mb', 10); + $maxSizeKb = max(1, $maxSizeMb) * 1024; + + // 설정이 아직 로드되지 않은 환경(신규 설치 직후 등)에서 형식 검증이 통째로 비활성화되지 + // 않도록, 키가 없으면 모듈 기본값으로 폴백한다. 명시적 빈 배열만 "제한 없음" 이다. + $allowedTypes = g7_module_settings('sirsoft-page', 'attachment.allowed_types', null); + if (! is_array($allowedTypes)) { + $allowedTypes = self::FALLBACK_ALLOWED_TYPES; + } + $allowedTypes = array_values(array_filter(array_map('strval', $allowedTypes))); + + $fileRules = ['required', 'file', 'max:'.$maxSizeKb]; + // 목록이 비면 형식 제한 없음 — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($allowedTypes !== []) { + $fileRules[] = 'mimetypes:'.implode(',', $allowedTypes); + } + return [ - 'file' => ['required', 'file', 'max:10240', 'mimes:jpg,jpeg,png,gif,webp,pdf,zip,doc,docx,xls,xlsx,ppt,pptx,hwp,txt'], + 'file' => $fileRules, 'page_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], 'temp_key' => ['nullable', 'string', 'max:64'], @@ -56,6 +93,7 @@ class UploadPageAttachmentRequest extends FormRequest 'file.file' => __('sirsoft-page::validation.attachment.file.file'), 'file.max' => __('sirsoft-page::validation.attachment.file.max'), 'file.mimes' => __('sirsoft-page::validation.attachment.file.mimes'), + 'file.mimetypes' => __('sirsoft-page::validation.attachment.file.mimetypes'), ]; } } diff --git a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php index 23a40eea..ab794baa 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php @@ -5,9 +5,11 @@ namespace Modules\Sirsoft\Page\Services; use App\Contracts\Extension\StorageInterface; use App\Extension\HookManager; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; +use Modules\Sirsoft\Page\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Page\Models\PageAttachment; use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -24,6 +26,44 @@ class PageAttachmentService private StorageInterface $storage, ) {} + /** + * 첨부 개수가 설정 상한을 넘지 않는지 검증합니다 (도메인 불변조건). + * + * 총합은 **직접 업로드 + `temp_key` 임시첨부 + 기존 첨부** 이고 임시첨부 쪽은 DB 조회가 + * 필요하므로 FormRequest 만으로는 판정할 수 없습니다. 모든 연결 경로가 반드시 지나는 + * 이 지점이 상한의 SSoT 입니다. + * + * @param int|null $pageId 페이지 ID (신규 작성 중이면 null) + * @param int $additional 이번에 추가하려는 첨부 개수 + * @param string|null $tempKey 임시 업로드 키 (신규 작성 시 기존 임시첨부 합산용) + * + * @throws AttachmentLimitExceededException 상한 초과 시 + */ + public function assertAttachmentCountWithin(?int $pageId, int $additional, ?string $tempKey = null): void + { + if ($additional <= 0) { + return; + } + + $limit = (int) g7_module_settings('sirsoft-page', 'attachment.max_count', 0); + if ($limit <= 0) { + // 상한 미설정은 제한하지 않는다 (기존 동작 유지) + return; + } + + $existing = 0; + if ($pageId !== null) { + $existing = $this->attachmentRepository->getByPageId($pageId)->count(); + } elseif ($tempKey !== null) { + $existing = $this->attachmentRepository->getByTempKey($tempKey)->count(); + } + + $total = $existing + $additional; + if ($total > $limit) { + throw new AttachmentLimitExceededException($limit, $total); + } + } + /** * 파일을 업로드합니다. * @@ -39,6 +79,9 @@ class PageAttachmentService string $collection = 'attachments', ?string $tempKey = null ): PageAttachment { + // 개수 상한 최종 방어선 — 단일 업로드를 N 번 반복하는 경로도 여기서 막힌다 + $this->assertAttachmentCountWithin($pageId, 1, $tempKey); + HookManager::doAction('sirsoft-page.attachment.before_upload', $file, $pageId); $file = HookManager::applyFilters('sirsoft-page.attachment.filter_upload_file', $file); @@ -102,6 +145,9 @@ class PageAttachmentService public function linkTempAttachmentsWithMove(string $tempKey, int $pageId): int { $tempAttachments = $this->attachmentRepository->getByTempKey($tempKey); + + // 개수 상한 최종 방어선 — FormRequest 는 단건 업로드만 보므로 temp_key 연결 경로가 그대로 뚫린다 + $this->assertAttachmentCountWithin($pageId, $tempAttachments->count()); $linkedCount = 0; foreach ($tempAttachments as $index => $attachment) { @@ -187,7 +233,7 @@ class PageAttachmentService * @param int $id 첨부파일 ID * @return PageAttachment 첨부파일 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function getById(int $id): PageAttachment { diff --git a/modules/_bundled/sirsoft-page/src/lang/en/messages.php b/modules/_bundled/sirsoft-page/src/lang/en/messages.php index 7c71470b..2d93504d 100644 --- a/modules/_bundled/sirsoft-page/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-page/src/lang/en/messages.php @@ -46,5 +46,6 @@ return [ 'version_belongs_to_different_page' => 'The specified version does not belong to this page.', 'permission_denied' => 'Permission denied.', 'validation_failed' => 'Validation failed.', + 'attachment_limit_exceeded' => 'You can attach up to :limit file(s). (requested: :attempted)', ], ]; diff --git a/modules/_bundled/sirsoft-page/src/lang/en/validation.php b/modules/_bundled/sirsoft-page/src/lang/en/validation.php index 6862cfdb..57d3090c 100644 --- a/modules/_bundled/sirsoft-page/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-page/src/lang/en/validation.php @@ -97,6 +97,7 @@ return [ 'file' => 'The file format is invalid.', 'max' => 'The file size must not exceed :maxKB.', 'mimes' => 'The file type is not allowed.', + 'mimetypes' => 'The file type is not allowed.', ], 'order' => [ 'required' => 'Order information is required.', diff --git a/modules/_bundled/sirsoft-page/src/lang/ko/messages.php b/modules/_bundled/sirsoft-page/src/lang/ko/messages.php index 8710edbc..ed51d2d6 100644 --- a/modules/_bundled/sirsoft-page/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-page/src/lang/ko/messages.php @@ -46,5 +46,6 @@ return [ 'version_belongs_to_different_page' => '지정된 버전이 해당 페이지에 속하지 않습니다.', 'permission_denied' => '권한이 없습니다.', 'validation_failed' => '입력값이 올바르지 않습니다.', + 'attachment_limit_exceeded' => '첨부파일은 최대 :limit개까지 등록할 수 있습니다. (요청: :attempted개)', ], ]; diff --git a/modules/_bundled/sirsoft-page/src/lang/ko/validation.php b/modules/_bundled/sirsoft-page/src/lang/ko/validation.php index defdd4b4..303dd406 100644 --- a/modules/_bundled/sirsoft-page/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-page/src/lang/ko/validation.php @@ -97,6 +97,7 @@ return [ 'file' => '올바른 파일 형식이 아닙니다.', 'max' => '파일 크기는 :maxKB를 초과할 수 없습니다.', 'mimes' => '허용되지 않는 파일 형식입니다.', + 'mimetypes' => '허용되지 않는 파일 형식입니다.', ], 'order' => [ 'required' => '순서 정보는 필수입니다.', diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php index b5c06188..63064adc 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageAttachmentControllerTest.php @@ -125,6 +125,133 @@ class PageAttachmentControllerTest extends FeatureTestCase ->assertJsonValidationErrors(['file']); } + /** + * 설정 상한을 넘는 용량이 실제 요청에서 422 로 차단되는지 확인 (E3) + * + * `rules()` 문자열 단언만으로는 규칙이 실제 요청에 적용되는지 알 수 없다 — Request 가 + * 라우트에 배선되지 않았거나 컨트롤러가 우회하면 문자열은 맞는데 요청은 통과한다. + * 기본값(10MB)과 다른 값을 주입해 하드코딩과의 우연한 일치를 피한다. + */ + public function test_upload_over_configured_size_returns_422(): void + { + config(['g7_settings.modules.sirsoft-page.attachment' => [ + 'max_count' => 5, + 'max_size_mb' => 5, + 'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'], + ]]); + + // 상한 5MB 에 6MB → 초과 + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-page/admin/attachments', [ + 'file' => UploadedFile::fake()->create('over-size.png', 6 * 1024, 'image/png'), + 'temp_key' => 'test-over-size', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + } + + /** + * 개수 상한에 도달한 뒤의 단일 업로드가 422 로 차단되는지 확인 (E3) + * + * 개수 상한의 최종 관문은 Service 지만, 사용자는 파일을 한 건씩 올린다 — 마지막 한 건이 + * 500 이 아니라 422 로 돌아와야 화면이 사유를 안내할 수 있다. + */ + public function test_single_upload_beyond_count_limit_returns_422(): void + { + config(['g7_settings.modules.sirsoft-page.attachment' => [ + 'max_count' => 3, + 'max_size_mb' => 10, + 'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'], + ]]); + + $tempKey = 'test-count-limit'; + + for ($i = 0; $i < 3; $i++) { + PageAttachment::create([ + 'page_id' => null, + 'temp_key' => $tempKey, + 'disk' => 'local', + 'original_filename' => "seed-{$i}.png", + 'stored_filename' => "seed-{$i}.png", + 'path' => "temp/{$tempKey}/seed-{$i}.png", + 'mime_type' => 'image/png', + 'size' => 100, + 'order' => $i, + ]); + } + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-page/admin/attachments', [ + 'file' => UploadedFile::fake()->create('fourth.png', 10, 'image/png'), + 'temp_key' => $tempKey, + ]); + + $response->assertStatus(422); + } + + /** + * 수정 전 허용 목록에 있던 형식이 설정 기준으로 차단되는지 확인 (E3) + * + * 기존 `test_upload_disallowed_file_type_returns_422` 는 `.exe` 를 쓴다 — 수정 전 + * 하드코딩 목록(`mimes:...,doc,docx,xls,...`)에도 없던 형식이라 수정 전후 모두 422 다. + * 즉 그 케이스는 이 변경을 red 로 잡아내지 못한다. + * + * 문서 형식은 수정 전에는 통과했고 설정 기준으로는 차단되므로, 전환이 실제로 적용된 + * 것을 red 로 증명할 수 있는 유일한 경계다. + */ + public function test_upload_type_allowed_before_but_not_in_settings_returns_422(): void + { + config(['g7_settings.modules.sirsoft-page.attachment' => [ + 'max_count' => 5, + 'max_size_mb' => 10, + 'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'], + ]]); + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-page/admin/attachments', [ + 'file' => UploadedFile::fake()->create( + 'legacy.docx', + 100, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ), + 'temp_key' => 'test-legacy-type', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + } + + /** + * 설정에 추가한 형식은 곧바로 허용되는지 확인 (E3 — 차단 일변도가 아님) + * + * 위 케이스와 같은 파일을 설정에만 넣어 통과시킨다. 두 케이스가 짝을 이뤄야 "설정이 + * SSoT" 라는 계약이 증명된다 — 차단만 보면 목록이 그냥 좁아진 것과 구분되지 않는다. + */ + public function test_upload_type_added_to_settings_is_allowed(): void + { + config(['g7_settings.modules.sirsoft-page.attachment' => [ + 'max_count' => 5, + 'max_size_mb' => 10, + 'allowed_types' => [ + 'image/png', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + ]]); + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-page/admin/attachments', [ + 'file' => UploadedFile::fake()->create( + 'allowed.docx', + 100, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ), + 'temp_key' => 'test-added-type', + ]); + + $response->assertStatus(201); + } + /** * 허용되지 않는 파일 형식 업로드 시 422를 반환하는지 확인 */ diff --git a/modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyHttpTest.php b/modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyHttpTest.php new file mode 100644 index 00000000..73ad78f3 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyHttpTest.php @@ -0,0 +1,256 @@ +adminUser = $this->createAdminUser([ + 'sirsoft-page.pages.read', + 'sirsoft-page.pages.create', + 'sirsoft-page.pages.update', + 'sirsoft-page.pages.delete', + ]); + + // StorageInterface 를 모킹 (파일 시스템 의존 제거) + $storageMock = Mockery::mock(StorageInterface::class); + $storageMock->shouldReceive('put')->andReturn(true); + $storageMock->shouldReceive('get')->andReturn('file content'); + $storageMock->shouldReceive('exists')->andReturn(true); + $storageMock->shouldReceive('delete')->andReturn(true); + $storageMock->shouldReceive('deleteDirectory')->andReturn(true); + $storageMock->shouldReceive('getDisk')->andReturn('local'); + $storageMock->shouldReceive('url')->andReturn('/storage/test.pdf'); + $storageMock->shouldReceive('response')->andReturn(null); + $this->app->instance(StorageInterface::class, $storageMock); + } + + /** + * 테스트 정리 + */ + protected function tearDown(): void + { + Page::where('slug', 'like', 'policy-http-%')->forceDelete(); + Mockery::close(); + parent::tearDown(); + } + + /** + * 첨부 설정을 주입합니다. + * + * @param array $overrides 덮어쓸 설정 값 + */ + private function writeAttachmentSettings(array $overrides = []): void + { + config(['g7_settings.modules.sirsoft-page.attachment' => array_merge([ + 'max_count' => 5, + 'max_size_mb' => 10, + 'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'], + ], $overrides)]); + } + + /** + * 테스트용 페이지를 만듭니다. + * + * @param string $slug 페이지 슬러그 + * @return Page 생성된 페이지 + */ + private function makePage(string $slug): Page + { + return Page::factory()->create([ + 'slug' => $slug, + 'created_by' => $this->adminUser->id, + 'updated_by' => $this->adminUser->id, + ]); + } + + /** + * 허용 목록에 없는 형식(docx)은 422 로 거부된다 + * + * 수정 전에는 `mimes:` 16종 하드코딩이 설정과 무관하게 docx 를 통과시켰다. + */ + public function test_disallowed_mime_type_is_rejected_over_http(): void + { + $this->writeAttachmentSettings([ + 'allowed_types' => ['image/png', 'application/pdf'], + ]); + + $page = $this->makePage('policy-http-mime'); + + $file = UploadedFile::fake()->create( + 'contract.docx', + 100, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); + + $response = $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => $file, + 'page_id' => $page->id, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors(['file']); + + $this->assertDatabaseMissing('page_attachments', [ + 'page_id' => $page->id, + 'original_filename' => 'contract.docx', + ]); + } + + /** + * 허용 목록에 있는 형식은 그대로 통과한다 (과잉 차단 방지 대조군) + */ + public function test_allowed_mime_type_still_passes_over_http(): void + { + $this->writeAttachmentSettings([ + 'allowed_types' => ['image/png', 'application/pdf'], + ]); + + $page = $this->makePage('policy-http-mime-ok'); + + $file = UploadedFile::fake()->create('manual.pdf', 100, 'application/pdf'); + + $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => $file, + 'page_id' => $page->id, + ]) + ->assertStatus(201); + + $this->assertDatabaseHas('page_attachments', [ + 'page_id' => $page->id, + 'original_filename' => 'manual.pdf', + ]); + } + + /** + * 설정한 최대 용량을 넘는 파일은 422 로 거부된다 + * + * 설정을 5MB 로 낮춘 뒤 6MB 를 올린다 — 기본값(10MB) 상태였다면 통과했을 크기다. + */ + public function test_file_over_configured_max_size_is_rejected_over_http(): void + { + $this->writeAttachmentSettings(['max_size_mb' => 5]); + + $page = $this->makePage('policy-http-size'); + + // 6MB — 설정 상한 5MB 초과, 수정 전 하드코딩 10MB 였다면 통과 + $file = UploadedFile::fake()->create('large.pdf', 6 * 1024, 'application/pdf'); + + $response = $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => $file, + 'page_id' => $page->id, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors(['file']); + } + + /** + * 상한 이내 용량은 통과한다 (경계 대조군) + */ + public function test_file_within_configured_max_size_passes_over_http(): void + { + $this->writeAttachmentSettings(['max_size_mb' => 5]); + + $page = $this->makePage('policy-http-size-ok'); + + $file = UploadedFile::fake()->create('small.pdf', 4 * 1024, 'application/pdf'); + + $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => $file, + 'page_id' => $page->id, + ]) + ->assertStatus(201); + } + + /** + * 개수 상한을 넘는 6번째 업로드는 422 + 코드로 차단된다 + * + * Service 예외가 컨트롤러에서 422 로 매핑되는지까지 확인한다 + * (매핑이 없으면 generic 500 으로 떨어진다). + */ + public function test_upload_beyond_max_count_returns_422_with_code(): void + { + $this->writeAttachmentSettings(['max_count' => 5]); + + $page = $this->makePage('policy-http-count'); + + for ($i = 0; $i < 5; $i++) { + $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => UploadedFile::fake()->create("doc-{$i}.pdf", 10, 'application/pdf'), + 'page_id' => $page->id, + ]) + ->assertStatus(201); + } + + $response = $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => UploadedFile::fake()->create('doc-6.pdf', 10, 'application/pdf'), + 'page_id' => $page->id, + ]); + + $response->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + + // 6번째는 저장되지 않았다 — "차단은 했는데 이미 저장됨" 반쪽 통과 배제 + $this->assertSame(5, PageAttachment::where('page_id', $page->id)->count()); + } + + /** + * 상한 미설정(0)이면 개수를 제한하지 않는다 (기존 동작 보존) + */ + public function test_zero_max_count_is_unrestricted_over_http(): void + { + $this->writeAttachmentSettings(['max_count' => 0]); + + $page = $this->makePage('policy-http-count-zero'); + + for ($i = 0; $i < 6; $i++) { + $this->actingAs($this->adminUser) + ->postJson(self::ENDPOINT, [ + 'file' => UploadedFile::fake()->create("free-{$i}.pdf", 10, 'application/pdf'), + 'page_id' => $page->id, + ]) + ->assertStatus(201); + } + + $this->assertSame(6, PageAttachment::where('page_id', $page->id)->count()); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyTest.php b/modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyTest.php new file mode 100644 index 00000000..3e31a8a0 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyTest.php @@ -0,0 +1,180 @@ + array_merge([ + 'max_count' => 5, + 'max_size_mb' => 10, + 'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'], + ], $overrides)]); + } + + /** + * 임시 첨부를 N 개 만듭니다. + * + * @param string $tempKey 임시 키 + * @param int $count 생성 개수 + */ + private function seedTempAttachments(string $tempKey, int $count): void + { + for ($i = 0; $i < $count; $i++) { + PageAttachment::create([ + 'page_id' => null, + 'temp_key' => $tempKey, + 'collection' => 'attachments', + 'original_filename' => "temp-{$i}.png", + 'stored_filename' => "temp-{$i}.png", + 'path' => "temp/{$tempKey}/temp-{$i}.png", + 'mime_type' => 'image/png', + 'size' => 100, + 'order' => $i, + ]); + } + } + + /** + * 설정한 최대 용량이 검증 규칙에 반영되는지 테스트합니다. + */ + public function test_max_size_setting_is_reflected_in_rules(): void + { + // 기본값(10MB)과 다른 값이어야 하드코딩과의 우연한 일치를 피할 수 있다 + $this->writeAttachmentSettings(['max_size_mb' => 5]); + + $rules = (new UploadPageAttachmentRequest)->rules(); + + $this->assertContains('max:5120', $rules['file']); + $this->assertNotContains('max:10240', $rules['file']); + } + + /** + * 허용 형식이 설정의 MIME 목록으로 강제되는지 테스트합니다. + */ + public function test_allowed_types_use_mimetypes_from_settings(): void + { + $this->writeAttachmentSettings(['allowed_types' => ['image/png', 'application/pdf']]); + + $rules = (new UploadPageAttachmentRequest)->rules(); + + $this->assertContains('mimetypes:image/png,application/pdf', $rules['file']); + // 수정 전: mimes:jpg,jpeg,png,... 16종 하드코딩 (설정과 무관) + foreach ($rules['file'] as $rule) { + $this->assertStringNotContainsString('mimes:jpg', (string) $rule); + } + } + + /** + * 허용 형식 목록이 비면 형식을 제한하지 않는지 테스트합니다 (탈출구). + */ + public function test_empty_allowed_types_disables_type_restriction(): void + { + $this->writeAttachmentSettings(['allowed_types' => []]); + + $rules = (new UploadPageAttachmentRequest)->rules(); + + foreach ($rules['file'] as $rule) { + $this->assertStringNotContainsString('mimetypes:', (string) $rule); + } + } + + /** + * temp_key 연결이 개수 상한을 초과하면 차단되는지 테스트합니다. + */ + public function test_temp_key_link_over_limit_is_blocked(): void + { + $this->writeAttachmentSettings(['max_count' => 5]); + + $tempKey = 'tk_'.str_repeat('a', 20); + $this->seedTempAttachments($tempKey, 8); + + $this->expectException(AttachmentLimitExceededException::class); + + app(PageAttachmentService::class)->linkTempAttachmentsWithMove($tempKey, 1); + } + + /** + * 상한과 같은 개수는 통과하는지 테스트합니다 (경계). + */ + public function test_link_at_limit_passes(): void + { + $this->writeAttachmentSettings(['max_count' => 5]); + + $tempKey = 'tk_'.str_repeat('b', 20); + $this->seedTempAttachments($tempKey, 5); + + app(PageAttachmentService::class)->assertAttachmentCountWithin(null, 5, 'tk_none'); + + $this->assertTrue(true); + } + + /** + * 기존 첨부와 합산해 판정하는지 테스트합니다. + */ + public function test_existing_plus_new_over_limit_is_blocked(): void + { + $this->writeAttachmentSettings(['max_count' => 5]); + + $tempKey = 'tk_'.str_repeat('c', 20); + $this->seedTempAttachments($tempKey, 4); + + $this->expectException(AttachmentLimitExceededException::class); + + app(PageAttachmentService::class)->assertAttachmentCountWithin(null, 2, $tempKey); + } + + /** + * 상한 미설정(0)이면 제한하지 않는지 테스트합니다. + */ + public function test_zero_limit_is_unrestricted(): void + { + $this->writeAttachmentSettings(['max_count' => 0]); + + app(PageAttachmentService::class)->assertAttachmentCountWithin(null, 100, null); + + $this->assertTrue(true); + } + + /** + * 스토리지 디스크가 모듈 설정에 의존하지 않는지 테스트합니다. + * + * 설정 파일 자체가 이 디스크에 저장되므로 여기서 module_setting() 을 호출하면 + * 설정 로드 → getStorage() → getStorageDisk() → 설정 로드 무한 재귀가 된다. + * (기존 `config('sirsoft-page.attachment.disk')` 참조도 해당 config 가 없어 항상 폴백이었다.) + */ + public function test_storage_disk_does_not_depend_on_module_settings(): void + { + $this->writeAttachmentSettings(); + + $module = new Module; + + $this->assertSame('modules', $module->getStorageDisk()); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-policy.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-policy.yaml new file mode 100644 index 00000000..4b84f563 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-policy.yaml @@ -0,0 +1,55 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(첨부 검증·설정 참조)이며 +# PHPUnit Feature 테스트가 축 조합을 커버하고 green 이다. + +feature: 페이지 첨부 정책(개수·용량·형식) 강제 (E3) + +description: | + 페이지 모듈의 첨부 설정이 실제 업로드·연결 경로에서 강제되도록 한다. + + 배경: + - `config/settings/defaults.json` 의 `attachment.*` 가 SSoT 로 선언만 되고 참조가 0건이었다. + - Request 는 용량 `max:10240` 과 mimes 16종을 하드코딩했고, 설정의 MIME 목록(6종)과 달랐다. + - 개수 제한은 어디에도 강제되지 않아 `temp_key` 연결로 무제한 첨부가 가능했다. + + 단위 규약: + - 설정 `attachment.max_size_mb` = MB (관리자 UI 단위) + - Laravel `max:` 규칙 = KB → 변환은 Request 규칙 생성 지점 한 곳 + - 저장 디스크는 첨부 설정과 무관하다(스토리지 드라이버 소관) — 설정 변경이 디스크를 바꾸지 않는다 + + 함정: + - 하드코딩 값(10240KB)과 기본 설정이 우연히 같아 기본값 상태에서는 수정 전에도 green 이 된다. + 용량 축은 반드시 기본값과 다른 값을 주입해 판정한다. + +axes: + entry_point: [direct_upload, temp_key_link, existing_plus_new] + count_state: [within_limit, at_limit, over_limit, unlimited_zero] + size_setting: [default, raised, lowered] + type_setting: [configured_list, empty_list] + file_type: [allowed, disallowed] + +exclusions: + - { type_setting: empty_list, file_type: disallowed, reason: "목록이 비면 형식 제한 자체가 없음 (탈출구)" } + - { count_state: unlimited_zero, entry_point: existing_plus_new, reason: "0 = 무제한이므로 합산 판정 자체가 성립하지 않음" } + +effects: + - max_size_rule_reads_setting_not_literal + - allowed_types_rule_reads_setting_mimetypes + - empty_allowed_types_disables_type_restriction + - temp_key_link_over_limit_is_blocked + - link_at_limit_passes + - existing_plus_new_over_limit_is_blocked + - zero_limit_is_unrestricted + - storage_disk_independent_of_module_settings + +test_files: + - modules/_bundled/sirsoft-page/tests/Feature/PageAttachmentPolicyTest.php + +rules_layer_coverage: + - rule: formrequest-numeric-boundary-drift + coverage: 용량 경계는 모듈 설정 단일 출처 (Request 리터럴 제거) + - rule: service-direct-data-access + coverage: PageAttachmentService 는 Repository 위임 유지 + - rule: i18n-throw-hardcoded-korean + coverage: 개수 초과 예외 메시지는 모듈 lang 의 attachment 키 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 0437ee69..fb58ddfb 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- PC 결제창 요청의 최소 결제금액이 100원으로 제한되어, 모바일에서는 성립하는 소액 결제가 PC 에서만 거부되던 문제를 수정했습니다. 이제 PC·모바일이 동일한 기준을 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + - 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 해외결제(CBT) 연결 진단을 실행했을 때 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md index 5f6c0863..aa3a24f5 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md @@ -212,7 +212,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | oid | body | string | 예 | max 40 | 결제 대상 주문의 주문번호. 서버가 주문을 조회해 결제 가능 상태·통화·금액을 검증하고 이 값을 서명 생성 입력으로 사용한다. | -| price | body | integer | 예 | min 100 | PC 표준결제창 결제 금액(최소 100원). 저장된 주문 청구액과 일치하는지 검증한 뒤 signature 생성에 반영한다. | +| price | body | integer | 예 | min 1 | PC 표준결제창 결제 금액. 저장된 주문 청구액과 일치하는지 검증한 뒤 signature 생성에 반영한다. 하한은 모바일 요청과 동일하며(`PaymentLimits::MIN_PRICE`), 실제 청구 가능 최소 금액은 PG 계약에 따른다. | | timestamp | body | string | 예 | max 20 | 결제창이 생성한 요청 타임스탬프. 재생 공격 방지를 위해 신선도(만료 여부)를 확인하고 서명 계산에 함께 사용한다. | | buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. 제공 시 주문의 구매자 정보와 대조해 본인 결제 요청인지 확인하는 데 사용된다. | | buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 제공 시 주문의 구매자 정보와 대조해 본인 결제 요청인지 확인하는 데 사용된다. | diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/MobileSignatureRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/MobileSignatureRequest.php index 75e2eb7d..f73c347b 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/MobileSignatureRequest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/MobileSignatureRequest.php @@ -5,34 +5,31 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Plugins\Sirsoft\PayKginicis\Support\PaymentLimits; class MobileSignatureRequest extends FormRequest { /** * authorize * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { return true; } -/** - - * rules - - * - - * @return array - - */ - + /** + * rules + * + * @return array 검증 규칙 + */ public function rules(): array { return [ - 'oid' => ['required', 'string', 'max:40'], - 'price' => ['required', 'integer', 'min:1'], + 'oid' => ['required', 'string', 'max:40'], + // PC·모바일 동일 하한 (PaymentLimits SSoT) + 'price' => ['required', 'integer', 'min:'.PaymentLimits::MIN_PRICE], 'timestamp' => ['required', 'string', 'max:20'], 'buyer_email' => ['nullable', 'string', 'max:255'], 'buyer_phone' => ['nullable', 'string', 'max:30'], diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php index daa92d59..c228f6dc 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/PaymentCloseReportRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Plugins\Sirsoft\PayKginicis\Support\PaymentLimits; class PaymentCloseReportRequest extends FormRequest { @@ -26,12 +27,12 @@ class PaymentCloseReportRequest extends FormRequest public function rules(): array { return [ - 'oid' => ['required', 'string', 'max:40'], - 'price' => ['required', 'integer', 'min:1'], - 'buyer_email' => ['nullable', 'string', 'max:255'], - 'buyer_phone' => ['nullable', 'string', 'max:30'], + 'oid' => ['required', 'string', 'max:40'], + 'price' => ['required', 'integer', 'min:'.PaymentLimits::MIN_PRICE], + 'buyer_email' => ['nullable', 'string', 'max:255'], + 'buyer_phone' => ['nullable', 'string', 'max:30'], 'payment_method' => ['nullable', 'string', 'max:50'], - 'reason' => ['nullable', 'string', 'max:80'], + 'reason' => ['nullable', 'string', 'max:80'], ]; } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/SignatureRequest.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/SignatureRequest.php index 8532332c..5f91ce74 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/SignatureRequest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Http/Requests/SignatureRequest.php @@ -5,34 +5,32 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Plugins\Sirsoft\PayKginicis\Support\PaymentLimits; class SignatureRequest extends FormRequest { /** * authorize * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { return true; } - /** - - * rules - - * - - * @return array - - */ - + /** + * rules + * + * @return array 검증 규칙 + */ public function rules(): array { return [ 'oid' => ['required', 'string', 'max:40'], - 'price' => ['required', 'integer', 'min:100'], + // PC·모바일 동일 하한 (PaymentLimits SSoT) — 모바일에서 1원 결제가 성립 중이므로 + // 100 으로 통일하면 현재 성공 중인 모바일 결제를 깨뜨린다 + 'price' => ['required', 'integer', 'min:'.PaymentLimits::MIN_PRICE], 'timestamp' => ['required', 'string', 'max:20'], 'buyer_email' => ['nullable', 'string', 'max:255'], 'buyer_phone' => ['nullable', 'string', 'max:30'], diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCheckoutTokenService.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCheckoutTokenService.php index 8509947b..f1deeb68 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCheckoutTokenService.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCheckoutTokenService.php @@ -12,6 +12,23 @@ class CbtCheckoutTokenService private const DEFAULT_TTL_SECONDS = 600; + /** + * @var int 토큰 수명 하한(초) — 발급 즉시 만료된 토큰이 나가는 것을 막는 방어값이며 + * 설정으로 조절되는 정책 상한이 아니다. + */ + private const MIN_TTL_SECONDS = 60; + + /** + * 결제 컨텍스트를 묶은 서명 토큰을 발급합니다. + * + * @param string $oid 주문번호 + * @param int $price 결제 금액 + * @param string $buyerEmail 구매자 이메일 (해시로만 저장) + * @param string $buyerPhone 구매자 연락처 (해시로만 저장) + * @param Request $request 발급 요청 (IP·UA 해시 수집용) + * @param int $ttlSeconds 토큰 수명(초). MIN_TTL_SECONDS 미만이면 하한으로 올림 + * @return string `payload.signature` 형식의 토큰 + */ public function issue( string $oid, int $price, @@ -28,7 +45,7 @@ class CbtCheckoutTokenService 'buyer_phone_hash' => $this->contextHash($this->normalizePhone($buyerPhone)), 'ip_hash' => $this->contextHash((string) $request->ip()), 'ua_hash' => $this->contextHash(substr((string) $request->userAgent(), 0, 255)), - 'exp' => time() + max(60, $ttlSeconds), + 'exp' => time() + max(self::MIN_TTL_SECONDS, $ttlSeconds), 'nonce' => bin2hex(random_bytes(16)), ]; @@ -37,9 +54,20 @@ class CbtCheckoutTokenService hash_hmac('sha256', $payloadSegment, $this->signingKey(), true) ); - return $payloadSegment . '.' . $signatureSegment; + return $payloadSegment.'.'.$signatureSegment; } + /** + * 토큰이 현재 결제 컨텍스트와 일치하고 만료되지 않았는지 검증합니다. + * + * @param string $token 검증할 토큰 + * @param string $oid 주문번호 + * @param int $price 결제 금액 + * @param string $buyerEmail 구매자 이메일 + * @param string $buyerPhone 구매자 연락처 + * @param Request $request 검증 요청 (IP·UA 대조용) + * @return bool 서명·컨텍스트·만료가 모두 유효하면 true + */ public function verify( string $token, string $oid, @@ -102,7 +130,7 @@ class CbtCheckoutTokenService private function signingKey(): string { - return hash('sha256', (string) config('app.key') . '|sirsoft-pay_kginicis|cbt-checkout-token', true); + return hash('sha256', (string) config('app.key').'|sirsoft-pay_kginicis|cbt-checkout-token', true); } private function base64UrlEncode(string $value): string diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Support/PaymentLimits.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Support/PaymentLimits.php new file mode 100644 index 00000000..6c8c8616 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Support/PaymentLimits.php @@ -0,0 +1,21 @@ +assertStatus(422); } + /** + * 결제 금액 하한이 세 요청 경로에서 동일한지 확인합니다 (#493 E9). + * + * 같은 필드에 PC 는 min:100, 모바일은 min:1 이 걸려 있어, 모바일에서 성립한 결제가 PC 에서는 + * 거부됐습니다. 하한이 다시 갈라지면 이 테스트가 먼저 깨집니다. + * + * @scenario channel=all, price_boundary=rule_parity + * + * @effects price_lower_bound_identical_across_request_paths + */ + public function test_price_lower_bound_is_shared_across_signature_requests(): void + { + $expected = 'min:'.PaymentLimits::MIN_PRICE; + + foreach ([SignatureRequest::class, MobileSignatureRequest::class, PaymentCloseReportRequest::class] as $class) { + $rules = (new $class)->rules()['price']; + + $this->assertContains( + $expected, + $rules, + $class.' 의 price 하한이 공용 상수를 읽지 않습니다 — 경로마다 하한이 갈라집니다.' + ); + } + } + + /** + * 최소 화폐단위(1원) 결제도 PC 서명 요청이 받아들이는지 확인합니다 (#493 E9). + * + * @scenario channel=pc, price_boundary=minimum + * + * @effects minimum_price_accepted_on_pc + */ + public function test_pc_signature_accepts_minimum_price(): void + { + $order = $this->makePendingOrder('ORD-SIGN-MIN', PaymentLimits::MIN_PRICE); + $order->setRelation('shippingAddress', new OrderAddress([ + 'address_type' => 'shipping', + 'orderer_email' => 'buyer@example.com', + 'orderer_phone' => '010-1234-5678', + ])); + + $orderService = Mockery::mock(OrderProcessingService::class); + $orderService->shouldReceive('findByOrderNumber') + ->with('ORD-SIGN-MIN') + ->andReturn($order); + + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldReceive('hasStandardPaymentCredentials')->andReturnTrue(); + $apiService->shouldReceive('generateSignature')->andReturn('signature-ok'); + $apiService->shouldReceive('generateVerification')->andReturn('verification-ok'); + $apiService->shouldReceive('getMKey')->andReturn('mkey-ok'); + + $this->app->instance(OrderProcessingService::class, $orderService); + $this->app->instance(KgInicisApiService::class, $apiService); + + $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/signature', [ + 'oid' => 'ORD-SIGN-MIN', + 'price' => PaymentLimits::MIN_PRICE, + 'timestamp' => $this->freshEpochMs(), + 'buyer_email' => 'buyer@example.com', + 'buyer_phone' => '01012345678', + ])->assertOk(); + } + + /** + * 최소 화폐단위(1원) 결제를 모바일 서명 요청도 받아들이는지 확인합니다 (#493 E9). + * + * 하한 통일의 근거가 "모바일에서 이미 1원 결제가 성립 중" 이었다. 그 전제를 PC 왕복만 + * 으로 가드하면, 모바일 경로가 조용히 100 원으로 되돌아가도 아무 테스트도 red 가 되지 + * 않는다 — 통일로 지키려던 바로 그 결제가 깨진다. + * + * @scenario channel=mobile, price_boundary=minimum + * + * @effects minimum_price_accepted_on_mobile + */ + public function test_mobile_signature_accepts_minimum_price(): void + { + $order = $this->makePendingOrder('ORD-MSIGN-MIN', PaymentLimits::MIN_PRICE); + + $orderService = Mockery::mock(OrderProcessingService::class); + $orderService->shouldReceive('findByOrderNumber') + ->with('ORD-MSIGN-MIN') + ->andReturn($order); + + $apiService = Mockery::mock(KgInicisApiService::class); + $apiService->shouldReceive('hasMobilePaymentCredentials')->andReturnTrue(); + $apiService->shouldReceive('generateMobileChkfake')->andReturn('chkfake-ok'); + $apiService->shouldReceive('getMobilePaymentUrl')->andReturn('https://mobile.inicis.test/pay'); + + $this->app->instance(OrderProcessingService::class, $orderService); + $this->app->instance(KgInicisApiService::class, $apiService); + + $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature', [ + 'oid' => 'ORD-MSIGN-MIN', + 'price' => PaymentLimits::MIN_PRICE, + 'timestamp' => $this->freshEpochMs(), + ])->assertOk(); + } + + /** + * 모바일 서명 요청도 같은 하한 경계를 실제 요청에서 지키는지 확인합니다 (#493 E9). + * + * 규칙 문자열 대조(위 parity 테스트)는 세 Request 가 같은 상수를 읽는 것까지만 보장한다. + * 그 규칙이 모바일 라우트에도 실제로 걸리는지는 왕복으로만 알 수 있다. + * + * @scenario channel=mobile, price_boundary=zero + * + * @effects zero_price_rejected_422 + */ + public function test_mobile_signature_rejects_zero_price(): void + { + $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature', [ + 'oid' => 'ORD-MSIGN-ZERO', + 'price' => 0, + 'timestamp' => $this->freshEpochMs(), + ])->assertStatus(422); + } + + /** + * 0 원 결제 요청은 거부되는지 확인합니다 (하한 통일이 하한 제거가 아님). + * + * @scenario channel=pc, price_boundary=zero + * + * @effects zero_price_rejected_422 + */ + public function test_pc_signature_rejects_zero_price(): void + { + $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/signature', [ + 'oid' => 'ORD-SIGN-ZERO', + 'price' => 0, + 'timestamp' => $this->freshEpochMs(), + 'buyer_email' => 'buyer@example.com', + 'buyer_phone' => '01012345678', + ])->assertStatus(422); + } + private function makePendingOrder(string $orderNumber, int $amount, string $currency = 'KRW', array $currencySnapshot = []): Order { $order = new Order; diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/scenarios/payment-min-price.yaml b/plugins/_bundled/sirsoft-pay_kginicis/tests/scenarios/payment-min-price.yaml new file mode 100644 index 00000000..4fd68cdf --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/scenarios/payment-min-price.yaml @@ -0,0 +1,41 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(결제 금액 하한 통일) +# 이며 PHPUnit Feature 테스트가 채널·경계 조합을 커버하고 green 이다. + +feature: KG이니시스 결제 금액 하한 통일 (E9) + +description: | + 서명 요청의 `price` 하한을 PC·모바일·종료보고 세 경로에서 동일하게 맞춘다. + + 배경: + - 같은 필드에 PC 는 `min:100`, 모바일은 `min:1` 이 걸려 있었다. 모바일에서 이미 1원 결제가 + 성립 중이므로 PG 제약이 100원이 아님이 실증됐고, 100 으로 통일하면 **현재 성공 중인 + 모바일 결제를 깨뜨린다**. 따라서 `min:1` 로 통일한다. + + 경계 소유: + - 플러그인 내부 상수 `Support/PaymentLimits::MIN_PRICE` 가 SSoT. + - ecommerce 설정을 참조하지 않는다 — 참조하면 플러그인이 모듈 버전 상향을 강제하게 된다. + +axes: + channel: [pc, mobile, close_report, all] + price_boundary: [zero, minimum, normal, rule_parity] + +exclusions: + - { channel: close_report, price_boundary: minimum, reason: "종료보고는 승인 후 통보 경로 — 최소금액 수용 여부가 결제 성립을 좌우하지 않음" } + - { channel: all, price_boundary: zero, reason: "rule_parity 축에서만 세 경로를 한 번에 비교" } + - { channel: all, price_boundary: normal, reason: "동일" } + - { channel: all, price_boundary: minimum, reason: "동일" } + +effects: + - price_lower_bound_identical_across_request_paths + - minimum_price_accepted_on_pc + - minimum_price_accepted_on_mobile + - zero_price_rejected_422 + +test_files: + - plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php + +rules_layer_coverage: + - rule: formrequest-numeric-boundary-drift + coverage: 세 Request 의 price 하한이 갈라지면 이 룰이 cross-file 불일치로 검출한다 diff --git a/public/build/core/devtools.min.js b/public/build/core/devtools.min.js index 66ccd681..4ba3df17 100644 --- a/public/build/core/devtools.min.js +++ b/public/build/core/devtools.min.js @@ -1558,4 +1558,3 @@ details[open] > .g7dt-summary::before { } `,ve=()=>{const d="g7-devtools-styles";if(document.getElementById(d))return;const t=document.createElement("style");t.id=d,t.textContent=be,document.head.appendChild(t)},le={state:"상태",actions:"액션",cache:"캐시",diagnose:"진단",performance:"성능",lifecycle:"라이프사이클",network:"네트워크",form:"Form",conditional:"조건부",expression:"표현식",handlers:"핸들러",events:"이벤트",datasources:"데이터소스",renders:"렌더링",styles:"스타일",auth:"인증",logs:"로그",layout:"레이아웃",changedetection:"변경감지",staleclosure:"Stale Closure",nestedcontext:"Context",modalscope:"모달스코프",namedactions:"Named Actions"},Ne=700,ue=500,re=400,ce=300,ne="g7-devtools-panel",te=d=>d===null||d===void 0?"g7dt-text-gray":typeof d=="string"?"g7dt-text-yellow":typeof d=="number"?"g7dt-text-cyan":typeof d=="boolean"?"g7dt-text-pink":Array.isArray(d)?"g7dt-text-orange":typeof d=="object"?"g7dt-text-blue":"g7dt-text-white",ye=d=>d===null?"null":d===void 0?"undefined":Array.isArray(d)?`Array(${d.length})`:typeof d=="object"?`Object(${Object.keys(d).length})`:typeof d,we=d=>d===null?"null":d===void 0?"undefined":typeof d=="string"?d.length>50?`"${d.substring(0,50)}..."`:`"${d}"`:typeof d=="boolean"?d?"true":"false":typeof d=="number"?String(d):"",oe=()=>{try{const d=localStorage.getItem(ne);if(d)return JSON.parse(d)}catch(d){console.warn("[G7DevTools] 설정 로드 실패:",d)}return{}},ie=d=>{try{const s={...oe(),...d};localStorage.setItem(ne,JSON.stringify(s))}catch(t){console.warn("[G7DevTools] 설정 저장 실패:",t)}},W=({keyName:d,value:t,path:s,expandedPaths:a,onToggle:g,searchTerm:r,depth:o=0})=>{const p=t!==null&&typeof t=="object",v=a.has(s),l=Array.isArray(t),f=r&&(d.toLowerCase().includes(r.toLowerCase())||typeof t=="string"&&t.toLowerCase().includes(r.toLowerCase())||typeof t=="number"&&String(t).includes(r)),c=y=>{y.stopPropagation(),p&&g(s)},h=p?Object.entries(t):[];return e.jsxs("div",{style:{marginLeft:o>0?"16px":"0"},children:[e.jsxs("div",{className:`g7dt-tree-node g7dt-flex g7dt-items-center g7dt-gap-1 ${f?"g7dt-ring-highlight":""}`,onClick:c,children:[p?e.jsx("span",{className:"g7dt-text-gray g7dt-w-4 g7dt-text-xs g7dt-select-none",style:{textAlign:"center"},children:v?"▼":"▶"}):e.jsx("span",{className:"g7dt-w-4"}),e.jsx("span",{className:"g7dt-text-purple g7dt-text-xs",children:d}),e.jsx("span",{className:"g7dt-text-gray-dark g7dt-text-xs",children:":"}),p?e.jsxs("span",{className:`g7dt-text-xs ${te(t)}`,children:[l?"[":"{",!v&&e.jsx("span",{className:"g7dt-text-gray",style:{margin:"0 4px"},children:ye(t)}),!v&&(l?"]":"}")]}):e.jsx("span",{className:`g7dt-text-xs ${te(t)}`,children:we(t)}),!p&&e.jsxs("span",{className:"g7dt-text-gray-dark g7dt-text-xs g7dt-ml-1",children:["(",typeof t,")"]})]}),p&&v&&e.jsxs("div",{className:"g7dt-border-l",style:{marginLeft:"8px"},children:[h.map(([y,n])=>e.jsx(W,{keyName:l?`[${y}]`:y,value:n,path:`${s}.${y}`,expandedPaths:a,onToggle:g,searchTerm:r,depth:o+1},`${s}.${y}`)),e.jsx("div",{className:"g7dt-ml-4 g7dt-text-xs",children:e.jsx("span",{className:te(t),children:l?"]":"}"})})]})]})},ke=({state:d,devTools:t})=>{const[s,a]=x.useState(new Set(["_global","_local","_computed","_isolated","$parent"])),[g,r]=x.useState(""),[o,p]=x.useState(!1),[v,l]=x.useState(!1),[f,c]=x.useState(null),h=x.useCallback(()=>{t&&c(t.getComputedDependencyTrackingInfo())},[t]);x.useEffect(()=>{if(v&&t){h();const w=setInterval(h,2e3);return()=>clearInterval(w)}},[v,t,h]);const y=w=>{switch(w){case"state-change":return"📊";case"prop-change":return"📥";case"dependency-change":return"🔗";case"manual":return"👆";case"initial":return"🚀";default:return"❓"}},n=x.useCallback(w=>{a(S=>{const z=new Set(S);return z.has(w)?z.delete(w):z.add(w),z})},[]),i=x.useCallback((w,S)=>{const z=[S];if(w!==null&&typeof w=="object")for(const[O,K]of Object.entries(w))z.push(...i(K,`${S}.${O}`));return z},[]),u=x.useCallback(()=>{if(o)a(new Set(["_global","_local","_computed","_isolated","$parent"]));else{const w=new Set;i(d._global,"_global").forEach(S=>w.add(S)),i(d._local,"_local").forEach(S=>w.add(S)),i(d._computed,"_computed").forEach(S=>w.add(S)),i(d._isolated,"_isolated").forEach(S=>w.add(S)),d.$parent&&i(d.$parent,"$parent").forEach(S=>w.add(S)),a(w)}p(!o)},[o,d,i]),m=Object.keys(d._global||{}).length,k=Object.keys(d._local||{}).length,b=Object.keys(d._computed||{}).length,N=Object.keys(d._isolated||{}).length,C=Object.keys(d.$parent||{}).length;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-items-center g7dt-flex-shrink-0",children:[e.jsx("input",{type:"text",placeholder:"키 또는 값 검색...",value:g,onChange:w=>r(w.target.value),className:"g7dt-input g7dt-flex-1"}),e.jsx("button",{onClick:u,className:"g7dt-btn g7dt-bg-gray-700",children:o?"모두 접기":"모두 펼치기"})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-3 g7dt-text-xs g7dt-text-gray g7dt-flex-wrap g7dt-flex-shrink-0",children:[e.jsxs("span",{children:[e.jsx("span",{className:"g7dt-text-yellow",children:"●"})," string"]}),e.jsxs("span",{children:[e.jsx("span",{className:"g7dt-text-cyan",children:"●"})," number"]}),e.jsxs("span",{children:[e.jsx("span",{className:"g7dt-text-pink",children:"●"})," boolean"]}),e.jsxs("span",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"●"})," null"]}),e.jsxs("span",{children:[e.jsx("span",{className:"g7dt-text-orange",children:"●"})," array"]}),e.jsxs("span",{children:[e.jsx("span",{className:"g7dt-text-blue",children:"●"})," object"]})]}),e.jsxs("div",{className:"g7dt-bg-gray-800 g7dt-rounded g7dt-p-2 g7dt-overflow-auto g7dt-border",style:{flex:1,minHeight:0},children:[e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-sm g7dt-font-bold g7dt-text-green g7dt-mb-1 g7dt-cursor-pointer",onClick:()=>n("_global"),children:[e.jsx("span",{children:s.has("_global")?"▼":"▶"}),e.jsx("span",{children:"_global"}),e.jsxs("span",{className:"g7dt-text-gray g7dt-text-xs",style:{fontWeight:"normal"},children:["(",m,"개 키)"]})]}),s.has("_global")&&e.jsx("div",{className:"g7dt-ml-2 g7dt-border-l",children:Object.entries(d._global||{}).map(([w,S])=>e.jsx(W,{keyName:w,value:S,path:`_global.${w}`,expandedPaths:s,onToggle:n,searchTerm:g},`_global.${w}`))})]}),e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-sm g7dt-font-bold g7dt-text-blue g7dt-mb-1 g7dt-cursor-pointer",onClick:()=>n("_local"),children:[e.jsx("span",{children:s.has("_local")?"▼":"▶"}),e.jsx("span",{children:"_local"}),e.jsxs("span",{className:"g7dt-text-gray g7dt-text-xs",style:{fontWeight:"normal"},children:["(",k,"개 키)"]})]}),s.has("_local")&&e.jsx("div",{className:"g7dt-ml-2 g7dt-border-l",children:Object.entries(d._local||{}).map(([w,S])=>e.jsx(W,{keyName:w,value:S,path:`_local.${w}`,expandedPaths:s,onToggle:n,searchTerm:g},`_local.${w}`))})]}),e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-sm g7dt-font-bold g7dt-text-purple g7dt-mb-1 g7dt-cursor-pointer",onClick:()=>n("_computed"),children:[e.jsx("span",{children:s.has("_computed")?"▼":"▶"}),e.jsx("span",{children:"_computed"}),e.jsxs("span",{className:"g7dt-text-gray g7dt-text-xs",style:{fontWeight:"normal"},children:["(",b,"개 키)"]})]}),s.has("_computed")&&e.jsx("div",{className:"g7dt-ml-2 g7dt-border-l",children:Object.entries(d._computed||{}).map(([w,S])=>e.jsx(W,{keyName:w,value:S,path:`_computed.${w}`,expandedPaths:s,onToggle:n,searchTerm:g},`_computed.${w}`))})]}),N>0&&e.jsxs("div",{children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-sm g7dt-font-bold g7dt-text-orange g7dt-mb-1 g7dt-cursor-pointer",onClick:()=>n("_isolated"),children:[e.jsx("span",{children:s.has("_isolated")?"▼":"▶"}),e.jsx("span",{children:"_isolated"}),e.jsxs("span",{className:"g7dt-text-gray g7dt-text-xs",style:{fontWeight:"normal"},children:["(",N,"개 스코프)"]})]}),s.has("_isolated")&&e.jsx("div",{className:"g7dt-ml-2 g7dt-border-l",children:Object.entries(d._isolated||{}).map(([w,S])=>e.jsx(W,{keyName:w,value:S,path:`_isolated.${w}`,expandedPaths:s,onToggle:n,searchTerm:g},`_isolated.${w}`))})]}),C>0&&e.jsxs("div",{children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-sm g7dt-font-bold g7dt-text-cyan g7dt-mb-1 g7dt-cursor-pointer",onClick:()=>n("$parent"),children:[e.jsx("span",{children:s.has("$parent")?"▼":"▶"}),e.jsx("span",{children:"$parent"}),e.jsxs("span",{className:"g7dt-text-gray g7dt-text-xs",style:{fontWeight:"normal"},children:["(",C,"개 키)"]})]}),s.has("$parent")&&e.jsx("div",{className:"g7dt-ml-2 g7dt-border-l",children:Object.entries(d.$parent||{}).map(([w,S])=>e.jsx(W,{keyName:w,value:S,path:`$parent.${w}`,expandedPaths:s,onToggle:n,searchTerm:g},`$parent.${w}`))})]})]}),t&&e.jsx("div",{className:"g7dt-flex g7dt-justify-end g7dt-mt-2",children:e.jsx("button",{onClick:()=>l(!v),className:`g7dt-tab-button ${v?"g7dt-tab-active":""}`,children:"🧮 Computed 추적"})}),v&&f&&e.jsxs("div",{className:"g7dt-card g7dt-mt-2",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:["Computed 의존성 추적 (",f.properties.length,"개)"]}),e.jsxs("div",{className:"g7dt-stats-grid g7dt-mb-3",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-purple",children:f.stats.totalComputed}),e.jsx("div",{className:"g7dt-stat-label",children:"Computed"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-blue",children:f.stats.totalRecalculations}),e.jsx("div",{className:"g7dt-stat-label",children:"재계산"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-yellow",children:f.stats.unnecessaryRecalculations}),e.jsx("div",{className:"g7dt-stat-label",children:"불필요"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsxs("div",{className:"g7dt-stat-value g7dt-text-green",children:[f.stats.avgComputationTime.toFixed(1),"ms"]}),e.jsx("div",{className:"g7dt-stat-label",children:"평균시간"})]})]}),f.stats.cycleDetectionCount>0&&e.jsxs("div",{className:"g7dt-text-xs g7dt-text-red g7dt-mb-3 g7dt-p-2 g7dt-bg-red-900 g7dt-rounded",children:["⚠️ 순환 의존성 감지: ",f.stats.cycleDetectionCount,"건"]}),f.stats.topRecalculated.length>0&&e.jsxs("div",{className:"g7dt-mb-3",children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"자주 재계산되는 속성"}),e.jsx("div",{className:"g7dt-space-y-1",children:f.stats.topRecalculated.slice(0,3).map((w,S)=>e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-text-xs",children:[e.jsx("span",{className:"g7dt-text-purple g7dt-font-mono",children:w.name}),e.jsxs("span",{className:"g7dt-text-muted",children:[w.count,"회"]})]},S))})]}),f.recalcLogs.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"재계산 로그가 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-40 g7dt-overflow-y-auto",children:f.recalcLogs.slice().reverse().slice(0,20).map(w=>e.jsxs("div",{className:`g7dt-list-item ${w.valueChanged?"":"g7dt-border-yellow"}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:y(w.trigger)}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-purple g7dt-font-mono",children:w.computedName}),!w.valueChanged&&e.jsx("span",{className:"g7dt-badge g7dt-text-xs g7dt-text-yellow",children:"값 변경 없음"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:[w.computationTime.toFixed(1),"ms"]})]}),w.triggeredBy&&e.jsxs("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-1",children:["트리거: ",e.jsx("span",{className:"g7dt-text-blue",children:w.triggeredBy.path})]})]},w.id))})]})]})},Ce=({actions:d,devTools:t})=>{const[s,a]=x.useState(""),[g,r]=x.useState("actions"),[o,p]=x.useState(null),[v,l]=x.useState(null),f=x.useCallback(()=>{t&&p(t.getSequenceTrackingInfo())},[t]);x.useEffect(()=>{if(g==="sequences"&&t){f();const i=setInterval(f,2e3);return()=>clearInterval(i)}},[g,t,f]);const c=s?d.filter(i=>i.type.toLowerCase().includes(s.toLowerCase())||i.status===s):d,h=i=>{switch(i){case"success":return"g7dt-status-success";case"error":return"g7dt-status-error";case"started":return"g7dt-status-started";default:return"g7dt-status-default"}},y=i=>{switch(i){case"success":return"g7dt-badge-success";case"error":return"g7dt-badge-error";default:return"g7dt-badge-warning"}},n=v&&o?o.executions.find(i=>i.id===v):null;return e.jsxs("div",{className:"g7dt-space-y-4",children:[t&&e.jsx("div",{className:"g7dt-card",children:e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsxs("button",{className:`g7dt-tab-button ${g==="actions"?"g7dt-tab-active":""}`,onClick:()=>r("actions"),children:["📋 액션 (",d.length,")"]}),e.jsxs("button",{className:`g7dt-tab-button ${g==="sequences"?"g7dt-tab-active":""}`,onClick:()=>r("sequences"),children:["🔗 Sequence (",o?.executions.length||0,")"]})]})}),g==="actions"&&e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-flex-shrink-0",children:[e.jsx("input",{type:"text",placeholder:"액션 타입 또는 상태로 필터...",value:s,onChange:i=>a(i.target.value),className:"g7dt-input g7dt-flex-1"}),e.jsx("button",{onClick:()=>a(""),className:"g7dt-btn-text",children:"초기화"})]}),g==="sequences"&&o&&e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"Sequence 통계"}),e.jsxs("div",{className:"g7dt-stats-grid-3",children:[e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-blue",children:o.stats.totalSequences}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"총 실행"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-green",children:o.stats.successCount}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"성공"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-red",children:o.stats.errorCount}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"실패"})]})]})]}),e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"Sequence 실행 목록"}),o.executions.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"실행된 Sequence가 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-40 g7dt-overflow-y-auto",children:o.executions.slice().reverse().map(i=>e.jsx("div",{className:`g7dt-list-item g7dt-cursor-pointer ${v===i.id?"g7dt-selected":""} ${i.status==="error"?"g7dt-border-red":""}`,onClick:()=>l(v===i.id?null:i.id),children:e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:i.status==="completed"?"✅":i.status==="error"?"❌":"⏳"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-white",children:[i.actions.length,"개 액션"]}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:[i.totalDuration.toFixed(0),"ms"]})]})},i.id))})]}),n&&e.jsxs("div",{className:"g7dt-card g7dt-border-blue",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:["🔗 Sequence 상세 (",n.actions.length,"개 액션)"]}),e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:n.actions.map((i,u)=>e.jsxs("div",{className:`g7dt-list-item ${i.status==="error"?"g7dt-border-red":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted",children:["#",i.index+1]}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white g7dt-font-mono",children:i.handler}),e.jsx("span",{className:`g7dt-text-xs ${i.status==="success"?"g7dt-text-green":"g7dt-text-red"}`,children:i.status==="success"?"✓":"✕"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:[i.duration.toFixed(0),"ms"]})]}),i.stateChanges&&i.stateChanges.length>0&&e.jsxs("div",{className:"g7dt-mt-1",children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"상태 변경:"}),i.stateChanges.slice(0,3).map((m,k)=>e.jsx("div",{className:"g7dt-text-xs g7dt-text-yellow g7dt-ml-2",children:m.path},k)),i.stateChanges.length>3&&e.jsxs("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-2",children:["...외 ",i.stateChanges.length-3,"개"]})]})]},u))})]})]}),g==="actions"&&e.jsx("div",{className:"g7dt-section",children:e.jsx("div",{className:"g7dt-scroll-list g7dt-space-y-2-flat g7dt-action-list",children:c.length===0?e.jsx("div",{className:"g7dt-empty-message",children:"액션 이력이 없습니다"}):c.slice(-30).reverse().map((i,u)=>e.jsxs("div",{className:`g7dt-action-item ${h(i.status)}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-start",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-font-mono g7dt-font-bold",children:i.type}),e.jsx("span",{className:`g7dt-status-badge ${y(i.status)}`,children:i.status})]}),e.jsx("span",{className:"g7dt-text-gray",children:i.duration?`${i.duration.toFixed(2)}ms`:"-"})]}),i.error&&e.jsx("div",{className:"g7dt-error-box",children:i.error.message}),i.params&&Object.keys(i.params).length>0&&e.jsxs("details",{className:"g7dt-details",children:[e.jsx("summary",{className:"g7dt-summary",children:"파라미터"}),e.jsx("pre",{className:"g7dt-pre",children:JSON.stringify(i.params,null,2)})]})]},`${i.id}-${u}`))})})]})},Se=({stats:d,devTools:t})=>{const[s,a]=x.useState(null),[g,r]=x.useState(!1),o=x.useCallback(()=>{a(t.getCacheDecisionTrackingInfo())},[t]);x.useEffect(()=>{if(g){o();const l=setInterval(o,2e3);return()=>clearInterval(l)}},[g,o]);const p=()=>{t.resetCacheStats(),console.log("[G7DevTools] 캐시 통계 초기화")},v=l=>{switch(l){case"use_cache":return"✅";case"skip_cache":return"⏭️";case"invalidate":return"🗑️";default:return"❓"}};return e.jsxs("div",{className:"g7dt-space-y-6",children:[e.jsxs("div",{className:"g7dt-stats-grid",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-green",children:d.hits}),e.jsx("div",{className:"g7dt-stat-label",children:"Cache Hits"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-red",children:d.misses}),e.jsx("div",{className:"g7dt-stat-label",children:"Cache Misses"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-blue",children:d.entries}),e.jsx("div",{className:"g7dt-stat-label",children:"Entries"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsxs("div",{className:"g7dt-stat-value g7dt-text-purple",children:[(d.hitRate*100).toFixed(1),"%"]}),e.jsx("div",{className:"g7dt-stat-label",children:"Hit Rate"})]})]}),e.jsxs("div",{className:"g7dt-space-y-2",children:[e.jsx("div",{className:"g7dt-text-sm g7dt-text-gray",children:"캐시 히트율"}),e.jsx("div",{className:"g7dt-progress-bar",children:e.jsx("div",{className:"g7dt-progress-fill",style:{width:`${d.hitRate*100}%`}})})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsx("button",{onClick:p,className:"g7dt-btn-danger",children:"캐시 통계 초기화"}),e.jsx("button",{onClick:()=>r(!g),className:`g7dt-tab-button ${g?"g7dt-tab-active":""}`,children:"📋 Decision Log"})]}),g&&s&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:["캐시 결정 로그 (",s.decisions.length,"개)"]}),s.decisions.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"캐시 결정 기록이 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:s.decisions.slice().reverse().slice(0,50).map(l=>e.jsxs("div",{className:`g7dt-list-item ${l.decision==="skip_cache"?"g7dt-border-yellow":l.decision==="invalidate"?"g7dt-border-red":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:v(l.decision)}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white g7dt-font-mono g7dt-truncate",style:{maxWidth:"200px"},children:l.expression}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:l.decision})]}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-1",children:l.reason}),l.context&&e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-mt-1",children:[l.context.isInIteration&&e.jsx("span",{className:"g7dt-badge g7dt-text-xs",children:"🔄 iteration"}),l.context.isInAction&&e.jsx("span",{className:"g7dt-badge g7dt-text-xs",children:"⚡ action"})]})]},l.id))})]})]})},Ie=()=>{const[d,t]=x.useState([]),[s,a]=x.useState([]),[g,r]=x.useState(""),o=["이전 값이 전송됨","모든 행이 같은 값","상태가 업데이트 안됨","API 호출이 안됨","초기값이 없음","무한 로딩","Form 값이 저장 안됨","iteration 데이터 중복"],p=f=>{t(c=>c.includes(f)?c.filter(h=>h!==f):[...c,f])},v=()=>{g&&!d.includes(g)&&(t(f=>[...f,g]),r(""))},l=async()=>{const{DiagnosticEngine:f}=await Promise.resolve().then(()=>pe),h=new f().analyze(d);a(h)};return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"증상 선택"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:o.map(f=>e.jsx("button",{onClick:()=>p(f),className:d.includes(f)?"g7dt-symptom-btn g7dt-symptom-active":"g7dt-symptom-btn",children:f},f))})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsx("input",{type:"text",placeholder:"직접 증상 입력...",value:g,onChange:f=>r(f.target.value),onKeyDown:f=>f.key==="Enter"&&v(),className:"g7dt-input g7dt-flex-1"}),e.jsx("button",{onClick:v,className:"g7dt-btn-secondary",children:"추가"})]}),e.jsxs("button",{onClick:l,disabled:d.length===0,className:d.length===0?"g7dt-btn-primary g7dt-btn-disabled g7dt-w-full":"g7dt-btn-primary g7dt-w-full",children:["진단 실행 (",d.length,"개 증상)"]}),s.length>0&&e.jsxs("div",{className:"g7dt-space-y-3",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:"진단 결과"}),s.slice(0,5).map((f,c)=>e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("span",{className:"g7dt-font-bold g7dt-text-yellow",children:f.rule.name}),e.jsxs("span",{className:"g7dt-confidence-badge",children:[(f.confidence*100).toFixed(0),"%"]})]}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-lightgray",children:f.rule.solution}),f.rule.codeExample&&e.jsx("pre",{className:"g7dt-pre g7dt-mt-2",children:f.rule.codeExample})]},c))]})]})},Te=({devTools:d})=>{const t=d.getPerformanceInfo();return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-stats-grid-2",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-blue",children:t.bindingEvalCount}),e.jsx("div",{className:"g7dt-stat-label",children:"바인딩 평가 횟수"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-purple",children:t.renderCounts.size}),e.jsx("div",{className:"g7dt-stat-label",children:"렌더링된 컴포넌트"})]})]}),t.memoryWarnings.length>0&&e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-red g7dt-mb-2",children:"메모리 경고"}),e.jsx("div",{className:"g7dt-space-y-2",children:t.memoryWarnings.map((s,a)=>e.jsxs("div",{className:"g7dt-warning-card",children:[e.jsx("div",{className:"g7dt-font-bold",children:s.type}),e.jsx("div",{className:"g7dt-text-lightgray",children:s.message}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-gray g7dt-mt-1",children:s.suggestion})]},a))})]}),t.renderCounts.size>0&&e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"컴포넌트별 렌더링"}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-scroll-list",children:Array.from(t.renderCounts.entries()).sort((s,a)=>a[1]-s[1]).slice(0,10).map(([s,a])=>e.jsxs("div",{className:"g7dt-list-item",children:[e.jsx("span",{className:"g7dt-text-lightgray g7dt-truncate",children:s}),e.jsxs("span",{className:a>50?"g7dt-text-red":"g7dt-text-gray",children:[a,"회"]})]},s))})]})]})},De=({devTools:d})=>{const t=d.getLifecycleInfo();return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-section",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-green g7dt-mb-2 g7dt-flex-shrink-0",children:["마운트된 컴포넌트 (",t.mountedComponents.length,")"]}),e.jsx("div",{className:"g7dt-scroll-list",children:t.mountedComponents.length===0?e.jsx("div",{className:"g7dt-empty-message",children:"추적된 컴포넌트 없음"}):t.mountedComponents.map(s=>e.jsxs("div",{className:"g7dt-list-item g7dt-text-xs",children:[e.jsx("span",{className:"g7dt-text-lightgray",children:s.name}),e.jsx("span",{className:"g7dt-text-gray",children:s.id})]},s.id))})]}),t.orphanedListeners.length>0&&e.jsxs("div",{className:"g7dt-section",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-red g7dt-mb-2 g7dt-flex-shrink-0",children:["정리되지 않은 리스너 (",t.orphanedListeners.length,")"]}),e.jsx("div",{className:"g7dt-scroll-list",children:t.orphanedListeners.map((s,a)=>e.jsxs("div",{className:"g7dt-warning-card g7dt-text-xs",children:[e.jsx("span",{className:"g7dt-text-lightgray",children:s.type}),e.jsxs("span",{className:"g7dt-text-gray g7dt-ml-2",children:["on ",s.target]})]},a))})]})]})},Le=({devTools:d})=>{const t=d.getNetworkInfo(),s=o=>{switch(o){case"success":return"g7dt-text-green";case"error":return"g7dt-text-red";case"pending":return"g7dt-text-yellow";default:return"g7dt-text-gray"}},a=o=>o.queryParams&&Object.keys(o.queryParams).length>0,g=o=>Object.entries(o).map(([p,v])=>e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-text-xs",children:[e.jsxs("span",{className:"g7dt-text-purple",children:[p,":"]}),e.jsx("span",{className:"g7dt-text-lightgray",children:Array.isArray(v)?JSON.stringify(v):v})]},p)),r=(o,p=5e3)=>{try{const v=JSON.stringify(o,null,2);return v.length>p?v.substring(0,p)+` ... (truncated)`:v}catch{return"(직렬화 불가)"}};return e.jsxs("div",{className:"g7dt-space-y-4",children:[t.activeRequests.length>0&&e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-yellow g7dt-mb-2",children:["진행 중 (",t.activeRequests.length,")"]}),e.jsx("div",{className:"g7dt-space-y-1",children:t.activeRequests.map(o=>e.jsxs("details",{className:"g7dt-details",children:[e.jsxs("summary",{className:"g7dt-summary g7dt-pending-request",children:[e.jsx("span",{className:"g7dt-font-bold",children:o.method}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-lightgray g7dt-truncate",children:o.fullUrl||o.url})]}),e.jsxs("div",{className:"g7dt-card g7dt-mt-1 g7dt-text-xs",children:[a(o)&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("div",{className:"g7dt-text-blue g7dt-font-bold g7dt-mb-1",children:"쿼리 파라미터"}),e.jsx("div",{className:"g7dt-pl-2 g7dt-border-l-2 g7dt-border-blue",children:g(o.queryParams)})]}),o.requestBody&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-orange g7dt-font-bold g7dt-mb-1",children:"요청 본문"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs",children:r(o.requestBody,2e3)})]})]})]},o.id))})]}),e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"요청 이력"}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-scroll-list-lg",children:t.requestHistory.length===0?e.jsx("div",{className:"g7dt-empty-message",children:"요청 이력 없음"}):t.requestHistory.slice(-20).reverse().map(o=>e.jsxs("details",{className:"g7dt-details",children:[e.jsxs("summary",{className:"g7dt-summary g7dt-card g7dt-text-xs",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-w-full",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-font-bold",children:o.method}),e.jsx("span",{className:`g7dt-ml-2 ${s(o.status)}`,children:o.statusCode||o.status})]}),e.jsxs("span",{className:"g7dt-text-gray",children:[o.duration,"ms"]})]}),e.jsx("div",{className:"g7dt-text-gray g7dt-truncate g7dt-mt-1",children:o.fullUrl||o.url})]}),e.jsxs("div",{className:"g7dt-card g7dt-mt-1 g7dt-text-xs g7dt-ml-2 g7dt-border-l-2 g7dt-border-gray",children:[e.jsx("div",{className:"g7dt-mb-2",children:e.jsxs("div",{className:"g7dt-text-xs g7dt-space-y-1",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"URL:"})," ",e.jsx("span",{className:"g7dt-text-lightgray",children:o.fullUrl||o.url})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"상태:"})," ",e.jsx("span",{className:s(o.status),children:o.statusCode||o.status})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"소요시간:"})," ",e.jsxs("span",{className:"g7dt-text-lightgray",children:[o.duration,"ms"]})]}),o.dataSourceId&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"데이터소스:"})," ",e.jsx("span",{className:"g7dt-text-blue",children:o.dataSourceId})]}),o.error&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"에러:"})," ",e.jsx("span",{className:"g7dt-text-red",children:o.error})]})]})}),a(o)&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("div",{className:"g7dt-text-blue g7dt-font-bold g7dt-mb-1",children:"쿼리 파라미터"}),e.jsx("div",{className:"g7dt-pl-2 g7dt-border-l-2 g7dt-border-blue",children:g(o.queryParams)})]}),o.requestBody&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("div",{className:"g7dt-text-orange g7dt-font-bold g7dt-mb-1",children:"요청 본문"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs g7dt-max-h-40 g7dt-overflow-auto",children:r(o.requestBody,2e3)})]}),o.response!==void 0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-green g7dt-font-bold g7dt-mb-1",children:"응답 내용"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs g7dt-max-h-60 g7dt-overflow-auto",children:r(o.response,5e3)})]})]})]},o.id))})]}),t.pendingDataSources.length>0&&e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-blue g7dt-mb-2",children:["대기 중인 데이터소스 (",t.pendingDataSources.length,")"]}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:t.pendingDataSources.map(o=>e.jsx("span",{className:"g7dt-tag-blue",children:o},o))})]})]})},Ee=({devTools:d})=>{const t=d.getFormInfo(),[s,a]=x.useState(!1),[g,r]=x.useState(null),o=x.useCallback(()=>{r(d.getFormBindingValidationTrackingInfo())},[d]);x.useEffect(()=>{if(s){o();const l=setInterval(o,2e3);return()=>clearInterval(l)}},[s,o]);const p=l=>{switch(l){case"missing-datakey":return"🔑";case"missing-input-name":return"📝";case"context-not-propagated":return"🔗";case"sortable-context-break":return"📊";case"modal-context-isolation":return"🪟";case"duplicate-input-name":return"👯";case"value-type-mismatch":return"⚠️";case"binding-path-invalid":return"❌";default:return"❓"}},v=l=>{switch(l){case"error":return"g7dt-text-red";case"warning":return"g7dt-text-yellow";case"info":return"g7dt-text-blue";default:return"g7dt-text-gray"}};return e.jsxs("div",{className:"g7dt-space-y-4",children:[t.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-py-8",children:"추적된 Form이 없습니다"}):t.map(l=>e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("span",{className:"g7dt-font-bold g7dt-text-blue",children:l.dataKey||"(no dataKey)"}),!l.dataKey&&e.jsx("span",{className:"g7dt-text-red g7dt-text-xs",children:"dataKey 필요"})]}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-mb-2",children:l.inputs.map((f,c)=>e.jsxs("div",{className:"g7dt-text-xs g7dt-flex g7dt-justify-between",children:[e.jsx("span",{children:f.name||e.jsx("span",{className:"g7dt-text-red",children:"name 없음"})}),e.jsx("span",{className:"g7dt-text-gray",children:f.type})]},c))}),e.jsxs("details",{className:"g7dt-details",children:[e.jsx("summary",{className:"g7dt-summary",children:"현재 값 보기"}),e.jsx("pre",{className:"g7dt-pre",children:JSON.stringify(l,null,2)})]})]},l.id)),e.jsx("div",{className:"g7dt-flex g7dt-justify-end",children:e.jsx("button",{onClick:()=>a(!s),className:`g7dt-tab-button ${s?"g7dt-tab-active":""}`,children:"🔍 Auto-Binding Check"})}),s&&g&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"Form 바인딩 검증 결과"}),e.jsxs("div",{className:"g7dt-stats-grid g7dt-mb-3",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-blue",children:g.stats.totalFormsValidated}),e.jsx("div",{className:"g7dt-stat-label",children:"검증된 Form"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-green",children:g.stats.validForms}),e.jsx("div",{className:"g7dt-stat-label",children:"유효"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-red",children:g.stats.formsWithIssues}),e.jsx("div",{className:"g7dt-stat-label",children:"문제 있음"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-yellow",children:g.stats.totalIssues}),e.jsx("div",{className:"g7dt-stat-label",children:"총 이슈"})]})]}),g.stats.topIssues.length>0&&e.jsxs("div",{className:"g7dt-mb-3",children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"자주 발생하는 이슈"}),e.jsx("div",{className:"g7dt-space-y-1",children:g.stats.topIssues.slice(0,3).map((l,f)=>e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-xs",children:[e.jsx("span",{children:p(l.type)}),e.jsx("span",{className:"g7dt-text-white",children:l.description}),e.jsxs("span",{className:"g7dt-text-muted",children:["(",l.count,")"]})]},f))})]}),g.issues.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"바인딩 이슈가 없습니다 ✅"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:g.issues.slice().reverse().slice(0,30).map(l=>e.jsxs("div",{className:`g7dt-list-item ${l.severity==="error"?"g7dt-border-red":l.severity==="warning"?"g7dt-border-yellow":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:p(l.type)}),e.jsx("span",{className:`g7dt-text-xs g7dt-font-bold ${v(l.severity)}`,children:l.type}),l.formDataKey&&e.jsx("span",{className:"g7dt-text-xs g7dt-text-blue g7dt-font-mono",children:l.formDataKey})]}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-white g7dt-mt-1",children:l.description}),e.jsxs("div",{className:"g7dt-text-xs g7dt-text-green g7dt-mt-1",children:["💡 ",l.suggestion]}),l.contextInfo&&e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-mt-1",children:[l.contextInfo.isInsideModal&&e.jsx("span",{className:"g7dt-badge g7dt-text-xs",children:"🪟 modal"}),l.contextInfo.isInsideSortable&&e.jsx("span",{className:"g7dt-badge g7dt-text-xs",children:"📊 sortable"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted",children:["depth: ",l.contextInfo.depth]})]})]},l.id))})]})]})},$e=({devTools:d})=>{const t=d.getConditionalInfo();return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-purple g7dt-mb-2",children:["if 조건 (",t.ifConditions.length,")"]}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-scroll-list",children:t.ifConditions.length===0?e.jsx("div",{className:"g7dt-empty-message",children:"추적된 if 조건 없음"}):t.ifConditions.map(s=>e.jsx("div",{className:`g7dt-condition-item ${s.evaluatedValue?"g7dt-condition-true":"g7dt-condition-false"}`,children:e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsx("span",{className:"g7dt-font-mono g7dt-text-lightgray g7dt-truncate-60",children:s.expression}),e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:s.evaluatedValue?"g7dt-badge-success":"g7dt-badge-error",children:s.evaluatedValue?"true":"false"}),e.jsxs("span",{className:"g7dt-text-gray",children:[s.evaluationCount,"회"]})]})]})},s.id))})]}),e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-cyan g7dt-mb-2",children:["iteration (",t.iterations.length,")"]}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-scroll-list",children:t.iterations.length===0?e.jsx("div",{className:"g7dt-empty-message",children:"추적된 iteration 없음"}):t.iterations.map(s=>e.jsxs("div",{className:"g7dt-card g7dt-text-xs",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-1",children:[e.jsx("span",{className:"g7dt-font-mono g7dt-text-lightgray g7dt-truncate-60",children:s.source}),e.jsxs("span",{className:s.sourceLength>0?"g7dt-badge-blue":"g7dt-badge-warning",children:[s.sourceLength,"개 항목"]})]}),e.jsxs("div",{className:"g7dt-text-gray",children:[e.jsxs("span",{className:"g7dt-mr-2",children:["item_var: ",s.itemVar]}),s.indexVar&&e.jsxs("span",{children:["index_var: ",s.indexVar]})]})]},s.id))})]}),(t.ifConditions.some(s=>s.evaluatedValue===!1&&s.evaluationCount>5)||t.iterations.some(s=>s.sourceLength===0))&&e.jsxs("div",{className:"g7dt-warning-box",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-yellow g7dt-mb-2",children:"잠재적 문제"}),e.jsxs("div",{className:"g7dt-space-y-1 g7dt-text-xs",children:[t.ifConditions.filter(s=>s.evaluatedValue===!1&&s.evaluationCount>5).map(s=>e.jsxs("div",{className:"g7dt-text-lightgray",children:["⚠️ if 조건 항상 false: ",s.expression]},s.id)),t.iterations.filter(s=>s.sourceLength===0).map(s=>e.jsxs("div",{className:"g7dt-text-lightgray",children:["⚠️ iteration 소스 비어있음: ",s.source]},s.id))]})]})]})},ze=({devTools:d})=>{const[t,s]=x.useState([]),[a,g]=x.useState({totalEvaluations:0,uniqueExpressions:0,warningCount:0,cacheHitRate:0,averageDuration:0,byType:{},byWarning:{}}),[r,o]=x.useState(""),[p,v]=x.useState(!1),[l,f]=x.useState(null),c=x.useCallback(()=>{const m=d.getExpressionInfo();s(m.expressions),g(m.stats)},[d]);x.useEffect(()=>{c();const m=d.watchExpressions(()=>{c()}),k=setInterval(c,2e3);return()=>{m(),clearInterval(k)}},[d,c]);const h=t.filter(m=>{if(p&&!m.warning)return!1;if(r){const k=r.toLowerCase();return m.expression.toLowerCase().includes(k)||m.componentName?.toLowerCase().includes(k)||m.propName?.toLowerCase().includes(k)||m.resultType.toLowerCase().includes(k)}return!0}),y=m=>{switch(m){case"string":return"g7dt-text-green";case"number":return"g7dt-text-blue";case"boolean":return"g7dt-text-purple";case"array":return"g7dt-text-cyan";case"object":return"g7dt-text-yellow";case"undefined":case"null":return"g7dt-text-red";default:return"g7dt-text-gray"}},n=m=>{switch(m){case"undefined-result":return"undefined 결과";case"null-result":return"null 결과";case"array-to-string":return"배열→문자열";case"object-to-string":return"객체→문자열";case"missing-optional-chain":return"?. 누락 의심";case"wrong-iteration-var":return"잘못된 반복 변수";case"type-mismatch":return"타입 불일치";case"cache-stale":return"캐시 오래됨";case"slow-evaluation":return"느린 평가";default:return m}},i=(m,k)=>k==="undefined"?"undefined":k==="null"?"null":k==="string"?`"${String(m).substring(0,50)}${String(m).length>50?"...":""}"`:k==="array"?`Array(${Array.isArray(m)?m.length:"?"})`:k==="object"?"Object{...}":String(m),u=()=>{d.clearExpressions(),s([]),f(null)};return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-stats-grid-5",children:[e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-lg g7dt-text-blue",children:a.totalEvaluations}),e.jsx("div",{className:"g7dt-stat-label-xs",children:"총 평가"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-lg g7dt-text-green",children:a.uniqueExpressions}),e.jsx("div",{className:"g7dt-stat-label-xs",children:"고유 표현식"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-lg g7dt-text-yellow",children:a.warningCount}),e.jsx("div",{className:"g7dt-stat-label-xs",children:"경고"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsxs("div",{className:"g7dt-stat-value-lg g7dt-text-purple",children:[(a.cacheHitRate*100).toFixed(0),"%"]}),e.jsx("div",{className:"g7dt-stat-label-xs",children:"캐시 히트"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsxs("div",{className:"g7dt-stat-value-lg g7dt-text-cyan",children:[a.averageDuration.toFixed(1),"ms"]}),e.jsx("div",{className:"g7dt-stat-label-xs",children:"평균 시간"})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-items-center",children:[e.jsx("input",{type:"text",placeholder:"표현식, 컴포넌트, prop 검색...",value:r,onChange:m=>o(m.target.value),className:"g7dt-input g7dt-flex-1"}),e.jsxs("label",{className:"g7dt-checkbox-label",children:[e.jsx("input",{type:"checkbox",checked:p,onChange:m=>v(m.target.checked),className:"g7dt-checkbox"}),"경고만"]}),e.jsx("button",{onClick:u,className:"g7dt-btn-danger-sm",children:"초기화"})]}),Object.keys(a.byType).length>0&&e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1",children:Object.entries(a.byType).map(([m,k])=>e.jsxs("span",{className:`g7dt-type-tag ${y(m)}`,children:[m,": ",k]},m))}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-expr-list",children:h.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-py-4",children:r||p?"조건에 맞는 표현식이 없습니다":"추적된 표현식이 없습니다"}):h.slice(-50).reverse().map(m=>e.jsx("div",{onClick:()=>f(m),className:`g7dt-expr-item ${l?.id===m.id?"g7dt-expr-selected":m.warning?"g7dt-expr-warning":""}`,children:e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-start g7dt-gap-2",children:[e.jsxs("div",{className:"g7dt-flex-1 g7dt-min-w-0",children:[e.jsx("div",{className:"g7dt-font-mono g7dt-text-white g7dt-truncate",title:m.expression,children:m.expression}),e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-mt-1 g7dt-text-xxs",children:[m.componentName&&e.jsxs("span",{className:"g7dt-text-gray",children:[m.componentName,m.propName&&`.${m.propName}`]}),e.jsx("span",{className:y(m.resultType),children:i(m.result,m.resultType)})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-flex-col g7dt-items-end g7dt-gap-1",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-1",children:[m.fromCache&&e.jsx("span",{className:"g7dt-mini-badge g7dt-badge-success",children:"캐시"}),m.warning&&e.jsx("span",{className:"g7dt-mini-badge g7dt-badge-warning",children:n(m.warning.type)})]}),m.duration!==void 0&&e.jsxs("span",{className:"g7dt-text-xxs g7dt-text-gray",children:[m.duration.toFixed(2),"ms"]})]})]})},m.id))}),l&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:"표현식 상세"}),e.jsx("button",{onClick:()=>f(null),className:"g7dt-btn-close",children:"✕"})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"표현식:"}),e.jsx("code",{className:"g7dt-code g7dt-text-green",children:l.expression})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"결과:"}),e.jsx("span",{className:`g7dt-ml-2 ${y(l.resultType)}`,children:JSON.stringify(l.result,null,2)?.substring(0,200)}),e.jsxs("span",{className:"g7dt-ml-2 g7dt-text-darkgray",children:["(",l.resultType,")"]})]}),l.componentName&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"컴포넌트:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-blue",children:l.componentName}),l.propName&&e.jsxs("span",{className:"g7dt-text-gray",children:[".",l.propName]})]}),l.method&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"메서드:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-purple",children:l.method})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"캐시:"}),e.jsxs("span",{className:`g7dt-ml-2 ${l.fromCache?"g7dt-text-green":"g7dt-text-gray"}`,children:[l.fromCache?"캐시에서 로드":"새로 평가",l.skipCache&&" (skipCache)"]})]}),l.duration!==void 0&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"소요 시간:"}),e.jsxs("span",{className:`g7dt-ml-2 ${l.duration>10?"g7dt-text-red":"g7dt-text-lightgray"}`,children:[l.duration.toFixed(3),"ms"]})]}),l.warning&&e.jsxs("div",{className:"g7dt-warning-box-light",children:[e.jsxs("div",{className:"g7dt-text-yellow g7dt-font-bold",children:["⚠️ ",n(l.warning.type)]}),e.jsx("div",{className:"g7dt-text-lightgray g7dt-mt-1",children:l.warning.message}),l.warning.suggestion&&e.jsxs("div",{className:"g7dt-text-gray g7dt-mt-1 g7dt-text-xxs",children:["💡 ",l.warning.suggestion]})]}),l.steps&&l.steps.length>0&&e.jsxs("details",{className:"g7dt-details",children:[e.jsxs("summary",{className:"g7dt-summary",children:["평가 단계 (",l.steps.length,")"]}),e.jsx("div",{className:"g7dt-mt-1 g7dt-space-y-1 g7dt-pl-2 g7dt-border-l",children:l.steps.map((m,k)=>e.jsxs("div",{className:"g7dt-text-xxs",children:[e.jsx("span",{className:"g7dt-text-purple",children:m.step}),m.path&&e.jsx("span",{className:"g7dt-text-gray g7dt-ml-1",children:m.path}),m.fromCache&&e.jsx("span",{className:"g7dt-text-green g7dt-ml-1",children:"(cached)"}),m.error&&e.jsx("span",{className:"g7dt-text-red g7dt-ml-1",children:m.error})]},k))})]})]})]}),Object.keys(a.byWarning).length>0&&e.jsxs("div",{className:"g7dt-warning-box-light",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-yellow g7dt-mb-2",children:"경고 요약"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:Object.entries(a.byWarning).map(([m,k])=>e.jsxs("span",{className:"g7dt-warning-tag",children:[n(m),": ",k]},m))})]})]})},_e=({devTools:d})=>{const t=d.getHandlers(),[s,a]=x.useState(""),[g,r]=x.useState("all"),o={"built-in":t.filter(l=>l.category==="built-in"),custom:t.filter(l=>l.category==="custom"),module:t.filter(l=>l.category==="module"),plugin:t.filter(l=>l.category==="plugin")},p={all:t.length,"built-in":o["built-in"].length,custom:o.custom.length,module:o.module.length,plugin:o.plugin.length},v=t.filter(l=>g!=="all"&&l.category!==g?!1:s?l.name.toLowerCase().includes(s.toLowerCase())||l.description?.toLowerCase().includes(s.toLowerCase()):!0);return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsx("div",{className:"g7dt-flex g7dt-gap-2",children:["all","built-in","custom","module","plugin"].map(l=>e.jsxs("button",{onClick:()=>r(l),className:g===l?"g7dt-filter-btn g7dt-filter-active":"g7dt-filter-btn",children:[l==="all"?"전체":l," (",p[l],")"]},l))}),e.jsx("input",{type:"text",placeholder:"핸들러 이름 검색...",value:s,onChange:l=>a(l.target.value),className:"g7dt-input g7dt-w-full"}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-handler-list",children:v.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-py-4",children:"등록된 핸들러가 없습니다"}):v.map(l=>e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsx("span",{className:"g7dt-font-mono g7dt-text-blue",children:l.name}),e.jsx("span",{className:l.category==="built-in"?"g7dt-mini-badge g7dt-badge-success":l.category==="module"?"g7dt-mini-badge g7dt-badge-purple":l.category==="plugin"?"g7dt-mini-badge g7dt-badge-info":"g7dt-mini-badge g7dt-badge-warning",children:l.category})]}),l.description&&e.jsx("div",{className:"g7dt-text-xs g7dt-text-gray g7dt-mt-1",children:l.description})]},l.name))}),e.jsxs("div",{className:"g7dt-text-xs g7dt-text-gray",children:["총 ",t.length,"개 핸들러 | 빌트인 ",p["built-in"]," | 커스텀 ",p.custom," | 모듈 ",p.module," | 플러그인 ",p.plugin]})]})},je=({devTools:d})=>{const[t,s]=x.useState(d.getComponentEventInfo()),[a,g]=x.useState(null),r=x.useCallback(()=>{s(d.getComponentEventInfo())},[d]);x.useEffect(()=>{r();const p=setInterval(r,2e3);return()=>clearInterval(p)},[r]);const o=a?t.emitHistory.filter(p=>p.eventName===a):t.emitHistory;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-stats-grid-3",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value-lg g7dt-text-blue",children:t.subscriptions.length}),e.jsx("div",{className:"g7dt-stat-label",children:"이벤트 타입"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value-lg g7dt-text-green",children:t.totalSubscribers}),e.jsx("div",{className:"g7dt-stat-label",children:"총 구독자"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value-lg g7dt-text-purple",children:t.totalEmits}),e.jsx("div",{className:"g7dt-stat-label",children:"총 발행"})]})]}),e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"구독된 이벤트"}),e.jsxs("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:[e.jsx("button",{onClick:()=>g(null),className:a===null?"g7dt-filter-btn g7dt-filter-active":"g7dt-filter-btn",children:"전체"}),t.subscriptions.map(p=>e.jsxs("button",{onClick:()=>g(p.eventName),className:a===p.eventName?"g7dt-filter-btn g7dt-filter-active":"g7dt-filter-btn",children:[p.eventName," (",p.subscriberCount,")"]},p.eventName))]})]}),e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:["발행 이력 ",a&&`(${a})`]}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-event-list",children:o.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-py-4",children:"이벤트 발행 이력이 없습니다"}):o.slice(-30).reverse().map(p=>e.jsxs("div",{className:`g7dt-card g7dt-text-xs ${p.hasError?"g7dt-card-error":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsx("span",{className:"g7dt-font-mono g7dt-text-green",children:p.eventName}),e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsxs("span",{className:"g7dt-text-gray",children:[p.listenerCount," listeners"]}),p.hasError&&e.jsx("span",{className:"g7dt-text-red g7dt-text-xxs",children:"에러"})]})]}),p.data!==void 0&&e.jsxs("details",{className:"g7dt-details g7dt-mt-1",children:[e.jsx("summary",{className:"g7dt-summary",children:"데이터"}),e.jsx("pre",{className:"g7dt-pre",children:JSON.stringify(p.data,null,2)})]}),p.hasError&&p.errorMessage&&e.jsx("div",{className:"g7dt-error-inline",children:p.errorMessage})]},p.id))})]}),e.jsx("button",{onClick:r,className:"g7dt-btn-secondary",children:"새로고침"})]})},Ae=({devTools:d})=>{const t=d.getDataSources(),[s,a]=x.useState("all"),[g,r]=x.useState(null),[o,p]=x.useState(!1),[v,l]=x.useState(null),f=x.useCallback(()=>{l(d.getDataPathTransformTrackingInfo())},[d]);x.useEffect(()=>{if(o){f();const u=setInterval(f,2e3);return()=>clearInterval(u)}},[o,f]);const c=u=>{switch(u){case"api_response":return"📥";case"extract_data":return"📄";case"apply_path":return"🔗";case"init_global":return"🌐";case"init_local":return"📌";default:return"❓"}},h=t.filter(u=>s==="all"?!0:u.status===s),y={all:t.length,idle:t.filter(u=>u.status==="idle").length,loading:t.filter(u=>u.status==="loading").length,loaded:t.filter(u=>u.status==="loaded").length,error:t.filter(u=>u.status==="error").length},n=u=>{switch(u){case"loaded":return"g7dt-badge-success";case"loading":return"g7dt-badge-warning";case"error":return"g7dt-badge-error";default:return"g7dt-badge-gray"}},i=g?t.find(u=>u.id===g):null;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsx("div",{className:"g7dt-flex g7dt-gap-2",children:["all","idle","loading","loaded","error"].map(u=>e.jsxs("button",{onClick:()=>a(u),className:s===u?"g7dt-filter-btn g7dt-filter-active":"g7dt-filter-btn",children:[u==="all"?"전체":u," (",y[u],")"]},u))}),e.jsx("div",{className:"g7dt-ds-grid",children:h.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-col-span-2 g7dt-py-4",children:"데이터소스가 없습니다"}):h.map(u=>e.jsxs("div",{onClick:()=>r(u.id),className:`g7dt-ds-item ${g===u.id?"g7dt-ds-selected":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsx("span",{className:"g7dt-font-mono g7dt-text-blue g7dt-truncate",children:u.id}),e.jsx("span",{className:`g7dt-mini-badge ${n(u.status)}`,children:u.status})]}),e.jsx("div",{className:"g7dt-text-gray g7dt-mt-1 g7dt-truncate",children:u.endpoint||"(endpoint 없음)"}),u.itemCount!==void 0&&e.jsxs("div",{className:"g7dt-text-lightgray g7dt-mt-1",children:[u.itemCount,"개 항목"]})]},u.id))}),i&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:i.id}),e.jsx("button",{onClick:()=>r(null),className:"g7dt-btn-close",children:"✕"})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"엔드포인트:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-green",children:i.endpoint||"-"})]}),i.dataPath&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"데이터 경로:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-yellow",children:i.dataPath})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"상태:"}),e.jsx("span",{className:`g7dt-ml-2 g7dt-mini-badge ${n(i.status)}`,children:i.status})]}),i.itemCount!==void 0&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"항목 수:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-blue",children:i.itemCount})]}),i.keys&&i.keys.length>0&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"키:"}),e.jsxs("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1 g7dt-mt-1",children:[i.keys.slice(0,10).map(u=>e.jsx("span",{className:"g7dt-key-tag",children:u},u)),i.keys.length>10&&e.jsxs("span",{className:"g7dt-text-gray",children:["+",i.keys.length-10,"개"]})]})]}),i.dependencies&&i.dependencies.length>0&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"의존성:"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1 g7dt-mt-1",children:i.dependencies.map(u=>e.jsx("span",{className:"g7dt-dep-tag",children:u},u))})]})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("div",{className:"g7dt-text-xs g7dt-text-gray",children:["총 ",t.length,"개 데이터소스 | 로드됨 ",y.loaded," | 로딩중 ",y.loading," | 에러 ",y.error]}),e.jsx("button",{onClick:()=>p(!o),className:`g7dt-tab-button ${o?"g7dt-tab-active":""}`,children:"🔀 Path Transform"})]}),o&&v&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:["데이터 경로 변환 추적 (",v.transforms.length,"개)"]}),e.jsxs("div",{className:"g7dt-stats-grid-3 g7dt-mb-3",children:[e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-xl g7dt-font-bold g7dt-text-blue",children:v.stats.totalTransforms}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"총 변환"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-xl g7dt-font-bold g7dt-text-purple",children:Object.keys(v.stats.byDataSource).length}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"데이터소스"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-xl g7dt-font-bold g7dt-text-yellow",children:v.stats.warningsCount}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"경고"})]})]}),v.transforms.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"변환 기록이 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:v.transforms.slice().reverse().slice(0,20).map((u,m)=>e.jsxs("div",{className:`g7dt-list-item ${u.warnings.length>0?"g7dt-border-yellow":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:"g7dt-text-xs g7dt-text-blue g7dt-font-mono",children:u.dataSourceId}),u.warnings.length>0&&e.jsxs("span",{className:"g7dt-badge g7dt-text-xs g7dt-text-yellow",children:["⚠️ ",u.warnings.length]})]}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1 g7dt-mt-1",children:u.transformSteps.map((k,b)=>e.jsxs("span",{className:"g7dt-badge g7dt-text-xs",title:`${k.inputPath} → ${k.outputPath}`,children:[c(k.step)," ",k.step]},b))}),u.finalBinding&&e.jsxs("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-1",children:["최종: ",e.jsx("span",{className:"g7dt-text-green",children:u.finalBinding.expression}),"→ ",e.jsx("span",{className:"g7dt-text-blue",children:u.finalBinding.resolvedPath})]}),u.warnings.length>0&&e.jsx("div",{className:"g7dt-text-xs g7dt-text-yellow g7dt-mt-1",children:u.warnings[0]})]},`${u.dataSourceId}-${m}`))}),v.stats.commonWarnings.length>0&&e.jsxs("div",{className:"g7dt-mt-3",children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-1",children:"자주 발생하는 경고"}),e.jsx("div",{className:"g7dt-space-y-1",children:v.stats.commonWarnings.slice(0,5).map((u,m)=>e.jsxs("div",{className:"g7dt-text-xs g7dt-text-yellow",children:[u.warning," (",u.count,"회)"]},m))})]})]})]})},Oe=({devTools:d})=>{const[t,s]=x.useState(d.getStateRenderingInfo()),[a,g]=x.useState(null),[r,o]=x.useState(!1),[p,v]=x.useState(""),l=x.useCallback(()=>{s(d.getStateRenderingInfo())},[d]);x.useEffect(()=>{l();const n=setInterval(l,2e3);return()=>clearInterval(n)},[l]);const f=t.logs.filter(n=>!(r&&n.renderedComponents.length>0||p&&!n.statePath.toLowerCase().includes(p.toLowerCase()))),c=a?t.logs.find(n=>n.setStateId===a):null,h=t.stats,y=t.logs.filter(n=>n.renderedComponents.length===0).length;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-stats-grid",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-blue",children:h.totalStateChanges}),e.jsx("div",{className:"g7dt-stat-label",children:"상태 변경"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-green",children:h.totalRenders}),e.jsx("div",{className:"g7dt-stat-label",children:"총 렌더링"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsxs("div",{className:"g7dt-stat-value g7dt-text-purple",children:[h.avgRenderDuration.toFixed(1),"ms"]}),e.jsx("div",{className:"g7dt-stat-label",children:"평균 렌더 시간"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:`g7dt-stat-value ${y>0?"g7dt-text-yellow":"g7dt-text-green"}`,children:y}),e.jsx("div",{className:"g7dt-stat-label",children:"렌더링 없음"})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsx("input",{type:"text",placeholder:"상태 경로 필터 (예: _global.user)",value:p,onChange:n=>v(n.target.value),className:"g7dt-input g7dt-flex-1"}),e.jsx("button",{onClick:()=>o(!r),className:r?"g7dt-filter-btn-active-yellow":"g7dt-filter-btn",children:"렌더링 없음만"})]}),e.jsx("div",{className:"g7dt-scroll-list",children:f.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"상태-렌더링 로그가 없습니다"}):f.slice(-30).reverse().map(n=>{const i=n.renderedComponents.length>0,u=i?"✅":"⚠️",m=new Date(n.timestamp).toLocaleTimeString();return e.jsxs("div",{onClick:()=>g(n.setStateId),className:`g7dt-render-log-item ${a===n.setStateId?"g7dt-selected":i?"":"g7dt-warning"}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("span",{className:"g7dt-mono g7dt-text-blue",children:[u," ",n.statePath]}),e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[i?e.jsxs("span",{className:"g7dt-text-green",children:[n.renderedComponents.length,"개 렌더"]}):e.jsx("span",{className:"g7dt-text-yellow",children:"렌더링 없음"}),e.jsx("span",{className:"g7dt-text-muted",children:m})]})]}),n.triggeredBy?.handlerType&&e.jsxs("div",{className:"g7dt-text-muted g7dt-mt-1",children:["트리거: ",n.triggeredBy.handlerType]})]},n.setStateId)})}),c&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:c.statePath}),e.jsx("button",{onClick:()=>g(null),className:"g7dt-close-btn",children:"✕"})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"setStateId:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-purple g7dt-mono",children:c.setStateId})]}),c.triggeredBy&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"트리거:"}),e.jsxs("span",{className:"g7dt-ml-2 g7dt-text-yellow",children:[c.triggeredBy.handlerType,c.triggeredBy.actionId&&` (${c.triggeredBy.actionId})`]})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"이전 값:"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-red",children:JSON.stringify(c.oldValue,null,2)?.slice(0,200)})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"새 값:"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-green",children:JSON.stringify(c.newValue,null,2)?.slice(0,200)})]}),c.renderedComponents.length>0?e.jsxs("div",{children:[e.jsxs("span",{className:"g7dt-text-muted",children:["렌더링된 컴포넌트 (",c.renderedComponents.length,"개):"]}),e.jsxs("div",{className:"g7dt-mt-1 g7dt-space-y-1",children:[c.renderedComponents.slice(0,10).map((n,i)=>e.jsxs("div",{className:"g7dt-render-comp-item",children:[e.jsx("span",{className:"g7dt-text-blue",children:n.componentName}),e.jsxs("span",{className:"g7dt-text-muted",children:[n.renderDuration.toFixed(2),"ms"]})]},i)),c.renderedComponents.length>10&&e.jsxs("div",{className:"g7dt-text-muted g7dt-text-center",children:["... 외 ",c.renderedComponents.length-10,"개"]})]}),e.jsxs("div",{className:"g7dt-mt-2 g7dt-text-muted",children:["총 렌더 시간: ",c.totalRenderDuration.toFixed(2),"ms"]})]}):e.jsxs("div",{className:"g7dt-warning-box",children:[e.jsx("span",{className:"g7dt-text-yellow",children:"⚠️ 렌더링이 발생하지 않았습니다"}),e.jsxs("div",{className:"g7dt-text-muted g7dt-mt-1 g7dt-text-xxs",children:["가능한 원인:",e.jsxs("ul",{className:"g7dt-list g7dt-mt-1",children:[e.jsx("li",{children:"해당 상태를 바인딩하는 컴포넌트가 없음"}),e.jsx("li",{children:"컴포넌트가 마운트되지 않은 상태"}),e.jsx("li",{children:"동일한 값으로 상태 설정"})]})]})]})]})]}),h.topRenderedComponents.length>0&&e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"가장 많이 렌더링된 컴포넌트"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:h.topRenderedComponents.slice(0,5).map((n,i)=>e.jsxs("span",{className:"g7dt-tag",children:[n.name,": ",e.jsx("span",{className:"g7dt-text-blue",children:n.count})]},i))})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsx("button",{onClick:l,className:"g7dt-btn-secondary",children:"새로고침"}),e.jsx("button",{onClick:()=>{d.clearStateRenderingLogs(),l()},className:"g7dt-btn-danger",children:"로그 초기화"})]})]})},Me=({devTools:d})=>{const[t,s]=x.useState(null),[a,g]=x.useState(null),[r,o]=x.useState("all"),p=x.useCallback(()=>{s(d.getStyleValidationInfo())},[d]);if(x.useEffect(()=>{p();const c=setInterval(p,2e3);return()=>clearInterval(c)},[p]),!t)return e.jsx("div",{className:"g7dt-empty-state",children:"로딩 중..."});const v=t.issues.filter(c=>r==="all"?!0:c.type===r),l=a?t.issues.find(c=>c.componentId===a):null,f=t.stats;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-stats-grid",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value g7dt-text-blue",children:f.totalComponents}),e.jsx("div",{className:"g7dt-stat-label",children:"총 컴포넌트"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:`g7dt-stat-value ${f.invisibleCount>0?"g7dt-text-red":"g7dt-text-green"}`,children:f.invisibleCount}),e.jsx("div",{className:"g7dt-stat-label",children:"숨겨진 요소"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:`g7dt-stat-value ${f.tailwindIssueCount>0?"g7dt-text-yellow":"g7dt-text-green"}`,children:f.tailwindIssueCount}),e.jsx("div",{className:"g7dt-stat-label",children:"Tailwind 이슈"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:`g7dt-stat-value ${f.darkModeIssueCount>0?"g7dt-text-purple":"g7dt-text-green"}`,children:f.darkModeIssueCount}),e.jsx("div",{className:"g7dt-stat-label",children:"다크모드 이슈"})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsxs("select",{value:r,onChange:c=>o(c.target.value),className:"g7dt-select",children:[e.jsx("option",{value:"all",children:"모든 이슈"}),e.jsx("option",{value:"invisible-element",children:"숨겨진 요소"}),e.jsx("option",{value:"tailwind-purging",children:"Tailwind 퍼징"}),e.jsx("option",{value:"dark-mode-missing",children:"다크모드 누락"})]}),e.jsx("button",{onClick:p,className:"g7dt-btn-secondary",children:"새로고침"})]}),e.jsx("div",{className:"g7dt-scroll-list",children:v.length===0?e.jsx("div",{className:"g7dt-empty-state",children:t.issues.length===0?"스타일 이슈가 없습니다 ✓":"필터에 맞는 이슈가 없습니다"}):v.map(c=>{const h=c.type==="invisible-element"?"👁️":c.type==="tailwind-purging"?"🎨":"🌙",y=c.severity==="error"?"g7dt-severity-error":c.severity==="warning"?"g7dt-severity-warning":"g7dt-severity-info";return e.jsxs("div",{onClick:()=>g(c.componentId),className:`g7dt-style-issue-item ${y} ${a===c.componentId?"g7dt-selected":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("span",{className:"g7dt-mono g7dt-text-blue",children:[h," ",c.componentName]}),e.jsx("span",{className:c.severity==="error"?"g7dt-badge-error":c.severity==="warning"?"g7dt-badge-warning":"g7dt-badge-info",children:c.type})]}),e.jsx("div",{className:"g7dt-text-muted g7dt-mt-1 g7dt-truncate",children:c.description})]},c.componentId)})}),l&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:l.componentName}),e.jsx("button",{onClick:()=>g(null),className:"g7dt-close-btn",children:"✕"})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"이슈 타입:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-yellow",children:l.type})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"설명:"}),e.jsx("div",{className:"g7dt-mt-1 g7dt-text-white",children:l.description})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"CSS 속성:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-purple",children:l.property}),e.jsxs("span",{className:"g7dt-ml-2 g7dt-text-muted",children:["= ",l.currentValue]}),l.expectedValue&&e.jsxs("span",{className:"g7dt-ml-2 g7dt-text-green",children:["(예상: ",l.expectedValue,")"]})]}),l.suggestion&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"해결 방법:"}),e.jsx("div",{className:"g7dt-mt-1 g7dt-text-green",children:l.suggestion})]})]})]}),t.componentStyles.length>0&&e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:["컴포넌트 스타일 (",t.componentStyles.length,")"]}),e.jsx("div",{className:"g7dt-scroll-list-sm",children:t.componentStyles.slice(0,20).map((c,h)=>e.jsxs("div",{className:"g7dt-list-item",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-blue",children:c.componentName}),e.jsxs("span",{className:"g7dt-text-muted",children:[c.classes.length," classes"]})]}),c.classes.length>0&&e.jsxs("div",{className:"g7dt-text-muted g7dt-mt-1 g7dt-truncate",children:[c.classes.slice(0,5).join(" "),c.classes.length>5&&"..."]})]},h))})]})]})},Pe=({devTools:d})=>{const[t,s]=x.useState(null),[a,g]=x.useState(null),[r,o]=x.useState("all"),p=x.useCallback(()=>{s(d.getAuthDebugInfo())},[d]);if(x.useEffect(()=>{p();const h=setInterval(p,2e3);return()=>clearInterval(h)},[p]),!t)return e.jsx("div",{className:"g7dt-empty-state",children:"로딩 중..."});const v=t.events.filter(h=>r==="all"?!0:h.type===r),l=a?t.events.find(h=>h.id===a):null,f=t.stats,c=t.state;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-justify-between g7dt-mb-3",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:"현재 인증 상태"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted",children:["소스: ",c.source||"unknown"]})]}),e.jsxs("div",{className:"g7dt-stats-grid-3 g7dt-mb-3",children:[e.jsx("div",{className:"g7dt-text-center",children:e.jsx("div",{className:`g7dt-text-lg g7dt-font-bold ${c.isAuthenticated?"g7dt-text-green":"g7dt-text-red"}`,children:c.isAuthenticated?"✓ 인증됨":"✕ 미인증"})}),e.jsx("div",{className:"g7dt-text-center",children:e.jsx("div",{className:`g7dt-text-lg g7dt-font-bold ${c.tokens.hasAccessToken?"g7dt-text-green":"g7dt-text-yellow"}`,children:c.tokens.hasAccessToken?"✓ 토큰 유효":"⚠ 토큰 없음"})}),e.jsx("div",{className:"g7dt-text-center",children:e.jsx("div",{className:"g7dt-text-lg g7dt-font-bold g7dt-text-blue",children:c.user?.name||c.user?.email||"익명"})})]}),c.user&&e.jsxs("div",{className:"g7dt-border-t g7dt-pt-3 g7dt-mt-3",children:[e.jsxs("div",{className:"g7dt-grid-2 g7dt-gap-2 g7dt-text-xs",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"ID:"}),e.jsx("span",{className:"g7dt-text-white g7dt-mono",children:c.user.id})]}),e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"이메일:"}),e.jsx("span",{className:"g7dt-text-white g7dt-truncate g7dt-ml-2",title:c.user.email,children:c.user.email})]}),c.user.name&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"이름:"}),e.jsx("span",{className:"g7dt-text-white",children:c.user.name})]}),c.user.nickname&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"닉네임:"}),e.jsx("span",{className:"g7dt-text-white",children:c.user.nickname})]}),c.user.phone&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"전화번호:"}),e.jsx("span",{className:"g7dt-text-white",children:c.user.phone})]}),c.user.status&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"상태:"}),e.jsx("span",{className:c.user.status==="active"?"g7dt-text-green":"g7dt-text-yellow",children:c.user.status})]}),c.user.locale&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"언어:"}),e.jsx("span",{className:"g7dt-text-white",children:c.user.locale})]})]}),c.user.roles&&c.user.roles.length>0&&e.jsxs("div",{className:"g7dt-mt-2",children:[e.jsx("span",{className:"g7dt-text-muted g7dt-text-xs",children:"역할: "}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1 g7dt-mt-1",children:c.user.roles.map((h,y)=>e.jsx("span",{className:"g7dt-role-tag",title:h.guard_name?`guard: ${h.guard_name}`:void 0,children:typeof h=="string"?h:h.name},y))})]}),c.user.permissions&&c.user.permissions.length>0&&e.jsxs("div",{className:"g7dt-mt-2",children:[e.jsx("span",{className:"g7dt-text-muted g7dt-text-xs",children:"권한: "}),e.jsxs("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1 g7dt-mt-1 g7dt-max-h-16 g7dt-overflow-auto",children:[c.user.permissions.slice(0,10).map((h,y)=>e.jsx("span",{className:"g7dt-perm-tag",title:h.guard_name?`guard: ${h.guard_name}`:void 0,children:typeof h=="string"?h:h.name},y)),c.user.permissions.length>10&&e.jsxs("span",{className:"g7dt-text-muted g7dt-text-xs",children:["+",c.user.permissions.length-10,"개"]})]})]}),e.jsxs("div",{className:"g7dt-mt-2 g7dt-grid-2 g7dt-gap-2 g7dt-text-xs",children:[c.user.created_at&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"가입일:"}),e.jsx("span",{className:"g7dt-text-muted",children:new Date(c.user.created_at).toLocaleDateString()})]}),c.user.last_login_at&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"마지막 로그인:"}),e.jsx("span",{className:"g7dt-text-muted",children:new Date(c.user.last_login_at).toLocaleString()})]}),c.user.email_verified_at&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"이메일 인증:"}),e.jsx("span",{className:"g7dt-text-green",children:"✓ 인증됨"})]})]})]}),e.jsx("div",{className:"g7dt-border-t g7dt-pt-3 g7dt-mt-3",children:e.jsxs("div",{className:"g7dt-grid-2 g7dt-gap-2 g7dt-text-xs",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"저장소:"}),e.jsx("span",{className:"g7dt-text-white",children:c.tokens.storage})]}),c.tokens.accessTokenPreview&&e.jsxs("div",{className:"g7dt-flex g7dt-justify-between",children:[e.jsx("span",{className:"g7dt-text-muted",children:"토큰:"}),e.jsx("span",{className:"g7dt-text-muted g7dt-mono",children:c.tokens.accessTokenPreview})]})]})}),c.tokens.accessTokenExpiresAt&&e.jsxs("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-2 g7dt-text-center",children:["토큰 만료: ",new Date(c.tokens.accessTokenExpiresAt).toLocaleString()]})]}),e.jsxs("div",{className:"g7dt-stats-grid-5",children:[e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-blue",children:f.loginAttempts}),e.jsx("div",{className:"g7dt-stat-label",children:"로그인 시도"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-green",children:f.successfulLogins}),e.jsx("div",{className:"g7dt-stat-label",children:"성공"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:`g7dt-stat-value-sm ${f.failedLogins>0?"g7dt-text-red":"g7dt-text-muted"}`,children:f.failedLogins}),e.jsx("div",{className:"g7dt-stat-label",children:"실패"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-purple",children:f.tokenRefreshes}),e.jsx("div",{className:"g7dt-stat-label",children:"토큰 갱신"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:`g7dt-stat-value-sm ${f.unauthorizedResponses>0?"g7dt-text-red":"g7dt-text-muted"}`,children:f.unauthorizedResponses}),e.jsx("div",{className:"g7dt-stat-label",children:"401 응답"})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsxs("select",{value:r,onChange:h=>o(h.target.value),className:"g7dt-select",children:[e.jsx("option",{value:"all",children:"모든 이벤트"}),e.jsx("option",{value:"login",children:"로그인"}),e.jsx("option",{value:"logout",children:"로그아웃"}),e.jsx("option",{value:"token-refresh",children:"토큰 갱신"}),e.jsx("option",{value:"token-expired",children:"토큰 만료"}),e.jsx("option",{value:"api-unauthorized",children:"401 응답"})]}),e.jsx("button",{onClick:p,className:"g7dt-btn-secondary",children:"새로고침"})]}),e.jsx("div",{className:"g7dt-scroll-list-sm",children:v.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"인증 이벤트가 없습니다"}):v.slice(-20).reverse().map(h=>{const y=h.success?"✅":"❌",n=new Date(h.timestamp).toLocaleTimeString();return e.jsxs("div",{onClick:()=>g(h.id),className:`g7dt-auth-event-item ${a===h.id?"g7dt-selected":h.success?"":"g7dt-error"}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("span",{className:"g7dt-mono",children:[y," ",h.type]}),e.jsx("span",{className:"g7dt-text-muted",children:n})]}),h.error&&e.jsx("div",{className:"g7dt-text-red g7dt-mt-1 g7dt-truncate",children:h.error})]},h.id)})}),l&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:l.type}),e.jsx("button",{onClick:()=>g(null),className:"g7dt-close-btn",children:"✕"})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"결과:"}),e.jsx("span",{className:`g7dt-ml-2 ${l.success?"g7dt-text-green":"g7dt-text-red"}`,children:l.success?"성공":"실패"})]}),l.error&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"에러:"}),e.jsx("div",{className:"g7dt-mt-1 g7dt-text-red",children:l.error})]}),l.details&&Object.keys(l.details).length>0&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"상세:"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-green",children:JSON.stringify(l.details,null,2)})]})]})]}),t.headerAnalysis.length>0&&e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"최근 API 인증 헤더"}),e.jsx("div",{className:"g7dt-scroll-list-xs",children:t.headerAnalysis.slice(-10).reverse().map((h,y)=>e.jsxs("div",{className:"g7dt-header-item",children:[e.jsx("span",{className:"g7dt-text-blue g7dt-truncate g7dt-flex-1",children:h.url}),e.jsx("span",{className:`g7dt-ml-2 ${h.hasAuthHeader?"g7dt-text-green":"g7dt-text-yellow"}`,children:h.hasAuthHeader?`✓ ${h.headerType}`:"✕ 헤더 없음"}),h.responseStatus&&e.jsx("span",{className:`g7dt-ml-2 ${h.responseStatus<400?"g7dt-text-muted":"g7dt-text-red"}`,children:h.responseStatus})]},y))})]})]})},He=({devTools:d})=>{const[t,s]=x.useState([]),[a,g]=x.useState(null),[r,o]=x.useState(null),[p,v]=x.useState("all"),[l,f]=x.useState(""),[c,h]=x.useState(""),y=x.useCallback(()=>{const b=d.getLogInfo();s(b.entries),g(b.stats)},[d]);x.useEffect(()=>{y();const b=setInterval(y,1e3);return()=>clearInterval(b)},[y]);const n=t.filter(b=>!(p!=="all"&&b.level!==p||l&&!b.prefix.toLowerCase().includes(l.toLowerCase())||c&&!b.message.toLowerCase().includes(c.toLowerCase()))),i=r?t.find(b=>b.id===r):null,u=()=>{d.clearLogs(),y(),o(null)},m=b=>{switch(b){case"error":return"g7dt-text-red";case"warn":return"g7dt-text-yellow";case"info":return"g7dt-text-blue";case"debug":return"g7dt-text-muted";default:return"g7dt-text-white"}},k=b=>{switch(b){case"error":return"g7dt-log-error";case"warn":return"g7dt-log-warn";case"info":return"g7dt-log-info";case"debug":return"g7dt-log-debug";default:return"g7dt-log-default"}};return e.jsxs("div",{className:"g7dt-space-y-4",children:[a&&e.jsxs("div",{className:"g7dt-stats-grid-6",children:[e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-white",children:a.totalLogs}),e.jsx("div",{className:"g7dt-stat-label",children:"전체"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-red",children:a.byLevel.error}),e.jsx("div",{className:"g7dt-stat-label",children:"에러"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-yellow",children:a.byLevel.warn}),e.jsx("div",{className:"g7dt-stat-label",children:"경고"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:"g7dt-stat-value-sm g7dt-text-blue",children:a.byLevel.info}),e.jsx("div",{className:"g7dt-stat-label",children:"정보"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:`g7dt-stat-value-sm ${a.recentErrors>0?"g7dt-text-red":"g7dt-text-green"}`,children:a.recentErrors}),e.jsx("div",{className:"g7dt-stat-label",children:"최근 에러"})]}),e.jsxs("div",{className:"g7dt-stat-card-sm",children:[e.jsx("div",{className:`g7dt-stat-value-sm ${a.recentWarnings>0?"g7dt-text-yellow":"g7dt-text-green"}`,children:a.recentWarnings}),e.jsx("div",{className:"g7dt-stat-label",children:"최근 경고"})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-flex-wrap",children:[e.jsxs("select",{value:p,onChange:b=>v(b.target.value),className:"g7dt-select",children:[e.jsx("option",{value:"all",children:"모든 레벨"}),e.jsx("option",{value:"error",children:"에러"}),e.jsx("option",{value:"warn",children:"경고"}),e.jsx("option",{value:"info",children:"정보"}),e.jsx("option",{value:"log",children:"로그"}),e.jsx("option",{value:"debug",children:"디버그"})]}),e.jsx("input",{type:"text",placeholder:"prefix 필터",value:l,onChange:b=>f(b.target.value),className:"g7dt-input g7dt-w-32"}),e.jsx("input",{type:"text",placeholder:"메시지 검색",value:c,onChange:b=>h(b.target.value),className:"g7dt-input g7dt-flex-1"}),e.jsx("button",{onClick:y,className:"g7dt-btn-secondary",children:"새로고침"}),e.jsx("button",{onClick:u,className:"g7dt-btn-danger",children:"초기화"})]}),e.jsx("div",{className:"g7dt-scroll-list-lg",children:n.length===0?e.jsx("div",{className:"g7dt-empty-state",children:t.length===0?"로그가 없습니다":"필터에 맞는 로그가 없습니다"}):n.slice(-50).reverse().map(b=>{const N=new Date(b.timestamp).toLocaleTimeString();return e.jsxs("div",{onClick:()=>o(b.id),className:`g7dt-log-item ${r===b.id?"g7dt-selected":k(b.level)}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:`g7dt-font-bold g7dt-uppercase ${m(b.level)}`,children:b.level}),e.jsxs("span",{className:"g7dt-text-purple",children:["[",b.prefix,"]"]})]}),e.jsx("span",{className:"g7dt-text-muted",children:N})]}),e.jsx("div",{className:"g7dt-text-white g7dt-mt-1 g7dt-truncate",children:b.message})]},b.id)})}),i&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold",children:[e.jsx("span",{className:m(i.level),children:i.level.toUpperCase()})," ",e.jsxs("span",{className:"g7dt-text-purple",children:["[",i.prefix,"]"]})]}),e.jsx("button",{onClick:()=>o(null),className:"g7dt-close-btn",children:"✕"})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"시간:"}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-white",children:new Date(i.timestamp).toLocaleString()})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"메시지:"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-white g7dt-whitespace-pre-wrap",children:i.message})]}),i.args.length>0&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"인수:"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-green",children:JSON.stringify(i.args,null,2)})]}),i.stack&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"스택 트레이스:"}),e.jsx("pre",{className:"g7dt-pre-stack g7dt-text-red",children:i.stack})]})]})]}),a&&Object.keys(a.byPrefix).length>0&&e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"Prefix별 로그 수"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:Object.entries(a.byPrefix).sort((b,N)=>N[1]-b[1]).slice(0,10).map(([b,N])=>e.jsxs("button",{onClick:()=>f(b),className:l===b?"g7dt-prefix-btn-active":"g7dt-prefix-btn",children:[b,": ",e.jsx("span",{className:"g7dt-text-blue",children:N})]},b))})]})]})},Fe=({devTools:d})=>{const[t,s]=x.useState(d.getLayoutDebugInfo()),[a,g]=x.useState(!1),[r,o]=x.useState(null),[p,v]=x.useState(!1),[l,f]=x.useState({});x.useEffect(()=>{const m=setInterval(()=>{s(d.getLayoutDebugInfo())},1e3);return()=>clearInterval(m)},[d]),x.useEffect(()=>{(async()=>{try{const k=await fetch("/api/admin/permissions",{headers:{Authorization:`Bearer ${localStorage.getItem("auth_token")}`,Accept:"application/json"}});if(k.ok){const b=await k.json(),N={},C=w=>{w.forEach(S=>{N[S.identifier]=S.name,S.children?.length&&C(S.children)})};Object.values(b.data?.permissions||{}).forEach(w=>{w.permissions&&C(w.permissions)}),f(N)}}catch(k){console.warn("[G7DevTools] 권한 목록 조회 실패:",k)}})()},[]);const c=t.current,h=c?.layoutJson,y=t.history,n=t.stats,i=h?[{key:"components",label:"컴포넌트",count:Array.isArray(h.components)?h.components.length:0},{key:"data_sources",label:"데이터소스",count:Array.isArray(h.data_sources)?h.data_sources.length:0},{key:"computed",label:"Computed",count:h.computed?Object.keys(h.computed).length:0},{key:"defines",label:"Defines",count:h.defines?Object.keys(h.defines).length:0},{key:"slots",label:"Slots",count:h.slots?Object.keys(h.slots).length:0},{key:"modals",label:"Modals",count:h.modals?Object.keys(h.modals).length:0},{key:"init_actions",label:"초기화 액션",count:Array.isArray(h.init_actions)?h.init_actions.length:0},{key:"scripts",label:"스크립트",count:Array.isArray(h.scripts)?h.scripts.length:0},{key:"meta",label:"메타데이터",count:h.meta?Object.keys(h.meta).length:0},{key:"errorHandling",label:"에러핸들링",count:h.errorHandling?Object.keys(h.errorHandling).length:0},{key:"permissions",label:"권한",count:Array.isArray(h.permissions)?h.permissions.length:0}]:[],u=m=>{navigator.clipboard.writeText(JSON.stringify(m,null,2))};return c?e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"📊 로드 통계"}),e.jsxs("div",{className:"g7dt-grid-3",children:[e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"총 로드:"}),e.jsx("span",{className:"g7dt-text-blue",children:n.totalLoads})]}),e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"캐시 히트:"}),e.jsx("span",{className:"g7dt-text-green",children:n.cacheHits})]}),e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"API 로드:"}),e.jsx("span",{className:"g7dt-text-yellow",children:n.apiLoads})]})]})]}),e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:"📋 현재 레이아웃"}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsx("button",{onClick:()=>u(h),className:"g7dt-btn-small",title:"전체 JSON 복사",children:"📋 복사"}),e.jsx("button",{onClick:()=>g(!a),className:"g7dt-btn-small",children:a?"요약 보기":"전체 보기"})]})]}),e.jsxs("div",{className:"g7dt-space-y-2 g7dt-text-xs",children:[e.jsxs("div",{className:"g7dt-flex g7dt-gap-4",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"경로:"}),e.jsx("span",{className:"g7dt-ml-1 g7dt-text-white g7dt-font-mono",children:c.layoutPath})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"템플릿:"}),e.jsx("span",{className:"g7dt-ml-1 g7dt-text-blue",children:c.templateId})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-4",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"버전:"}),e.jsx("span",{className:"g7dt-ml-1 g7dt-text-white",children:c.version||"-"})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"소스:"}),e.jsx("span",{className:`g7dt-ml-1 ${c.source==="cache"?"g7dt-text-green":"g7dt-text-yellow"}`,children:c.source==="cache"?"💾 캐시":"🌐 API"})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"로드 시간:"}),e.jsx("span",{className:"g7dt-ml-1 g7dt-text-white",children:c.loadedAt?new Date(c.loadedAt).toLocaleTimeString():"-"})]})]}),e.jsx("div",{className:"g7dt-flex g7dt-gap-4 g7dt-mt-2",children:e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-muted",children:"권한:"}),h.permissions&&h.permissions.length>0?e.jsxs("span",{className:"g7dt-ml-1 g7dt-text-yellow",children:["🔒 ",h.permissions.map(m=>l[m]||m).join(", ")]}):h.permissions&&h.permissions.length===0?e.jsx("span",{className:"g7dt-ml-1 g7dt-text-green",children:"🌐 공개"}):e.jsx("span",{className:"g7dt-ml-1 g7dt-text-muted",children:"미정의 (부모 상속)"})]})})]})]}),!a&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"🏗️ 레이아웃 구조"}),e.jsx("div",{className:"g7dt-grid-2",children:i.map(m=>e.jsxs("button",{onClick:()=>o(r===m.key?null:m.key),className:`g7dt-section-btn ${r===m.key?"g7dt-selected":""}`,children:[e.jsx("span",{children:m.label}),e.jsxs("span",{className:"g7dt-text-blue",children:[m.count,"개"]})]},m.key))})]}),(a||r)&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:a?"📄 전체 레이아웃 JSON":`📎 ${r} 섹션`}),e.jsx("button",{onClick:()=>{a?u(h):r&&h&&u(h[r])},className:"g7dt-btn-small",children:"복사"})]}),r==="permissions"&&!a?e.jsx("div",{className:"g7dt-text-xs",children:h.permissions&&h.permissions.length>0?e.jsxs("table",{className:"g7dt-table g7dt-w-full",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"g7dt-border-b g7dt-border-gray-700",children:[e.jsx("th",{className:"g7dt-text-left g7dt-p-2 g7dt-text-muted",children:"권한 ID"}),e.jsx("th",{className:"g7dt-text-left g7dt-p-2 g7dt-text-muted",children:"명칭"})]})}),e.jsx("tbody",{children:h.permissions.map(m=>e.jsxs("tr",{className:"g7dt-border-b g7dt-border-gray-800",children:[e.jsx("td",{className:"g7dt-p-2 g7dt-font-mono g7dt-text-blue",children:m}),e.jsx("td",{className:"g7dt-p-2 g7dt-text-white",children:l[m]||e.jsx("span",{className:"g7dt-text-muted",children:"-"})})]},m))})]}):h.permissions&&h.permissions.length===0?e.jsx("p",{className:"g7dt-text-muted g7dt-p-2",children:"🌐 공개 레이아웃 (permissions: [])"}):e.jsx("p",{className:"g7dt-text-muted g7dt-p-2",children:"권한 미정의 (부모 레이아웃 상속)"})}):e.jsx("pre",{className:"g7dt-pre g7dt-text-green g7dt-max-h-96 g7dt-overflow-auto",children:JSON.stringify(a?h:r&&h?h[r]:null,null,2)})]}),y.length>0&&e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-cursor-pointer",onClick:()=>v(!p),children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold",children:["📜 로드 이력 (",y.length,")"]}),e.jsx("span",{className:"g7dt-text-muted",children:p?"▼":"▶"})]}),p&&e.jsx("div",{className:"g7dt-mt-2 g7dt-space-y-1",children:y.slice(-20).reverse().map(m=>e.jsxs("div",{className:"g7dt-history-item",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsx("span",{className:"g7dt-font-mono g7dt-text-xs",children:m.layoutPath}),e.jsx("span",{className:`g7dt-text-xs ${m.source==="cache"?"g7dt-text-green":"g7dt-text-yellow"}`,children:m.source==="cache"?"💾":"🌐"})]}),e.jsxs("div",{className:"g7dt-text-xs g7dt-text-muted",children:[m.templateId," · ",m.loadedAt?new Date(m.loadedAt).toLocaleTimeString():"-"]})]},m.id))})]}),e.jsxs("div",{className:"g7dt-card g7dt-text-xs g7dt-text-muted",children:["💡 레이아웃 캐시 문제가 의심되면:",e.jsxs("ul",{className:"g7dt-ml-4 g7dt-list-disc",children:[e.jsx("li",{children:'"소스" 항목이 💾 캐시인지 🌐 API인지 확인'}),e.jsx("li",{children:"캐시에서 로드된 경우, JSON 내용이 최신인지 확인"}),e.jsxs("li",{children:["문제가 있으면 ",e.jsx("code",{className:"g7dt-code",children:"php artisan template:refresh-layout"})," 실행"]})]})]})]}):e.jsx("div",{className:"g7dt-space-y-4",children:e.jsxs("div",{className:"g7dt-empty-state",children:["현재 로드된 레이아웃이 없습니다.",e.jsx("br",{}),"페이지를 새로고침하거나 라우트를 변경해보세요."]})})},We=({devTools:d})=>{const[t,s]=x.useState(d.getChangeDetectionInfo()),[a,g]=x.useState(null),[r,o]=x.useState(!1),[p,v]=x.useState(new Set);x.useEffect(()=>{const b=setInterval(()=>{s(d.getChangeDetectionInfo())},500);return()=>clearInterval(b)},[d]);const{executionDetails:l,alerts:f,stats:c}=t,h=r?l.filter(b=>b.alerts.length>0||b.stateChanges.length===0):l,y=a?l.find(b=>b.executionId===a):null,n=b=>{v(N=>{const C=new Set(N);return C.has(b)?C.delete(b):C.add(b),C})},i=b=>{switch(b){case"normal":return"✅";case"early-return-condition":return"⏎";case"early-return-validation":return"❌";case"error":return"💥";default:return"❓"}},u=b=>{switch(b){case"no-state-change":return"⚠️";case"no-datasource-change":return"📊";case"expected-not-fulfilled":return"❗";case"object-reference-same":return"🔄";case"early-return-detected":return"⏎";case"async-timing-issue":return"⏱️";default:return"📢"}},m=b=>{switch(b){case"error":return"g7dt-text-red";case"warning":return"g7dt-text-yellow";case"info":return"g7dt-text-blue";default:return"g7dt-text-white"}},k=b=>new Date(b).toLocaleTimeString("ko-KR",{hour:"2-digit",minute:"2-digit",second:"2-digit",fractionalSecondDigits:3});return e.jsxs("div",{className:"g7dt-space-y-4",children:[f.filter(b=>b.severity==="error"||b.severity==="warning").length>0&&e.jsx("div",{className:"g7dt-card",style:{backgroundColor:"#7f1d1d",borderColor:"#991b1b"},children:e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("span",{className:"g7dt-font-bold",children:["⚠️ 변경 감지 알림: ",f.filter(b=>b.severity==="error").length,"개 에러,"," ",f.filter(b=>b.severity==="warning").length,"개 경고"]}),e.jsx("button",{onClick:()=>d.clearChangeDetectionData(),className:"g7dt-btn-small",children:"초기화"})]})}),e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"📊 변경 감지 통계"}),e.jsxs("div",{className:"g7dt-grid-3",children:[e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"총 실행:"}),e.jsx("span",{className:"g7dt-text-blue",children:c.totalExecutions})]}),e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"상태 변경 있음:"}),e.jsx("span",{className:"g7dt-text-green",children:c.executionsWithStateChange})]}),e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"상태 변경 없음:"}),e.jsx("span",{className:c.executionsWithoutStateChange>0?"g7dt-text-yellow":"g7dt-text-gray",children:c.executionsWithoutStateChange})]}),e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"Early Return:"}),e.jsx("span",{className:"g7dt-text-orange",children:c.earlyReturnCount})]}),e.jsxs("div",{className:"g7dt-stat-item",children:[e.jsx("span",{className:"g7dt-text-muted",children:"알림:"}),e.jsx("span",{className:c.alertCount>0?"g7dt-text-red":"g7dt-text-gray",children:c.alertCount})]})]})]}),f.length>0&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"🔔 최근 알림"}),e.jsx("div",{className:"g7dt-space-y-2",style:{maxHeight:"200px",overflowY:"auto"},children:f.slice(-10).reverse().map(b=>e.jsxs("div",{className:"g7dt-list-item",onClick:()=>n(b.id),style:{cursor:"pointer"},children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-start",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:u(b.type)}),e.jsx("span",{className:`g7dt-text-xs ${m(b.severity)}`,children:b.message})]}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted",children:k(b.timestamp)})]}),p.has(b.id)&&e.jsxs("div",{className:"g7dt-mt-2 g7dt-text-xs g7dt-text-muted g7dt-pl-6",children:[e.jsx("p",{children:b.description}),b.statePath&&e.jsxs("p",{className:"g7dt-mt-1",children:[e.jsx("span",{className:"g7dt-text-cyan",children:"경로:"})," ",b.statePath]}),b.suggestion&&e.jsxs("p",{className:"g7dt-mt-1",children:[e.jsx("span",{className:"g7dt-text-green",children:"제안:"})," ",b.suggestion]}),b.docLink&&e.jsxs("p",{className:"g7dt-mt-1",children:[e.jsx("span",{className:"g7dt-text-blue",children:"문서:"})," ",e.jsx("code",{className:"g7dt-code",children:b.docLink})]})]})]},b.id))})]}),e.jsxs("div",{className:"g7dt-card",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-mb-2",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold",children:"🔧 핸들러 실행 이력"}),e.jsxs("label",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-xs",children:[e.jsx("input",{type:"checkbox",checked:r,onChange:b=>o(b.target.checked)}),e.jsx("span",{children:"경고만 표시"})]})]}),h.length===0?e.jsxs("div",{className:"g7dt-empty-state",children:["핸들러 실행 이력이 없습니다.",e.jsx("br",{}),"액션을 실행하면 여기에 표시됩니다."]}):e.jsx("div",{className:"g7dt-space-y-1",style:{maxHeight:"300px",overflowY:"auto"},children:h.slice(-20).reverse().map(b=>e.jsxs("div",{className:`g7dt-list-item ${a===b.executionId?"g7dt-selected":""}`,onClick:()=>g(a===b.executionId?null:b.executionId),style:{cursor:"pointer"},children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:i(b.exitReason)}),e.jsx("span",{className:"g7dt-text-white g7dt-font-mono g7dt-text-xs",children:b.handlerName}),b.alerts.length>0&&e.jsx("span",{className:"g7dt-badge g7dt-badge-warning",children:b.alerts.length})]}),e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-xs",children:[e.jsxs("span",{className:"g7dt-text-muted",children:["상태: ",b.stateChanges.length]}),e.jsx("span",{className:"g7dt-text-muted",children:b.duration?`${b.duration}ms`:"-"}),e.jsx("span",{className:"g7dt-text-muted",children:k(b.startTime)})]})]}),a===b.executionId&&y&&e.jsxs("div",{className:"g7dt-mt-3 g7dt-pt-3 g7dt-border-t g7dt-border-gray-700",children:[e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted",children:"종료 사유: "}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white",children:y.exitReason||"unknown"}),y.exitLocation&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-2",children:"위치: "}),e.jsx("code",{className:"g7dt-code g7dt-text-xs",children:y.exitLocation})]})]}),y.exitDescription&&e.jsx("div",{className:"g7dt-mb-2 g7dt-text-xs g7dt-text-muted",children:y.exitDescription}),y.stateChanges.length>0&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsxs("span",{className:"g7dt-text-xs g7dt-font-bold g7dt-text-green",children:["상태 변경 (",y.stateChanges.length,")"]}),e.jsx("div",{className:"g7dt-mt-1 g7dt-space-y-1",children:y.stateChanges.map(N=>e.jsxs("div",{className:"g7dt-text-xs g7dt-pl-2",children:[e.jsx("span",{className:"g7dt-text-cyan",children:N.path}),e.jsxs("span",{className:"g7dt-text-muted",children:[" (",N.changeType,")"]}),N.comparison.changedKeys&&e.jsxs("span",{className:"g7dt-text-muted g7dt-ml-1",children:["[변경: ",N.comparison.changedKeys.join(", "),"]"]})]},N.id))})]}),y.dataSourceChanges.length>0&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsxs("span",{className:"g7dt-text-xs g7dt-font-bold g7dt-text-blue",children:["데이터소스 변경 (",y.dataSourceChanges.length,")"]}),e.jsx("div",{className:"g7dt-mt-1 g7dt-space-y-1",children:y.dataSourceChanges.map((N,C)=>e.jsxs("div",{className:"g7dt-text-xs g7dt-pl-2",children:[e.jsx("span",{className:"g7dt-text-cyan",children:N.dataSourceId}),e.jsxs("span",{className:"g7dt-text-muted",children:[" ","(",N.changeType,": ",N.previousStatus," → ",N.newStatus,")"]})]},C))})]}),y.expectedChanges&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("span",{className:"g7dt-text-xs g7dt-font-bold g7dt-text-yellow",children:"기대 변경"}),e.jsxs("div",{className:"g7dt-mt-1 g7dt-text-xs g7dt-pl-2 g7dt-text-muted",children:["상태: ",y.expectedChanges.expectedStatePaths.join(", ")||"없음",e.jsx("br",{}),"데이터소스: ",y.expectedChanges.expectedDataSources.join(", ")||"없음"]})]}),y.alerts.length>0&&e.jsxs("div",{children:[e.jsxs("span",{className:"g7dt-text-xs g7dt-font-bold g7dt-text-red",children:["알림 (",y.alerts.length,")"]}),e.jsx("div",{className:"g7dt-mt-1 g7dt-space-y-1",children:y.alerts.map(N=>e.jsxs("div",{className:"g7dt-text-xs g7dt-pl-2",children:[e.jsx("span",{children:u(N.type)}),e.jsx("span",{className:`g7dt-ml-1 ${m(N.severity)}`,children:N.message})]},N.id))})]})]})]},b.executionId))})]}),e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"💡 변경 감지 가이드"}),e.jsxs("ul",{className:"g7dt-help-list",children:[e.jsx("li",{children:"핸들러 실행 후 상태 변경이 없으면 ⚠️ 경고가 표시됩니다"}),e.jsx("li",{children:"early return으로 종료된 경우 ⏎ 아이콘으로 표시됩니다"}),e.jsx("li",{children:"객체 변경 시 참조가 동일하면 🔄 경고가 발생합니다"}),e.jsx("li",{children:"문제 해결: exitLocation 확인 → 조건문 검토 → 불변성 확인"})]})]})]})},Ve=({devTools:d})=>{const[t,s]=x.useState(null),[a,g]=x.useState(null),[r,o]=x.useState("all"),p=x.useCallback(()=>{s(d.getStaleClosureTrackingInfo())},[d]);if(x.useEffect(()=>{p();const n=setInterval(p,2e3);return()=>clearInterval(n)},[p]),!t)return e.jsx("div",{className:"g7dt-empty-state",children:"로딩 중..."});const{warnings:v,stats:l}=t,f=v.filter(n=>r==="all"?!0:n.severity===r),c=a?v.find(n=>n.id===a):null,h=n=>{switch(n){case"error":return"❌";case"warning":return"⚠️";default:return"❓"}},y=n=>{switch(n){case"state-changed-during-async":return"⏳";case"captured-in-callback":return"📸";case"stale-in-handler":return"🔄";default:return"❓"}};return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"Stale Closure 통계"}),e.jsxs("div",{className:"g7dt-stats-grid-3",children:[e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-yellow",children:l.totalWarnings||0}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"총 경고"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-red",children:l.warningsBySeverity?.error||0}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"에러"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-orange",children:l.warningsBySeverity?.warning||0}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"경고"})]})]})]}),l.warningsByType&&Object.keys(l.warningsByType).length>0&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"타입별 분포"}),e.jsx("div",{className:"g7dt-space-y-2",children:Object.entries(l.warningsByType).map(([n,i])=>e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-text-xs",children:[e.jsxs("span",{className:"g7dt-text-muted",children:[y(n)," ",n]}),e.jsx("span",{className:"g7dt-text-white g7dt-font-mono",children:i})]},n))})]}),e.jsx("div",{className:"g7dt-card",children:e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted",children:"심각도:"}),e.jsxs("select",{value:r,onChange:n=>o(n.target.value),className:"g7dt-select",children:[e.jsx("option",{value:"all",children:"전체"}),e.jsx("option",{value:"error",children:"에러"}),e.jsx("option",{value:"warning",children:"경고"})]}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:[f.length,"개 표시"]})]})}),e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"Stale Closure 경고"}),f.length===0?e.jsx("div",{className:"g7dt-empty-state g7dt-text-green",children:"✅ Stale Closure 경고가 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:f.slice().reverse().map(n=>e.jsxs("div",{className:`g7dt-list-item g7dt-cursor-pointer ${a===n.id?"g7dt-selected":""}`,onClick:()=>g(a===n.id?null:n.id),children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:h(n.severity)}),e.jsx("span",{children:y(n.type)}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white g7dt-truncate",children:n.location}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:new Date(n.timestamp).toLocaleTimeString()})]}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-1 g7dt-truncate",children:n.statePath})]},n.id))})]}),c&&e.jsxs("div",{className:"g7dt-card g7dt-border-yellow",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:[h(c.severity)," 경고 상세"]}),e.jsxs("div",{className:"g7dt-space-y-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"위치"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white g7dt-mono",children:c.location})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"타입"}),e.jsxs("div",{className:"g7dt-text-sm g7dt-text-white",children:[y(c.type)," ",c.type]})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"상태 경로"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-yellow g7dt-mono",children:c.statePath})]}),e.jsxs("div",{className:"g7dt-grid-2 g7dt-gap-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"캡처된 값"}),e.jsx("pre",{className:"g7dt-code-block g7dt-text-red",children:JSON.stringify(c.capturedValue,null,2)})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"현재 값"}),e.jsx("pre",{className:"g7dt-code-block g7dt-text-green",children:JSON.stringify(c.currentValue,null,2)})]})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"시간 차이"}),e.jsxs("div",{className:"g7dt-text-sm g7dt-text-white",children:[c.timeDiff,"ms"]})]}),c.suggestion&&e.jsxs("div",{className:"g7dt-bg-blue-dark g7dt-p-2 g7dt-rounded",children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-blue",children:"💡 제안"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white g7dt-mt-1",children:c.suggestion})]})]})]})]})},Be=({devTools:d})=>{const[t,s]=x.useState(null),[a,g]=x.useState(null),[r,o]=x.useState("all"),p=x.useCallback(()=>{s(d.getNestedContextTrackingInfo())},[d]);if(x.useEffect(()=>{p();const n=setInterval(p,2e3);return()=>clearInterval(n)},[p]),!t)return e.jsx("div",{className:"g7dt-empty-state",children:"로딩 중..."});const{contexts:v,stats:l}=t,f=v.filter(n=>r==="all"?!0:n.componentType===r),c=a?v.find(n=>n.id===a):null,h=n=>{switch(n){case"expandChildren":return"📂";case"cellChildren":return"📊";case"iteration":return"🔄";case"modal":return"🪟";case"slot":return"🔲";default:return"❓"}},y=n=>"│".repeat(Math.max(0,n-1))+(n>0?"├─":"");return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"Nested Context 통계"}),e.jsxs("div",{className:"g7dt-stats-grid-3",children:[e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-blue",children:l.totalContexts||0}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"총 컨텍스트"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-purple",children:l.maxDepth||0}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"최대 깊이"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-red",children:l.failedAccessCount||0}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"접근 오류"})]})]})]}),l.byType&&Object.keys(l.byType).length>0&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"타입별 분포"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-3",children:Object.entries(l.byType).map(([n,i])=>e.jsxs("div",{className:`g7dt-badge g7dt-cursor-pointer ${r===n?"g7dt-badge-selected":""}`,onClick:()=>o(r===n?"all":n),children:[h(n)," ",n,": ",i]},n))})]}),e.jsx("div",{className:"g7dt-card",children:e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted",children:"타입:"}),e.jsxs("select",{value:r,onChange:n=>o(n.target.value),className:"g7dt-select",children:[e.jsx("option",{value:"all",children:"전체"}),e.jsx("option",{value:"expandChildren",children:"expandChildren"}),e.jsx("option",{value:"cellChildren",children:"cellChildren"}),e.jsx("option",{value:"iteration",children:"iteration"}),e.jsx("option",{value:"modal",children:"modal"}),e.jsx("option",{value:"slot",children:"slot"})]}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:[f.length,"개 표시"]})]})}),e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"컨텍스트 목록"}),f.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"추적된 Nested Context가 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:f.slice().reverse().map(n=>{const i=n.accessAttempts?.some(m=>!m.found),u=n.mergedContext?.all||[];return e.jsxs("div",{className:`g7dt-list-item g7dt-cursor-pointer ${a===n.id?"g7dt-selected":""} ${i?"g7dt-border-red":""}`,onClick:()=>g(a===n.id?null:n.id),children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:"g7dt-text-muted g7dt-mono g7dt-text-xs",children:y(n.depth)}),e.jsx("span",{children:h(n.componentType)}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white",children:n.componentId||n.componentType}),i&&e.jsx("span",{className:"g7dt-text-red g7dt-text-xs",children:"⚠️ 오류"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:["깊이: ",n.depth]})]}),e.jsxs("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-1",children:["변수: ",u.slice(0,5).join(", "),u.length>5&&"..."]})]},n.id)})})]}),c&&e.jsxs("div",{className:"g7dt-card g7dt-border-blue",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:[h(c.componentType)," 컨텍스트 상세"]}),e.jsxs("div",{className:"g7dt-space-y-3",children:[e.jsxs("div",{className:"g7dt-grid-2 g7dt-gap-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"타입"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white",children:c.componentType})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"깊이"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white",children:c.depth})]})]}),c.componentId&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"컴포넌트 ID"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-blue g7dt-mono",children:c.componentId})]}),c.parentId&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"부모 컨텍스트"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-purple g7dt-mono",children:c.parentId})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"사용 가능한 변수"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1",children:(c.mergedContext?.all||[]).map(n=>e.jsx("span",{className:"g7dt-badge g7dt-badge-blue g7dt-text-xs",children:n},n))})]}),c.ownContext?.added&&c.ownContext.added.length>0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"추가된 변수 (이 컨텍스트에서)"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1",children:c.ownContext.added.map(n=>e.jsxs("span",{className:"g7dt-badge g7dt-badge-green g7dt-text-xs",children:["+ ",n]},n))})]}),c.accessAttempts&&c.accessAttempts.length>0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"접근 시도"}),e.jsx("div",{className:"g7dt-space-y-1",children:c.accessAttempts.slice(0,10).map((n,i)=>e.jsxs("div",{className:`g7dt-flex g7dt-justify-between g7dt-text-xs ${n.found?"":"g7dt-text-red"}`,children:[e.jsx("span",{className:"g7dt-mono",children:n.path}),e.jsx("span",{children:n.found?"✓":"✕"})]},i))})]})]})]})]})},Ge=({devTools:d})=>{const[t,s]=x.useState(null),[a,g]=x.useState(null),[r,o]=x.useState(null),[p,v]=x.useState("modals"),l=x.useCallback(()=>{s(d.getModalStateScopeTrackingInfo())},[d]);if(x.useEffect(()=>{l();const N=setInterval(l,2e3);return()=>clearInterval(N)},[l]),!t)return e.jsx("div",{className:"g7dt-empty-state",children:"로딩 중..."});const{modals:f,issues:c,relations:h,changeLogs:y,stats:n}=t,i=a?f.find(N=>N.modalId===a):null,u=r?c.find(N=>N.id===r):null,m=N=>{switch(N){case"isolated":return"🔒";case"shared":return"🔓";case"inherited":return"📥";default:return"❓"}},k=N=>{switch(N){case"state-leakage":return"💧";case"isolation-violation":return"🔓";case"parent-mutation":return"👆";case"orphaned-state":return"👻";case"scope-mismatch":return"🔀";case"cleanup-failure":return"🧹";case"missing-definition":return"🚫";default:return"❓"}},b=N=>{switch(N){case"error":return"❌";case"warning":return"⚠️";default:return"❓"}};return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"모달 상태 스코프 통계"}),e.jsxs("div",{className:"g7dt-stats-grid-3",children:[e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-blue",children:n.totalModals}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"총 모달"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-green",children:n.openModals}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"열린 모달"})]}),e.jsxs("div",{className:"g7dt-text-center",children:[e.jsx("div",{className:"g7dt-text-2xl g7dt-font-bold g7dt-text-red",children:n.totalIssues}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"이슈"})]})]})]}),e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"스코프별 / 이슈별"}),e.jsxs("div",{className:"g7dt-grid-2 g7dt-gap-4",children:[e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"스코프 타입"}),e.jsx("div",{className:"g7dt-space-y-1",children:Object.entries(n.byScope).map(([N,C])=>e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-text-xs",children:[e.jsxs("span",{children:[m(N)," ",N]}),e.jsx("span",{className:"g7dt-font-mono",children:C})]},N))})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-2",children:"이슈 타입"}),e.jsxs("div",{className:"g7dt-space-y-1",children:[Object.entries(n.issuesByType).filter(([N,C])=>C>0).map(([N,C])=>e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-text-xs",children:[e.jsxs("span",{children:[k(N)," ",N]}),e.jsx("span",{className:"g7dt-font-mono g7dt-text-red",children:C})]},N)),Object.values(n.issuesByType).every(N=>N===0)&&e.jsx("div",{className:"g7dt-text-xs g7dt-text-green",children:"✅ 이슈 없음"})]})]})]})]}),e.jsx("div",{className:"g7dt-card",children:e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsxs("button",{className:`g7dt-tab-button ${p==="modals"?"g7dt-tab-active":""}`,onClick:()=>v("modals"),children:["🪟 모달 (",f.length,")"]}),e.jsxs("button",{className:`g7dt-tab-button ${p==="issues"?"g7dt-tab-active":""}`,onClick:()=>v("issues"),children:["⚠️ 이슈 (",c.length,")"]}),e.jsxs("button",{className:`g7dt-tab-button ${p==="logs"?"g7dt-tab-active":""}`,onClick:()=>v("logs"),children:["📝 로그 (",y.length,")"]})]})}),p==="modals"&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"모달 목록"}),f.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"추적된 모달이 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:f.map(N=>{const C=c.some(w=>w.modalId===N.modalId);return e.jsx("div",{className:`g7dt-list-item g7dt-cursor-pointer ${a===N.modalId?"g7dt-selected":""} ${C?"g7dt-border-red":""}`,onClick:()=>g(a===N.modalId?null:N.modalId),children:e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:N.closedAt===null?"📭":"📪"}),e.jsx("span",{children:m(N.scopeType)}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white",children:N.modalName}),C&&e.jsx("span",{className:"g7dt-text-red",children:"⚠️"}),e.jsxs("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:["변경: ",N.stateChangeCount]})]})},N.modalId)})})]}),p==="issues"&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"이슈 목록"}),c.length===0?e.jsx("div",{className:"g7dt-empty-state g7dt-text-green",children:"✅ 모달 상태 이슈가 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:c.slice().reverse().map(N=>e.jsxs("div",{className:`g7dt-list-item g7dt-cursor-pointer ${r===N.id?"g7dt-selected":""}`,onClick:()=>o(r===N.id?null:N.id),children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{children:b(N.severity)}),e.jsx("span",{children:k(N.type)}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-white",children:N.modalName}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:new Date(N.timestamp).toLocaleTimeString()})]}),e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mt-1 g7dt-truncate",children:N.description})]},N.id))})]}),p==="logs"&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"상태 변경 로그"}),y.length===0?e.jsx("div",{className:"g7dt-empty-state",children:"상태 변경 로그가 없습니다"}):e.jsx("div",{className:"g7dt-space-y-2 g7dt-max-h-60 g7dt-overflow-y-auto",children:y.slice().reverse().slice(0,50).map(N=>e.jsxs("div",{className:`g7dt-list-item ${N.violatesIsolation?"g7dt-border-red":""}`,children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2",children:[e.jsx("span",{className:"g7dt-text-xs g7dt-text-white",children:N.modalName}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-blue g7dt-mono",children:N.stateKey}),N.violatesIsolation&&e.jsx("span",{className:"g7dt-text-red",children:"⚠️ 격리 위반"}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted g7dt-ml-auto",children:new Date(N.timestamp).toLocaleTimeString()})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-text-xs g7dt-mt-1",children:[e.jsx("span",{className:"g7dt-text-red g7dt-truncate",style:{maxWidth:"45%"},children:JSON.stringify(N.previousValue)}),e.jsx("span",{className:"g7dt-text-muted",children:"→"}),e.jsx("span",{className:"g7dt-text-green g7dt-truncate",style:{maxWidth:"45%"},children:JSON.stringify(N.newValue)})]})]},N.id))})]}),i&&p==="modals"&&e.jsxs("div",{className:"g7dt-card g7dt-border-blue",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:[m(i.scopeType)," 모달 상세: ",i.modalName]}),e.jsxs("div",{className:"g7dt-space-y-3",children:[e.jsxs("div",{className:"g7dt-grid-2 g7dt-gap-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"스코프"}),e.jsxs("div",{className:"g7dt-text-sm g7dt-text-white",children:[m(i.scopeType)," ",i.scopeType]})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"상태"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white",children:i.closedAt===null?"📭 열림":"📪 닫힘"})]})]}),i.parentModalId&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"부모 모달"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-purple g7dt-mono",children:i.parentModalId})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"상태 변경 횟수"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white",children:i.stateChangeCount})]}),i.isolatedStateKeys.length>0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-1",children:"격리된 상태 키"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1",children:i.isolatedStateKeys.map(N=>e.jsxs("span",{className:"g7dt-badge g7dt-badge-red g7dt-text-xs",children:["🔒 ",N]},N))})]}),i.sharedStateKeys.length>0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted g7dt-mb-1",children:"공유된 상태 키"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1",children:i.sharedStateKeys.map(N=>e.jsxs("span",{className:"g7dt-badge g7dt-badge-blue g7dt-text-xs",children:["🔓 ",N]},N))})]})]})]}),u&&p==="issues"&&e.jsxs("div",{className:"g7dt-card g7dt-border-red",children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:[b(u.severity)," 이슈 상세"]}),e.jsxs("div",{className:"g7dt-space-y-3",children:[e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"타입"}),e.jsxs("div",{className:"g7dt-text-sm g7dt-text-white",children:[k(u.type)," ",u.type]})]}),e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"설명"}),e.jsx("div",{className:"g7dt-text-sm g7dt-text-white",children:u.description})]}),u.affectedStateKeys.length>0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"영향 받는 상태 키"}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-1 g7dt-mt-1",children:u.affectedStateKeys.map(N=>e.jsx("span",{className:"g7dt-badge g7dt-text-xs",children:N},N))})]}),u.leakedValue!==void 0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-xs g7dt-text-muted",children:"유출된 값"}),e.jsx("pre",{className:"g7dt-code-block g7dt-text-red",children:JSON.stringify(u.leakedValue,null,2)})]})]})]}),h.length>0&&e.jsxs("div",{className:"g7dt-card",children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-3",children:"모달 관계"}),e.jsx("div",{className:"g7dt-space-y-2",children:h.map((N,C)=>e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-gap-2 g7dt-text-xs",children:[e.jsx("span",{className:"g7dt-text-purple",children:N.parentModalId}),e.jsx("span",{className:"g7dt-text-muted",children:"→"}),e.jsx("span",{className:"g7dt-text-blue",children:N.childModalId}),e.jsx("span",{className:"g7dt-badge g7dt-text-xs g7dt-ml-auto",children:N.relationType})]},C))})]})]})},Je=({devTools:d})=>{const[t,s]=x.useState("definitions"),[a,g]=x.useState(null),[r,o]=x.useState(""),p=x.useCallback(()=>{d&&g(d.getNamedActionTrackingInfo())},[d]);if(x.useEffect(()=>{p();const n=setInterval(p,2e3);return()=>clearInterval(n)},[p]),!a)return e.jsx("div",{className:"g7dt-empty-message g7dt-py-4",children:"데이터 로딩 중..."});const{definitions:v,refLogs:l,stats:f}=a,c=Object.keys(v),h=c.filter(n=>!r||n.toLowerCase().includes(r.toLowerCase())||v[n]?.handler?.toLowerCase().includes(r.toLowerCase())),y=l.filter(n=>!r||n.actionRefName.toLowerCase().includes(r.toLowerCase())||n.resolvedHandler.toLowerCase().includes(r.toLowerCase()));return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-grid g7dt-grid-cols-3 g7dt-gap-2",children:[e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value",children:f.totalDefinitions}),e.jsx("div",{className:"g7dt-stat-label",children:"정의"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value",children:f.totalRefs}),e.jsx("div",{className:"g7dt-stat-label",children:"참조"})]}),e.jsxs("div",{className:"g7dt-stat-card",children:[e.jsx("div",{className:"g7dt-stat-value",style:{color:f.unusedDefinitions.length>0?"#f59e0b":"#10b981"},children:f.unusedDefinitions.length}),e.jsx("div",{className:"g7dt-stat-label",children:"미사용"})]})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-2",children:[e.jsxs("button",{onClick:()=>s("definitions"),className:t==="definitions"?"g7dt-filter-btn g7dt-filter-active":"g7dt-filter-btn",children:["정의 목록 (",c.length,")"]}),e.jsxs("button",{onClick:()=>s("refs"),className:t==="refs"?"g7dt-filter-btn g7dt-filter-active":"g7dt-filter-btn",children:["참조 이력 (",l.length,")"]}),e.jsx("button",{onClick:p,className:"g7dt-filter-btn",title:"새로고침",children:"↻"})]}),e.jsx("input",{type:"text",placeholder:t==="definitions"?"액션 이름 검색...":"actionRef 검색...",value:r,onChange:n=>o(n.target.value),className:"g7dt-input g7dt-w-full"}),t==="definitions"&&e.jsx("div",{className:"g7dt-space-y-1",children:h.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-py-4",children:c.length===0?"정의된 named_actions가 없습니다":"검색 결과 없음"}):h.map(n=>{const i=v[n],u=f.refCountByName[n]||0,m=f.unusedDefinitions.includes(n);return e.jsxs("div",{className:"g7dt-card",style:{borderLeft:m?"3px solid #f59e0b":"3px solid #10b981"},children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-font-bold",children:n}),e.jsxs("span",{className:"g7dt-text-muted g7dt-ml-2",children:["→ ",i.handler]})]}),e.jsxs("span",{className:"g7dt-badge",title:"참조 횟수",children:[u,"회"]})]}),i.params&&e.jsxs("details",{className:"g7dt-mt-1",children:[e.jsx("summary",{className:"g7dt-text-xs g7dt-text-muted g7dt-cursor-pointer",children:"params"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs g7dt-mt-1",style:{maxHeight:"200px",overflow:"auto"},children:JSON.stringify(i.params,null,2)})]}),m&&e.jsx("div",{className:"g7dt-text-xs",style:{color:"#f59e0b",marginTop:"4px"},children:"⚠ 미사용 정의"})]},n)})}),t==="refs"&&e.jsx("div",{className:"g7dt-space-y-1",children:y.length===0?e.jsx("div",{className:"g7dt-empty-message g7dt-py-4",children:l.length===0?"actionRef 해석 이력이 없습니다":"검색 결과 없음"}):[...y].reverse().map(n=>e.jsx("div",{className:"g7dt-card",children:e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-font-bold",children:n.actionRefName}),e.jsxs("span",{className:"g7dt-text-muted g7dt-ml-2",children:["→ ",n.resolvedHandler]})]}),e.jsx("span",{className:"g7dt-text-xs g7dt-text-muted",children:new Date(n.timestamp).toLocaleTimeString()})]})},n.id))})]})},ge=()=>{x.useEffect(()=>{ve()},[]);const d=x.useRef(oe()),[t,s]=x.useState(d.current.isOpen??!1),[a,g]=x.useState(!1),[r,o]=x.useState("state"),[p,v]=x.useState({_global:{},_local:{},_computed:{}}),[l,f]=x.useState([]),[c,h]=x.useState({hits:0,misses:0,entries:0,hitRate:0}),[y,n]=x.useState(!1),[i,u]=x.useState(d.current.position??{x:-1,y:-1}),[m,k]=x.useState(!1),b=x.useRef(null),N=x.useRef(null),[C,w]=x.useState(d.current.size??{width:Ne,height:ue}),[S,z]=x.useState(!1),[O,K]=x.useState(null),L=x.useRef(null),[V,he]=x.useState(!1),q=x.useRef(null),I=G.getInstance(),se=R();x.useEffect(()=>{if(i.x===-1&&i.y===-1){const T=window.innerWidth-C.width-16;u({x:T,y:16})}},[i,C.width]),x.useEffect(()=>{if(i.x===-1&&i.y===-1||m||S)return;const T=setTimeout(()=>{ie({position:i,size:C})},300);return()=>clearTimeout(T)},[i,C,m,S]),x.useEffect(()=>{ie({isOpen:t})},[t]);const Ke=x.useCallback(T=>{T.target.tagName!=="BUTTON"&&(k(!0),b.current={x:T.clientX,y:T.clientY,posX:i.x,posY:i.y},T.preventDefault())},[i]);x.useEffect(()=>{if(!m)return;const T=_=>{if(!b.current)return;const Y=_.clientX-b.current.x,X=_.clientY-b.current.y;let j=b.current.posX+Y,A=b.current.posY+X;const H=window.innerWidth-C.width,F=window.innerHeight-C.height;j=Math.max(0,Math.min(j,H)),A=Math.max(0,Math.min(A,F)),u({x:j,y:A})},D=()=>{k(!1),b.current=null};return document.addEventListener("mousemove",T),document.addEventListener("mouseup",D),()=>{document.removeEventListener("mousemove",T),document.removeEventListener("mouseup",D)}},[m,C.width,C.height]);const M=x.useCallback(T=>D=>{D.preventDefault(),D.stopPropagation(),z(!0),K(T),L.current={x:D.clientX,y:D.clientY,width:C.width,height:C.height,posX:i.x,posY:i.y}},[C,i]);x.useEffect(()=>{if(!S||!O)return;const T=_=>{if(!L.current)return;const Y=_.clientX-L.current.x,X=_.clientY-L.current.y;let j=L.current.width,A=L.current.height,H=L.current.posX,F=L.current.posY;if(O.includes("e")&&(j=Math.max(re,L.current.width+Y)),O.includes("w")){const B=Math.min(Y,L.current.width-re);j=L.current.width-B,H=L.current.posX+B}if(O.includes("s")&&(A=Math.max(ce,L.current.height+X)),O.includes("n")){const B=Math.min(X,L.current.height-ce);A=L.current.height-B,F=L.current.posY+B}j=Math.min(j,window.innerWidth-H),A=Math.min(A,window.innerHeight-F),H=Math.max(0,H),F=Math.max(0,F),w({width:j,height:A}),u({x:H,y:F})},D=()=>{z(!1),K(null),L.current=null};return document.addEventListener("mousemove",T),document.addEventListener("mouseup",D),()=>{document.removeEventListener("mousemove",T),document.removeEventListener("mouseup",D)}},[S,O]);const qe=x.useCallback(()=>{V?(q.current&&(u(q.current.position),w(q.current.size)),he(!1)):(q.current={position:i,size:C},u({x:0,y:0}),w({width:window.innerWidth,height:window.innerHeight}),he(!0))},[V,i,C]),U=x.useCallback(()=>{I.isEnabled()&&(v(I.getState()),f(I.getActionHistory()),h(I.getCacheStats()),n(se.isConnected()))},[I,se]);x.useEffect(()=>{const T=D=>{D.ctrlKey&&D.shiftKey&&D.key==="G"&&(D.preventDefault(),s(_=>!_))};return window.addEventListener("keydown",T),()=>window.removeEventListener("keydown",T)},[]),x.useEffect(()=>{if(!t||!I.isEnabled())return;U();const T=I.watchState("*",()=>{v(I.getState())}),D=I.watchActions(()=>{f(I.getActionHistory()),h(I.getCacheStats())}),_=setInterval(U,5e3);return()=>{T(),D(),clearInterval(_)}},[t,I,U]);const Ue=async()=>{try{const T=await se.dumpState();console.log("[G7DevTools] 상태 덤프 완료:",T),n(!0)}catch(T){console.error("[G7DevTools] 상태 덤프 실패:",T),n(!1)}};return I.isEnabled()?t?a?e.jsx("div",{className:"g7dt-minimized",children:e.jsxs("div",{className:"g7dt-minimized-content",children:[e.jsx("span",{className:"g7dt-font-bold g7dt-text-sm",children:"G7 DevTools"}),e.jsx("button",{onClick:()=>g(!1),className:"g7dt-btn g7dt-text-xs",title:"확장",children:"▲"}),e.jsx("button",{onClick:()=>s(!1),className:"g7dt-btn g7dt-text-xs",children:"✕"})]})}):e.jsxs("div",{ref:N,className:`g7dt-panel ${V?"maximized":""} ${S?"g7dt-resizing":""}`,style:{left:`${i.x>=0?i.x:0}px`,top:`${i.y>=0?i.y:0}px`,width:`${C.width}px`,height:`${C.height}px`},children:[!V&&e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-corner g7dt-resize-nw",onMouseDown:M("nw")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-corner g7dt-resize-ne",onMouseDown:M("ne")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-corner g7dt-resize-sw",onMouseDown:M("sw")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-corner g7dt-resize-se",onMouseDown:M("se")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-edge-h g7dt-resize-n",onMouseDown:M("n")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-edge-h g7dt-resize-s",onMouseDown:M("s")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-edge-v g7dt-resize-w",onMouseDown:M("w")}),e.jsx("div",{className:"g7dt-resize-handle g7dt-resize-edge-v g7dt-resize-e",onMouseDown:M("e")})]}),e.jsxs("div",{className:`g7dt-header ${m?"dragging":""}`,onMouseDown:Ke,children:[e.jsxs("div",{className:"g7dt-header-left",children:[e.jsx("span",{className:"g7dt-header-title",children:"G7 DevTools"}),e.jsx("span",{className:`g7dt-header-badge ${y?"connected":""}`,title:y?"서버 덤프 성공":"서버에 덤프되지 않음 (덤프 버튼 클릭)",children:y?"덤프 완료":"로컬 모드"})]}),e.jsxs("div",{className:"g7dt-header-right",children:[e.jsx("button",{onClick:Ue,className:"g7dt-btn g7dt-btn-primary",title:"서버로 상태 덤프",children:"덤프"}),e.jsx("button",{onClick:U,className:"g7dt-btn",title:"새로고침",children:"↻"}),e.jsx("button",{onClick:qe,className:"g7dt-btn",title:V?"복원":"최대화",children:V?"❐":"□"}),e.jsx("button",{onClick:()=>g(!0),className:"g7dt-btn",title:"최소화",children:"▼"}),e.jsx("button",{onClick:()=>s(!1),className:"g7dt-btn",children:"✕"})]})]}),e.jsx("div",{className:"g7dt-tabs",children:Object.keys(le).map(T=>e.jsx("button",{onClick:()=>o(T),className:`g7dt-tab ${r===T?"active":""}`,children:le[T]},T))}),e.jsxs("div",{className:"g7dt-content",children:[r==="state"&&e.jsx(ke,{state:p,devTools:I}),r==="actions"&&e.jsx(Ce,{actions:l,devTools:I}),r==="cache"&&e.jsx(Se,{stats:c,devTools:I}),r==="diagnose"&&e.jsx(Ie,{}),r==="performance"&&e.jsx(Te,{devTools:I}),r==="lifecycle"&&e.jsx(De,{devTools:I}),r==="network"&&e.jsx(Le,{devTools:I}),r==="form"&&e.jsx(Ee,{devTools:I}),r==="conditional"&&e.jsx($e,{devTools:I}),r==="expression"&&e.jsx(ze,{devTools:I}),r==="handlers"&&e.jsx(_e,{devTools:I}),r==="events"&&e.jsx(je,{devTools:I}),r==="datasources"&&e.jsx(Ae,{devTools:I}),r==="renders"&&e.jsx(Oe,{devTools:I}),r==="styles"&&e.jsx(Me,{devTools:I}),r==="auth"&&e.jsx(Pe,{devTools:I}),r==="logs"&&e.jsx(He,{devTools:I}),r==="layout"&&e.jsx(Fe,{devTools:I}),r==="changedetection"&&e.jsx(We,{devTools:I}),r==="staleclosure"&&e.jsx(Ve,{devTools:I}),r==="nestedcontext"&&e.jsx(Be,{devTools:I}),r==="modalscope"&&e.jsx(Ge,{devTools:I}),r==="namedactions"&&e.jsx(Je,{devTools:I})]}),e.jsxs("div",{className:"g7dt-footer",children:[e.jsx("span",{children:"Ctrl+Shift+G 토글 | 헤더 드래그로 이동"}),e.jsxs("span",{children:["액션: ",l.length," | 캐시 히트율: ",(c.hitRate*100).toFixed(1),"%"]})]})]}):e.jsx("button",{onClick:()=>s(!0),className:"g7dt-floating-btn",title:"G7 DevTools (Ctrl+Shift+G)",children:e.jsxs("svg",{xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:[e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"}),e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 12a3 3 0 11-6 0 3 3 0 016 0z"})]})}):t?e.jsxs("div",{className:"g7dt-disabled-notice",children:[e.jsxs("div",{className:"g7dt-flex g7dt-items-center g7dt-justify-between g7dt-mb-2",children:[e.jsx("span",{className:"g7dt-font-bold g7dt-text-yellow",children:"G7 DevTools"}),e.jsx("button",{onClick:()=>s(!1),className:"g7dt-btn",children:"✕"})]}),e.jsx("p",{className:"g7dt-text-sm g7dt-text-gray",children:"DevTools가 비활성화되어 있습니다."}),e.jsx("p",{className:"g7dt-text-xs g7dt-text-gray-dark g7dt-mt-2",children:"환경설정 > 고급 설정 > 디버그 모드를 켜세요."})]}):null};if(typeof window<"u"){const d=window.G7Core||(window.G7Core={});if(d.__devtools={DiagnosticEngine:Z,getServerConnector:R,getStyleTracker:ae,DevToolsPanel:ge},typeof d.__onDevToolsReady=="function")try{d.__onDevToolsReady()}catch{}}$.DevToolsPanel=ge,$.DiagnosticEngine=Z,$.getServerConnector=R,$.getStyleTracker=ae,Object.defineProperty($,Symbol.toStringTag,{value:"Module"})})(this.G7DevToolsBundle=this.G7DevToolsBundle||{},ReactJSXRuntime,React); -//# sourceMappingURL=devtools.min.js.map diff --git a/public/build/core/layout-editor.min.js b/public/build/core/layout-editor.min.js index afea6728..134f02b3 100644 --- a/public/build/core/layout-editor.min.js +++ b/public/build/core/layout-editor.min.js @@ -11,4 +11,3 @@ var iM=Object.defineProperty;var sM=(dn,i,u)=>i in dn?iM(dn,i,{enumerable:!0,con Press space again to drop the item in its new position, or press escape to cancel. `},z7={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was moved over droppable area "+n.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was dropped over droppable area "+n.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function M7(e){let{announcements:t=z7,container:n,hiddenTextDescribedById:o,screenReaderInstructions:r=N7}=e;const{announce:s,announcement:a}=A7(),l=ei("DndLiveRegion"),[d,c]=u.useState(!1);if(u.useEffect(()=>{c(!0)},[]),T7(u.useMemo(()=>({onDragStart(f){let{active:y}=f;s(t.onDragStart({active:y}))},onDragMove(f){let{active:y,over:g}=f;t.onDragMove&&s(t.onDragMove({active:y,over:g}))},onDragOver(f){let{active:y,over:g}=f;s(t.onDragOver({active:y,over:g}))},onDragEnd(f){let{active:y,over:g}=f;s(t.onDragEnd({active:y,over:g}))},onDragCancel(f){let{active:y,over:g}=f;s(t.onDragCancel({active:y,over:g}))}}),[s,t])),!d)return null;const p=u.createElement(u.Fragment,null,u.createElement(E7,{id:o,value:r.draggable}),u.createElement(I7,{id:l,announcement:a}));return n?Cn.createPortal(p,n):p}var it;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(it||(it={}));function oi(){}function L7(e,t){return u.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function P7(){for(var e=arguments.length,t=new Array(e),n=0;n[...t].filter(o=>o!=null),[...t])}const Ht=Object.freeze({x:0,y:0});function O7(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function B7(e,t){const n=ni(e);if(!n)return"0 0";const o={x:(n.x-t.left)/t.width*100,y:(n.y-t.top)/t.height*100};return o.x+"% "+o.y+"%"}function W7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return n-o}function j7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return o-n}function H7(e){let{left:t,top:n,height:o,width:r}=e;return[{x:t,y:n},{x:t+r,y:n},{x:t,y:n+o},{x:t+r,y:n+o}]}function F7(e,t){if(!e||e.length===0)return null;const[n]=e;return n[t]}function K7(e,t){const n=Math.max(t.top,e.top),o=Math.max(t.left,e.left),r=Math.min(t.left+t.width,e.left+e.width),s=Math.min(t.top+t.height,e.top+e.height),a=r-o,l=s-n;if(o{let{collisionRect:t,droppableRects:n,droppableContainers:o}=e;const r=[];for(const s of o){const{id:a}=s,l=n.get(a);if(l){const d=K7(l,t);d>0&&r.push({id:a,data:{droppableContainer:s,value:d}})}}return r.sort(j7)};function U7(e,t){const{top:n,left:o,bottom:r,right:s}=t;return n<=e.y&&e.y<=r&&o<=e.x&&e.x<=s}const q7=e=>{let{droppableContainers:t,droppableRects:n,pointerCoordinates:o}=e;if(!o)return[];const r=[];for(const s of t){const{id:a}=s,l=n.get(a);if(l&&U7(o,l)){const c=H7(l).reduce((f,y)=>f+O7(o,y),0),p=Number((c/4).toFixed(4));r.push({id:a,data:{droppableContainer:s,value:p}})}}return r.sort(W7)};function G7(e,t,n){return{...e,scaleX:t&&n?t.width/n.width:1,scaleY:t&&n?t.height/n.height:1}}function jp(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Ht}function Y7(e){return function(n){for(var o=arguments.length,r=new Array(o>1?o-1:0),s=1;s({...a,top:a.top+e*l.y,bottom:a.bottom+e*l.y,left:a.left+e*l.x,right:a.right+e*l.x}),{...n})}}const X7=Y7(1);function Hp(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function J7(e,t,n){const o=Hp(t);if(!o)return e;const{scaleX:r,scaleY:s,x:a,y:l}=o,d=e.left-a-(1-r)*parseFloat(n),c=e.top-l-(1-s)*parseFloat(n.slice(n.indexOf(" ")+1)),p=r?e.width/r:e.width,f=s?e.height/s:e.height;return{width:p,height:f,top:c,right:d+p,bottom:c+f,left:d}}const Z7={ignoreTransform:!1};function jo(e,t){t===void 0&&(t=Z7);let n=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:c,transformOrigin:p}=_t(e).getComputedStyle(e);c&&(n=J7(n,c,p))}const{top:o,left:r,width:s,height:a,bottom:l,right:d}=n;return{top:o,left:r,width:s,height:a,bottom:l,right:d}}function Fp(e){return jo(e,{ignoreTransform:!0})}function Q7(e){const t=e.innerWidth,n=e.innerHeight;return{top:0,left:0,right:t,bottom:n,width:t,height:n}}function R7(e,t){return t===void 0&&(t=_t(e).getComputedStyle(e)),t.position==="fixed"}function e2(e,t){t===void 0&&(t=_t(e).getComputedStyle(e));const n=/(auto|scroll|overlay)/;return["overflow","overflowX","overflowY"].some(r=>{const s=t[r];return typeof s=="string"?n.test(s):!1})}function da(e,t){const n=[];function o(r){if(t!=null&&n.length>=t||!r)return n;if(sa(r)&&r.scrollingElement!=null&&!n.includes(r.scrollingElement))return n.push(r.scrollingElement),n;if(!Po(r)||Pp(r)||n.includes(r))return n;const s=_t(e).getComputedStyle(r);return r!==e&&e2(r,s)&&n.push(r),R7(r,s)?n:o(r.parentNode)}return e?o(e):n}function Kp(e){const[t]=da(e,1);return t??null}function ca(e){return!Jr||!e?null:lo(e)?e:ia(e)?sa(e)||e===co(e).scrollingElement?window:Po(e)?e:null:null}function Vp(e){return lo(e)?e.scrollX:e.scrollLeft}function Up(e){return lo(e)?e.scrollY:e.scrollTop}function ua(e){return{x:Vp(e),y:Up(e)}}var lt;(function(e){e[e.Forward=1]="Forward",e[e.Backward=-1]="Backward"})(lt||(lt={}));function qp(e){return!Jr||!e?!1:e===document.scrollingElement}function Gp(e){const t={x:0,y:0},n=qp(e)?{height:window.innerHeight,width:window.innerWidth}:{height:e.clientHeight,width:e.clientWidth},o={x:e.scrollWidth-n.width,y:e.scrollHeight-n.height},r=e.scrollTop<=t.y,s=e.scrollLeft<=t.x,a=e.scrollTop>=o.y,l=e.scrollLeft>=o.x;return{isTop:r,isLeft:s,isBottom:a,isRight:l,maxScroll:o,minScroll:t}}const t2={x:.2,y:.2};function n2(e,t,n,o,r){let{top:s,left:a,right:l,bottom:d}=n;o===void 0&&(o=10),r===void 0&&(r=t2);const{isTop:c,isBottom:p,isLeft:f,isRight:y}=Gp(e),g={x:0,y:0},m={x:0,y:0},h={height:t.height*r.y,width:t.width*r.x};return!c&&s<=t.top+h.height?(g.y=lt.Backward,m.y=o*Math.abs((t.top+h.height-s)/h.height)):!p&&d>=t.bottom-h.height&&(g.y=lt.Forward,m.y=o*Math.abs((t.bottom-h.height-d)/h.height)),!y&&l>=t.right-h.width?(g.x=lt.Forward,m.x=o*Math.abs((t.right-h.width-l)/h.width)):!f&&a<=t.left+h.width&&(g.x=lt.Backward,m.x=o*Math.abs((t.left+h.width-a)/h.width)),{direction:g,speed:m}}function o2(e){if(e===document.scrollingElement){const{innerWidth:s,innerHeight:a}=window;return{top:0,left:0,right:s,bottom:a,width:s,height:a}}const{top:t,left:n,right:o,bottom:r}=e.getBoundingClientRect();return{top:t,left:n,right:o,bottom:r,width:e.clientWidth,height:e.clientHeight}}function Yp(e){return e.reduce((t,n)=>uo(t,ua(n)),Ht)}function r2(e){return e.reduce((t,n)=>t+Vp(n),0)}function i2(e){return e.reduce((t,n)=>t+Up(n),0)}function Xp(e,t){if(t===void 0&&(t=jo),!e)return;const{top:n,left:o,bottom:r,right:s}=t(e);Kp(e)&&(r<=0||s<=0||n>=window.innerHeight||o>=window.innerWidth)&&e.scrollIntoView({block:"center",inline:"center"})}const s2=[["x",["left","right"],r2],["y",["top","bottom"],i2]];class pa{constructor(t,n){this.rect=void 0,this.width=void 0,this.height=void 0,this.top=void 0,this.bottom=void 0,this.right=void 0,this.left=void 0;const o=da(n),r=Yp(o);this.rect={...t},this.width=t.width,this.height=t.height;for(const[s,a,l]of s2)for(const d of a)Object.defineProperty(this,d,{get:()=>{const c=l(o),p=r[s]-c;return this.rect[d]+p},enumerable:!0});Object.defineProperty(this,"rect",{enumerable:!1})}}class Ho{constructor(t){this.target=void 0,this.listeners=[],this.removeAll=()=>{this.listeners.forEach(n=>{var o;return(o=this.target)==null?void 0:o.removeEventListener(...n)})},this.target=t}add(t,n,o){var r;(r=this.target)==null||r.addEventListener(t,n,o),this.listeners.push([t,n,o])}}function a2(e){const{EventTarget:t}=_t(e);return e instanceof t?e:co(e)}function fa(e,t){const n=Math.abs(e.x),o=Math.abs(e.y);return typeof t=="number"?Math.sqrt(n**2+o**2)>t:"x"in t&&"y"in t?n>t.x&&o>t.y:"x"in t?n>t.x:"y"in t?o>t.y:!1}var At;(function(e){e.Click="click",e.DragStart="dragstart",e.Keydown="keydown",e.ContextMenu="contextmenu",e.Resize="resize",e.SelectionChange="selectionchange",e.VisibilityChange="visibilitychange"})(At||(At={}));function Jp(e){e.preventDefault()}function l2(e){e.stopPropagation()}var Ke;(function(e){e.Space="Space",e.Down="ArrowDown",e.Right="ArrowRight",e.Left="ArrowLeft",e.Up="ArrowUp",e.Esc="Escape",e.Enter="Enter",e.Tab="Tab"})(Ke||(Ke={}));const Zp={start:[Ke.Space,Ke.Enter],cancel:[Ke.Esc],end:[Ke.Space,Ke.Enter,Ke.Tab]},d2=(e,t)=>{let{currentCoordinates:n}=t;switch(e.code){case Ke.Right:return{...n,x:n.x+25};case Ke.Left:return{...n,x:n.x-25};case Ke.Down:return{...n,y:n.y+25};case Ke.Up:return{...n,y:n.y-25}}};class Qp{constructor(t){this.props=void 0,this.autoScrollEnabled=!1,this.referenceCoordinates=void 0,this.listeners=void 0,this.windowListeners=void 0,this.props=t;const{event:{target:n}}=t;this.props=t,this.listeners=new Ho(co(n)),this.windowListeners=new Ho(_t(n)),this.handleKeyDown=this.handleKeyDown.bind(this),this.handleCancel=this.handleCancel.bind(this),this.attach()}attach(){this.handleStart(),this.windowListeners.add(At.Resize,this.handleCancel),this.windowListeners.add(At.VisibilityChange,this.handleCancel),setTimeout(()=>this.listeners.add(At.Keydown,this.handleKeyDown))}handleStart(){const{activeNode:t,onStart:n}=this.props,o=t.node.current;o&&Xp(o),n(Ht)}handleKeyDown(t){if(la(t)){const{active:n,context:o,options:r}=this.props,{keyboardCodes:s=Zp,coordinateGetter:a=d2,scrollBehavior:l="smooth"}=r,{code:d}=t;if(s.end.includes(d)){this.handleEnd(t);return}if(s.cancel.includes(d)){this.handleCancel(t);return}const{collisionRect:c}=o.current,p=c?{x:c.left,y:c.top}:Ht;this.referenceCoordinates||(this.referenceCoordinates=p);const f=a(t,{active:n,context:o.current,currentCoordinates:p});if(f){const y=ti(f,p),g={x:0,y:0},{scrollableAncestors:m}=o.current;for(const h of m){const v=t.code,{isTop:b,isRight:x,isLeft:k,isBottom:w,maxScroll:_,minScroll:S}=Gp(h),E=o2(h),C={x:Math.min(v===Ke.Right?E.right-E.width/2:E.right,Math.max(v===Ke.Right?E.left:E.left+E.width/2,f.x)),y:Math.min(v===Ke.Down?E.bottom-E.height/2:E.bottom,Math.max(v===Ke.Down?E.top:E.top+E.height/2,f.y))},N=v===Ke.Right&&!x||v===Ke.Left&&!k,$=v===Ke.Down&&!w||v===Ke.Up&&!b;if(N&&C.x!==f.x){const A=h.scrollLeft+y.x,z=v===Ke.Right&&A<=_.x||v===Ke.Left&&A>=S.x;if(z&&!y.y){h.scrollTo({left:A,behavior:l});return}z?g.x=h.scrollLeft-A:g.x=v===Ke.Right?h.scrollLeft-_.x:h.scrollLeft-S.x,g.x&&h.scrollBy({left:-g.x,behavior:l});break}else if($&&C.y!==f.y){const A=h.scrollTop+y.y,z=v===Ke.Down&&A<=_.y||v===Ke.Up&&A>=S.y;if(z&&!y.x){h.scrollTo({top:A,behavior:l});return}z?g.y=h.scrollTop-A:g.y=v===Ke.Down?h.scrollTop-_.y:h.scrollTop-S.y,g.y&&h.scrollBy({top:-g.y,behavior:l});break}}this.handleMove(t,uo(ti(f,this.referenceCoordinates),g))}}}handleMove(t,n){const{onMove:o}=this.props;t.preventDefault(),o(n)}handleEnd(t){const{onEnd:n}=this.props;t.preventDefault(),this.detach(),n()}handleCancel(t){const{onCancel:n}=this.props;t.preventDefault(),this.detach(),n()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll()}}Qp.activators=[{eventName:"onKeyDown",handler:(e,t,n)=>{let{keyboardCodes:o=Zp,onActivation:r}=t,{active:s}=n;const{code:a}=e.nativeEvent;if(o.start.includes(a)){const l=s.activatorNode.current;return l&&e.target!==l?!1:(e.preventDefault(),r?.({event:e.nativeEvent}),!0)}return!1}}];function Rp(e){return!!(e&&"distance"in e)}function ef(e){return!!(e&&"delay"in e)}class ga{constructor(t,n,o){var r;o===void 0&&(o=a2(t.event.target)),this.props=void 0,this.events=void 0,this.autoScrollEnabled=!0,this.document=void 0,this.activated=!1,this.initialCoordinates=void 0,this.timeoutId=null,this.listeners=void 0,this.documentListeners=void 0,this.windowListeners=void 0,this.props=t,this.events=n;const{event:s}=t,{target:a}=s;this.props=t,this.events=n,this.document=co(a),this.documentListeners=new Ho(this.document),this.listeners=new Ho(o),this.windowListeners=new Ho(_t(a)),this.initialCoordinates=(r=ni(s))!=null?r:Ht,this.handleStart=this.handleStart.bind(this),this.handleMove=this.handleMove.bind(this),this.handleEnd=this.handleEnd.bind(this),this.handleCancel=this.handleCancel.bind(this),this.handleKeydown=this.handleKeydown.bind(this),this.removeTextSelection=this.removeTextSelection.bind(this),this.attach()}attach(){const{events:t,props:{options:{activationConstraint:n,bypassActivationConstraint:o}}}=this;if(this.listeners.add(t.move.name,this.handleMove,{passive:!1}),this.listeners.add(t.end.name,this.handleEnd),t.cancel&&this.listeners.add(t.cancel.name,this.handleCancel),this.windowListeners.add(At.Resize,this.handleCancel),this.windowListeners.add(At.DragStart,Jp),this.windowListeners.add(At.VisibilityChange,this.handleCancel),this.windowListeners.add(At.ContextMenu,Jp),this.documentListeners.add(At.Keydown,this.handleKeydown),n){if(o!=null&&o({event:this.props.event,activeNode:this.props.activeNode,options:this.props.options}))return this.handleStart();if(ef(n)){this.timeoutId=setTimeout(this.handleStart,n.delay),this.handlePending(n);return}if(Rp(n)){this.handlePending(n);return}}this.handleStart()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll(),setTimeout(this.documentListeners.removeAll,50),this.timeoutId!==null&&(clearTimeout(this.timeoutId),this.timeoutId=null)}handlePending(t,n){const{active:o,onPending:r}=this.props;r(o,t,this.initialCoordinates,n)}handleStart(){const{initialCoordinates:t}=this,{onStart:n}=this.props;t&&(this.activated=!0,this.documentListeners.add(At.Click,l2,{capture:!0}),this.removeTextSelection(),this.documentListeners.add(At.SelectionChange,this.removeTextSelection),n(t))}handleMove(t){var n;const{activated:o,initialCoordinates:r,props:s}=this,{onMove:a,options:{activationConstraint:l}}=s;if(!r)return;const d=(n=ni(t))!=null?n:Ht,c=ti(r,d);if(!o&&l){if(Rp(l)){if(l.tolerance!=null&&fa(c,l.tolerance))return this.handleCancel();if(fa(c,l.distance))return this.handleStart()}if(ef(l)&&fa(c,l.tolerance))return this.handleCancel();this.handlePending(l,c);return}t.cancelable&&t.preventDefault(),a(d)}handleEnd(){const{onAbort:t,onEnd:n}=this.props;this.detach(),this.activated||t(this.props.active),n()}handleCancel(){const{onAbort:t,onCancel:n}=this.props;this.detach(),this.activated||t(this.props.active),n()}handleKeydown(t){t.code===Ke.Esc&&this.handleCancel()}removeTextSelection(){var t;(t=this.document.getSelection())==null||t.removeAllRanges()}}const c2={cancel:{name:"pointercancel"},move:{name:"pointermove"},end:{name:"pointerup"}};class ha extends ga{constructor(t){const{event:n}=t,o=co(n.target);super(t,c2,o)}}ha.activators=[{eventName:"onPointerDown",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;return!n.isPrimary||n.button!==0?!1:(o?.({event:n}),!0)}}];const u2={move:{name:"mousemove"},end:{name:"mouseup"}};var ya;(function(e){e[e.RightClick=2]="RightClick"})(ya||(ya={}));class p2 extends ga{constructor(t){super(t,u2,co(t.event.target))}}p2.activators=[{eventName:"onMouseDown",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;return n.button===ya.RightClick?!1:(o?.({event:n}),!0)}}];const ba={cancel:{name:"touchcancel"},move:{name:"touchmove"},end:{name:"touchend"}};class f2 extends ga{constructor(t){super(t,ba)}static setup(){return window.addEventListener(ba.move.name,t,{capture:!1,passive:!1}),function(){window.removeEventListener(ba.move.name,t)};function t(){}}}f2.activators=[{eventName:"onTouchStart",handler:(e,t)=>{let{nativeEvent:n}=e,{onActivation:o}=t;const{touches:r}=n;return r.length>1?!1:(o?.({event:n}),!0)}}];var Fo;(function(e){e[e.Pointer=0]="Pointer",e[e.DraggableRect=1]="DraggableRect"})(Fo||(Fo={}));var ri;(function(e){e[e.TreeOrder=0]="TreeOrder",e[e.ReversedTreeOrder=1]="ReversedTreeOrder"})(ri||(ri={}));function g2(e){let{acceleration:t,activator:n=Fo.Pointer,canScroll:o,draggingRect:r,enabled:s,interval:a=5,order:l=ri.TreeOrder,pointerCoordinates:d,scrollableAncestors:c,scrollableAncestorRects:p,delta:f,threshold:y}=e;const g=y2({delta:f,disabled:!s}),[m,h]=w7(),v=u.useRef({x:0,y:0}),b=u.useRef({x:0,y:0}),x=u.useMemo(()=>{switch(n){case Fo.Pointer:return d?{top:d.y,bottom:d.y,left:d.x,right:d.x}:null;case Fo.DraggableRect:return r}},[n,r,d]),k=u.useRef(null),w=u.useCallback(()=>{const S=k.current;if(!S)return;const E=v.current.x*b.current.x,C=v.current.y*b.current.y;S.scrollBy(E,C)},[]),_=u.useMemo(()=>l===ri.TreeOrder?[...c].reverse():c,[l,c]);u.useEffect(()=>{if(!s||!c.length||!x){h();return}for(const S of _){if(o?.(S)===!1)continue;const E=c.indexOf(S),C=p[E];if(!C)continue;const{direction:N,speed:$}=n2(S,C,x,t,y);for(const A of["x","y"])g[A][N[A]]||($[A]=0,N[A]=0);if($.x>0||$.y>0){h(),k.current=S,m(w,a),v.current=$,b.current=N;return}}v.current={x:0,y:0},b.current={x:0,y:0},h()},[t,w,o,h,s,a,JSON.stringify(x),JSON.stringify(g),m,c,_,p,JSON.stringify(y)])}const h2={x:{[lt.Backward]:!1,[lt.Forward]:!1},y:{[lt.Backward]:!1,[lt.Forward]:!1}};function y2(e){let{delta:t,disabled:n}=e;const o=Rr(t);return Bo(r=>{if(n||!o||!r)return h2;const s={x:Math.sign(t.x-o.x),y:Math.sign(t.y-o.y)};return{x:{[lt.Backward]:r.x[lt.Backward]||s.x===-1,[lt.Forward]:r.x[lt.Forward]||s.x===1},y:{[lt.Backward]:r.y[lt.Backward]||s.y===-1,[lt.Forward]:r.y[lt.Forward]||s.y===1}}},[n,t,o])}function b2(e,t){const n=t!=null?e.get(t):void 0,o=n?n.node.current:null;return Bo(r=>{var s;return t==null?null:(s=o??r)!=null?s:null},[o,t])}function m2(e,t){return u.useMemo(()=>e.reduce((n,o)=>{const{sensor:r}=o,s=r.activators.map(a=>({eventName:a.eventName,handler:t(a.handler,o)}));return[...n,...s]},[]),[e,t])}var Ko;(function(e){e[e.Always=0]="Always",e[e.BeforeDragging=1]="BeforeDragging",e[e.WhileDragging=2]="WhileDragging"})(Ko||(Ko={}));var ma;(function(e){e.Optimized="optimized"})(ma||(ma={}));const tf=new Map;function v2(e,t){let{dragging:n,dependencies:o,config:r}=t;const[s,a]=u.useState(null),{frequency:l,measure:d,strategy:c}=r,p=u.useRef(e),f=v(),y=Oo(f),g=u.useCallback(function(b){b===void 0&&(b=[]),!y.current&&a(x=>x===null?b:x.concat(b.filter(k=>!x.includes(k))))},[y]),m=u.useRef(null),h=Bo(b=>{if(f&&!n)return tf;if(!b||b===tf||p.current!==e||s!=null){const x=new Map;for(let k of e){if(!k)continue;if(s&&s.length>0&&!s.includes(k.id)&&k.rect.current){x.set(k.id,k.rect.current);continue}const w=k.node.current,_=w?new pa(d(w),w):null;k.rect.current=_,_&&x.set(k.id,_)}return x}return b},[e,s,n,f,d]);return u.useEffect(()=>{p.current=e},[e]),u.useEffect(()=>{f||g()},[n,f]),u.useEffect(()=>{s&&s.length>0&&a(null)},[JSON.stringify(s)]),u.useEffect(()=>{f||typeof l!="number"||m.current!==null||(m.current=setTimeout(()=>{g(),m.current=null},l))},[l,f,g,...o]),{droppableRects:h,measureDroppableContainers:g,measuringScheduled:s!=null};function v(){switch(c){case Ko.Always:return!1;case Ko.BeforeDragging:return n;default:return!n}}}function va(e,t){return Bo(n=>e?n||(typeof t=="function"?t(e):e):null,[t,e])}function _2(e,t){return va(e,t)}function x2(e){let{callback:t,disabled:n}=e;const o=Zr(t),r=u.useMemo(()=>{if(n||typeof window>"u"||typeof window.MutationObserver>"u")return;const{MutationObserver:s}=window;return new s(o)},[o,n]);return u.useEffect(()=>()=>r?.disconnect(),[r]),r}function ii(e){let{callback:t,disabled:n}=e;const o=Zr(t),r=u.useMemo(()=>{if(n||typeof window>"u"||typeof window.ResizeObserver>"u")return;const{ResizeObserver:s}=window;return new s(o)},[n]);return u.useEffect(()=>()=>r?.disconnect(),[r]),r}function w2(e){return new pa(jo(e),e)}function nf(e,t,n){t===void 0&&(t=w2);const[o,r]=u.useState(null);function s(){r(d=>{if(!e)return null;if(e.isConnected===!1){var c;return(c=d??n)!=null?c:null}const p=t(e);return JSON.stringify(d)===JSON.stringify(p)?d:p})}const a=x2({callback(d){if(e)for(const c of d){const{type:p,target:f}=c;if(p==="childList"&&f instanceof HTMLElement&&f.contains(e)){s();break}}}}),l=ii({callback:s});return sn(()=>{s(),e?(l?.observe(e),a?.observe(document.body,{childList:!0,subtree:!0})):(l?.disconnect(),a?.disconnect())},[e]),o}function k2(e){const t=va(e);return jp(e,t)}const of=[];function S2(e){const t=u.useRef(e),n=Bo(o=>e?o&&o!==of&&e&&t.current&&e.parentNode===t.current.parentNode?o:da(e):of,[e]);return u.useEffect(()=>{t.current=e},[e]),n}function C2(e){const[t,n]=u.useState(null),o=u.useRef(e),r=u.useCallback(s=>{const a=ca(s.target);a&&n(l=>l?(l.set(a,ua(a)),new Map(l)):null)},[]);return u.useEffect(()=>{const s=o.current;if(e!==s){a(s);const l=e.map(d=>{const c=ca(d);return c?(c.addEventListener("scroll",r,{passive:!0}),[c,ua(c)]):null}).filter(d=>d!=null);n(l.length?new Map(l):null),o.current=e}return()=>{a(e),a(s)};function a(l){l.forEach(d=>{const c=ca(d);c?.removeEventListener("scroll",r)})}},[r,e]),u.useMemo(()=>e.length?t?Array.from(t.values()).reduce((s,a)=>uo(s,a),Ht):Yp(e):Ht,[e,t])}function rf(e,t){t===void 0&&(t=[]);const n=u.useRef(null);return u.useEffect(()=>{n.current=null},t),u.useEffect(()=>{const o=e!==Ht;o&&!n.current&&(n.current=e),!o&&n.current&&(n.current=null)},[e]),n.current?ti(e,n.current):Ht}function $2(e){u.useEffect(()=>{if(!Jr)return;const t=e.map(n=>{let{sensor:o}=n;return o.setup==null?void 0:o.setup()});return()=>{for(const n of t)n?.()}},e.map(t=>{let{sensor:n}=t;return n}))}function E2(e,t){return u.useMemo(()=>e.reduce((n,o)=>{let{eventName:r,handler:s}=o;return n[r]=a=>{s(a,t)},n},{}),[e,t])}function sf(e){return u.useMemo(()=>e?Q7(e):null,[e])}const af=[];function I2(e,t){t===void 0&&(t=jo);const[n]=e,o=sf(n?_t(n):null),[r,s]=u.useState(af);function a(){s(()=>e.length?e.map(d=>qp(d)?o:new pa(t(d),d)):af)}const l=ii({callback:a});return sn(()=>{l?.disconnect(),a(),e.forEach(d=>l?.observe(d))},[e]),r}function lf(e){if(!e)return null;if(e.children.length>1)return e;const t=e.children[0];return Po(t)?t:e}function A2(e){let{measure:t}=e;const[n,o]=u.useState(null),r=u.useCallback(c=>{for(const{target:p}of c)if(Po(p)){o(f=>{const y=t(p);return f?{...f,width:y.width,height:y.height}:y});break}},[t]),s=ii({callback:r}),a=u.useCallback(c=>{const p=lf(c);s?.disconnect(),p&&s?.observe(p),o(p?t(p):null)},[t,s]),[l,d]=Qr(a);return u.useMemo(()=>({nodeRef:l,rect:n,setRef:d}),[n,l,d])}const T2=[{sensor:ha,options:{}},{sensor:Qp,options:{}}],D2={current:{}},si={draggable:{measure:Fp},droppable:{measure:Fp,strategy:Ko.WhileDragging,frequency:ma.Optimized},dragOverlay:{measure:jo}};class Vo extends Map{get(t){var n;return t!=null&&(n=super.get(t))!=null?n:void 0}toArray(){return Array.from(this.values())}getEnabled(){return this.toArray().filter(t=>{let{disabled:n}=t;return!n})}getNodeFor(t){var n,o;return(n=(o=this.get(t))==null?void 0:o.node.current)!=null?n:void 0}}const N2={activatorEvent:null,active:null,activeNode:null,activeNodeRect:null,collisions:null,containerNodeRect:null,draggableNodes:new Map,droppableRects:new Map,droppableContainers:new Vo,over:null,dragOverlay:{nodeRef:{current:null},rect:null,setRef:oi},scrollableAncestors:[],scrollableAncestorRects:[],measuringConfiguration:si,measureDroppableContainers:oi,windowRect:null,measuringScheduled:!1},df={activatorEvent:null,activators:[],active:null,activeNodeRect:null,ariaDescribedById:{draggable:""},dispatch:oi,draggableNodes:new Map,over:null,measureDroppableContainers:oi},Uo=u.createContext(df),cf=u.createContext(N2);function z2(){return{draggable:{active:null,initialCoordinates:{x:0,y:0},nodes:new Map,translate:{x:0,y:0}},droppable:{containers:new Vo}}}function M2(e,t){switch(t.type){case it.DragStart:return{...e,draggable:{...e.draggable,initialCoordinates:t.initialCoordinates,active:t.active}};case it.DragMove:return e.draggable.active==null?e:{...e,draggable:{...e.draggable,translate:{x:t.coordinates.x-e.draggable.initialCoordinates.x,y:t.coordinates.y-e.draggable.initialCoordinates.y}}};case it.DragEnd:case it.DragCancel:return{...e,draggable:{...e.draggable,active:null,initialCoordinates:{x:0,y:0},translate:{x:0,y:0}}};case it.RegisterDroppable:{const{element:n}=t,{id:o}=n,r=new Vo(e.droppable.containers);return r.set(o,n),{...e,droppable:{...e.droppable,containers:r}}}case it.SetDroppableDisabled:{const{id:n,key:o,disabled:r}=t,s=e.droppable.containers.get(n);if(!s||o!==s.key)return e;const a=new Vo(e.droppable.containers);return a.set(n,{...s,disabled:r}),{...e,droppable:{...e.droppable,containers:a}}}case it.UnregisterDroppable:{const{id:n,key:o}=t,r=e.droppable.containers.get(n);if(!r||o!==r.key)return e;const s=new Vo(e.droppable.containers);return s.delete(n),{...e,droppable:{...e.droppable,containers:s}}}default:return e}}function L2(e){let{disabled:t}=e;const{active:n,activatorEvent:o,draggableNodes:r}=u.useContext(Uo),s=Rr(o),a=Rr(n?.id);return u.useEffect(()=>{if(!t&&!o&&s&&a!=null){if(!la(s)||document.activeElement===s.target)return;const l=r.get(a);if(!l)return;const{activatorNode:d,node:c}=l;if(!d.current&&!c.current)return;requestAnimationFrame(()=>{for(const p of[d.current,c.current]){if(!p)continue;const f=C7(p);if(f){f.focus();break}}})}},[o,t,r,a,s]),null}function uf(e,t){let{transform:n,...o}=t;return e!=null&&e.length?e.reduce((r,s)=>s({transform:r,...o}),n):n}function P2(e){return u.useMemo(()=>({draggable:{...si.draggable,...e?.draggable},droppable:{...si.droppable,...e?.droppable},dragOverlay:{...si.dragOverlay,...e?.dragOverlay}}),[e?.draggable,e?.droppable,e?.dragOverlay])}function O2(e){let{activeNode:t,measure:n,initialRect:o,config:r=!0}=e;const s=u.useRef(!1),{x:a,y:l}=typeof r=="boolean"?{x:r,y:r}:r;sn(()=>{if(!a&&!l||!t){s.current=!1;return}if(s.current||!o)return;const c=t?.node.current;if(!c||c.isConnected===!1)return;const p=n(c),f=jp(p,o);if(a||(f.x=0),l||(f.y=0),s.current=!0,Math.abs(f.x)>0||Math.abs(f.y)>0){const y=Kp(c);y&&y.scrollBy({top:f.y,left:f.x})}},[t,a,l,o,n])}const ai=u.createContext({...Ht,scaleX:1,scaleY:1});var hn;(function(e){e[e.Uninitialized=0]="Uninitialized",e[e.Initializing=1]="Initializing",e[e.Initialized=2]="Initialized"})(hn||(hn={}));const B2=u.memo(function(t){var n,o,r,s;let{id:a,accessibility:l,autoScroll:d=!0,children:c,sensors:p=T2,collisionDetection:f=V7,measuring:y,modifiers:g,...m}=t;const h=u.useReducer(M2,void 0,z2),[v,b]=h,[x,k]=D7(),[w,_]=u.useState(hn.Uninitialized),S=w===hn.Initialized,{draggable:{active:E,nodes:C,translate:N},droppable:{containers:$}}=v,A=E!=null?C.get(E):null,z=u.useRef({initial:null,translated:null}),D=u.useMemo(()=>{var J;return E!=null?{id:E,data:(J=A?.data)!=null?J:D2,rect:z}:null},[E,A]),O=u.useRef(null),[M,B]=u.useState(null),[T,H]=u.useState(null),L=Oo(m,Object.values(m)),I=ei("DndDescribedBy",a),P=u.useMemo(()=>$.getEnabled(),[$]),F=P2(y),{droppableRects:G,measureDroppableContainers:V,measuringScheduled:Y}=v2(P,{dragging:S,dependencies:[N.x,N.y],config:F.droppable}),Z=b2(C,E),se=u.useMemo(()=>T?ni(T):null,[T]),re=ue(),U=_2(Z,F.draggable.measure);O2({activeNode:E!=null?C.get(E):null,config:re.layoutShiftCompensation,initialRect:U,measure:F.draggable.measure});const ee=nf(Z,F.draggable.measure,U),fe=nf(Z?Z.parentElement:null),K=u.useRef({activatorEvent:null,active:null,activeNode:Z,collisionRect:null,collisions:null,droppableRects:G,draggableNodes:C,draggingNode:null,draggingNodeRect:null,droppableContainers:$,over:null,scrollableAncestors:[],scrollAdjustedTranslate:null}),ne=$.getNodeFor((n=K.current.over)==null?void 0:n.id),me=A2({measure:F.dragOverlay.measure}),Ce=(o=me.nodeRef.current)!=null?o:Z,ze=S?(r=me.rect)!=null?r:ee:null,Se=!!(me.nodeRef.current&&me.rect),qe=k2(Se?null:ee),De=sf(Ce?_t(Ce):null),Pe=S2(S?ne??Z:null),Qe=I2(Pe),Me=uf(g,{transform:{x:N.x-qe.x,y:N.y-qe.y,scaleX:1,scaleY:1},activatorEvent:T,active:D,activeNodeRect:ee,containerNodeRect:fe,draggingNodeRect:ze,over:K.current.over,overlayNodeRect:me.rect,scrollableAncestors:Pe,scrollableAncestorRects:Qe,windowRect:De}),Re=se?uo(se,N):null,ve=C2(Pe),te=rf(ve),he=rf(ve,[ee]),Q=uo(Me,te),ie=ze?X7(ze,Me):null,_e=D&&ie?f({active:D,collisionRect:ie,droppableRects:G,droppableContainers:P,pointerCoordinates:Re}):null,ge=F7(_e,"id"),[Ae,Ne]=u.useState(null),Le=Se?Me:uo(Me,he),q=G7(Le,(s=Ae?.rect)!=null?s:null,ee),ae=u.useRef(null),pe=u.useCallback((J,le)=>{let{sensor:Ie,options:dt}=le;if(O.current==null)return;const pt=C.get(O.current);if(!pt)return;const ot=J.nativeEvent,Oe=new Ie({active:O.current,activeNode:pt,event:ot,options:dt,context:K,onAbort(Ye){if(!C.get(Ye))return;const{onDragAbort:Dt}=L.current,Nt={id:Ye};Dt?.(Nt),x({type:"onDragAbort",event:Nt})},onPending(Ye,Tt,Dt,Nt){if(!C.get(Ye))return;const{onDragPending:wn}=L.current,Zt={id:Ye,constraint:Tt,initialCoordinates:Dt,offset:Nt};wn?.(Zt),x({type:"onDragPending",event:Zt})},onStart(Ye){const Tt=O.current;if(Tt==null)return;const Dt=C.get(Tt);if(!Dt)return;const{onDragStart:Nt}=L.current,xn={activatorEvent:ot,active:{id:Tt,data:Dt.data,rect:z}};Cn.unstable_batchedUpdates(()=>{Nt?.(xn),_(hn.Initializing),b({type:it.DragStart,initialCoordinates:Ye,active:Tt}),x({type:"onDragStart",event:xn}),B(ae.current),H(ot)})},onMove(Ye){b({type:it.DragMove,coordinates:Ye})},onEnd:He(it.DragEnd),onCancel:He(it.DragCancel)});ae.current=Oe;function He(Ye){return async function(){const{active:Dt,collisions:Nt,over:xn,scrollAdjustedTranslate:wn}=K.current;let Zt=null;if(Dt&&wn){const{cancelDrop:Hn}=L.current;Zt={activatorEvent:ot,active:Dt,collisions:Nt,delta:wn,over:xn},Ye===it.DragEnd&&typeof Hn=="function"&&await Promise.resolve(Hn(Zt))&&(Ye=it.DragCancel)}O.current=null,Cn.unstable_batchedUpdates(()=>{b({type:Ye}),_(hn.Uninitialized),Ne(null),B(null),H(null),ae.current=null;const Hn=Ye===it.DragEnd?"onDragEnd":"onDragCancel";if(Zt){const ln=L.current[Hn];ln?.(Zt),x({type:Hn,event:Zt})}})}}},[C]),$e=u.useCallback((J,le)=>(Ie,dt)=>{const pt=Ie.nativeEvent,ot=C.get(dt);if(O.current!==null||!ot||pt.dndKit||pt.defaultPrevented)return;const Oe={active:ot};J(Ie,le.options,Oe)===!0&&(pt.dndKit={capturedBy:le.sensor},O.current=dt,pe(Ie,le))},[C,pe]),be=m2(p,$e);$2(p),sn(()=>{ee&&w===hn.Initializing&&_(hn.Initialized)},[ee,w]),u.useEffect(()=>{const{onDragMove:J}=L.current,{active:le,activatorEvent:Ie,collisions:dt,over:pt}=K.current;if(!le||!Ie)return;const ot={active:le,activatorEvent:Ie,collisions:dt,delta:{x:Q.x,y:Q.y},over:pt};Cn.unstable_batchedUpdates(()=>{J?.(ot),x({type:"onDragMove",event:ot})})},[Q.x,Q.y]),u.useEffect(()=>{const{active:J,activatorEvent:le,collisions:Ie,droppableContainers:dt,scrollAdjustedTranslate:pt}=K.current;if(!J||O.current==null||!le||!pt)return;const{onDragOver:ot}=L.current,Oe=dt.get(ge),He=Oe&&Oe.rect.current?{id:Oe.id,rect:Oe.rect.current,data:Oe.data,disabled:Oe.disabled}:null,Ye={active:J,activatorEvent:le,collisions:Ie,delta:{x:pt.x,y:pt.y},over:He};Cn.unstable_batchedUpdates(()=>{Ne(He),ot?.(Ye),x({type:"onDragOver",event:Ye})})},[ge]),sn(()=>{K.current={activatorEvent:T,active:D,activeNode:Z,collisionRect:ie,collisions:_e,droppableRects:G,draggableNodes:C,draggingNode:Ce,draggingNodeRect:ze,droppableContainers:$,over:Ae,scrollableAncestors:Pe,scrollAdjustedTranslate:Q},z.current={initial:ze,translated:ie}},[D,Z,_e,ie,C,Ce,ze,G,$,Ae,Pe,Q]),g2({...re,delta:N,draggingRect:ie,pointerCoordinates:Re,scrollableAncestors:Pe,scrollableAncestorRects:Qe});const j=u.useMemo(()=>({active:D,activeNode:Z,activeNodeRect:ee,activatorEvent:T,collisions:_e,containerNodeRect:fe,dragOverlay:me,draggableNodes:C,droppableContainers:$,droppableRects:G,over:Ae,measureDroppableContainers:V,scrollableAncestors:Pe,scrollableAncestorRects:Qe,measuringConfiguration:F,measuringScheduled:Y,windowRect:De}),[D,Z,ee,T,_e,fe,me,C,$,G,Ae,V,Pe,Qe,F,Y,De]),oe=u.useMemo(()=>({activatorEvent:T,activators:be,active:D,activeNodeRect:ee,ariaDescribedById:{draggable:I},dispatch:b,draggableNodes:C,over:Ae,measureDroppableContainers:V}),[T,be,D,ee,b,I,C,Ae,V]);return u.createElement(Wp.Provider,{value:k},u.createElement(Uo.Provider,{value:oe},u.createElement(cf.Provider,{value:j},u.createElement(ai.Provider,{value:q},c)),u.createElement(L2,{disabled:l?.restoreFocus===!1})),u.createElement(M7,{...l,hiddenTextDescribedById:I}));function ue(){const J=M?.autoScrollEnabled===!1,le=typeof d=="object"?d.enabled===!1:d===!1,Ie=S&&!J&&!le;return typeof d=="object"?{...d,enabled:Ie}:{enabled:Ie}}}),W2=u.createContext(null),pf="button",j2="Draggable";function H2(e){let{id:t,data:n,disabled:o=!1,attributes:r}=e;const s=ei(j2),{activators:a,activatorEvent:l,active:d,activeNodeRect:c,ariaDescribedById:p,draggableNodes:f,over:y}=u.useContext(Uo),{role:g=pf,roleDescription:m="draggable",tabIndex:h=0}=r??{},v=d?.id===t,b=u.useContext(v?ai:W2),[x,k]=Qr(),[w,_]=Qr(),S=E2(a,t),E=Oo(n);sn(()=>(f.set(t,{id:t,key:s,node:x,activatorNode:w,data:E}),()=>{const N=f.get(t);N&&N.key===s&&f.delete(t)}),[f,t]);const C=u.useMemo(()=>({role:g,tabIndex:h,"aria-disabled":o,"aria-pressed":v&&g===pf?!0:void 0,"aria-roledescription":m,"aria-describedby":p.draggable}),[o,g,h,v,m,p.draggable]);return{active:d,activatorEvent:l,activeNodeRect:c,attributes:C,isDragging:v,listeners:o?void 0:S,node:x,over:y,setNodeRef:k,setActivatorNodeRef:_,transform:b}}function F2(){return u.useContext(cf)}const K2="Droppable",V2={timeout:25};function U2(e){let{data:t,disabled:n=!1,id:o,resizeObserverConfig:r}=e;const s=ei(K2),{active:a,dispatch:l,over:d,measureDroppableContainers:c}=u.useContext(Uo),p=u.useRef({disabled:n}),f=u.useRef(!1),y=u.useRef(null),g=u.useRef(null),{disabled:m,updateMeasurementsFor:h,timeout:v}={...V2,...r},b=Oo(h??o),x=u.useCallback(()=>{if(!f.current){f.current=!0;return}g.current!=null&&clearTimeout(g.current),g.current=setTimeout(()=>{c(Array.isArray(b.current)?b.current:[b.current]),g.current=null},v)},[v]),k=ii({callback:x,disabled:m||!a}),w=u.useCallback((C,N)=>{k&&(N&&(k.unobserve(N),f.current=!1),C&&k.observe(C))},[k]),[_,S]=Qr(w),E=Oo(t);return u.useEffect(()=>{!k||!_.current||(k.disconnect(),f.current=!1,k.observe(_.current))},[_,k]),u.useEffect(()=>(l({type:it.RegisterDroppable,element:{id:o,key:s,disabled:n,node:_,rect:y,data:E}}),()=>l({type:it.UnregisterDroppable,key:s,id:o})),[o]),u.useEffect(()=>{n!==p.current.disabled&&(l({type:it.SetDroppableDisabled,id:o,key:s,disabled:n}),p.current.disabled=n)},[o,s,n,l]),{active:a,rect:y,isOver:d?.id===o,node:_,over:d,setNodeRef:S}}function q2(e){let{animation:t,children:n}=e;const[o,r]=u.useState(null),[s,a]=u.useState(null),l=Rr(n);return!n&&!o&&l&&r(l),sn(()=>{if(!s)return;const d=o?.key,c=o?.props.id;if(d==null||c==null){r(null);return}Promise.resolve(t(c,s)).then(()=>{r(null)})},[t,o,s]),u.createElement(u.Fragment,null,n,o?u.cloneElement(o,{ref:a}):null)}const G2={x:0,y:0,scaleX:1,scaleY:1};function Y2(e){let{children:t}=e;return u.createElement(Uo.Provider,{value:df},u.createElement(ai.Provider,{value:G2},t))}const X2={position:"fixed",touchAction:"none"},J2=e=>la(e)?"transform 250ms ease":void 0,Z2=u.forwardRef((e,t)=>{let{as:n,activatorEvent:o,adjustScale:r,children:s,className:a,rect:l,style:d,transform:c,transition:p=J2}=e;if(!l)return null;const f=r?c:{...c,scaleX:1,scaleY:1},y={...X2,width:l.width,height:l.height,top:l.top,left:l.left,transform:Wo.Transform.toString(f),transformOrigin:r&&o?B7(o,l):void 0,transition:typeof p=="function"?p(o):p,...d};return u.createElement(n,{className:a,style:y,ref:t},s)}),Q2={duration:250,easing:"ease",keyframes:e=>{let{transform:{initial:t,final:n}}=e;return[{transform:Wo.Transform.toString(t)},{transform:Wo.Transform.toString(n)}]},sideEffects:(e=>t=>{let{active:n,dragOverlay:o}=t;const r={},{styles:s,className:a}=e;if(s!=null&&s.active)for(const[l,d]of Object.entries(s.active))d!==void 0&&(r[l]=n.node.style.getPropertyValue(l),n.node.style.setProperty(l,d));if(s!=null&&s.dragOverlay)for(const[l,d]of Object.entries(s.dragOverlay))d!==void 0&&o.node.style.setProperty(l,d);return a!=null&&a.active&&n.node.classList.add(a.active),a!=null&&a.dragOverlay&&o.node.classList.add(a.dragOverlay),function(){for(const[d,c]of Object.entries(r))n.node.style.setProperty(d,c);a!=null&&a.active&&n.node.classList.remove(a.active)}})({styles:{active:{opacity:"0"}}})};function R2(e){let{config:t,draggableNodes:n,droppableContainers:o,measuringConfiguration:r}=e;return Zr((s,a)=>{if(t===null)return;const l=n.get(s);if(!l)return;const d=l.node.current;if(!d)return;const c=lf(a);if(!c)return;const{transform:p}=_t(a).getComputedStyle(a),f=Hp(p);if(!f)return;const y=typeof t=="function"?t:eI(t);return Xp(d,r.draggable.measure),y({active:{id:s,data:l.data,node:d,rect:r.draggable.measure(d)},draggableNodes:n,dragOverlay:{node:a,rect:r.dragOverlay.measure(c)},droppableContainers:o,measuringConfiguration:r,transform:f})})}function eI(e){const{duration:t,easing:n,sideEffects:o,keyframes:r}={...Q2,...e};return s=>{let{active:a,dragOverlay:l,transform:d,...c}=s;if(!t)return;const p={x:l.rect.left-a.rect.left,y:l.rect.top-a.rect.top},f={scaleX:d.scaleX!==1?a.rect.width*d.scaleX/l.rect.width:1,scaleY:d.scaleY!==1?a.rect.height*d.scaleY/l.rect.height:1},y={x:d.x-p.x,y:d.y-p.y,...f},g=r({...c,active:a,dragOverlay:l,transform:{initial:d,final:y}}),[m]=g,h=g[g.length-1];if(JSON.stringify(m)===JSON.stringify(h))return;const v=o?.({active:a,dragOverlay:l,...c}),b=l.node.animate(g,{duration:t,easing:n,fill:"forwards"});return new Promise(x=>{b.onfinish=()=>{v?.(),x()}})}}let ff=0;function tI(e){return u.useMemo(()=>{if(e!=null)return ff++,ff},[e])}const nI=u.memo(e=>{let{adjustScale:t=!1,children:n,dropAnimation:o,style:r,transition:s,modifiers:a,wrapperElement:l="div",className:d,zIndex:c=999}=e;const{activatorEvent:p,active:f,activeNodeRect:y,containerNodeRect:g,draggableNodes:m,droppableContainers:h,dragOverlay:v,over:b,measuringConfiguration:x,scrollableAncestors:k,scrollableAncestorRects:w,windowRect:_}=F2(),S=u.useContext(ai),E=tI(f?.id),C=uf(a,{activatorEvent:p,active:f,activeNodeRect:y,containerNodeRect:g,draggingNodeRect:v.rect,over:b,overlayNodeRect:v.rect,scrollableAncestors:k,scrollableAncestorRects:w,transform:S,windowRect:_}),N=va(y),$=R2({config:o,draggableNodes:m,droppableContainers:h,measuringConfiguration:x}),A=N?v.setRef:void 0;return u.createElement(Y2,null,u.createElement(q2,{animation:$},f&&E?u.createElement(Z2,{key:E,id:f.id,ref:A,as:l,activatorEvent:p,adjustScale:t,className:d,transition:s,rect:N,style:{zIndex:c,...r},transform:C},n):null))});function qo(e,t){return!t||!Array.isArray(t.draggable)?!1:t.draggable.includes(e)}function oI({draggedComponentName:e,targetContainerName:t,nesting:n}){if(!n||!qo(e,n))return!1;const o=n.containers;if(!o||typeof o!="object")return!1;const r=o[t];return!r||!Array.isArray(r.accepts)?!1:r.accepts.includes(e)}function gf(e,t){if(!t?.containers)return!1;const n=t.containers[e];return!!(n&&Array.isArray(n.accepts)&&n.accepts.length>0)}function rI(e,t,n,o,r){if(!n||n.length===0)return{parentPath:[],index:r};if(e&&gf(e,o))return{parentPath:n,index:t};const s=n.slice(0,-1),a=n[n.length-1]??0;return{parentPath:s,index:a+1}}function hf(e,t){return e===t||e.startsWith(`${t}.`)}function yf(e,t){if(e==="")return/^\d+$/.test(t)?Number(t):null;const n=`${e}.children.`;if(!t.startsWith(n))return null;const o=t.slice(n.length);return/^\d+$/.test(o)?Number(o):null}function iI(e,t){const n=[];return e.querySelectorAll("[data-editor-path]").forEach(o=>{if(o.hasAttribute("data-dnd-placeholder")||o.closest("[data-dnd-placeholder]"))return;const r=o.dataset.editorPath;typeof r=="string"&&n.push({path:t(r),domPath:r,rect:o.getBoundingClientRect()})}),n}const sI=16;function aI(e){const{frameEl:t,draggedPath:n,acceptsContainer:o,allowsNestSlot:r,includeContainer:s}=e,a=e.normalizeContainerPath??(g=>g),l=t.getBoundingClientRect(),d=iI(t,a),c=new Map;for(const g of d)c.has(g.path)||c.set(g.path,g.domPath);const p=a(n),f=new Set([""]);for(const g of d){const m=dI(g.path);m!==null&&f.add(m)}for(const g of d)f.add(g.path);const y=[];for(const g of f){const m=g==="",h=m?null:c.get(g)??g,v=m?t:t.querySelector(`[data-editor-path="${vf(h)}"]`);if(!v&&!m||!m&&hf(g,p))continue;const b=g;if(!m&&s&&!s(b))continue;const x=S=>{const E=c.get(S);if(!E||typeof window>"u")return!1;const C=t.querySelector(`[data-editor-path="${vf(E)}"]`);return C?(window.getComputedStyle(C).display??"").toLowerCase()==="contents":!1},k=d.filter(S=>yf(g,S.path)!==null).filter(S=>!hf(S.path,p)).filter(S=>!x(S.path)).map(S=>({origIndex:yf(g,S.path),rect:lI(S.rect,S.path,d)})).filter(S=>S.rect.width>0||S.rect.height>0).sort((S,E)=>S.origIndex-E.origIndex),w=Ip(m?t:v).kind,_=w==="flex_row_single"||w==="flex_row_wrap"||w==="grid_single_row"||w==="grid_2d";if(k.length>0){if(o(b)){k.forEach(E=>{y.push(bf(b,E.origIndex,E.rect,l,_,"before"))});const S=k[k.length-1];y.push(bf(b,S.origIndex+1,S.rect,l,_,"after"))}}else if(!m&&r(b)&&v){const S=v.getBoundingClientRect();S.width>0&&S.height>0&&y.push({id:`slot:${b}:0`,kind:"nest",containerPath:b,index:0,box:mf(S,l),orientation:"area"})}else m&&y.push({id:"slot::0",kind:"gap",containerPath:"",index:0,box:mf(l,l),orientation:"area"})}return y.filter(g=>ao(g.box,t))}function bf(e,t,n,o,r,s){const a=sI;let l;if(r)l={left:(s==="before"?n.left:n.right)-a/2,top:n.top,width:a,height:n.height};else{const d=s==="before"?n.top:n.bottom;l={left:n.left,top:d-a/2,width:n.width,height:a}}return{id:`slot:${e}:${t}`,kind:"gap",containerPath:e,index:t,box:{left:l.left-o.left,top:l.top-o.top,width:l.width,height:l.height,scale:1},orientation:r?"vertical":"horizontal"}}function lI(e,t,n){if(e.width>0||e.height>0)return e;const o=`${t}.children.`;let r=1/0,s=1/0,a=-1/0,l=-1/0;for(const d of n)d.path.startsWith(o)&&(d.rect.width===0&&d.rect.height===0||(r=Math.min(r,d.rect.left),s=Math.min(s,d.rect.top),a=Math.max(a,d.rect.right),l=Math.max(l,d.rect.bottom)));return r===1/0?e:{left:r,top:s,width:a-r,height:l-s,right:a,bottom:l,x:r,y:s,toJSON:()=>({})}}function mf(e,t){return{left:e.left-t.left,top:e.top-t.top,width:e.width,height:e.height,scale:1}}function dI(e){if(e==="")return null;const t=e.lastIndexOf(".children.");return t<0?"":e.slice(0,t)}function cI(e){if(!e.startsWith("slot:"))return null;const t=e.slice(5),n=t.lastIndexOf(":");if(n<0)return null;const o=t.slice(0,n),r=Number(t.slice(n+1));return Number.isFinite(r)?{containerPath:o,index:r}:null}function vf(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function _a(e){return typeof e=="string"&&e.length>0?e:null}function xa(e,t){return t&&e.startsWith(`${t}.`)?t:e}function _f(e){if(typeof e!="string")return null;const t=cI(e);return t?{containerPath:t.containerPath,index:t.index}:null}function wa(e,t){return t?e.startsWith(`${t}.iteration.`):!1}function ka(e,t,n){return t?n==="iteration_item"?wa(e,t):e===t||e.startsWith(`${t}.`):!1}function uI(e){const{frameEl:t,nesting:n,editMode:o,currentExtensionId:r,editableRootPath:s,components:a,patchLayout:l,pushHistory:d,onMovePath:c,selectedPath:p,showBranchBoundaryHint:f}=e,y=(o==="iteration_item"||o==="modal")&&s&&s.length>0?at(s):null,[g,m]=u.useState(null),[h,v]=u.useState(null),[b,x]=u.useState(null),k=u.useRef({components:a,nesting:n,editMode:o,currentExtensionId:r,selectedPath:p,editableRootSourcePath:y});k.current={components:a,nesting:n,editMode:o,currentExtensionId:r,selectedPath:p,editableRootSourcePath:y};const w=u.useRef(null),_=u.useCallback(M=>({children:M}),[]),S=u.useCallback((M,B)=>{const T=We(_(M),B);return T?typeof T.name=="string"?T.name:typeof T.type=="string"?T.type:null:null},[_]),E=u.useCallback(M=>B=>{const T=k.current;if(B==="")return qo(M,T.nesting);const H=Ze(B),L=We(_(T.components),H);if(!L)return!1;const I=typeof L.name=="string"?L.name:typeof L.type=="string"?L.type:"";if(!I)return!1;const P=!!T.editableRootSourcePath&&(B===T.editableRootSourcePath||B.startsWith(`${T.editableRootSourcePath}.`));if(T.editableRootSourcePath&&!P)return!1;if(!P){const F=Sa(_(T.components),H);if(gn(L,T.editMode,T.currentExtensionId,F)!=="none")return!1}return gf(I,T.nesting)?oI({draggedComponentName:M,targetContainerName:I,nesting:T.nesting}):!1},[_]),C=u.useCallback(M=>{if(M==="")return!0;const B=k.current,T=Ze(M),H=w.current;if(H&&H.length>0){const P=H.slice(0,-1);if(li(T,P))return!0}const L=We(_(B.components),T);return L?(typeof L.name=="string"?L.name:typeof L.type=="string"?L.type:"").length>0:!1},[_]),N=u.useCallback(M=>{const B=E(M),T=k.current,H=w.current,L=H?H.slice(0,-1):null,I=new Set;if(L)for(let V=L.length;V>=0;V--)I.add(di(L.slice(0,V)));const P=di(H??[]),F=V=>!!H&&(V===P||V.startsWith(`${P}.`)),G=(V,Y)=>{for(let Z=0;ZV===""?!0:F(V)?!1:I.has(V),allowsNestSlot:V=>{if(!B(V))return!1;if(L){const Y=Ze(V);if(li(Y,L))return!1}return C(V)}}},[E,C,_]),$=u.useCallback(M=>{const B=_a(M.active.id),T=k.current;if(!B)return;const H=xa(B,T.selectedPath),L=Ze(H),I=We(_(T.components),L),P=I&&typeof I.name=="string"?I.name:I&&typeof I.type=="string"?I.type:null;m(H),v(P),x(null),w.current=I?L:null;let F="allowed";if(!P||!qo(P,T.nesting))F="denied_no_draggable";else if(I){const G=Sa(_(T.components),L),V=gn(I,T.editMode,T.currentExtensionId,G);T.editableRootSourcePath&&!ka(H,T.editableRootSourcePath,T.editMode)||Xi(G)&&!wa(H,T.editableRootSourcePath)?F="denied_data_bound":V==="base"||V==="partial"?F="denied_base_locked":(V==="extension"||V==="extension_point")&&(F="denied_extension_locked")}$n({source:"drag",draggedComponentName:P,targetContainerName:null,targetContainerPath:null,decision:F,result:F==="allowed"?"completed":"denied",timestamp:Date.now()})},[_]),A=u.useCallback(M=>{if(!t)return;const B=_a(M.active.id);if(!B)return;const T=h;if(!T){x(null);return}const H=k.current,L=xa(B,H.selectedPath),I=Ze(L),P=We(_(H.components),I);if(!P||!qo(T,H.nesting)){x(null);return}const F=Sa(_(H.components),I);if(H.editableRootSourcePath&&!ka(L,H.editableRootSourcePath,H.editMode)){x(null);return}const G=gn(P,H.editMode,H.currentExtensionId,F),V=wa(L,H.editableRootSourcePath);if(Xi(F)&&!V||G!=="none"&&G!=="data_bound"&&!V){x(null);return}x(_f(M.over?.id))},[t,h,_]),z=u.useCallback(()=>{m(null),v(null),x(null),w.current=null},[]),D=u.useCallback(M=>{const B=_a(M.active.id),T=h,H=k.current,L=B?xa(B,H.selectedPath):null;if(!t||!L||!T){z();return}const I=_f(M.over?.id),P=Ze(L);if(!I){$n({source:"drag",draggedComponentName:T,targetContainerName:null,targetContainerPath:null,decision:"denied_no_accepts",result:"cancelled",timestamp:Date.now()}),z();return}const F=I.containerPath===""?[]:Ze(I.containerPath),G=I.containerPath===""?null:S(H.components,F);if(!pI(P.slice(0,-1),F)){$n({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"denied_responsive_branch_boundary",result:"denied",timestamp:Date.now()}),f?.(),z();return}if(H.editableRootSourcePath&&!ka(I.containerPath,H.editableRootSourcePath,H.editMode)){$n({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"denied_data_bound",result:"denied",timestamp:Date.now()}),z();return}const V=P.slice(0,-1),Y=P[P.length-1]??-1;if(li(V,F)&&(I.index===Y||I.index===Y+1)){$n({source:"drag",draggedComponentName:T,targetContainerName:G,targetContainerPath:I.containerPath||null,decision:"allowed",insertionIndex:I.index,result:"cancelled",timestamp:Date.now()}),z();return}const re=li(V,F),U=I.index;let ee=[];l(me=>{const Se=Ib({children:me},P,F,U).children??[];return ee=Se,Se}),d(ee,`move ${T}`);const fe=id(F,P),K=re&&Y{z()},[z]);return{activeDragPath:g,activeDragName:h,activeDropZone:b,onDragStart:$,onDragMove:A,onDragEnd:D,onDragCancel:O,buildSlotPredicates:N}}function Sa(e,t){const n=[];let o=e,r=Array.isArray(e.children)?e.children:[];for(let s=0;s0?at(s):null,{t:m}=tt(),[h,v]=u.useState(!1),b=u.useRef(null),x=u.useCallback(()=>{v(!0),b.current&&clearTimeout(b.current),b.current=setTimeout(()=>v(!1),2600)},[]);u.useEffect(()=>()=>{b.current&&clearTimeout(b.current)},[]);const k=uI({frameEl:t,nesting:n,editMode:o,currentExtensionId:r,editableRootPath:s,components:a,patchLayout:l,pushHistory:d,onMovePath:p,selectedPath:y,showBranchBoundaryHint:x}),w=P7(L7(ha,{activationConstraint:{distance:8}})),[_,S]=u.useState([]),E=u.useCallback(()=>{if(!t){S([]);return}if(k.activeDragPath!==null)return;const L={children:a},I=t.querySelectorAll("[data-editor-path]"),P=[],F=new Map;I.forEach(G=>{const V=G.dataset.editorPath;if(typeof V!="string"||o!=="iteration_item"&&g&&!hI(V,g,o))return;const Y=fI(V);if(Y!==null&&!Go(V,g)){if(/\.iteration\.\d+$/.test(V)){const K=so(G,t);if(K&&ao(K,t)){const ne=F.get(Y)??[];ne.push(K),F.set(Y,ne)}}return}if(Go(V,g)&&/\.iteration\.\d+$/.test(V))return;const Z=Ze(V),se=We(L,Z);if(!se||se.iteration&&se.iteration.source!==void 0)return;const re=typeof se.name=="string"?se.name:typeof se.type=="string"?se.type:null;if(!re||!qo(re,n))return;const U=wf(L,Z);if(Xi(U)&&!Go(V,g))return;const ee=gn(se,o,r,U);if(ee!=="none"&&ee!=="data_bound"&&!Go(V,g))return;const fe=so(G,t);fe&&ao(fe,t)&&P.push({path:V,name:re,box:fe})});for(const[G,V]of F){const Y=Ze(G),Z=We(L,Y);if(!Z)continue;const se=typeof Z.name=="string"?Z.name:typeof Z.type=="string"?Z.type:null;if(!se)continue;const re=wf(L,Y),U=gn(Z,o,r,re);if(U!=="none"&&U!=="data_bound")continue;const ee=yI(V);ee&&ao(ee,t)&&P.push({path:G,name:se,box:ee,isIterationGroup:!0})}S(P)},[t,a,n,o,r,g,k.activeDragPath]);u.useEffect(()=>{E()},[E]),u.useEffect(()=>{if(t)return kp(E,t)},[t,E]);const C=k.activeDragPath!==null,N=k.activeDragName,$=k.activeDragPath,[A,z]=u.useState(null),D=u.useRef(null);u.useEffect(()=>{if(!$||!t){$||(D.current=null,z(null));return}if(D.current===$)return;D.current=$;const L=t.querySelector(`[data-editor-path="${xf($)}"]`);if(L){const re=L.getBoundingClientRect();z({node:L.cloneNode(!0),width:re.width,height:re.height});return}const I=Array.from(t.querySelectorAll(`[data-editor-path^="${xf($)}.iteration."]`)).filter(re=>/\.iteration\.\d+$/.test(re.dataset.editorPath??""));if(I.length===0){z(null);return}let P=1/0,F=1/0,G=-1/0,V=-1/0;for(const re of I){const U=re.getBoundingClientRect();P=Math.min(P,U.left),F=Math.min(F,U.top),G=Math.max(G,U.right),V=Math.max(V,U.bottom)}const Y=G-P,Z=V-F,se=document.createElement("div");se.style.position="relative",se.style.width=`${Y}px`,se.style.height=`${Z}px`;for(const re of I){const U=re.getBoundingClientRect(),ee=re.cloneNode(!0);ee.style.position="absolute",ee.style.left=`${U.left-P}px`,ee.style.top=`${U.top-F}px`,ee.style.width=`${U.width}px`,ee.style.height=`${U.height}px`,ee.style.margin="0",se.appendChild(ee)}z({node:se,width:Y,height:Z})},[$,t]);const[O,M]=u.useState([]),B=u.useRef(null),T=u.useRef(k.buildSlotPredicates);if(T.current=k.buildSlotPredicates,u.useEffect(()=>{if(!$||!N||!t){B.current=null,M(F=>F.length===0?F:[]);return}if(B.current===$)return;B.current=$;const{acceptsContainer:L,allowsNestSlot:I,includeContainer:P}=T.current(N);M(aI({frameEl:t,draggedPath:$,acceptsContainer:L,allowsNestSlot:I,includeContainer:P,normalizeContainerPath:F=>gI(F)}))},[$,N,t]),!t)return null;const H=k.activeDropZone?`slot:${k.activeDropZone.containerPath}:${k.activeDropZone.index}`:null;return i.jsxs(B2,{sensors:w,collisionDetection:q7,onDragStart:k.onDragStart,onDragMove:k.onDragMove,onDragEnd:k.onDragEnd,onDragCancel:k.onDragCancel,children:[_.map(L=>i.jsx(_I,{path:L.path,name:L.name,box:L.box,dimmed:C&&k.activeDragPath!==L.path,isIterationGroup:L.isIterationGroup,onSelect:c,onRequestInlineEdit:f},L.path)),C&&O.map(L=>i.jsx(vI,{slot:L,active:L.id===H},L.id)),Cn.createPortal(i.jsx(nI,{dropAnimation:null,zIndex:zp,children:N?A?i.jsx(mI,{name:N,node:A.node,width:A.width,height:A.height}):i.jsx("div",{className:"g7le-dnd-drag-ghost","data-testid":"g7le-dnd-drag-ghost",style:{padding:"4px 10px",background:"rgba(79,70,229,0.95)",color:"#fff",fontSize:12,fontWeight:600,borderRadius:4,pointerEvents:"none",whiteSpace:"nowrap"},children:`<${N}>`}):null}),document.body),h&&Cn.createPortal(i.jsxs("div",{"data-testid":"g7le-branch-boundary-hint",role:"status",style:{position:"absolute",left:"50%",top:12,transform:"translateX(-50%)",background:"rgba(245,158,11,0.97)",color:"#fff",fontSize:13,fontWeight:600,padding:"8px 16px",borderRadius:8,boxShadow:"0 4px 14px rgba(0,0,0,0.2)",pointerEvents:"none",whiteSpace:"nowrap",zIndex:zp},children:["⚠️ ",m("layout_editor.overlay.branch_boundary_denied")]}),t)]})}function mI(e){const{node:t,width:n,height:o}=e,r=u.useRef(null);return u.useEffect(()=>{const s=r.current;if(s)return s.replaceChildren(),s.appendChild(t.cloneNode(!0)),()=>{s.replaceChildren()}},[t]),i.jsx("div",{ref:r,className:"g7le-dnd-drag-ghost","data-testid":"g7le-dnd-drag-ghost",style:{width:n,height:o,opacity:.7,pointerEvents:"none",boxShadow:"0 6px 18px rgba(0,0,0,0.25)",borderRadius:4,overflow:"hidden",cursor:"grabbing"}})}function vI(e){const{slot:t,active:n}=e,{setNodeRef:o}=U2({id:t.id}),r=t.orientation!=="area";return i.jsx("div",{ref:o,"data-testid":`g7le-dnd-slot-${t.id}`,"data-dnd-slot-id":t.id,style:{position:"absolute",left:t.box.left,top:t.box.top,width:t.box.width,height:t.box.height,pointerEvents:"auto",zIndex:u7,boxSizing:"border-box",...r?{background:n?"rgba(79,70,229,0.15)":"transparent",...n?t.orientation==="vertical"?{borderLeft:"2px solid #4f46e5"}:{borderTop:"2px solid #4f46e5"}:{}}:{border:n?"2px dashed #4f46e5":"1px dashed transparent",background:n?"rgba(79,70,229,0.08)":"transparent",borderRadius:4}}})}function xf(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function _I(e){const{path:t,box:n,dimmed:o,isIterationGroup:r,onSelect:s,onRequestInlineEdit:a}=e,{attributes:l,listeners:d,setNodeRef:c,isDragging:p}=H2({id:t}),f=(t.match(/\.children\./g)||[]).length,y=p7(f);return i.jsx("div",{ref:c,"data-testid":`g7le-dnd-handle-${t}`,"data-dnd-handle-path":t,"data-dnd-iteration-group":r?"true":void 0,...d,...l,onClick:g=>{if(g.stopPropagation(),!r&&typeof document<"u"&&typeof document.elementsFromPoint=="function"){const m=document.elementsFromPoint(g.clientX,g.clientY);for(const h of m){const b=h.closest?.("[data-editor-path]")?.dataset?.editorPath;if(typeof b=="string"&&(b===t||b.startsWith(`${t}.`))){s(b);return}}}s(t)},onDoubleClick:g=>{g.stopPropagation(),g.preventDefault(),a?.(t)},style:{position:"absolute",left:n.left,top:n.top,width:n.width,height:n.height,cursor:"grab",pointerEvents:"auto",background:p?"rgba(79,70,229,0.06)":"transparent",...r?{border:"1px dashed rgba(79,70,229,0.5)",borderRadius:4}:{},opacity:o?.6:1,zIndex:y,touchAction:"none"}})}function wf(e,t){const n=[];let o=e,r=Array.isArray(e.children)?e.children:[];for(let s=0;s0?[o]:[];const r=[],s=(a,l)=>{a.forEach((d,c)=>{const p=[...l,c];if(!Ji(d,t,n)){r.push(p);return}const y=Array.isArray(d.children)?d.children:[];y.length>0&&s(y,p)})};return s(e,[]),r}function wI(e){return at(e)}function ci(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\]/g,"\\$&")}function kI({frameEl:e,components:t,editMode:n,currentExtensionId:o,editableRootPath:r}){const[s,a]=u.useState(null),[l]=u.useState(()=>`g7le-dim-mask-${Math.floor(Math.random()*1e9)}`);if(u.useEffect(()=>{if(!e){a(null);return}let g=0;const m=()=>{const x=xI(t,n,o,r);if(x.length===0){a(null);return}const k=e.getBoundingClientRect(),w={left:0,top:0,width:k.width,height:k.height},_=[];for(const S of x){const E=wI(S),C=e.querySelector(`[data-editor-path="${ci(E)}"]`);let N=null;if(C){const $=C.getBoundingClientRect();$.width>0&&$.height>0&&(N={left:$.left,top:$.top,width:$.width,height:$.height})}if(!N){const $=Array.from(e.querySelectorAll(`[data-editor-path^="${ci(E)}.iteration."], [data-editor-path^="${ci(E)}.sortable."], [data-editor-path^="${ci(E)}.children."]`));let A=1/0,z=1/0,D=-1/0,O=-1/0;for(const M of $){const B=M.getBoundingClientRect();B.width<=0||B.height<=0||(A=Math.min(A,B.left),z=Math.min(z,B.top),D=Math.max(D,B.right),O=Math.max(O,B.bottom))}if(D>A&&O>z&&(N={left:A,top:z,width:D-A,height:O-z}),N&&$.length>0){const M=$[0].closest('[role="dialog"]');if(M&&e.contains(M)){const B=M.getBoundingClientRect();B.width>0&&B.height>0&&(N={left:B.left,top:B.top,width:B.width,height:B.height})}}}N&&_.push({left:N.left-k.left,top:N.top-k.top,width:N.width,height:N.height})}if(_.length===0){a(null);return}a({frame:w,holes:_})},h=()=>{g&&cancelAnimationFrame(g),g=requestAnimationFrame(m)};h();const v=typeof ResizeObserver<"u"?new ResizeObserver(h):null;v?.observe(e);const b=typeof MutationObserver<"u"?new MutationObserver(h):null;return b?.observe(e,{childList:!0,subtree:!0,attributes:!0}),window.addEventListener("scroll",h,!0),window.addEventListener("resize",h),()=>{g&&cancelAnimationFrame(g),v?.disconnect(),b?.disconnect(),window.removeEventListener("scroll",h,!0),window.removeEventListener("resize",h)}},[e,t,n,o,r?.join(".")??""]),!s||s.holes.length===0)return null;const{frame:d,holes:c}=s,p=n==="modal"||n==="iteration_item";let f=[];if(p){let g=1/0,m=1/0,h=-1/0,v=-1/0;for(const S of c)g=Math.min(g,S.left),m=Math.min(m,S.top),h=Math.max(h,S.left+S.width),v=Math.max(v,S.top+S.height);const b=(S,E,C)=>Math.max(E,Math.min(C,S)),x=b(g,0,d.width),k=b(m,0,d.height),w=b(h,0,d.width),_=b(v,0,d.height);f=[{left:0,top:0,width:d.width,height:k},{left:0,top:_,width:d.width,height:d.height-_},{left:0,top:k,width:x,height:_-k},{left:w,top:k,width:d.width-w,height:_-k}].filter(S=>S.width>0&&S.height>0)}const y=g=>{g.preventDefault(),g.stopPropagation()};return i.jsxs(i.Fragment,{children:[f.map((g,m)=>i.jsx("div",{className:"g7le-source-lock-block","data-testid":"g7le-source-lock-block",onClickCapture:y,onPointerDownCapture:y,onMouseDownCapture:y,onDragStartCapture:y,style:{position:"absolute",left:g.left,top:g.top,width:g.width,height:g.height,zIndex:Np,pointerEvents:"auto",cursor:"not-allowed",background:"transparent"}},m)),i.jsxs("svg",{className:"g7le-source-lock-dim","data-testid":"g7le-source-lock-dim",width:d.width,height:d.height,style:{position:"absolute",left:0,top:0,width:d.width,height:d.height,zIndex:Np,pointerEvents:"none"},children:[i.jsx("defs",{children:i.jsxs("mask",{id:l,children:[i.jsx("rect",{x:0,y:0,width:d.width,height:d.height,fill:"white"}),c.map((g,m)=>i.jsx("rect",{"data-testid":"g7le-source-lock-dim-hole",x:g.left,y:g.top,width:g.width,height:g.height,rx:4,fill:"black"},m))]})}),i.jsx("rect",{"data-testid":"g7le-source-lock-dim-box",x:0,y:0,width:d.width,height:d.height,fill:"rgba(15, 23, 42, 0.45)",mask:`url(#${l})`})]})]})}function kf(e){const t={type:e.type??"basic",name:e.name},n={};if(e.props)for(const[o,r]of Object.entries(e.props))o!=="children"&&r?.default!==void 0&&(n[o]=r.default);return Object.keys(n).length>0&&(t.props=n),t}function SI(e){if(!e?.components)return[];const t=[];for(const[n,o]of Object.entries(e.components))if(Array.isArray(o))for(const r of o)t.push({...r,type:r.type??n});return t}function CI(e,t,n,o,r){const s=SI(e);let a=null;if(n&&t?.containers){const l=t.containers[n];l&&Array.isArray(l.accepts)?a=new Set(l.accepts):a=new Set}else!n&&t?.draggable&&(a=new Set(t.draggable));return s.filter(l=>{if(a&&!a.has(l.name))return!1;if(o){const d=o.toLowerCase(),c=r(l).toLowerCase(),p=l.name.toLowerCase();if(!c.includes(d)&&!p.includes(d)&&!`<${p}>`.includes(d)&&!(l.description??"").toLowerCase().includes(d))return!1}return!0})}function $I(e,t){const n={key:"all",label:"layout_editor.palette.category.all",entries:e,isI18nKey:!0};if(t?.groups&&t.groups.length>0){const a=new Map(e.map(p=>[p.name,p])),l=new Set,d=[];for(const p of t.groups){const f=[];for(const y of p.components??[]){const g=a.get(y);g&&(f.push(g),l.add(y))}f.length!==0&&d.push({key:p.kind??p.label,label:p.label,entries:f,isI18nKey:p.label.startsWith("$t:")||/^[a-z_.]+$/i.test(p.label)})}const c=e.filter(p=>!l.has(p.name));return c.length>0&&d.push({key:"other",label:"layout_editor.palette.category.other",entries:c,isI18nKey:!0}),[n,...d]}const o=new Map;for(const a of e){const l=(a.type??"basic").toLowerCase(),d=o.get(l)??[];d.push(a),o.set(l,d)}const r=["basic","composite","layout"],s=[];for(const a of r){const l=o.get(a);l&&l.length>0&&(s.push({key:a,label:`layout_editor.palette.category.fallback.${a}`,entries:l,isI18nKey:!0}),o.delete(a))}for(const[a,l]of o.entries())s.push({key:a,label:a,entries:l,isI18nKey:!1});return[n,...s]}function Sf(e){const{manifest:t,nesting:n,componentPalette:o,targetParentPath:r,targetContainerName:s,onInsert:a,insertionIndex:l,onClose:d,editorTemplateId:c,editorLocale:p}=e,{t:f,translationEngine:y}=tt(),g=u.useMemo(()=>{if(!c||!y)return f;const A=p??null;return z=>{const D={templateId:c,locale:A??"ko"},O=y.translate(z,D);return O!==z?O:f(z)}},[c,p,y,f]),[m,h]=u.useState(""),[v,b]=u.useState("all"),x=o?.entries??null,k=u.useMemo(()=>A=>{const z=x?.[A.name];return z?.label?ui(z.label,g):A.description??A.name},[x,g]),w=u.useMemo(()=>CI(t,n,s,m,k),[t,n,s,m,k]),_=u.useMemo(()=>$I(w,o??null),[w,o]),S=_.find(A=>A.key===v)??_[0]??null,E=!n||!n.draggable&&!n.containers,C=w.length===0,N=S?.entries??[],$=A=>{const z=x?.[A.name],D=!!z?.defaultNode&&typeof z.defaultNode=="object";if(z?.requiresDefaultNode&&!D)return;const O=D?JSON.parse(JSON.stringify(z.defaultNode)):kf(A),M=r??[];a(O,M,l),$n({source:"palette",draggedComponentName:A.name,targetContainerName:s,targetContainerPath:M.length===0?null:M.join("/"),decision:"allowed",insertionIndex:l,result:"completed",timestamp:Date.now()}),d()};return i.jsxs("div",{className:"g7le-palette","data-testid":"g7le-palette",style:TI,children:[i.jsxs("div",{style:DI,children:[i.jsx("strong",{style:{fontSize:16},children:f("layout_editor.palette.title")}),i.jsx("button",{type:"button",onClick:d,"aria-label":"close","data-testid":"g7le-palette-close",style:NI,children:"×"})]}),i.jsxs("div",{style:zI,children:[i.jsx("aside",{style:MI,"data-testid":"g7le-palette-sidebar",children:i.jsx("nav",{children:i.jsx("ul",{style:LI,children:_.map(A=>{const z=S?.key===A.key,D=A.isI18nKey?ui(A.label,g):A.label;return i.jsx("li",{children:i.jsxs("button",{type:"button","data-testid":`g7le-palette-category-${A.key}`,"data-active":z?"true":"false",onClick:()=>b(A.key),style:{...PI,background:z?"#eff6ff":"transparent",color:z?"#1d4ed8":"#334155",fontWeight:z?600:500},children:[i.jsx("span",{children:D}),i.jsx("span",{style:jI,children:A.entries.length})]})},A.key)})})})}),i.jsxs("main",{style:OI,children:[i.jsx("input",{type:"text",placeholder:f("layout_editor.palette.search_placeholder"),value:m,onChange:A=>h(A.target.value),"data-testid":"g7le-palette-search",style:BI}),i.jsx("div",{style:WI,children:E?i.jsx("div",{style:Cf,"data-testid":"g7le-palette-spec-missing",children:f("layout_editor.palette.spec_missing")}):C||N.length===0?i.jsx("div",{style:Cf,"data-testid":"g7le-palette-empty",children:f("layout_editor.palette.empty")}):i.jsx("div",{"data-testid":`g7le-palette-group-${S?.key??"all"}`,style:{marginBottom:16},children:i.jsx("div",{style:HI,children:N.map(A=>i.jsx(EI,{entry:A,entrySpec:x?.[A.name]??null,onClick:$,t:f,entryT:g},A.name))})})})]})]})]})}function EI(e){const{entry:t,entrySpec:n,onClick:o,t:r,entryT:s}=e,a=n?.label?ui(n.label,s):t.description??t.name,l=(t.type??"").toLowerCase(),d=l==="basic"||l==="composite"||l==="layout",c=!!n?.defaultNode&&typeof n.defaultNode=="object",p=!!n?.requiresDefaultNode&&!c,f=!c,y=p?r("layout_editor.palette.requires_default_node_missing"):n?.label?ui(n.label,s):t.name;return i.jsxs("button",{type:"button",onClick:()=>o(t),disabled:p,"aria-disabled":p||void 0,"data-testid":`g7le-palette-item-${t.name}`,"data-component-type":l||void 0,"data-blocked":p?"true":void 0,"data-default-node-missing":f?"true":void 0,title:y,style:p?KI:FI,children:[i.jsx("div",{style:UI,"aria-hidden":"true",children:AI(t)}),i.jsx("div",{style:qI,children:a}),i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-tag`,style:GI,children:`<${t.name}>`}),d&&i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-badge`,style:II(l),children:l}),f&&i.jsx("div",{"data-testid":`g7le-palette-item-${t.name}-default-missing-badge`,style:VI,children:r("layout_editor.palette.default_node_missing_badge")})]})}function ui(e,t){return e.startsWith("$t:")?t(e.slice(3)):t(e)}function II(e){const n={basic:{bg:"#dbeafe",fg:"#1e40af"},composite:{bg:"#dcfce7",fg:"#14532d"},layout:{bg:"#fef3c7",fg:"#854d0e"}}[e]??{bg:"#e2e8f0",fg:"#334155"};return{fontSize:9,fontWeight:600,color:n.fg,background:n.bg,padding:"1px 6px",borderRadius:999,textTransform:"uppercase",letterSpacing:.4,lineHeight:1.6}}function AI(e){const t=e.name;return{Button:"🔘",A:"🔗",Img:"🖼",Icon:"✨",Hr:"➖",P:"📄",Span:"T",H1:"H₁",H2:"H₂",H3:"H₃",H4:"H₄",Ul:"☰",Li:"•",Nav:"🧭",Div:"▢",Container:"▢",Flex:"⇆",Grid:"▦",SectionLayout:"▤",ThreeColumnLayout:"⫼",Form:"📝",Input:"⎕",Textarea:"▭",Select:"⏷",Checkbox:"☑",PasswordInput:"🔒",Label:"🏷",DataGrid:"▦",Pagination:"↔",SearchBar:"🔍",ProductCard:"🛍",ImageGallery:"🖼",Avatar:"👤",UserInfo:"👥",HtmlContent:"⟨/⟩",HtmlEditor:"✏",RichTextEditor:"✏",TabNavigation:"◰",QuantitySelector:"#",ProductImageViewer:"🖼",FileUploader:"📎",AvatarUploader:"📷",SocialLoginButtons:"🌐",PostReactions:"❤"}[t]??"◻"}const TI={display:"flex",flexDirection:"column",width:"100%",height:"100%",minHeight:0,fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif'},DI={display:"flex",justifyContent:"space-between",alignItems:"center",padding:"14px 20px",borderBottom:"1px solid #e2e8f0",flexShrink:0},NI={background:"transparent",border:"none",fontSize:24,cursor:"pointer",lineHeight:1,color:"#64748b",padding:"0 4px"},zI={display:"flex",flex:1,minHeight:0},MI={width:180,flexShrink:0,borderRight:"1px solid #e2e8f0",padding:"14px 8px",background:"#f8fafc",overflowY:"auto"},LI={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:4},PI={display:"flex",width:"100%",alignItems:"center",justifyContent:"space-between",textAlign:"left",padding:"8px 12px",border:"none",borderRadius:6,fontSize:13,cursor:"pointer",transition:"background-color 120ms"},OI={flex:1,display:"flex",flexDirection:"column",minHeight:0,padding:"14px 20px"},BI={width:"100%",padding:"8px 10px",border:"1px solid #cbd5e1",borderRadius:6,marginBottom:12,fontSize:13,flexShrink:0,boxSizing:"border-box"},WI={flex:1,overflowY:"auto",minHeight:0},jI={fontSize:11,color:"#94a3b8",marginLeft:8,fontWeight:500},HI={display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(96px, 1fr))",gap:10},FI={display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:6,padding:"14px 6px",background:"#fff",border:"1px solid #e2e8f0",borderRadius:8,cursor:"pointer",fontSize:12,minHeight:84,transition:"border-color 120ms, box-shadow 120ms"},KI={display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:6,padding:"14px 6px",background:"#f8fafc",border:"1px dashed #cbd5e1",borderRadius:8,cursor:"not-allowed",fontSize:12,minHeight:84,color:"#94a3b8",opacity:.65},VI={fontSize:9,fontWeight:600,color:"#92400e",background:"#fef3c7",padding:"1px 6px",borderRadius:999,textTransform:"none",letterSpacing:0,lineHeight:1.6},UI={fontSize:22,lineHeight:1,color:"#475569"},qI={fontSize:12,fontWeight:500,color:"#0f172a",textAlign:"center",wordBreak:"break-word"},GI={fontFamily:'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',fontSize:10,color:"#64748b",textAlign:"center",wordBreak:"break-all"},Cf={padding:24,color:"#64748b",fontSize:13,textAlign:"center"};function YI(e){const t=[],n=r=>{if(Array.isArray(r))for(const s of r)s&&typeof s=="object"&&t.push(s)};n(e.children);const o=e.responsive;if(o&&typeof o=="object")for(const r of Object.values(o))r&&typeof r=="object"&&n(r.children);return t}function $f(e){const t=[],n=o=>{const r="isolatedState"in o&&o.isolatedState!=null,s=o.isolatedScopeId;(r||typeof s=="string"&&s.length>0)&&t.push({scopeId:typeof s=="string"?s:void 0,initialState:o.isolatedState&&typeof o.isolatedState=="object"?o.isolatedState:void 0});for(const a of YI(o))n(a)};if(Array.isArray(e))for(const o of e)o&&typeof o=="object"&&n(o);return t}const XI=["scroll","selector","wizard","slider"];function JI(e,t){const n=new Set;for(const o of e)o.scopeId&&n.add(o.scopeId);for(const o of t){n.add(o);for(const r of XI)n.add(`${o}-${r}`)}return Array.from(n)}function ZI(e,t,n){const o=t.label;return typeof o=="string"?o.startsWith("$t:")?n(o.slice(3)):o:e}function pi({controlKey:e,control:t,node:n,t:o,onPatch:r,candidates:s,bindingCandidates:a,scope:l=Wt}){const d=Tn(t.widget),c=ZI(e,t,o),p=u.useMemo(()=>To(n,t,l),[n,t,l]),f=t.widget==="i18n-text"&&t.apply?.type==="propValue",y=t.widget==="text"&&t.apply?.type==="propValue",g=p.darkReadonly===!0||l.colorScheme==="dark"&&t.apply!==void 0&&!Us(t.apply),m=t.apply,h=l.colorScheme==="dark"&&!g&&m?.type==="classToken"&&typeof m.tokenTemplate=="string"&&m.tokenTemplate.includes("{value}"),v=p.scopedValue===void 0&&p.baseFallback!==void 0,b=u.useCallback(x=>{if(g)return;const k=fn(n,t,x,l);r(k)},[n,t,r,l,g]);return i.jsxs("div",{className:"g7le-control-row","data-testid":`g7le-control-${e}`,style:QI,children:[i.jsxs("span",{className:"g7le-control-label",style:RI,children:[c,p.conflict&&i.jsx("span",{"data-testid":`g7le-control-conflict-${e}`,title:o("layout_editor.property_modal.value_conflict"),style:eA,children:"!"})]}),i.jsx("div",{className:"g7le-control-widget",style:{flex:1,minWidth:0,opacity:v?.45:1},"data-placeholder":v?"true":void 0,children:f?i.jsx(vt,{value:typeof p.value=="string"?p.value:"",onChange:x=>b(x),t:o,testidPrefix:`g7le-prop-i18n-${e}`,candidates:a,enableExpressionTree:!0,expressionTreeCollapsible:!0}):y?i.jsx(Ve,{value:typeof p.value=="string"?p.value:"",onChange:x=>b(x===""?void 0:x),t:o,testidPrefix:`g7le-prop-value-${e}`,candidates:a}):g?i.jsx("span",{"data-testid":`g7le-control-dark-readonly-${e}`,style:nA,children:o("layout_editor.property_modal.dark_code_only")}):d?i.jsx(d,{control:t,value:p.value,onChange:b,t:o,candidates:s,bindingCandidates:a,freeValueDisabled:h}):i.jsx("span",{"data-testid":`g7le-control-unsupported-${e}`,style:tA,children:o("layout_editor.property_modal.unsupported_widget")})})]})}const QI={display:"flex",alignItems:"center",gap:12,padding:"6px 0",minHeight:32,minWidth:0},RI={fontSize:12,color:"#475569",minWidth:88,display:"inline-flex",alignItems:"center",gap:4},eA={display:"inline-flex",width:14,height:14,borderRadius:7,background:"#f59e0b",color:"#fff",fontSize:10,alignItems:"center",justifyContent:"center",fontWeight:700},tA={fontSize:11,color:"#94a3b8",fontStyle:"italic"},nA={fontSize:11,color:"#94a3b8",fontStyle:"italic"};function fi(e,t,n){return typeof e=="string"?e.startsWith("$t:")?n(e.slice(3)):e:t}function oA(e,t){const n=e.props??{};return t.key in n?n[t.key]:t.default}function rA({spec:e,node:t,t:n,onPatchProp:o}){const r=e.groups??[];return i.jsx("div",{className:"g7le-composite-settings","data-testid":"g7le-composite-settings",style:sA,children:r.map((s,a)=>i.jsxs("div",{className:"g7le-settings-group",style:aA,children:[i.jsx("div",{style:lA,children:fi(s.label,n("layout_editor.property_modal.settings_group",{index:a+1}),n)}),(s.fields??[]).map(l=>i.jsxs("div",{className:"g7le-settings-field","data-testid":`g7le-setting-${l.key}`,style:dA,children:[i.jsx("span",{style:cA,children:fi(l.label,l.key,n)}),i.jsx("div",{style:{flex:1},children:iA(l,t,n,o)})]},l.key))]},a))})}function iA(e,t,n,o){const r=oA(t,e);switch(e.type){case"number":return i.jsx("input",{type:"number","data-testid":`g7le-setting-input-${e.key}`,value:typeof r=="number"?r:"",min:e.min,max:e.max,onChange:s=>o(e.key,s.target.value===""?void 0:Number(s.target.value)),style:Ca});case"text":return i.jsx("input",{type:"text","data-testid":`g7le-setting-input-${e.key}`,value:typeof r=="string"?r:"",onChange:s=>o(e.key,s.target.value===""?void 0:s.target.value),style:Ca});case"toggle":return i.jsx("input",{type:"checkbox","data-testid":`g7le-setting-input-${e.key}`,checked:r===!0,onChange:s=>o(e.key,s.target.checked)});case"checkbox-group":{const s=Array.isArray(r)?r:[];return i.jsx("div",{style:uA,"data-testid":`g7le-setting-input-${e.key}`,children:(e.options??[]).map(a=>{const l=s.includes(a.value);return i.jsxs("label",{style:pA,children:[i.jsx("input",{type:"checkbox",checked:l,onChange:d=>{const c=d.target.checked?[...s,a.value]:s.filter(p=>p!==a.value);o(e.key,c)}}),fi(a.label,String(a.value),n)]},String(a.value))})})}default:return i.jsxs("select",{"data-testid":`g7le-setting-input-${e.key}`,value:r==null?"":String(r),onChange:s=>{const a=s.target.value;if(a===""){o(e.key,void 0);return}const l=(e.options??[]).find(d=>String(d.value)===a);o(e.key,l?l.value:a)},style:Ca,children:[i.jsx("option",{value:"",children:"—"}),(e.options??[]).map(s=>i.jsx("option",{value:String(s.value),children:fi(s.label,String(s.value),n)},String(s.value)))]})}}const sA={display:"flex",flexDirection:"column",gap:16},aA={display:"flex",flexDirection:"column",gap:6},lA={fontSize:12,fontWeight:700,color:"#0f172a",borderBottom:"1px solid #e2e8f0",paddingBottom:4},dA={display:"flex",alignItems:"center",gap:12,minHeight:30},cA={fontSize:12,color:"#475569",minWidth:96},Ca={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",boxSizing:"border-box"},uA={display:"flex",flexWrap:"wrap",gap:10},pA={display:"inline-flex",alignItems:"center",gap:4,fontSize:12,color:"#0f172a"},fA=["data_binding","isolatedScopeId","parentFormContextProp","skipBindingKeys","isolatedState","component_layout","sortable","itemTemplate"],gA=/\{\{|\$switch|\$get|\|\s*(date|number|truncate|currency|relative)\b/;function gi(e,t=0){return t>6?!1:typeof e=="string"?gA.test(e):Array.isArray(e)?e.some(n=>gi(n,t+1)):e&&typeof e=="object"?Object.values(e).some(n=>gi(n,t+1)):!1}function hA(e){if(Zl(e))return!1;const t=Fl(e);return t.length===0?!1:t.some(n=>n.isComplex)}function Ef(e){const t=[],n=e.props??{};for(const o of fA)o in e&&e[o]!==void 0?t.push({key:o,kind:"developer_prop"}):o in n&&n[o]!==void 0&&t.push({key:`props.${o}`,kind:"developer_prop"});typeof e.text=="string"?hA(e.text)&&t.push({key:"text",kind:"complex_expression"}):gi(e.text)&&t.push({key:"text",kind:"complex_expression"});for(const[o,r]of Object.entries(n))o==="style"||o==="className"||gi(r)&&t.push({key:`props.${o}`,kind:"complex_expression"});return t}function yA(e){return Ef(e).length>0}const bA=new Set(["permissions","blur_until_loaded","comment"]);function mA({node:e,advanced:t,t:n,onPatch:o,permissionCandidates:r}){const s=u.useMemo(()=>Ef(e),[e]),a=(l,d)=>{const c={...e};d===void 0?delete c[l]:c[l]=d,o(c)};return i.jsxs("div",{className:"g7le-advanced-props","data-testid":"g7le-advanced-props",style:vA,children:[t.map(l=>{if(!bA.has(l))return i.jsxs("div",{"data-testid":`g7le-advanced-deferred-${l}`,style:xA,children:[n("layout_editor.property_modal.advanced_deferred"),":",l]},l);if(l==="permissions")return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-permissions",style:$a,children:[i.jsx("div",{style:Ea,children:n("layout_editor.property_modal.advanced.permissions")}),i.jsx(ys,{control:{widget:"tag-input"},value:e.permissions,onChange:d=>a("permissions",d),t:n,candidates:r})]},l);if(l==="blur_until_loaded"){const d=e.blur_until_loaded===!0||e.blur_until_loaded!=null&&e.blur_until_loaded!==!1;return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-blur",style:$a,children:[i.jsx("div",{style:Ea,children:n("layout_editor.property_modal.advanced.blur_until_loaded")}),i.jsxs("label",{style:{display:"inline-flex",alignItems:"center",gap:6,fontSize:12},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-advanced-blur-toggle",checked:!!d,onChange:c=>a("blur_until_loaded",c.target.checked?!0:void 0)}),n("layout_editor.property_modal.advanced.blur_hint")]})]},l)}return i.jsxs("div",{className:"g7le-advanced-group","data-testid":"g7le-advanced-comment",style:$a,children:[i.jsx("div",{style:Ea,children:n("layout_editor.property_modal.advanced.comment")}),i.jsx("input",{type:"text","data-testid":"g7le-advanced-comment-input",value:typeof e.comment=="string"?e.comment:"",onChange:d=>a("comment",d.target.value===""?void 0:d.target.value),style:_A})]},l)}),s.length>0&&i.jsxs("div",{className:"g7le-advanced-preserved","data-testid":"g7le-advanced-preserved",style:wA,children:[i.jsx("div",{style:kA,children:n("layout_editor.property_modal.advanced_preserved_title",{count:s.length})}),i.jsx("ul",{style:SA,children:s.map(l=>i.jsxs("li",{"data-testid":`g7le-advanced-preserved-${l.key}`,style:CA,children:[i.jsx("code",{style:{fontSize:11},children:l.key}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:l.kind==="developer_prop"?n("layout_editor.property_modal.advanced_kind_developer"):n("layout_editor.property_modal.advanced_kind_expression")})]},l.key))})]})]})}const vA={display:"flex",flexDirection:"column",gap:14},$a={display:"flex",flexDirection:"column",gap:6},Ea={fontSize:12,fontWeight:700,color:"#0f172a"},_A={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%",boxSizing:"border-box"},xA={fontSize:11,color:"#94a3b8",fontStyle:"italic"},wA={borderTop:"1px dashed #cbd5e1",paddingTop:10,marginTop:4},kA={fontSize:11,color:"#b45309",marginBottom:6},SA={listStyle:"none",margin:0,padding:0,display:"flex",flexDirection:"column",gap:4},CA={display:"flex",justifyContent:"space-between",gap:8,padding:"3px 0"},$A={onClick:"click",onHover:"mouseenter",onChange:"change",onSubmit:"submit",onMount:"mount",onUnmount:"unmount",onFocus:"focus",onBlur:"blur"};function Ia(e){return $A[e]??e.replace(/^on/,"").toLowerCase()}function If(e){const t=e.actions;if(Array.isArray(t))return t.filter(r=>!!r&&typeof r=="object");const n=e.props??{};if(Array.isArray(n.actions))return n.actions.filter(r=>!!r&&typeof r=="object");const o=n.events;if(o&&typeof o=="object"){const r=[];for(const[s,a]of Object.entries(o)){if(!Array.isArray(a))continue;const l=Ia(s);for(const d of a)d&&typeof d=="object"&&r.push("type"in d?d:{type:l,...d})}return r}return[]}function EA(e,t){const n=Ia(t);return If(e).filter(o=>o.type===n||t==="onClick"&&o.type===void 0)}function IA(e,t,n){const o=Ia(t),r=If(e).filter(d=>!(d.type===o||t==="onClick"&&d.type===void 0)),s=n.map(d=>"type"in d?d:{type:o,...d}),a={...e};if(a.props&&typeof a.props=="object"){const d={...a.props};let c=!1;"actions"in d&&(delete d.actions,c=!0),"events"in d&&(delete d.events,c=!0),c&&(a.props=d)}const l=[...r,...s];return l.length===0?delete a.actions:a.actions=l,a}function AA({node:e,spec:t,capability:n,t:o,onPatchNode:r,pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,bindingCandidates:d}){const c=t?.actionRecipes,p=u.useMemo(()=>({pageCandidates:s,dataSourceCandidates:a,stateKeyCandidates:l,bindingCandidates:d}),[s,a,l,d]),f=Array.isArray(n?.events)?n.events:[];return f.length===0?i.jsx("div",{"data-testid":"g7le-action-no-events",style:Af,children:o("layout_editor.action.no_events")}):!c||Object.keys(c).length===0?i.jsx("div",{"data-testid":"g7le-action-no-recipes",style:Af,children:o("layout_editor.action.no_recipes")}):i.jsx("div",{className:"g7le-action-editor","data-testid":"g7le-action-editor",style:{display:"flex",flexDirection:"column",gap:14},children:f.map(y=>i.jsxs("div",{className:"g7le-action-event-slot","data-testid":`g7le-action-event-${y}`,children:[i.jsx("div",{style:TA,children:o(`layout_editor.action.event.${y}`)}),i.jsx(Gt,{actions:EA(e,y),onChange:g=>r(IA(e,y,g)),t:o,recipes:c,candidatePools:p,actionChipCandidates:t?.actionChipCandidates??null,addContext:"component",showSourceBadge:!0,testIdPrefix:`g7le-action-slot-${y}`})]},y))})}const Af={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},TA={fontSize:12,fontWeight:700,color:"#475569",marginBottom:6};function Aa(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function DA(e,t){return Aa(t,e.labelKey,e.path||e.sourceId)}function NA({node:e,spec:t,candidates:n,t:o,onPatchNode:r}){const a=(e.props??{})[t.propKey],l=typeof a=="string"?a:void 0,d=u.useMemo(()=>Kt(a),[a]),c=l!==void 0&&l!==""&&d===null,[p,f]=u.useState(!1),[y,g]=u.useState(""),m=Aa(o,t.label,t.propKey),h=o(t.shape==="array"?"layout_editor.binding.shape_array":t.shape==="object"?"layout_editor.binding.shape_object":"layout_editor.binding.shape_scalar"),v=u.useMemo(()=>Fi(n,t.shape).map(_=>({..._,resolvedLabel:DA(_,o)})),[n,t.shape,o]),b=u.useMemo(()=>Ki(v,y),[v,y]),x=w=>{const _={...e.props??{}};w===void 0?delete _[t.propKey]:_[t.propKey]=w,r({...e,props:_})},k=w=>{x(En(w.sourceId,w.path,t.shape)),f(!1),g("")};return i.jsxs("div",{"data-testid":`g7le-binding-row-${t.propKey}`,style:PA,children:[i.jsxs("div",{style:OA,children:[i.jsx("span",{style:{fontWeight:600,fontSize:12},children:m}),i.jsx("span",{style:BA,children:h}),t.required&&i.jsx("span",{"data-testid":`g7le-binding-required-${t.propKey}`,style:WA,children:o("layout_editor.binding.required")})]}),c?i.jsxs("div",{"data-testid":`g7le-binding-complex-${t.propKey}`,style:KA,children:[o("layout_editor.binding.complex"),i.jsx("code",{style:Tf,children:l})]}):d?i.jsxs("div",{style:jA,children:[i.jsx("code",{"data-testid":`g7le-binding-expr-${t.propKey}`,style:Tf,children:l}),i.jsx("button",{type:"button","data-testid":`g7le-binding-clear-${t.propKey}`,onClick:()=>x(void 0),style:FA,children:o("layout_editor.binding.clear")})]}):i.jsx("div",{"data-testid":`g7le-binding-empty-${t.propKey}`,style:HA,children:o("layout_editor.binding.not_connected")}),d&&(t.shape==="array"||t.shape==="object")&&t.itemFields&&t.itemFields.length>0&&i.jsxs("div",{style:VA,children:[o("layout_editor.binding.item_fields"),": ",t.itemFields.join(", ")]}),!c&&i.jsxs("div",{style:{marginTop:4},children:[i.jsxs("button",{type:"button","data-testid":`g7le-binding-search-toggle-${t.propKey}`,onClick:()=>f(w=>!w),style:UA,children:["🔍 ",o("layout_editor.binding.search")]}),p&&i.jsxs("div",{"data-testid":`g7le-binding-picker-${t.propKey}`,style:qA,children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":`g7le-binding-search-input-${t.propKey}`,value:y,placeholder:o("layout_editor.binding.search_placeholder"),onChange:w=>g(w.target.value),style:GA}),i.jsx("div",{style:YA,children:b.length===0?i.jsx("div",{style:XA,children:o("layout_editor.binding.no_results")}):b.map(w=>i.jsxs("button",{type:"button","data-testid":`g7le-binding-candidate-${w.expression}`,onClick:()=>k(w),style:JA,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:w.resolvedLabel}),i.jsxs("span",{style:ZA,children:[Aa(o,w.groupLabelKey,w.sourceId)," · ",w.path||w.sourceId]})]}),i.jsx("span",{style:QA,children:w.preview})]},w.expression))})]})]})]})}function zA({node:e,dataProps:t,candidates:n,t:o,onPatchNode:r}){return!Array.isArray(t)||t.length===0?null:i.jsxs("div",{"data-testid":"g7le-data-binding-section",style:MA,children:[i.jsxs("div",{style:LA,children:["🔗 ",o("layout_editor.binding.section_title")]}),t.map(s=>i.jsx(NA,{node:e,spec:s,candidates:n,t:o,onPatchNode:r},s.propKey)),i.jsx("div",{style:RA})]})}const MA={display:"flex",flexDirection:"column",marginBottom:8},LA={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},PA={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},OA={display:"flex",alignItems:"center",gap:6},BA={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#e0e7ff",color:"#3730a3"},WA={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#fef3c7",color:"#92400e"},jA={display:"flex",alignItems:"center",gap:6},HA={fontSize:11,color:"#94a3b8"},Tf={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},FA={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},KA={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},VA={fontSize:10,color:"#94a3b8"},UA={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 10px",cursor:"pointer",textAlign:"left",width:"100%"},qA={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},GA={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},YA={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},XA={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},JA={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},ZA={fontSize:10,color:"#94a3b8"},QA={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},RA={borderTop:"1px solid #e2e8f0",marginTop:4};function e6({node:e,candidates:t,t:n,onPatchNode:o,templateIdentifier:r}){const s=typeof e.text=="string"?e.text:"",{state:a}=Je(),l=r??a.templateIdentifier,d=a.selectedRoute?.layoutName??null,c=a.locale,[p,f]=u.useState(!1),y=/^\s*\$t:custom\.[A-Za-z0-9._-]+\s*$/.test(s),m=Zl(s)&&(mt(s)?.key.startsWith("custom.")??!1)||y,h=u.useMemo(()=>m?mt(s):null,[m,s]),v=u.useMemo(()=>m?[]:Fl(s),[m,s]),b=N=>{o({...e,text:N})},x=()=>{if(!(typeof window>"u"))try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:l,locale:c}}))}catch{}},k=(N,$)=>{const A=En($.sourceId,$.path,"scalar");b(vb(s,N,A))},w=N=>{const $=mt(s);b(bo(s,N));const A=$?.key;l&&A&&Hd(l,A,N).then(x)},_=N=>{const $=n(N);return $&&$!==N&&!$.startsWith("$t:")?$:""},S=N=>N.replace(/\$t:[a-zA-Z0-9._-]+/g,$=>_($.slice(3))).replace(/\s+/g," ").trim(),E=N=>{p||(f(!0),ls(l,d,c,s,s.length,N.sourceId,N.path,"scalar",S,$=>n($)).then($=>{$.kind==="ok"&&(b($.text),x())}).finally(()=>f(!1)))},C=N=>{p||!h&&!y||(f(!0),wr(l,c,s,Number.MAX_SAFE_INTEGER,N.sourceId,N.path,"scalar").then($=>{$.kind==="ok"&&(b($.text),x())}).finally(()=>f(!1)))};if(m){const N=h?.params??[];return i.jsxs("div",{"data-testid":"g7le-inline-binding-section",style:Df,children:[i.jsxs("div",{style:Nf,children:["🔗 ",n("layout_editor.inline_binding.section_title")]}),N.length===0?i.jsx("div",{"data-testid":"g7le-inline-binding-none",style:Lf,children:n("layout_editor.inline_binding.none")}):N.map($=>i.jsx("div",{"data-testid":`g7le-inline-binding-param-${$.name}`,style:zf,children:$.parsed===null?i.jsxs("div",{"data-testid":`g7le-inline-binding-param-complex-${$.name}`,style:n6,children:[n("layout_editor.inline_binding.complex"),i.jsx("code",{style:Ta,children:$.expression})]}):i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Mf,children:[i.jsx("code",{"data-testid":`g7le-inline-binding-param-expr-${$.name}`,style:Ta,children:$.expression}),i.jsx("button",{type:"button","data-testid":`g7le-inline-binding-param-clear-${$.name}`,onClick:()=>w($.name),style:t6,children:n("layout_editor.inline_binding.clear")})]}),i.jsx($t,{candidates:t,t:n,onSelect:A=>k($.name,A),testIdSuffix:`param-${$.name}`})]})},$.name)),i.jsxs("div",{"data-testid":"g7le-inline-binding-param-append",style:Pf,children:[i.jsx("div",{style:Da,children:n("layout_editor.inline_binding.append_hint")}),i.jsx($t,{candidates:t,t:n,onSelect:C,testIdSuffix:"param-append"})]}),i.jsx("div",{"data-testid":"g7le-inline-binding-param-hint",style:Da,children:n("layout_editor.inline_binding.param_hint")}),i.jsx("div",{style:Of})]})}return i.jsxs("div",{"data-testid":"g7le-inline-binding-section",style:Df,children:[i.jsxs("div",{style:Nf,children:["🔗 ",n("layout_editor.inline_binding.section_title")]}),v.length===0?i.jsx("div",{"data-testid":"g7le-inline-binding-none",style:Lf,children:n("layout_editor.inline_binding.none")}):v.map(N=>i.jsx("div",{"data-testid":`g7le-inline-binding-row-${N.bindingIndex}`,style:zf,children:i.jsx("div",{style:Mf,children:i.jsx("code",{"data-testid":`g7le-inline-binding-expr-${N.bindingIndex}`,style:Ta,children:N.expression})})},N.bindingIndex)),i.jsxs("div",{"data-testid":"g7le-inline-binding-append",style:Pf,children:[i.jsx("div",{style:Da,children:n("layout_editor.inline_binding.append_hint")}),i.jsx($t,{candidates:t,t:n,onSelect:E,testIdSuffix:"append"})]}),i.jsx("div",{style:Of})]})}const Df={display:"flex",flexDirection:"column",marginBottom:8},Nf={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},zf={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},Mf={display:"flex",alignItems:"center",gap:6},Lf={fontSize:11,color:"#94a3b8"},Ta={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},t6={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},n6={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},Pf={display:"flex",flexDirection:"column",gap:2,padding:"8px 0 0"},Da={fontSize:11,color:"#475569",fontWeight:600},Of={borderTop:"1px solid #e2e8f0",marginTop:4};function Bf(e,t,n){if(!t)return n;const o=e(t);return!o||o===t||o.startsWith("$t:")?n:o}function o6(e,t){return Bf(t,e.labelKey,e.path||e.sourceId)}function r6(e){const t=e.iteration;return t&&typeof t=="object"?t:null}function i6({node:e,candidates:t,t:n,onPatchNode:o}){const r=r6(e),s=r?.source,a=typeof s=="string"?s:void 0,l=u.useMemo(()=>Kt(s),[s]),d=a!==void 0&&a!==""&&l===null,[c,p]=u.useState(!1),[f,y]=u.useState(""),g=u.useMemo(()=>Fi(t,"array").map(w=>({...w,resolvedLabel:o6(w,n)})),[t,n]),m=u.useMemo(()=>Ki(g,f),[g,f]);if(!r)return null;const h=typeof r.item_var=="string"?r.item_var:void 0,v=typeof r.index_var=="string"?r.index_var:void 0,b=k=>{const w={...r};k===void 0?delete w.source:w.source=k,o({...e,iteration:w})},x=k=>{b(En(k.sourceId,k.path,"array")),p(!1),y("")};return i.jsxs("div",{"data-testid":"g7le-iteration-binding-section",style:s6,children:[i.jsxs("div",{style:a6,children:["🔁 ",n("layout_editor.binding.iteration_title")]}),i.jsx("div",{style:l6,children:n("layout_editor.binding.iteration_hint")}),i.jsxs("div",{style:d6,children:[i.jsxs("div",{style:c6,children:[i.jsx("span",{style:{fontWeight:600,fontSize:12},children:n("layout_editor.binding.iteration_label")}),i.jsx("span",{style:u6,children:n("layout_editor.binding.shape_array")})]}),d?i.jsxs("div",{"data-testid":"g7le-iteration-binding-complex",style:h6,children:[n("layout_editor.binding.complex"),i.jsx("code",{style:Wf,children:a})]}):l?i.jsxs("div",{style:p6,children:[i.jsx("code",{"data-testid":"g7le-iteration-binding-expr",style:Wf,children:a}),i.jsx("button",{type:"button","data-testid":"g7le-iteration-binding-clear",onClick:()=>b(void 0),style:g6,children:n("layout_editor.binding.clear")})]}):i.jsx("div",{"data-testid":"g7le-iteration-binding-empty",style:f6,children:n("layout_editor.binding.not_connected")}),(h||v)&&i.jsxs("div",{"data-testid":"g7le-iteration-vars",style:y6,children:[h&&i.jsxs("span",{children:[n("layout_editor.binding.iteration_item_var"),": ",i.jsx("code",{style:jf,children:h})]}),v&&i.jsxs("span",{children:[n("layout_editor.binding.iteration_index_var"),": ",i.jsx("code",{style:jf,children:v})]})]}),!d&&i.jsxs("div",{style:{marginTop:4},children:[i.jsxs("button",{type:"button","data-testid":"g7le-iteration-binding-search-toggle",onClick:()=>p(k=>!k),style:b6,children:["🔍 ",n("layout_editor.binding.search")]}),c&&i.jsxs("div",{"data-testid":"g7le-iteration-binding-picker",style:m6,children:[i.jsx("input",{type:"text",autoFocus:!0,"data-testid":"g7le-iteration-binding-search-input",value:f,placeholder:n("layout_editor.binding.search_placeholder"),onChange:k=>y(k.target.value),style:v6}),i.jsx("div",{style:_6,children:m.length===0?i.jsx("div",{style:x6,children:n("layout_editor.binding.no_results")}):m.map(k=>i.jsxs("button",{type:"button","data-testid":`g7le-iteration-binding-candidate-${k.expression}`,onClick:()=>x(k),style:w6,children:[i.jsxs("span",{style:{display:"flex",flexDirection:"column",alignItems:"flex-start"},children:[i.jsx("span",{style:{fontSize:12},children:k.resolvedLabel}),i.jsxs("span",{style:k6,children:[Bf(n,k.groupLabelKey,k.sourceId)," · ",k.path||k.sourceId]})]}),i.jsx("span",{style:S6,children:k.preview})]},k.expression))})]})]})]}),i.jsx("div",{style:C6})]})}const s6={display:"flex",flexDirection:"column",marginBottom:8},a6={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:4},l6={fontSize:10,color:"#94a3b8",marginBottom:6,lineHeight:1.4},d6={display:"flex",flexDirection:"column",gap:4,padding:"8px 0"},c6={display:"flex",alignItems:"center",gap:6},u6={fontSize:10,padding:"1px 6px",borderRadius:4,background:"#e0e7ff",color:"#3730a3"},p6={display:"flex",alignItems:"center",gap:6},f6={fontSize:11,color:"#94a3b8"},Wf={fontSize:11,fontFamily:"monospace",background:"#f1f5f9",padding:"2px 6px",borderRadius:4,color:"#0f172a",wordBreak:"break-all"},g6={fontSize:11,border:"1px solid #e2e8f0",borderRadius:4,background:"#fff",color:"#dc2626",padding:"2px 8px",cursor:"pointer"},h6={display:"flex",flexDirection:"column",gap:2,fontSize:11,color:"#64748b"},y6={display:"flex",gap:12,fontSize:10,color:"#94a3b8",marginTop:2},jf={fontSize:10,fontFamily:"monospace",background:"#f1f5f9",padding:"1px 4px",borderRadius:3,color:"#475569"},b6={fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#334155",padding:"4px 10px",cursor:"pointer",textAlign:"left",width:"100%"},m6={marginTop:4,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",padding:6},v6={width:"100%",padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"},_6={marginTop:4,maxHeight:200,overflowY:"auto",display:"flex",flexDirection:"column",gap:2},x6={fontSize:11,color:"#94a3b8",padding:"8px 0",textAlign:"center"},w6={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8,border:"none",background:"transparent",cursor:"pointer",padding:"6px 8px",borderRadius:4,textAlign:"left"},k6={fontSize:10,color:"#94a3b8"},S6={fontSize:10,color:"#64748b",fontFamily:"monospace",whiteSpace:"nowrap"},C6={borderTop:"1px solid #e2e8f0",marginTop:4},$6=new Set(["Span","P","H1","H2","H3","H4","H5","H6","A","Button","Li","Label"]);function E6(e,t){if(!e||e.iteration&&typeof e.iteration=="object")return!1;const n=t?.textBinding;if(n===!1)return!1;if(n===!0)return!0;const o=typeof e.name=="string"?e.name:"";return $6.has(o)?!0:typeof e.text=="string"}const yn={direction:"flexDirection",justify:"flexJustify",align:"flexAlign",wrap:"flexWrap",gap:"flexGap",enable:"flexEnable"},hi={grow:"flexItemGrow",selfAlign:"flexItemAlign",order:"flexItemOrder"};function Hf(e){const t=e?.flexEditor;return t==="container"||t==="item"||t==="auto"?t:null}function I6(e){if(!e||typeof window>"u"||typeof window.getComputedStyle!="function")return!1;const t=(window.getComputedStyle(e).display??"").toLowerCase();return t==="flex"||t==="inline-flex"}function A6(e,t,n=Wt){if(!e||!t)return!1;const o=To(e,t,n),r=t.options,s=Array.isArray(r)?t.onValue??r[0]?.value:void 0,a=l=>s!==void 0?l===s:l!==void 0&&l!==!1;return n.breakpoint!=="base"?a(o.scopedRawValue):o.matched&&a(o.value)}function T6(e,t,n=!1,o=!1,r=Wt){if(e==="container")return{showContainer:!0,showEnableButton:!1,showDisableButton:!1};if(e==="item")return{showContainer:!1,showEnableButton:!1,showDisableButton:!1};if(e==="auto"){const s=r.breakpoint==="base";return!o&&!s?{showContainer:!1,showEnableButton:!1,showDisableButton:!1}:(o?n:I6(t))?{showContainer:!0,showEnableButton:!1,showDisableButton:o}:{showContainer:!1,showEnableButton:!0,showDisableButton:!1}}return{showContainer:!1,showEnableButton:!1,showDisableButton:!1}}const D6={[yn.direction]:"layout_editor.flex.direction",[yn.justify]:"layout_editor.flex.justify",[yn.align]:"layout_editor.flex.align",[yn.wrap]:"layout_editor.flex.wrap",[yn.gap]:"layout_editor.flex.gap"},N6={[hi.grow]:"layout_editor.flex.item_grow",[hi.selfAlign]:"layout_editor.flex.item_align",[hi.order]:"layout_editor.flex.item_order"};function z6({node:e,spec:t,capability:n,t:o,onPatchNode:r,liveElement:s,scope:a=Wt}){const l=Hf(n),d=Ct(t,yn.enable),c=A6(e,d,a),{showContainer:p,showEnableButton:f,showDisableButton:y}=T6(l,s,c,!!d,a),g=Object.values(yn).filter(_=>_!==yn.enable&&Ct(t,_)),m=Object.values(hi).filter(_=>Ct(t,_)),h=d?.options,v=d?.onValue??h?.[0]?.value??!0,b=Array.isArray(h)&&h.length>=2?h[1].value:void 0,x=()=>{if(!d)return;const _=fn(e,d,v,a);r(_)},k=()=>{if(!d)return;const _=a.breakpoint!=="base"&&b!==void 0?b:void 0,S=fn(e,d,_,a);r(S)},w=(_,S,E)=>i.jsx("div",{"data-testid":E,style:{display:"flex",flexDirection:"column"},children:_.map(C=>{const N=Ct(t,C);if(!N)return null;const $=N.label?N:{...N,label:`$t:${S[C]}`};return i.jsx(pi,{controlKey:C,control:$,node:e,t:o,onPatch:r,scope:a},C)})});return i.jsxs("div",{className:"g7le-flex-editor","data-testid":"g7le-flex-editor",style:{display:"flex",flexDirection:"column",gap:12},children:[f&&i.jsx("button",{type:"button","data-testid":"g7le-flex-enable",onClick:x,style:M6,children:o("layout_editor.flex.make_flex")}),p&&g.length>0&&i.jsxs("div",{className:"g7le-flex-container-section","data-testid":"g7le-flex-container-section",children:[i.jsx("div",{style:Ff,children:o("layout_editor.flex.container_title")}),w(g,D6,"g7le-flex-container-controls"),y&&i.jsx("button",{type:"button","data-testid":"g7le-flex-disable",onClick:k,style:L6,children:o("layout_editor.flex.unmake_flex")})]}),l==="item"&&m.length>0&&i.jsxs("div",{className:"g7le-flex-item-section","data-testid":"g7le-flex-item-section",children:[i.jsx("div",{style:Ff,children:o("layout_editor.flex.item_title")}),w(m,N6,"g7le-flex-item-controls")]}),!p&&!f&&l!=="item"&&i.jsx("div",{"data-testid":"g7le-flex-empty",style:P6,children:o("layout_editor.flex.not_flex")})]})}const Ff={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:6},M6={padding:"8px 12px",fontSize:12,border:"1px dashed #2563eb",borderRadius:6,background:"#eff6ff",color:"#2563eb",cursor:"pointer"},L6={marginTop:8,padding:"6px 10px",fontSize:12,border:"1px solid #e2e8f0",borderRadius:6,background:"#f8fafc",color:"#64748b",cursor:"pointer"},P6={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},Kf=["desktop","tablet","mobile","portable"],Na=1280,O6=820,B6=390,W6=1920,j6=320;function H6(e){const t=[],n=new Set,o=r=>{if(!r||typeof r!="object")return;const s=r.responsive;if(s&&typeof s=="object")for(const a of Object.keys(s)){n.has(a)||(n.add(a),t.push(a));const l=s[a]?.children;if(Array.isArray(l))for(const d of l)o(d)}if(Array.isArray(r.children))for(const a of r.children)o(a)};if(Array.isArray(e))for(const r of e)o(r);return t}function F6(e){const t=[...Kf],n=new Set(Kf);for(const o of H6(e))!n.has(o)&&!t.includes(o)&&t.push(o);return t}function K6(e,t){const n=e?.responsive;if(!n||typeof n!="object")return[];const o=[];for(const r of Object.keys(n)){const s=n[r];!s||!Array.isArray(s.children)||r!==t&&o.push(r)}return o}function V6(e){switch(e){case"desktop":return Na;case"tablet":return O6;case"mobile":return B6}const t=e in Ao?Ao[e]:Mn.parseRange(e);return!t||!Number.isFinite(t.max)?Na:Math.round(Math.max(j6,Math.min(W6,t.max)))}function Vf(){const t=Ft()?.document?.raw?.components;return u.useMemo(()=>F6(t??null),[t])}const U6={desktop:"layout_editor.property_modal.scope.device_desktop",tablet:"layout_editor.property_modal.scope.device_tablet",mobile:"layout_editor.property_modal.scope.device_mobile",portable:"layout_editor.property_modal.scope.device_portable"},q6=[{key:"base",labelKey:"layout_editor.property_modal.scope.scheme_light"},{key:"dark",labelKey:"layout_editor.property_modal.scope.scheme_dark"}];function Uf({scope:e,onChange:t,node:n,t:o,showColorScheme:r=!0,onClearScope:s}){const[a,l]=u.useState(!1),[d,c]=u.useState(""),[p,f]=u.useState(""),[y,g]=u.useState(null),m=Vf(),h=u.useMemo(()=>{const k=["base",...m],w=new Set(k);for(const _ of Object.keys(n.responsive??{}))_!=="base"&&!w.has(_)&&(w.add(_),k.push(_));return e.breakpoint!=="base"&&!w.has(e.breakpoint)&&k.push(e.breakpoint),k},[m,n.responsive,e.breakpoint]),v=k=>t({...e,colorScheme:k}),b=k=>t({...e,breakpoint:k}),x=()=>{const k=d.trim(),w=p.trim();if(k===""&&w===""){g(o("layout_editor.property_modal.scope.custom_invalid"));return}const _=`${k}-${w}`;if(!uC(_)){g(o("layout_editor.property_modal.scope.custom_invalid"));return}g(null),l(!1),c(""),f(""),b(_)};return i.jsxs("div",{className:"g7le-style-scope-tabs","data-testid":"g7le-style-scope-tabs",style:G6,children:[r&&i.jsx("div",{className:"g7le-style-scope-scheme","data-testid":"g7le-style-scope-scheme",role:"tablist","aria-label":o("layout_editor.property_modal.scope.scheme_light"),style:qf,children:q6.map(k=>{const w=e.colorScheme===k.key;return i.jsxs("button",{type:"button",role:"tab","aria-selected":w,"data-testid":`g7le-style-scheme-${k.key}`,"data-active":w?"true":"false",onClick:()=>v(k.key),style:Gf(w),children:[o(k.labelKey),k.key==="dark"&&Gs(n,{colorScheme:"dark",breakpoint:e.breakpoint})&&i.jsx("span",{"data-testid":"g7le-style-scheme-dark-dot",style:Yf})]},k.key)})}),i.jsxs("div",{className:"g7le-style-scope-device","data-testid":"g7le-style-scope-device",role:"tablist","aria-label":o("layout_editor.property_modal.scope.device_base"),style:qf,children:[h.map(k=>{const w=e.breakpoint===k,_=k==="base"||xu(k),S=k==="base"?"layout_editor.property_modal.scope.device_base":U6[k],E=S?o(S):k,C=_?`g7le-style-bp-${k}`:`g7le-style-bp-custom-${k}`;return i.jsxs("button",{type:"button",role:"tab","aria-selected":w,"data-testid":C,"data-active":w?"true":"false",onClick:()=>b(k),style:Gf(w),children:[E,Gs(n,{colorScheme:"base",breakpoint:k})&&i.jsx("span",{"data-testid":`${C}-dot`,style:Yf})]},String(k))}),i.jsxs("button",{type:"button","data-testid":"g7le-style-bp-add-custom",onClick:()=>l(k=>!k),style:J6,children:["+ ",o("layout_editor.property_modal.scope.add_custom")]})]}),a&&i.jsxs("div",{className:"g7le-style-scope-custom-input","data-testid":"g7le-style-scope-custom-input",style:Y6,children:[i.jsx("input",{type:"number","data-testid":"g7le-style-custom-min",placeholder:o("layout_editor.property_modal.scope.custom_min"),value:d,onChange:k=>c(k.target.value),style:Xf}),i.jsx("span",{style:{color:"#94a3b8"},children:"—"}),i.jsx("input",{type:"number","data-testid":"g7le-style-custom-max",placeholder:o("layout_editor.property_modal.scope.custom_max"),value:p,onChange:k=>f(k.target.value),style:Xf}),i.jsx("button",{type:"button","data-testid":"g7le-style-custom-confirm",onClick:x,style:Z6,children:o("layout_editor.property_modal.scope.add_custom")})]}),y&&i.jsx("div",{"data-testid":"g7le-style-custom-error",style:Q6,children:y}),e.breakpoint!=="base"&&!xu(e.breakpoint)&&i.jsx("div",{"data-testid":"g7le-style-custom-priority-note",style:R6,children:o("layout_editor.property_modal.scope.custom_priority_note")}),s&&Gs(n,e)&&i.jsxs("button",{type:"button","data-testid":"g7le-style-scope-reset",onClick:s,style:X6,children:["↺ ",o("layout_editor.property_modal.scope.reset_to_base")]})]})}const G6={display:"flex",flexDirection:"column",gap:0,padding:"0 0 8px",borderBottom:"1px solid #e2e8f0",marginBottom:8},Y6={display:"flex",gap:6,alignItems:"center",marginTop:4},qf={display:"flex",gap:2,flexWrap:"wrap",borderBottom:"1px solid #e2e8f0",margin:"0 -16px",padding:"0 12px"};function Gf(e){return{display:"inline-flex",alignItems:"center",gap:4,padding:"6px 12px",fontSize:12,border:"none",borderBottom:`2px solid ${e?"#2563eb":"transparent"}`,background:"transparent",color:e?"#2563eb":"#64748b",cursor:"pointer",fontWeight:e?700:500,marginBottom:-1}}const Yf={display:"inline-block",width:5,height:5,borderRadius:3,background:"#f59e0b"},X6={alignSelf:"flex-start",marginTop:2,padding:"3px 8px",fontSize:10,border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#475569",cursor:"pointer"},J6={padding:"6px 12px",fontSize:11,border:"none",borderBottom:"2px dashed #cbd5e1",background:"transparent",color:"#475569",cursor:"pointer",marginBottom:-1},Xf={width:70,padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6},Z6={padding:"4px 10px",fontSize:11,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},Q6={fontSize:11,color:"#dc2626"},R6={fontSize:10,color:"#94a3b8"},Jf=new Map;function Pn(e,t){Jf.set(e,t)}function eT(e){return e?Jf.get(e)??null:null}const za={id:{label:"$t:layout_editor.core_props.id.label",widget:"core-id",placeholder:"$t:layout_editor.core_props.id.placeholder",apply:{type:"propValue",propKey:"id"}},dataKey:{label:"$t:layout_editor.core_props.dataKey.label",widget:"core-datakey",placeholder:"$t:layout_editor.core_props.dataKey.placeholder",apply:{type:"nodeKey",nodeKey:"dataKey"}},isolatedState:{label:"$t:layout_editor.core_props.isolatedState.label",widget:"toggle",apply:{type:"nodeKey",nodeKey:"isolatedState"}},isolatedScopeId:{label:"$t:layout_editor.core_props.isolatedScopeId.label",widget:"core-scopeid",placeholder:"$t:layout_editor.core_props.isolatedScopeId.placeholder",apply:{type:"nodeKey",nodeKey:"isolatedScopeId"}}},tT=["id"];function nT(e){return e===!1?[]:Array.isArray(e)?e.filter(t=>t in za):[...tT]}function Zf(e){const t=e.props?.id??e.id;return typeof t=="string"&&t.length>0?`${t}-scope`:"scope"}function oT({node:e,onPatchNode:t,t:n,scopeIdCandidates:o=[],usedScopeIds:r=[]}){const s=e,a="isolatedState"in s&&s.isolatedState!=null,l=typeof s.isolatedScopeId=="string"?s.isolatedScopeId:"",[d,c]=u.useState(""),p=()=>{const h={...e};a?(delete h.isolatedState,delete h.isolatedScopeId):(h.isolatedState={},h.isolatedScopeId=Zf(e)),t(h)},f=h=>{const v={...e};v.isolatedScopeId=h.trim()===""?Zf(e):h,t(v)},y=h=>{const v={...e};v.isolatedState=h,t(v)},g=u.useMemo(()=>{const h=d.trim().toLowerCase();return o.filter(v=>!h||v.toLowerCase().includes(h))},[o,d]),m=l!==""&&r.includes(l);return i.jsxs("div",{className:"g7le-isolated-scope","data-testid":"g7le-isolated-scope-control",style:rT,children:[i.jsx("div",{style:iT,children:n("layout_editor.core_props.isolatedState.label")}),i.jsxs("div",{style:sT,children:[i.jsx("button",{type:"button","data-testid":"g7le-isolated-toggle",role:"switch","aria-checked":a,onClick:p,style:aT(a),children:n(a?"layout_editor.overlay.on":"layout_editor.overlay.off")}),a?null:i.jsx("span",{"data-testid":"g7le-isolated-off-hint",style:lT,children:n("layout_editor.core_props.isolatedState.off_hint")})]}),a?i.jsxs(i.Fragment,{children:[i.jsxs("div",{style:Qf,children:[i.jsx("label",{style:Rf,children:n("layout_editor.core_props.isolatedScopeId.label")}),i.jsx("input",{type:"text","data-testid":"g7le-isolated-scopeid-input",value:l,placeholder:n("layout_editor.core_props.isolatedScopeId.placeholder"),onChange:h=>f(h.target.value),style:dT}),g.length>0?i.jsxs("div",{"data-testid":"g7le-isolated-scopeid-picker",style:cT,children:[i.jsx("input",{type:"text","data-testid":"g7le-isolated-scopeid-search",value:d,placeholder:n("layout_editor.core_props.isolatedScopeId.search"),onChange:h=>c(h.target.value),style:uT}),i.jsx("div",{style:pT,children:g.map(h=>i.jsx("button",{type:"button","data-testid":`g7le-isolated-scopeid-candidate-${h}`,onClick:()=>{f(h),c("")},style:fT,children:h},h))})]}):null,m?i.jsxs("p",{"data-testid":"g7le-isolated-scopeid-dup",style:gT,children:["ⓘ ",n("layout_editor.core_props.isolatedScopeId.duplicate")]}):null]}),i.jsxs("div",{style:Qf,children:[i.jsx("label",{style:Rf,children:n("layout_editor.core_props.isolatedState.initial_label")}),i.jsx("div",{"data-testid":"g7le-isolated-initial-value",children:i.jsx(Nn,{value:s.isolatedState??{},onChange:y,t:n,path:"isolatedState",scope:"isolated-node"})})]})]}):null]})}const rT={display:"flex",flexDirection:"column",gap:8,padding:10,border:"1px solid #e2e8f0",borderRadius:8,minWidth:0},iT={fontSize:12,fontWeight:700,color:"#0f172a"},sT={display:"flex",gap:8,alignItems:"center"},aT=e=>({padding:"4px 12px",fontSize:11,border:`1px solid ${e?"#3b82f6":"#cbd5e1"}`,borderRadius:14,background:e?"#eff6ff":"#fff",color:e?"#1d4ed8":"#64748b",cursor:"pointer"}),lT={fontSize:11,color:"#94a3b8"},Qf={display:"flex",flexDirection:"column",gap:4,minWidth:0},Rf={fontSize:11,color:"#64748b"},dT={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box",width:"100%"},cT={border:"1px solid #e2e8f0",borderRadius:6,padding:4,background:"#fff"},uT={width:"100%",padding:"4px 6px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,marginBottom:4,boxSizing:"border-box"},pT={display:"flex",flexDirection:"column",maxHeight:120,overflowY:"auto"},fT={textAlign:"left",padding:"4px 6px",fontSize:12,border:"none",background:"transparent",cursor:"pointer",borderRadius:4,color:"#0f172a"},gT={margin:0,fontSize:11,color:"#64748b"},hT={borderTop:"2px solid #c7d2fe",background:"#eef2ff",padding:"10px 12px",display:"flex",flexDirection:"column",gap:8},eg={fontSize:13,fontWeight:700,color:"#3730a3",display:"flex",alignItems:"center",gap:6},yT={fontSize:11,fontWeight:600,color:"#4338ca",background:"#e0e7ff",border:"1px solid #c7d2fe",borderRadius:4,padding:"1px 6px"},Ma={fontSize:11,color:"#6366f1"},bT={alignSelf:"flex-start",fontSize:12,fontWeight:600,color:"#fff",background:"#4f46e5",border:"none",borderRadius:4,padding:"4px 10px",cursor:"pointer"},mT={opacity:.5,cursor:"not-allowed"},vT={fontSize:11,color:"#dc2626"};function _T({entry:e,t,onSave:n}){const[o,r]=u.useState(()=>e.props),[s,a]=u.useState(!0),[l,d]=u.useState(null),[c,p]=u.useState(!1),[f,y]=u.useState(!1),g=e.extensionName??e.extensionIdentifier??`#${e.extensionId}`,m=async()=>{if(!s){d(t("layout_editor.property_modal.injected_props.invalid_json"));return}d(null),p(!0),y(!1);try{await n(o),y(!0)}catch(h){d(h instanceof Error?h.message:String(h))}finally{p(!1)}};return i.jsxs("div",{"data-testid":`g7le-injected-props-entry-${e.extensionId}`,style:{display:"flex",flexDirection:"column",gap:6},children:[i.jsx("div",{style:eg,children:i.jsx("span",{style:yT,"data-testid":"g7le-injected-props-origin-badge",children:t("layout_editor.property_modal.injected_props.origin",{source:g})})}),i.jsx(Bs,{value:o,shape:"object",emptyValue:{},testidPrefix:"g7le-injected-props-json",minHeight:80,invalidErrorKey:"layout_editor.property_modal.injected_props.invalid_json",shapeErrorKey:"layout_editor.property_modal.injected_props.invalid_json",t,onChange:h=>{r(h??{}),y(!1)},onValidityChange:h=>{a(h),h&&d(null),y(!1)}}),l&&i.jsx("div",{style:vT,"data-testid":"g7le-injected-props-error",children:l}),f&&i.jsx("div",{style:{...Ma,color:"#16a34a"},"data-testid":"g7le-injected-props-saved",children:t("layout_editor.property_modal.injected_props.saved")}),i.jsx("div",{style:Ma,children:t("layout_editor.property_modal.injected_props.save_target")}),i.jsx("button",{type:"button","data-testid":"g7le-injected-props-save",style:{...bT,...c||!s?mT:null},disabled:c||!s,onClick:m,children:c?t("layout_editor.property_modal.injected_props.saving"):t("layout_editor.property_modal.injected_props.save_button",{source:g})})]})}function xT({injectedProps:e,t,onSaveInjectedProps:n}){return!Array.isArray(e)||e.length===0?null:i.jsxs("div",{style:hT,"data-testid":"g7le-injected-props-section",children:[i.jsxs("div",{style:eg,children:["🧩 ",t("layout_editor.property_modal.injected_props.title")]}),i.jsx("div",{style:Ma,children:t("layout_editor.property_modal.injected_props.help")}),e.map(o=>i.jsx(_T,{entry:o,t,onSave:r=>n(o.extensionId,r)},o.extensionId))]})}function wT(e,t){const n=e?.components??{};for(const o of Object.values(n)){const r=o?.find(s=>s.name===t);if(r)return r}return null}function kT({node:e,spec:t,manifest:n,t:o,onPatchNode:r,onClose:s,onDelete:a,permissionCandidates:l,pageCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p,bindingCandidates:f,liveElement:y,scope:g=Wt,onScopeChange:m,templateIdentifier:h,initialTab:v,onSaveInjectedProps:b,branchLabel:x,isolatedScopeIdCandidates:k,usedScopeIds:w}){const _=e.__injectedProps,S=Array.isArray(_)&&_.length>0&&!!b,E=ve=>{m?.(ve)},C=()=>{r(mC(e,g))},N=typeof e.name=="string"?e.name:"",$=Oi(t,N),z=wT(n,N)?.settings,D=!!(z?.groups&&z.groups.length>0),O=Array.isArray($?.propControls)?$.propControls:[],M=nT($?.coreProps),B=new Set(M.map(ve=>za[ve].apply?.propKey)),T=O.filter(ve=>{const he=Ct(t,ve)?.apply?.propKey;return!(he!==void 0&&B.has(he))}),H=typeof $?.nodeEditor?.kind=="string"?$.nodeEditor.kind:void 0,L=$?.nodeEditor?.params,I=H?eT(H):null,P=!!I,F=Array.isArray($?.dataProps)?$.dataProps:[],G=F.length>0,V=!!(e.iteration&&typeof e.iteration=="object"),Y=E6(e,$),Z=M.length>0||T.length>0||P||G||Y||V,se=Array.isArray($?.styleControls)?$.styleControls:[],re=Array.isArray($?.advanced)?$.advanced:[],ee=(Array.isArray($?.events)?$.events:[]).length>0,fe=Hf($),K=se.length>0||fe!==null,me=!($?.visibilityCondition===!1)&&(Z||K||ee||D||re.length>0),Ce=u.useMemo(()=>{const ve=[];return D&&ve.push("settings"),Z&&ve.push("props"),K&&ve.push("style"),ee&&ve.push("action"),me&&ve.push("visibility"),ve.push("translation"),re.length>0&&ve.push("advanced"),ve},[D,Z,K,ee,me,re.length]),ze=T.length>0||P||G||V,[Se,qe]=u.useState(v&&Ce.includes(v)?v:D?"settings":ze?"props":K?"style":Z?"props":Ce[0]??"style"),De=yA(e),Pe=!Z&&!K&&!D&&!ee&&!me&&re.length===0,Qe=$?.label?$.label.startsWith("$t:")?o($.label.slice(3)):$.label:N||o("layout_editor.property_modal.element"),Me=(ve,te)=>{const he={...e.props??{}};te===void 0?delete he[ve]:he[ve]=te,r({...e,props:he})},Re={settings:o("layout_editor.property_modal.tab.settings"),props:o("layout_editor.property_modal.tab.props"),style:o("layout_editor.property_modal.tab.style"),action:o("layout_editor.property_modal.tab.action"),visibility:o("layout_editor.property_modal.tab.visibility"),translation:o("layout_editor.property_modal.tab.translation"),advanced:o("layout_editor.property_modal.tab.advanced")};return i.jsxs("div",{className:"g7le-property-modal","data-testid":"g7le-property-modal",style:ST,children:[i.jsxs("div",{"data-modal-drag-handle":!0,"data-testid":"g7le-property-modal-header",style:CT,children:[i.jsx("span",{"data-testid":"g7le-property-modal-title",style:{fontSize:14,fontWeight:700},children:D&&Se==="settings"?o("layout_editor.property_modal.settings_title",{name:Qe}):o("layout_editor.property_modal.title",{name:Qe})}),i.jsx("button",{type:"button","data-testid":"g7le-property-modal-close","aria-label":"close",onClick:s,style:$T,children:"✕"})]}),De&&i.jsx("div",{"data-testid":"g7le-property-modal-advanced-badge",style:ET,children:o("layout_editor.property_modal.advanced_badge")}),x&&i.jsx("div",{"data-testid":"g7le-property-modal-branch-badge",style:IT,children:o("layout_editor.property_modal.branch_badge",{device:x})}),i.jsx("div",{style:AT,role:"tablist",children:Ce.map(ve=>i.jsx("button",{type:"button",role:"tab","aria-selected":Se===ve,"data-testid":`g7le-property-tab-${ve}`,"data-active":Se===ve?"true":"false",onClick:()=>qe(ve),style:{...TT,color:Se===ve?"#2563eb":"#64748b",borderBottomColor:Se===ve?"#2563eb":"transparent"},children:Re[ve]},ve))}),i.jsxs("div",{style:DT,"data-testid":"g7le-property-modal-body",children:[Pe&&!S&&i.jsx("div",{"data-testid":"g7le-property-modal-no-editable",style:ng,children:o("layout_editor.property_modal.no_editable_props")}),S&&i.jsx(xT,{injectedProps:_,t:o,onSaveInjectedProps:b}),Se==="settings"&&z&&i.jsx(rA,{spec:z,node:e,t:o,onPatchProp:Me}),Se==="props"&&i.jsxs("div",{"data-testid":"g7le-property-props-tab",style:{display:"flex",flexDirection:"column"},children:[V&&i.jsx(i6,{node:e,candidates:f??[],t:o,onPatchNode:r}),G&&i.jsx(zA,{node:e,dataProps:F,candidates:f??[],t:o,onPatchNode:r}),Y&&i.jsx(e6,{node:e,candidates:f??[],t:o,onPatchNode:r,templateIdentifier:h}),P&&I&&i.jsx(I,{node:e,params:L,spec:t,manifest:n,t:o,onPatchNode:r,templateIdentifier:h,candidates:f}),M.map(ve=>i.jsx(pi,{controlKey:`core-${ve}`,control:za[ve],node:e,t:o,onPatch:r,scope:Wt,bindingCandidates:f},`core-${ve}`)),T.map(ve=>{const te=Ct(t,ve);return te?i.jsx(pi,{controlKey:ve,control:te,node:e,t:o,onPatch:r,scope:Wt,bindingCandidates:f},ve):null}),i.jsx(oT,{node:e,onPatchNode:r,t:o,scopeIdCandidates:k,usedScopeIds:w})]}),Se==="style"&&i.jsxs("div",{"data-testid":"g7le-property-style-tab",style:{display:"flex",flexDirection:"column"},children:[i.jsx("div",{style:tg,children:i.jsx(Uf,{scope:g,onChange:E,node:e,t:o,onClearScope:C})}),fe!==null&&i.jsx(z6,{node:e,spec:t,capability:$,t:o,onPatchNode:r,liveElement:y,scope:g}),se.map(ve=>{const te=Ct(t,ve);return te?i.jsx(pi,{controlKey:ve,control:te,node:e,t:o,onPatch:r,scope:g},ve):null})]}),Se==="action"&&i.jsx(AA,{node:e,spec:t,capability:$,t:o,onPatchNode:r,pageCandidates:d,dataSourceCandidates:c,stateKeyCandidates:p,bindingCandidates:f}),Se==="visibility"&&i.jsxs("div",{"data-testid":"g7le-property-visibility-tab",style:{display:"flex",flexDirection:"column"},children:[i.jsx("div",{style:tg,children:i.jsx(Uf,{scope:g,onChange:E,node:e,t:o,showColorScheme:!1})}),i.jsx($u,{node:e,spec:t,t:o,onPatchNode:r,dataSourceCandidates:c,stateKeyCandidates:p,scope:g})]}),Se==="translation"&&(h?i.jsx(Vd,{customKey:Kd(e),templateIdentifier:h,t:o,paramLabels:pv(e),onRemoveParam:ve=>{const te=typeof e.text=="string"?e.text:"";r({...e,text:bo(te,ve)});const he=Kd(e);h&&he&&kr(h,he,ve)}}):i.jsx("div",{"data-testid":"g7le-property-translation-deferred",style:ng,children:o("layout_editor.translation.not_a_key")})),Se==="advanced"&&i.jsx(mA,{node:e,advanced:re,t:o,onPatch:r,permissionCandidates:l})]}),i.jsxs("div",{style:NT,children:[i.jsx("span",{"data-testid":"g7le-property-modal-node-id",style:{fontSize:11,color:"#94a3b8"},children:typeof e.id=="string"||typeof e.id=="number"?`ID: ${e.id}`:""}),i.jsxs("div",{style:{display:"flex",gap:8},children:[i.jsx("button",{type:"button","data-testid":"g7le-property-modal-delete",onClick:a,style:zT,children:o("layout_editor.property_modal.delete_element")}),i.jsx("button",{type:"button","data-testid":"g7le-property-modal-done",onClick:s,style:MT,children:o("layout_editor.property_modal.close")})]})]})]})}const ST={display:"flex",flexDirection:"column",width:420,maxWidth:"92vw"},CT={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderBottom:"1px solid #e2e8f0",cursor:"move",userSelect:"none"},$T={border:"none",background:"transparent",fontSize:14,cursor:"pointer",color:"#64748b"},ET={margin:"8px 16px 0",padding:"6px 10px",fontSize:11,background:"#fef3c7",color:"#92400e",borderRadius:6},IT={margin:"8px 16px 0",padding:"6px 10px",fontSize:11,background:"#dbeafe",color:"#1e40af",borderRadius:6},AT={display:"flex",gap:2,padding:"0 12px",borderBottom:"1px solid #e2e8f0",overflowX:"auto"},TT={padding:"8px 10px",fontSize:12,border:"none",borderBottom:"2px solid transparent",background:"transparent",cursor:"pointer",whiteSpace:"nowrap"},DT={padding:16,overflowY:"auto",maxHeight:"60vh"},tg={position:"sticky",top:-16,zIndex:2,background:"#fff",margin:"-16px -16px 0",padding:"6px 16px 0"},ng={fontSize:12,color:"#94a3b8",padding:"16px 0",textAlign:"center"},NT={display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 16px",borderTop:"1px solid #e2e8f0"},zT={padding:"6px 12px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},MT={padding:"6px 12px",fontSize:12,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",cursor:"pointer"},La="g7le:component-target-picked",Pa="g7le:component-target-pick-request";let LT=1;function PT({value:e,onChange:t,t:n,control:o}){const r=yo(),s=e==null?"":String(e),a=o?.allowManualInput!==!1,l=u.useCallback(()=>{if(typeof window>"u")return;const d=`pick-${LT++}`,p=r.stack[r.stack.length-1]?.id;p&&r.minimize(p,n("layout_editor.target_picker.minimized_hint"));const f=y=>{const g=y.detail;g?.requestId===d&&(window.removeEventListener(La,f),p&&r.restore(p),!g.cancelled&&typeof g.id=="string"&&t(g.id))};window.addEventListener(La,f),window.dispatchEvent(new CustomEvent(Pa,{detail:{requestId:d}}))},[r,t,n]);return i.jsx("div",{className:"g7le-component-target-picker","data-testid":"g7le-component-target-picker",children:i.jsxs("div",{style:{display:"flex",gap:6,alignItems:"center"},children:[a?i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--text","data-testid":"g7le-component-target-input",value:s,placeholder:n("layout_editor.target_picker.id_placeholder"),onChange:d=>t(d.target.value===""?void 0:d.target.value),style:{flex:1,minWidth:0,padding:"6px 8px",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13}}):i.jsx("span",{"data-testid":"g7le-component-target-value",style:{flex:1,minWidth:0},children:s||n("layout_editor.target_picker.none")}),i.jsxs("button",{type:"button","data-testid":"g7le-component-target-pick",onClick:l,style:{display:"flex",alignItems:"center",gap:4,padding:"6px 10px",background:"#1e293b",color:"#fff",border:"none",borderRadius:6,cursor:"pointer",fontSize:13,whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",children:"🎯"}),n("layout_editor.target_picker.select_area")]})]})})}async function OT(e,t,n,o,r=fetch){const s=`/api/admin/templates/${encodeURIComponent(e)}/layout-extensions/${t}`;let a;try{a=await r(s,{headers:je(),credentials:"same-origin"})}catch(b){return{kind:"error",message:b instanceof Error?b.message:String(b)}}if(a.status===404)return{kind:"not_found"};if(!a.ok)return{kind:"error",message:`HTTP ${a.status}`};const l=await a.json().catch(()=>null),d=(l&&(l.data||l))??{};let c={};const p=d.content;if(typeof p=="string")try{c=JSON.parse(p)}catch{return{kind:"error",message:"invalid extension content"}}else p&&typeof p=="object"&&(c=p);const f=typeof d.lock_version=="number"?d.lock_version:0,y=Array.isArray(c.injections)?[...c.injections]:[],g=y.findIndex(b=>b&&b.position==="inject_props"&&b.target_id===n);if(g<0)return{kind:"injection_not_found"};y[g]={...y[g],props:o};const m={...c,injections:y};let h;try{h=await r(s,{method:"PUT",headers:je({"Content-Type":"application/json"}),credentials:"same-origin",body:JSON.stringify({content:m,expected_lock_version:f})})}catch(b){return{kind:"error",message:b instanceof Error?b.message:String(b)}}const v=await h.json().catch(()=>null);return h.ok?{kind:"success",newLockVersion:typeof v?.data?.lock_version=="number"?v.data.lock_version:f+1}:h.status===409?{kind:"conflict",currentVersion:v?.current_version??-1,yourVersion:v?.your_version??f}:{kind:"error",message:v?.message??`HTTP ${h.status}`}}const og=new Map;function Oa(e,t){og.set(e,t)}function BT(e){return e?og.get(e)??null:null}const rg="g7le.clipboard";function ig(){try{return typeof window>"u"||!window.sessionStorage?null:window.sessionStorage}catch{return null}}function WT(e){const t=JSON.parse(JSON.stringify(e)),n=o=>{for(const r of Object.keys(o))r.startsWith("__")&&delete o[r];Array.isArray(o.children)&&o.children.forEach(n)};return n(t),t}function sg(e,t=0){const n=ig();if(!n)return!1;try{const o={node:WT(e),copiedAt:t};return n.setItem(rg,JSON.stringify(o)),!0}catch{return!1}}function jT(){const e=ig();if(!e)return null;try{const t=e.getItem(rg);if(!t)return null;const n=JSON.parse(t);return!n||typeof n.node!="object"||n.node===null?null:JSON.parse(JSON.stringify(n.node))}catch{return null}}const Yo={nw:{width:-1,height:-1},n:{width:0,height:-1},ne:{width:1,height:-1},e:{width:1,height:0},se:{width:1,height:1},s:{width:0,height:1},sw:{width:-1,height:1},w:{width:-1,height:0}};function Ba(e){if(typeof e=="number")return e;if(typeof e=="string"){const t=e.match(/^(-?\d+(?:\.\d+)?)px$/);if(t)return Number(t[1])}return null}function HT(e,t){const n=Array.isArray(e.options)?e.options:[],o=Array.isArray(e.scale)?e.scale:[],r=n.length>0?n.map(l=>l.value):o;if(r.length===0)return;let s=r[0],a=1/0;return r.forEach((l,d)=>{const c=Ba(l)??d*40,p=Math.abs(c-t);p{if(!t)return;const m=Yo[y];if(m.width!==0&&!n||m.height!==0&&!o)return;const h=t.props?.style??{},v=s?.()??null,b=Ba(h.width)??(v?Math.round(v.width):0),x=Ba(h.height)??(v?Math.round(v.height):0);p.current={handle:y,startX:g.clientX,startY:g.clientY,startW:b,startH:x,node:t};const k=m.width!==0&&m.height!==0?"both":m.width!==0?"width":"height";d?.(k);let w=null;const _=E=>{const C=p.current;if(!C)return;const N=r||1,$=(E.clientX-C.startX)/N*Yo[C.handle].width,A=(E.clientY-C.startY)/N*Yo[C.handle].height;let z=C.node;if(Yo[C.handle].width!==0&&n){const D=ag(n,C.startW+$);z=fn(z,n,D)}if(Yo[C.handle].height!==0&&o){const D=ag(o,C.startH+A);z=fn(z,o,D)}w=z,a(z,k)},S=()=>{p.current=null,d?.(null),window.removeEventListener("pointermove",_),window.removeEventListener("pointerup",S),w&&l?.(w,k)};window.addEventListener("pointermove",_),window.addEventListener("pointerup",S)},[t,n,o,r,a,l,d]);return{enabledAxes:c,onHandlePointerDown:f}}const lg=320,dg=1920,VT=1024;function Wa(e){return Number.isFinite(e)?Math.round(Math.max(lg,Math.min(dg,e))):VT}function ja(e={}){const{state:t,dispatch:n}=Je(),{previewDevice:o,previewZoom:r,previewCustomWidth:s,previewColorScheme:a}=t,l=o==="custom"?Wa(s):V6(o),d=u.useMemo(()=>{const g=e.availableWidth??l,m=l*r;return r>=1||m<=g?r:Math.min(r,g/l)},[l,r,e.availableWidth]),c=u.useCallback(g=>{n({type:"SET_PREVIEW_DEVICE",device:g})},[n]),p=u.useCallback(g=>{const m=Math.max(.5,Math.min(1,g));n({type:"SET_PREVIEW_ZOOM",zoom:m})},[n]),f=u.useCallback(g=>{n({type:"SET_PREVIEW_CUSTOM_WIDTH",width:Wa(g)})},[n]),y=u.useCallback(g=>{n({type:"SET_PREVIEW_COLOR_SCHEME",scheme:g})},[n]);return{device:o,deviceWidth:l,zoom:r,scale:d,customWidth:Wa(s),setDevice:c,setZoom:p,setCustomWidth:f,colorScheme:a,setColorScheme:y}}const yi="g7le-dnd-handle-";function UT(e){if(typeof e!="string"||!e.startsWith(yi))return null;const t=e.slice(yi.length);return t.length>0?t:null}function qT(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\\]]/g,"\\$&")}function GT(e,t,n,o){const r=o?.handleFallback??!1;if(e instanceof HTMLElement){const s=e.closest("[data-editor-path]");if(s)return s;const a=e.closest('[data-testid^="g7le-"]');if(a&&!(a.getAttribute("data-testid")??"").startsWith(yi))return null;if(r){const l=e.closest(`[data-testid^="${yi}"]`);if(l&&t){const d=UT(l.getAttribute("data-testid"));if(d){const c=t.querySelector(`[data-editor-path="${qT(d)}"]`);if(c)return c}}}}if(r&&n&&typeof document<"u"&&typeof document.elementsFromPoint=="function")for(const s of document.elementsFromPoint(n.x,n.y)){const a=s.closest?.("[data-editor-path]");if(a)return a}return null}function YT(e,t,n){if(typeof e=="string"&&e.trim().length>0)return{color:e};const o=typeof n?.color=="string"&&n.color.length>0,r=typeof t=="string"&&t.trim().length>0;return o||r?{}:{color:"#0f172a"}}const XT="#ffffff",JT="#0f172a";function ZT(e){const t=e.trim(),n=t.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);if(n)return[Number(n[1]),Number(n[2]),Number(n[3])];const o=t.replace(/^#/,"");return/^[0-9a-f]{3}$/i.test(o)?[parseInt(o[0]+o[0],16),parseInt(o[1]+o[1],16),parseInt(o[2]+o[2],16)]:/^[0-9a-f]{6}$/i.test(o)?[parseInt(o.slice(0,2),16),parseInt(o.slice(2,4),16),parseInt(o.slice(4,6),16)]:null}function QT(e){if(!e||/transparent/i.test(e))return!1;const t=e.trim().match(/^oklch\(\s*([\d.]+)(%?)/i);if(t)return(t[2]==="%"?Number(t[1])/100:Number(t[1]))>=.62;const n=ZT(e);if(!n)return!1;const[o,r,s]=n;return .299*o+.587*r+.114*s>=180}function RT(e,t,n){const o=typeof n?.color=="string"?n.color:null;return QT(e??o??t)?JT:XT}function e4({box:e,initialValue:t,isCustomKey:n,t:o,onCommit:r,onCancel:s,mirrorClassName:a,mirrorStyle:l,nodeEffectiveColor:d,onOpenTranslations:c}){const p=u.useRef(null),f=u.useRef(!1),[y,g]=u.useState(t.trim().length>0),[m,h]=u.useState(null);u.useEffect(()=>{const w=p.current;if(w){w.textContent=t,w.focus();try{const _=window.document.createRange();_.selectNodeContents(w);const S=window.getSelection();S?.removeAllRanges(),S?.addRange(_)}catch{}f.current=!1}},[t]),u.useEffect(()=>{const w=p.current;if(!w||typeof window.getComputedStyle!="function"){h(null);return}try{h(window.getComputedStyle(w).color||null)}catch{h(null)}},[a,l]);const v=()=>(p.current?.textContent??"").trim(),b=()=>{f.current||(f.current=!0,r(v()))},x=()=>{f.current||(f.current=!0,s())},k=w=>{w.key==="Enter"&&!w.shiftKey?(w.preventDefault(),b()):w.key==="Escape"&&(w.preventDefault(),x())};return i.jsxs("div",{className:"g7le-inline-text-editor","data-testid":"g7le-inline-text-editor","data-custom-key":n?"true":"false",style:{position:"absolute",left:e.left,top:e.top,minWidth:Math.max(e.width,60),minHeight:e.height,pointerEvents:"auto",zIndex:1e4},children:[i.jsx("div",{ref:p,role:"textbox","aria-label":o("layout_editor.inline_edit.aria_label"),"aria-multiline":"false","data-testid":"g7le-inline-text-editable",className:a,contentEditable:!0,suppressContentEditableWarning:!0,spellCheck:!1,onInput:()=>g(v().length>0),onKeyDown:k,onBlur:b,style:{outline:"2px solid #2563eb",outlineOffset:1,borderRadius:3,background:RT(d,m,l),padding:"2px 4px",minHeight:e.height,whiteSpace:"pre-wrap",wordBreak:"break-word",boxShadow:"0 2px 8px rgba(37, 99, 235, 0.25)",...l??{},...YT(d,a,l)}}),i.jsxs("div",{className:"g7le-inline-text-editor__hint","data-testid":"g7le-inline-text-editor-hint",style:{marginTop:2,display:"inline-flex",alignItems:"center",gap:4,whiteSpace:"nowrap"},children:[c?i.jsxs("button",{type:"button","data-testid":"g7le-inline-text-editor-open-translations",onMouseDown:w=>w.preventDefault(),onClick:w=>{w.preventDefault(),w.stopPropagation(),c()},title:o("layout_editor.inline_edit.open_translations"),style:{padding:"2px 8px",fontSize:11,background:n?"#eff6ff":"#f0fdf4",color:n?"#1d4ed8":"#15803d",border:`1px solid ${n?"#bfdbfe":"#bbf7d0"}`,borderRadius:4,display:"inline-flex",alignItems:"center",gap:4,cursor:"pointer",whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",children:"🌐"}),i.jsx("span",{children:o(n?"layout_editor.inline_edit.existing_key_hint":"layout_editor.inline_edit.new_key_hint")}),i.jsxs("span",{"aria-hidden":"true",style:{opacity:.7},children:["· ",o("layout_editor.inline_edit.open_translations")," ›"]})]}):i.jsx("span",{style:{padding:"2px 6px",fontSize:11,background:n?"#eff6ff":"#f0fdf4",color:n?"#1d4ed8":"#15803d",border:`1px solid ${n?"#bfdbfe":"#bbf7d0"}`,borderRadius:4,display:"inline-flex",alignItems:"center",gap:4,whiteSpace:"nowrap"},children:n?i.jsxs(i.Fragment,{children:["⌐ ",o("layout_editor.inline_edit.existing_key_hint")]}):i.jsxs(i.Fragment,{children:["✎ ",o("layout_editor.inline_edit.new_key_hint")]})}),!y&&i.jsx("span",{"data-testid":"g7le-inline-text-editor-empty-warn",style:{color:"#b91c1c",marginLeft:4},children:o("layout_editor.inline_edit.empty_warning")})]})]})}function t4({box:e,node:t,customKey:n,initialChipValue:o,chipParamLabels:r,onKeyify:s,templateIdentifier:a,locale:l,t:d,candidates:c,onInsertBinding:p,onRemoveBinding:f,onCommit:y,onCancel:g}){const[m,h]=u.useState(null),v=u.useRef(""),[b,x]=u.useState(!1),k=u.useRef(!1),w=u.useRef(null),_=Ft(),S=u.useRef(()=>{}),[E,C]=u.useState(null),[N,$]=u.useState(!1),A=u.useRef(n??null);u.useEffect(()=>{n&&A.current!==n&&(A.current=n)},[n]);const z=u.useMemo(()=>{if(!n&&r)return r;const L={},I=mt(typeof t.text=="string"?t.text:"");for(const P of I?.params??[])L[P.name]=Vt(P.expression);return L},[t.text,n,r]);u.useEffect(()=>{let L=!1;if(!n){const I=o??"";return v.current=I,h(I),()=>{L=!0}}return(async()=>{const I=It(n,l);if(I!==void 0){v.current=I,h(I);return}const P=await cn(a,n);if(L)return;const F=P?.values?.[l]??"";v.current=F,h(F)})(),()=>{L=!0}},[a,n,l,o]);const D=async()=>{if(k.current)return;k.current=!0;const L=m??"";if(L===v.current){y();return}x(!0);let I=A.current;if(!I){if(!s){x(!1),k.current=!1;return}if(I=await s(L),x(!1),!I){k.current=!1;return}A.current=I,y();return}const P=await wo(a,I,l,L);if(x(!1),P.kind==="error"){k.current=!1;return}_?.markDirty?.(),y()},O=()=>{k.current||(k.current=!0,g())},M=L=>{C(L)},B=async L=>{if(!(E===null||!p||N)){$(!0);try{const I=A.current;I&&m!==null&&m!==v.current&&(await wo(a,I,l,m),v.current=m);const P=await p(E,L);P!==null&&(h(P),v.current=P,_?.markDirty?.())}finally{$(!1),C(null)}}},T=async L=>{if(!(!f||N)){$(!0);try{const I=A.current;I&&m!==null&&m!==v.current&&(await wo(a,I,l,m),v.current=m);const P=await f(L);P!==null&&(h(P),v.current=P,_?.markDirty?.())}finally{$(!1)}}},H=L=>{L.key==="Enter"&&!L.shiftKey?(L.preventDefault(),D()):L.key==="Escape"&&(L.preventDefault(),O())};return S.current=()=>{D()},u.useEffect(()=>{const L=P=>{const F=w.current;if(!F)return;const G=P.target;if(G&&F.contains(G))return;const V=G;V&&V.closest&&V.closest('[data-testid="g7le-inline-toolbar"], .g7le-inline-toolbar')||S.current()},I=w.current?.ownerDocument??document;return I.addEventListener("pointerdown",L,!0),()=>I.removeEventListener("pointerdown",L,!0)},[]),i.jsxs("div",{ref:w,className:"g7le-inline-param-chip-editor","data-testid":"g7le-inline-param-chip-editor",onKeyDown:H,style:{position:"absolute",left:e.left,top:e.top,minWidth:Math.max(e.width,120),pointerEvents:"auto",zIndex:1e4,background:"#fff",border:"2px solid #2563eb",borderRadius:4,boxShadow:"0 2px 8px rgba(37,99,235,0.25)",padding:4},children:[m===null?i.jsx("div",{"data-testid":"g7le-inline-param-chip-loading",style:{fontSize:12,color:"#94a3b8",padding:4},children:d("layout_editor.translation.loading")}):i.jsx(mr,{value:m,onChange:h,t:d,paramLabels:z,testIdSuffix:"inline",onRequestInsert:p?M:void 0,onRemoveChip:f?L=>{T(L)}:void 0}),E!==null&&p&&i.jsx("div",{"data-testid":"g7le-inline-param-chip-insert-picker",style:{marginTop:4},children:i.jsx($t,{candidates:c??[],t:d,onSelect:L=>{B(L)},testIdSuffix:"inline-insert",defaultOpen:!0,floating:!1})}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginTop:4},children:[i.jsx("button",{type:"button","data-testid":"g7le-inline-param-chip-save",disabled:b,onMouseDown:L=>L.preventDefault(),onClick:()=>{D()},style:{fontSize:11,border:"none",borderRadius:6,background:"#2563eb",color:"#fff",padding:"4px 12px",cursor:"pointer"},children:d(b?"layout_editor.translation.saving":"layout_editor.translation.save")}),i.jsx("span",{style:{fontSize:10,color:"#94a3b8"},children:d("layout_editor.translation.placeholder_locked")})]})]})}const n4=["fontWeight","fontBold","fontStyle","fontItalic","textDecoration","textUnderline","textAlign","fontSize","textColor"],o4={fontWeight:"B",fontBold:"B",fontStyle:"I",fontItalic:"I",textDecoration:"U",textUnderline:"U",textAlign:"≡",fontSize:"T",textColor:"A"},r4=[["fontWeight","fontBold"],["fontItalic","fontStyle"],["textUnderline","textDecoration"]];function cg({node:e,spec:t,t:n,onApplyControl:o,box:r,scope:s=Wt}){const a=typeof e.name=="string"?e.name:"",l=Oi(t,a),d=Array.isArray(l?.styleControls)?l.styleControls:[];let c=n4.filter(p=>d.includes(p)&&Ct(t,p)!==null);for(const p of r4){const f=p.filter(y=>c.includes(y));if(f.length>1){const y=f[0];c=c.filter(g=>!p.includes(g)||g===y)}}return c.length===0?null:i.jsx("div",{className:"g7le-inline-toolbar","data-testid":"g7le-inline-toolbar",role:"toolbar","aria-label":n("layout_editor.inline_edit.toolbar_label"),style:{position:"absolute",left:r.left,top:r.top-40,display:"inline-flex",gap:2,padding:4,background:"#0f172a",borderRadius:8,boxShadow:"0 4px 12px rgba(15, 23, 42, 0.35)",pointerEvents:"auto",zIndex:10001},children:c.map(p=>i.jsx(s4,{controlKey:p,node:e,spec:t,t:n,scope:s,onApplyControl:o},p))})}const i4={fontWeight:"bold",fontBold:"bold",fontStyle:"italic",fontItalic:"italic",textDecoration:"underline",textUnderline:"underline"};function ug(e,t){const n=r=>(r.split(/[-\s]/).pop()??r)===t||r===t;return e.find(r=>{const s=typeof r.value=="string"?r.value:"",a=Array.isArray(r.apply?.tokens)?r.apply.tokens:[];return n(s)||a.some(n)})?.value}function s4({controlKey:e,node:t,spec:n,t:o,scope:r,onApplyControl:s}){const[a,l]=u.useState(!1),d=Ct(n,e);if(!d)return null;const c=d.options,p=Array.isArray(c),f=To(t,d,r),y=i4[e],g=y&&Array.isArray(c)?ug(c,y):void 0,m=!y,h=g!==void 0?f.value===g:p&&f.matched&&f.value!==void 0&&f.value!=="",v=o(`layout_editor.inline_edit.format.${e}`),b=_=>typeof _.label=="string"?_.label.startsWith("$t:")?o(_.label.slice(3)):_.label:String(_.value??""),x=()=>{s(e,_=>{const S=To(_,d,r),E=Array.isArray(c)?ug(c,y):void 0,C=E!==void 0&&S.value===E?void 0:E;return fn(_,d,C,r)})},k=_=>{s(e,S=>{const C=To(S,d,r).value===_?void 0:_;return fn(S,d,C,r)}),l(!1)},w=_=>{if(_.preventDefault(),_.stopPropagation(),!m&&Array.isArray(c)){x();return}l(S=>!S)};return i.jsxs("div",{style:{position:"relative",display:"inline-flex"},children:[i.jsxs("button",{type:"button","data-testid":`g7le-inline-format-${e}`,"data-active":h?"true":"false","aria-label":v,"aria-pressed":p?h:void 0,title:v,onMouseDown:_=>_.preventDefault(),onClick:w,style:{display:"inline-flex",alignItems:"center",gap:5,height:28,padding:"0 9px",fontSize:12,border:"none",borderRadius:5,cursor:"pointer",background:h?"#2563eb":"transparent",color:"#ffffff",whiteSpace:"nowrap"},children:[i.jsx("span",{"aria-hidden":"true",style:{fontWeight:e==="fontWeight"||e==="fontBold"?700:600,fontStyle:e==="fontStyle"||e==="fontItalic"?"italic":"normal",textDecoration:e==="textDecoration"||e==="textUnderline"?"underline":"none"},children:o4[e]}),i.jsx("span",{children:v}),m&&i.jsx("span",{"aria-hidden":"true",style:{fontSize:9,opacity:.8},children:"▾"})]}),m&&a&&i.jsx("div",{"data-testid":`g7le-inline-popover-${e}`,role:"listbox",onMouseDown:_=>_.preventDefault(),style:{position:"absolute",top:32,left:0,minWidth:140,maxHeight:240,overflowY:"auto",background:"#0f172a",border:"1px solid #334155",borderRadius:8,boxShadow:"0 8px 24px rgba(15,23,42,0.45)",padding:4,zIndex:10002},children:Array.isArray(c)&&c.length>0?c.map((_,S)=>{const E=f.value===_.value;return i.jsxs("button",{type:"button",role:"option","aria-selected":E,"data-testid":`g7le-inline-option-${e}-${String(_.value??S)}`,onMouseDown:C=>C.preventDefault(),onClick:C=>{C.preventDefault(),C.stopPropagation(),k(_.value)},style:{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"6px 8px",fontSize:12,textAlign:"left",border:"none",borderRadius:5,cursor:"pointer",background:E?"#2563eb":"transparent",color:"#ffffff"},children:[typeof _.swatch=="string"&&i.jsx("span",{"aria-hidden":"true",style:{width:14,height:14,borderRadius:3,background:_.swatch,border:"1px solid rgba(255,255,255,0.3)"}}),i.jsx("span",{children:b(_)}),E&&i.jsx("span",{"aria-hidden":"true",style:{marginLeft:"auto"},children:"✓"})]},String(_.value??S))}):i.jsx(a4,{testid:`g7le-inline-freeinput-${e}`,placeholder:o(`layout_editor.inline_edit.prompt.${e}`),initial:typeof f.value=="string"?f.value:"",onSubmit:_=>{s(e,S=>fn(S,d,_,r)),l(!1)}})})]})}function a4({testid:e,placeholder:t,initial:n,onSubmit:o}){const[r,s]=u.useState(n);return i.jsx("input",{type:"text","data-testid":e,value:r,placeholder:t,autoFocus:!0,onMouseDown:a=>a.stopPropagation(),onChange:a=>s(a.currentTarget.value),onKeyDown:a=>{a.key==="Enter"&&(a.preventDefault(),o(r.trim()))},style:{width:130,padding:"6px 8px",fontSize:12,border:"1px solid #334155",borderRadius:5,background:"#1e293b",color:"#ffffff"}})}function l4(e){const{frameEl:t,manifest:n,nesting:o,componentPalette:r,spec:s=null,permissionCandidates:a,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c,onEditProps:p}=e,{state:f,dispatch:y}=Je(),{t:g}=tt(),m=ja(),h=u.useCallback((W,X)=>{const R=window.G7Core;if(R&&typeof R.t=="function"){const de=R.t(W,X);if(de&&de!==W&&!de.startsWith("$t:"))return de}return g(W,X)},[g]),v=Ft(),b=i7(50),x=yo(),k=u.useRef([]),w=u.useMemo(()=>v?.document?.raw?.components??[],[v?.document?.raw?.components]),_=u.useMemo(()=>w.length===0?null:{children:w},[w]),S=u.useMemo(()=>{if(f.editMode!=="extension")return;const W=/^__extension__\/(\d+)/.exec(f.selectedRoute?.path??"");return W?Number(W[1]):void 0},[f.editMode,f.selectedRoute?.path]),E=u.useMemo(()=>{if(f.editMode==="iteration_item"){const W=v?.document?.iterationContext?.sourceIndexPath;return Array.isArray(W)&&W.length>0?W:null}if(f.editMode==="modal"){const W=v?.document?.modalContext?.editIndexPath;return Array.isArray(W)&&W.length>0?W:null}return null},[f.editMode,v?.document?.iterationContext?.sourceIndexPath,v?.document?.modalContext?.editIndexPath]),C=JE({rootNode:_,editMode:f.editMode,currentExtensionId:S,editableRootPath:E,pathParser:Ze,resolveRouteMatch:l}),[N,$]=u.useState(null),[A,z]=u.useState(null),[D,O]=u.useState(null),M=u.useRef(null);M.current=D;const[B,T]=u.useState(null),H=u.useRef(null),L=u.useRef(null);L.current=C.selectedPath;const I=Pb(),[P,F]=u.useState(null),G=u.useCallback(W=>{if(!t)return;const X=Ze(W);if(!X)return;const R={children:Me.current.components},de=We(R,X),ye=I.classify(de);if(!ye.editable){Et({op:"inline_edit_blocked_binding",sourceState:ye.sourceState,componentPath:at(X),timestamp:Date.now()});return}const we=po(t,W);if(!we)return;const Te=de?.props??{},ke=fg(t,W);F({path:X,domPath:W,box:we,initialValue:ye.displayValue,isCustomKey:ye.sourceState==="custom_key",isParamKey:ye.isParamKey===!0,customKey:ye.customKey,chipValue:ye.chipValue,chipParamLabels:ye.chipParamLabels,mirrorClassName:typeof Te.className=="string"?Te.className:void 0,mirrorStyle:Te.style??void 0,nodeEffectiveColor:ke}),Et({op:"inline_edit_enter",sourceState:ye.sourceState,componentPath:at(X),timestamp:Date.now()})},[t,I]),V=u.useCallback(()=>{if(!t){$(null),z(null);return}z(po(t,C.selectedPath)),$(po(t,C.hoverPath))},[t,C.selectedPath,C.hoverPath,w]);u.useEffect(()=>{if(V(),typeof window<"u"&&typeof window.requestAnimationFrame=="function"){const W=window.requestAnimationFrame(()=>V());return()=>window.cancelAnimationFrame(W)}},[V]),u.useEffect(()=>{if(t)return kp(V,t)},[t,V]);const Y=u.useCallback((W,X)=>{if(typeof window>"u")return;window.dispatchEvent(new CustomEvent(La,{detail:X===null?{requestId:W,cancelled:!0}:{requestId:W,id:X}})),O(null),T(null);const R=H.current;R?C.handleSelect("",{dataset:{editorPath:R}}):C.clearSelection(),H.current=null},[C]);u.useEffect(()=>{if(typeof window>"u")return;const W=X=>{const R=X.detail;if(typeof R?.requestId!="string")return;const de=M.current;de&&de!==R.requestId&&Y(de,null),H.current=L.current,O(R.requestId)};return window.addEventListener(Pa,W),()=>window.removeEventListener(Pa,W)},[Y]),u.useEffect(()=>{if(!D)return;const W=X=>{X.key==="Escape"&&(X.preventDefault(),Y(D,null))};return window.addEventListener("keydown",W,!0),()=>window.removeEventListener("keydown",W,!0)},[D,Y]),u.useEffect(()=>{if(!t)return;const W=f.editMode==="iteration_item"||f.editMode==="modal",X=(xe,Ee)=>GT(xe,t,Ee?{x:Ee.clientX,y:Ee.clientY}:void 0,{handleFallback:W}),R=xe=>{const Ee=xe.dataset.editorPath??"",Fe=Ee?Ze(Ee):null,zt={children:Me.current.components},gt=Fe?We(zt,Fe):null,Mt=gt&&typeof gt.props?.id=="string"?gt.props.id:typeof gt?.id=="string"?gt.id:"";return Mt.startsWith("auto_")?"":Mt},de=xe=>{const Ee=X(xe.target,xe);if(!Ee)return;const Fe=M.current;if(Fe){xe.preventDefault(),xe.stopPropagation();const zt=R(Ee);zt&&Y(Fe,zt);return}xe.preventDefault(),xe.stopPropagation(),C.handleSelect(Ee.dataset.editorId??"",{currentTarget:Ee,dataset:Ee.dataset})},ye=xe=>{const Ee=X(xe.target,xe);if(M.current){if(!Ee){T(null);return}const Fe=po(t,Ee.dataset.editorPath??null);if(!Fe){T(null);return}T({box:Fe,hasId:R(Ee)!==""});return}if(!Ee){C.handleHover(null,{});return}C.handleHover(Ee.dataset.editorId??"",{currentTarget:Ee,dataset:Ee.dataset})},we=()=>{if(M.current){T(null);return}C.handleHover(null,{})},Te=xe=>{const Ee=X(xe.target,xe);if(!Ee)return;const Fe=Ee.dataset.editorPath??"";Fe&&(xe.preventDefault(),xe.stopPropagation(),G(Fe))},ke=t.parentElement??t;return ke.addEventListener("click",de,!0),ke.addEventListener("mousemove",ye,!0),ke.addEventListener("mouseleave",we,!0),ke.addEventListener("dblclick",Te,!0),()=>{ke.removeEventListener("click",de,!0),ke.removeEventListener("mousemove",ye,!0),ke.removeEventListener("mouseleave",we,!0),ke.removeEventListener("dblclick",Te,!0)}},[t,C,G,f.editMode,Y]);const Z=u.useMemo(()=>{const W=C.selectedPath;if(!W)return null;const X=W.match(/^(.*)\.children\.\d+$/);return X?X[1]:null},[C.selectedPath]),se=u.useCallback(()=>{Z&&C.handleSelect("",{dataset:{editorPath:Z}})},[Z,C]),re=u.useMemo(()=>!t||!C.selectedPath?null:t.querySelector(`[data-editor-path="${an(C.selectedPath)}"]`),[t,C.selectedPath]),U=u.useMemo(()=>C.selectedPath?Ze(C.selectedPath):null,[C.selectedPath]),ee=u.useMemo(()=>{if(!U)return null;const W=U.find(et);if(!W)return null;const{i18nKey:X,raw:R}=gC(W.responsive);return X?g(X):g("layout_editor.overlay.branch_custom",{range:R})},[U,g]),fe=u.useMemo(()=>C.selectedPath?C.selectedIsIteration&&C.selectedIterationSourcePath?C.selectedIterationSourcePath:C.selectedPath:null,[C.selectedPath,C.selectedIsIteration,C.selectedIterationSourcePath]),K=u.useMemo(()=>fe?Ze(fe):null,[fe]),ne=u.useMemo(()=>!t||!fe?null:t.querySelector(`[data-editor-path="${an(fe)}"]`),[t,fe]),me=u.useMemo(()=>{if(ne?.parentElement)return ne.parentElement;if(!t||!fe)return null;const W=fe.match(/^(.*)\.children\.\d+$/);return W?t.querySelector(`[data-editor-path="${an(W[1])}"]`):null},[ne,t,fe]),Ce=u.useMemo(()=>{const W=C.selectedNode;return W&&(typeof W.name=="string"?W.name:typeof W.type=="string"?W.type:"")||null},[C.selectedNode]),ze=u.useMemo(()=>{if(C.selectedLockKind!=="extension")return null;const W=C.selectedNode?.__source;if(!W||W.kind!=="extension")return null;const X=W.extensionName?.trim(),R=W.extensionIdentifier?.trim();return X&&R?`${X} (${R})`:X||R||W.extensionSourceType||null},[C.selectedLockKind,C.selectedNode]),Se=n7({parentEl:me,selectedPath:K,selectedEl:ne}),qe=u.useMemo(()=>!t||!fe||fe===C.selectedPath?A:po(t,fe),[t,fe,A,C.selectedPath,w]),De=u.useRef(null),Pe=u.useCallback(()=>{const W=De.current;W&&(x.close(W),De.current=null)},[x]),Qe=u.useRef(()=>{}),Me=u.useRef({manifest:n,nesting:o,componentPalette:r,components:w});Me.current={manifest:n,nesting:o,componentPalette:r,components:w};const Re=u.useCallback(W=>{const X=W.insertion?.parentPath??[],R=W.insertion?.index??0,de=Me.current,ye=pg(de.components,X);Pe();const we=x.open({ariaLabel:h("layout_editor.palette.title"),width:880,maxHeightRatio:.82,content:u.createElement(Sf,{manifest:de.manifest,nesting:de.nesting,componentPalette:de.componentPalette??null,targetParentPath:X,targetContainerName:ye,onInsert:(Te,ke,xe)=>{Qe.current(Te,ke,xe)},insertionIndex:R,onClose:()=>Pe(),editorTemplateId:f.templateIdentifier,editorLocale:f.locale}),onClose:()=>{De.current=null}});De.current=we},[x,Pe]),ve=u.useMemo(()=>{if(!f.isPaletteOpen)return null;if(!U)return{parentPath:[],index:w.length};if(C.selectedLockKind!=="none"){const de=K;return!de||de.length===0?{parentPath:[],index:w.length}:{parentPath:de.slice(0,-1),index:(de[de.length-1]??0)+1}}const X=C.selectedNode,R=Array.isArray(X?.children)?X.children.length:0;return rI(X?.name,R,U,o,w.length)},[f.isPaletteOpen,U,K,C.selectedLockKind,C.selectedNode,w.length,o]),te=u.useCallback(()=>{y({type:"SET_PALETTE_OPEN",open:!1})},[y]),he=u.useRef(null);u.useEffect(()=>{if(!f.isPaletteOpen){const ye=he.current;ye&&(x.close(ye),he.current=null);return}if(!ve||he.current)return;const W=ve,X=Me.current,R=pg(X.components,W.parentPath),de=x.open({ariaLabel:h("layout_editor.palette.title"),width:880,maxHeightRatio:.82,content:u.createElement(Sf,{manifest:X.manifest,nesting:X.nesting,componentPalette:X.componentPalette??null,targetParentPath:W.parentPath,targetContainerName:R,onInsert:(ye,we,Te)=>{Qe.current(ye,we,Te),te()},insertionIndex:W.index,onClose:()=>te(),editorTemplateId:f.templateIdentifier,editorLocale:f.locale}),onClose:()=>{he.current=null,y({type:"SET_PALETTE_OPEN",open:!1})}});he.current=de},[f.isPaletteOpen,ve,x,te,y]);const Q=u.useRef({layoutName:null,pushed:!1});u.useEffect(()=>{const W=v?.document?.layoutName??null;if(!W){Q.current={layoutName:null,pushed:!1};return}Q.current.layoutName!==W&&(b.clear(),b.push({actionKind:"inline_text_edit",label:"baseline",snapshot:w}),Q.current={layoutName:W,pushed:!0})},[v?.document?.layoutName,w,b]);const ie=u.useRef(0);u.useEffect(()=>{const W=v?.saveSuccessCounter??0;W!==ie.current&&(ie.current=W,W!==0&&(b.clear(),b.push({actionKind:"inline_text_edit",label:"save_baseline",snapshot:w}),k.current=[],c?.([])))},[v?.saveSuccessCounter,w,b,c]);const _e=u.useRef(0);u.useEffect(()=>{const W=v?.reloadCounter??0;W!==_e.current&&(_e.current=W,W!==0&&(Q.current={layoutName:null,pushed:!1},k.current=[],c?.([])))},[v?.reloadCounter,c]);const ge=u.useCallback((W,X,R)=>{if(!v)return;let de=[];v.patchLayout(ke=>{const xe={children:ke},Ee=Ha(xe,X),Fe=R<0?Array.isArray(Ee?.children)?Ee.children.length:0:R,gt=Qi(xe,X,Fe,W).children??[];return de=gt,gt}),b.push({actionKind:"insert",label:`insert ${W.name??W.type??""}`,snapshot:de});const ye=Ha({children:de},X),we=R<0?Array.isArray(ye?.children)?ye.children.length-1:0:R,Te=at([...X,we]);if(k.current=[...k.current,Te],c?.(k.current),t){const ke=`[data-editor-path="${an(Te)}"]`,xe=()=>{const Ee=t.querySelector(ke);Ee&&typeof Ee.scrollIntoView=="function"&&Ee.scrollIntoView({behavior:"smooth",block:"center",inline:"nearest"})};typeof window<"u"&&typeof window.requestAnimationFrame=="function"?window.requestAnimationFrame(()=>window.requestAnimationFrame(xe)):setTimeout(xe,32)}},[v,b,c,t]);Qe.current=ge;const Ae=u.useCallback(()=>{if(!v||!C.selectedNode||!U)return;const W=es(C.selectedNode);let X=[];v.patchLayout(R=>{const de={children:R},ye=U.slice(0,-1),we=U[U.length-1]??0,ke=Qi(de,ye,we+1,W).children??[];return X=ke,ke}),b.push({actionKind:"insert",label:"duplicate",snapshot:X})},[v,C.selectedNode,U,b]),Ne=u.useCallback(()=>{if(!v||!U)return;let W=[];v.patchLayout(X=>{const ye=Ri({children:X},U).children??[];return W=ye,ye}),b.push({actionKind:"remove",label:"remove",snapshot:W}),C.clearSelection()},[v,U,b,C]),Le=u.useMemo(()=>{if(!C.selectedNode||!U||U.some(et))return null;const W=C.selectedNode;if(!Array.isArray(W.children))return null;const X=fC(f.previewDevice,W,f.previewCustomWidth);return{mode:X.mode,key:X.key,sourceKey:X.sourceKey}},[C.selectedNode,U,f.previewDevice,f.previewCustomWidth]),q=u.useCallback(()=>{if(!v||!U||Le?.mode!=="separate")return;const W=Le.key,X=Le.sourceKey;let R=[];v.patchLayout(de=>(R=$b({children:de},U,W,X).children??[],R)),b.push({actionKind:"insert",label:"separate_branch",snapshot:R})},[v,U,Le,b]),ae=u.useCallback(()=>{if(!v||!U||Le?.mode!=="merge")return;const W=Le.key;let X=[];v.patchLayout(R=>(X=Eb({children:R},U,W).children??[],X)),b.push({actionKind:"remove",label:"merge_branch",snapshot:X})},[v,U,Le,b]),pe=u.useCallback(W=>{switch(W){case"mobile":return g("layout_editor.context_menu.device_mobile");case"portable":return g("layout_editor.context_menu.device_portable");case"tablet":return g("layout_editor.context_menu.device_tablet");case"desktop":return g("layout_editor.context_menu.device_desktop");default:return g("layout_editor.context_menu.device_custom",{range:W})}},[g]),$e=u.useMemo(()=>{if(!Le)return null;const W=pe(Le.key);return{separate:g("layout_editor.context_menu.separate_branch_device",{device:W}),merge:g("layout_editor.context_menu.merge_branch_device",{device:W})}},[Le,g,pe]),be=u.useMemo(()=>{if(!C.selectedNode||!U)return[];if(U.some(et))return[];const W=Ur(f.previewDevice,C.selectedNode,f.previewCustomWidth);return K6(C.selectedNode,W).map(X=>({key:X,label:pe(X)}))},[C.selectedNode,U,f.previewDevice,f.previewCustomWidth,pe]),j=u.useCallback(W=>{if(W==="desktop"||W==="tablet"||W==="mobile"||W==="portable"){m.setDevice(W);return}m.setDevice(W)},[m]),oe=u.useCallback(()=>{C.selectedNode&&sg(C.selectedNode)},[C.selectedNode]),ue=u.useCallback(()=>{C.selectedNode&&(sg(C.selectedNode),Ne())},[C.selectedNode,Ne]),J=u.useCallback(()=>{if(!v)return;const W=jT();if(!W)return;const X=es(W);if(U&&U.length>0){const R=U.slice(0,-1),de=(U[U.length-1]??0)+1;ge(X,R,de)}else ge(X,[],w.length)},[v,U,ge,w.length]),le=u.useCallback((W,X)=>{b.push({actionKind:"move",label:X,snapshot:W})},[b]),Ie=u.useCallback(()=>{if(C.selectedNavAffordance==="route_in_tree"&&C.selectedNavTargetPath){if(d){d(C.selectedNavTargetPath);return}y({type:"SELECT_ROUTE",route:{path:C.selectedNavTargetPath,layoutName:null}})}},[C.selectedNavAffordance,C.selectedNavTargetPath,d,y]),dt=u.useCallback(()=>{const W=Sb(C.selectedBaseLayout,f.selectedRoute?.layoutName);W&&y({type:"ENTER_BASE_EDIT",layoutName:W})},[C.selectedBaseLayout,f.selectedRoute?.layoutName,y]),pt=u.useCallback(()=>{const W=C.selectedNode?.__source?.extensionId;W!=null&&y({type:"ENTER_EXTENSION_EDIT",extensionId:String(W)})},[C.selectedNode,y]),ot=u.useCallback(()=>{const W=C.selectedIterationSourcePath;W&&y({type:"ENTER_ITERATION_ITEM_EDIT",sourcePath:W})},[C.selectedIterationSourcePath,y]),Oe=u.useRef(null),He=u.useRef(null),Ye=u.useRef(null),Tt=u.useRef({colorScheme:"base",breakpoint:"base"}),Dt=Oe.current!==null?x.stack.some(W=>W.id===Oe.current):!1,Nt=u.useMemo(()=>Nd(f.routeTree,h),[f.routeTree,h]),xn=u.useMemo(()=>zd(v?.document?.raw),[v?.document?.raw]),wn=u.useMemo(()=>Md(s),[s?.states]),Zt=u.useMemo(()=>{const W=v?.document?.raw,X=$f(W?.components),R=W?.initIsolated,de=R&&typeof R=="object"&&!Array.isArray(R)?Object.keys(R):[];return JI(X,de)},[v?.document?.raw]),Hn=u.useMemo(()=>{const W=v?.document?.raw;return $f(W?.components).map(X=>X.scopeId).filter(X=>typeof X=="string"&&X.length>0)},[v?.document?.raw]),ln=Dd({raw:v?.document?.raw,spec:s}),Ii=u.useCallback((W,X)=>{if(!v)return;const R={children:Me.current.components},de=We(R,W);if(!de)return;Tt.current={colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:Ur(f.previewDevice,de,f.previewCustomWidth)};const ye=t?.getBoundingClientRect(),we=A&&ye?{left:ye.left+A.left,top:ye.top+A.top,width:A.width,height:A.height}:void 0,Te=re,ke=W.find(et),xe=ke?pe(ke.responsive):null,Ee=W.filter(kn=>typeof kn=="number"),Fe=ab({children:Me.current.components},Ee,lb(ln)),zt=Fe.length>0?[...ji({iterationVars:Fe}),...ln]:ln,gt=(kn,rM)=>u.createElement(kT,{key:`pem-${at(He.current??[])}`,node:kn,spec:s,manifest:Me.current.manifest,t:h,permissionCandidates:a,pageCandidates:Nt,dataSourceCandidates:xn,stateKeyCandidates:wn,bindingCandidates:zt,liveElement:Te,templateIdentifier:f.templateIdentifier,initialTab:rM,branchLabel:xe,isolatedScopeIdCandidates:Zt,usedScopeIds:Hn,onSaveInjectedProps:async(bt,Sn)=>{const Ai=typeof kn.id=="string"?kn.id:"",Fn=await OT(f.templateIdentifier,bt,Ai,Sn);if(st({op:"save",layoutName:`extension:${bt}`,editMode:f.editMode,saveTarget:"layout_extension",endpoint:`/api/admin/templates/${f.templateIdentifier}/layout-extensions/${bt}`,isDirty:!0,timestamp:Date.now()}),Fn.kind!=="success")throw new Error(Fn.kind==="conflict"?h("layout_editor.save.conflict"):Fn.kind==="injection_not_found"?"injection not found":Fn.kind==="not_found"?"extension not found":Fn.message??"save failed")},scope:Tt.current,onScopeChange:bt=>{Tt.current=bt;const Sn=Oe.current;Sn&&x.open({id:Sn,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(kn),onClose:()=>{Oe.current=null,He.current=null}})},onPatchNode:bt=>{let Sn=[];v.patchLayout(Fn=>(Sn=Ot({children:Fn},W,()=>bt).children??[],Sn)),b.push({actionKind:"inline_text_edit",label:"edit_props",snapshot:Sn}),ir({source:"style_control",componentPath:at(W),componentName:typeof bt.name=="string"?bt.name:null,patchKey:null,timestamp:Date.now()});const Ai=Oe.current;Ai&&x.open({id:Ai,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(bt),onClose:()=>{Oe.current=null,He.current=null}})},onDelete:()=>{const bt=Oe.current;bt&&x.close(bt),Ne()},onClose:()=>{const bt=Oe.current;bt&&x.close(bt)}});He.current=W;const Mt=x.open({ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(de,X),onClose:()=>{Oe.current=null,He.current=null,Ye.current=null}});Oe.current=Mt,Ye.current=kn=>{x.open({id:Mt,ariaLabel:h("layout_editor.property_modal.title"),width:420,draggable:!0,avoidRect:we,content:gt(kn),onClose:()=>{Oe.current=null,He.current=null,Ye.current=null}})}},[v,s,h,a,Nt,xn,wn,ln,re,b,x,Ne,t,A,f.previewColorScheme,f.previewDevice,f.previewCustomWidth,pe]);u.useEffect(()=>{const W=Ye.current,X=He.current;if(!W||!X)return;const R={children:Me.current.components},de=We(R,X);de?W(de):Oe.current&&x.close(Oe.current)},[w]);const Hz=()=>{if(!Dt)return null;const X={position:"absolute",background:"rgba(15, 23, 42, 0.45)",pointerEvents:"auto",zIndex:d7},R=ke=>{ke.preventDefault(),ke.stopPropagation()};if(!A)return i.jsx("div",{"data-testid":"g7le-edit-lock-full",onClick:R,onMouseMove:R,style:{...X,inset:0}});const{left:de,top:ye,width:we,height:Te}=A;return i.jsxs("div",{"data-testid":"g7le-edit-lock",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[i.jsx("div",{"data-testid":"g7le-edit-lock-top",onClick:R,onMouseMove:R,style:{...X,left:0,top:0,right:0,height:Math.max(0,ye)}}),i.jsx("div",{"data-testid":"g7le-edit-lock-bottom",onClick:R,onMouseMove:R,style:{...X,left:0,top:ye+Te,right:0,bottom:0}}),i.jsx("div",{"data-testid":"g7le-edit-lock-left",onClick:R,onMouseMove:R,style:{...X,left:0,top:ye,width:Math.max(0,de),height:Te}}),i.jsx("div",{"data-testid":"g7le-edit-lock-right",onClick:R,onMouseMove:R,style:{...X,left:de+we,top:ye,right:0,height:Te}})]})},ml=u.useCallback(()=>{if(U&&bp(C.selectedLockKind)){if(p&&C.selectedPath){p(C.selectedPath);return}Ii(U)}},[U,C.selectedPath,C.selectedLockKind,p,Ii]),{scale:vl}=m,_l=typeof C.selectedNode?.name=="string"?C.selectedNode.name:"",Lh=s?.componentCapabilities?.[_l]??null,Ph=Array.isArray(Lh?.styleControls)?Lh.styleControls:[],Fz=Ph.includes("width")?Ct(s,"width"):null,Kz=Ph.includes("height")?Ct(s,"height"):null,Vz=u.useCallback((W,X)=>{if(!v||!U)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},U,()=>W).children??[],R)),ir({source:"resize",componentPath:at(U),componentName:_l||null,patchKey:X==="height"?"height":"width",resizeAxis:X,timestamp:Date.now()})},[v,U,_l]),Uz=u.useCallback((W,X)=>{if(!v||!U)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},U,()=>W).children??[],R)),b.push({actionKind:"inline_text_edit",label:`resize ${X}`,snapshot:R})},[v,U,b]),qz=u.useCallback(()=>{if(!re)return null;const W=re.getBoundingClientRect(),X=vl||1;return{width:W.width/X,height:W.height/X}},[re,vl]),rr=KT({node:C.selectedNode,widthControl:Fz,heightControl:Kz,scale:vl,measureStartSize:qz,onResize:Vz,onResizeEnd:Uz}),Gz=u.useCallback((W,X)=>{rr.onHandlePointerDown(W,X)},[rr]),xl=u.useCallback(()=>{const W=b.undo();!W||!v||v.setLayoutComponents(W.snapshot)},[b,v]),wl=u.useCallback(()=>{const W=b.redo();!W||!v||v.setLayoutComponents(W.snapshot)},[b,v]);a7({onUndo:xl,onRedo:wl});const Yz=u.useMemo(()=>({copy:oe,cut:ue,paste:J,delete:Ne,openProps:ml,selectParent:Z?se:void 0,deselect:C.clearSelection}),[oe,ue,J,Ne,ml,Z,se,C.clearSelection]);Ru({handlers:Yz,hasSelection:!!C.selectedPath}),u.useEffect(()=>(window.__g7LayoutEditorHasSelection=!!C.selectedPath,()=>{try{delete window.__g7LayoutEditorHasSelection}catch{}}),[C.selectedPath]),u.useEffect(()=>(window.__g7LayoutEditorHistory={undo:xl,redo:wl,canUndo:b.canUndo,canRedo:b.canRedo},()=>{try{delete window.__g7LayoutEditorHistory}catch{}}),[xl,wl,b.canUndo,b.canRedo]);const Xz=u.useCallback(W=>{const X=P;F(null),X&&(async()=>{if((await I.commit(X.path,W)).kind==="created"&&v){const de=v.document?.raw?.components??[];b.push({actionKind:"inline_text_edit",label:"inline_create_key",snapshot:de})}})()},[P,I,v,b]),Oh=u.useCallback(()=>{const W=P;F(null),I.trackCancel(W?.path??null)},[P,I]),Jz=u.useCallback(()=>{F(null)},[]),Zz=u.useCallback(async W=>{const X=P;if(!X)return null;const R=await I.keyifyChipValue(X.path,W);if(R.kind!=="created"||!R.translationKey)return null;const de=v?.document?.raw?.components??[];return b.push({actionKind:"inline_text_edit",label:"inline_keyify_chip",snapshot:de}),R.translationKey},[P,I,v,b]),Qz=u.useCallback(()=>{const W=P;W&&(F(null),C.handleSelect("",{dataset:{editorPath:W.domPath}}),Ii(W.path,"translation"))},[P,C,Ii]),xt=u.useMemo(()=>{if(!P)return null;const W=v?.document?.raw?.components??w;return We({children:W},P.path)},[P,v?.document,w]),Rz=u.useCallback(async(W,X)=>{const R=P;if(!v||!R)return null;const de=typeof xt?.text=="string"?xt.text:"";let ye;if(R.isParamKey)ye=await wr(f.templateIdentifier,f.locale,de,W,X.sourceId,X.path,"scalar");else{const Fe=zt=>zt.replace(/\$t:[a-zA-Z0-9._-]+/g,gt=>{const Mt=h(gt.slice(3));return Mt&&Mt!==gt.slice(3)&&!Mt.startsWith("$t:")?Mt:""}).replace(/\s+/g," ").trim();ye=await ls(f.templateIdentifier,f.selectedRoute?.layoutName??null,f.locale,de,W,X.sourceId,X.path,"scalar",Fe,h)}if(ye.kind==="error")return null;const we=R.path;let Te=[];v.patchLayout(Fe=>(Te=Ot({children:Fe},we,Mt=>({...Mt,text:ye.text})).children??[],Te)),b.push({actionKind:"inline_text_edit",label:"inline_insert_binding",snapshot:Te});const ke=ye.kind==="ok"&&"key"in ye?ye.key:void 0;!R.isParamKey&&ke&&F(Fe=>Fe&&Fe.path===we?{...Fe,isParamKey:!0,customKey:ke,chipValue:void 0,chipParamLabels:void 0}:Fe);try{window.dispatchEvent(new CustomEvent(kt,{detail:{templateIdentifier:f.templateIdentifier,locale:f.locale}}))}catch{}const xe=mt(ye.text),Ee=R.customKey??xe?.key??"";return Ee?It(Ee,f.locale)??null:null},[v,P,xt,f.templateIdentifier,f.locale,f.selectedRoute,b,h]),eM=u.useCallback(async W=>{const X=P;if(!v||!X)return null;const R=typeof xt?.text=="string"?xt.text:"",de=mt(R),ye=X.customKey??de?.key??null,we=bo(R,W),Te=X.path;let ke=[];return v.patchLayout(xe=>(ke=Ot({children:xe},Te,zt=>({...zt,text:we})).children??[],ke)),b.push({actionKind:"inline_text_edit",label:"inline_remove_binding",snapshot:ke}),ye&&await kr(f.templateIdentifier,ye,W,f.locale),ye?It(ye,f.locale)??null:null},[v,P,xt,f.templateIdentifier,f.locale,b]),[kl,Bh]=u.useState({}),tM=["fontSize","color","fontStyle","textDecoration","textAlign","letterSpacing","lineHeight","fontFamily"];u.useEffect(()=>{if(!P||!t||typeof MutationObserver>"u"){Bh({});return}const W=`[data-editor-path="${an(P.domPath)}"]`,X=()=>{const ye=t.querySelector(W);if(!ye)return;const we={};for(const ke of tM){const xe=ye.style.getPropertyValue(ke.replace(/([A-Z])/g,"-$1").toLowerCase());xe&&(we[ke]=xe)}const Te=fg(t,P.domPath)??void 0;Bh({className:ye.className,style:we,effectiveColor:Te})};X();const R=t.querySelector(W);if(!R)return;const de=new MutationObserver(X);return de.observe(R,{attributes:!0,attributeFilter:["class","style"]}),de.observe(t,{childList:!0,subtree:!0}),()=>de.disconnect()},[P?.path?at(P.path):null,t]);const Wh=u.useCallback((W,X)=>{if(!v||!P)return;const R=P.path;let de=[],ye=null;v.patchLayout(we=>(de=Ot({children:we},R,xe=>{const Ee=X(xe);return ye=typeof Ee.name=="string"?Ee.name:null,Ee}).children??[],de)),b.push({actionKind:"inline_text_edit",label:`inline_format ${W}`,snapshot:de}),ir({source:"style_control",componentPath:at(R),componentName:ye,patchKey:W,timestamp:Date.now()})},[v,P,b]),ft=u.useMemo(()=>{const W=C.selectedPath;if(!W||!_)return null;const X=[];let R=W;for(;R;){X.push(R);const de=R.match(/^(.*)\.children\.\d+$/);R=de?de[1]:null}for(const de of X){const ye=Ze(de);if(!ye)continue;const we=We(_,ye),Te=typeof we?.name=="string"?we.name:null;if(!Te)continue;const ke=Oi(s,Te),xe=ke?.canvasOverlay?.kind;if(typeof xe!="string"||!xe||!we)continue;const Ee=ke.canvasOverlay.params&&typeof ke.canvasOverlay.params=="object"?ke.canvasOverlay.params:void 0,Fe=de===W?null:W.slice(de.length+1);return{kind:xe,params:Ee,tablePath:de,tableNode:we,selectedCellRel:Fe}}return null},[C.selectedPath,_,s]),ho=ft?BT(ft.kind):null,jh=u.useMemo(()=>!ho||!t||!ft?null:po(t,ft.tablePath),[ho,t,ft,A,w]),nM=u.useMemo(()=>{if(!ho||!t||!ft)return[];const W=ft.tablePath,X=an(W),R=[];return t.querySelectorAll(`[data-editor-path^="${X}.children."]`).forEach(we=>{const Te=we.dataset.editorPath??"";if(!Te.startsWith(W+"."))return;const ke=Te.slice(W.length+1),xe=so(we,t);xe&&R.push({path:ke,top:xe.top,left:xe.left,width:xe.width,height:xe.height})}),t.querySelectorAll(`[data-editor-item-path^="${X}."]`).forEach(we=>{const Te=we.dataset.editorItemPath??"";if(!Te.startsWith(W+"."))return;const ke=Te.slice(W.length+1),xe=so(we,t);xe&&R.push({path:ke,top:xe.top,left:xe.left,width:xe.width,height:xe.height})}),R},[ho,t,ft,A,w]),oM=u.useCallback(W=>{const X=ft?Ze(ft.tablePath):null;if(!v||!X)return;let R=[];v.patchLayout(de=>(R=Ot({children:de},X,()=>W).children??[],R)),b.push({actionKind:"inline_text_edit",label:"inplace_edit",snapshot:R}),ir({source:"style_control",componentPath:ft?.tablePath??"",componentName:typeof W.name=="string"?W.name:null,patchKey:"canvas_inplace",timestamp:Date.now()})},[v,U,b]);return t?i.jsxs("div",{className:"g7le-editor-canvas-overlay","data-testid":"g7le-editor-canvas-overlay",style:{position:"absolute",inset:0,pointerEvents:"none"},children:[!D&&Hz(),D&&i.jsxs("div",{"data-testid":"g7le-canvas-pick-banner",style:{position:"absolute",top:8,left:"50%",transform:"translateX(-50%)",zIndex:9999,display:"flex",alignItems:"center",gap:12,padding:"8px 14px",background:"#1e293b",color:"#fff",borderRadius:8,fontSize:13,boxShadow:"0 4px 12px rgba(0,0,0,0.25)",pointerEvents:"auto"},children:[i.jsxs("span",{children:["🎯 ",h("layout_editor.target_picker.canvas_pick_hint")]}),i.jsx("button",{type:"button","data-testid":"g7le-canvas-pick-cancel",onClick:()=>Y(D,null),style:{border:"1px solid #475569",borderRadius:6,background:"transparent",color:"#cbd5e1",cursor:"pointer",fontSize:12,padding:"3px 10px"},children:h("layout_editor.target_picker.cancel")})]}),f.editMode!=="route"&&i.jsx(kI,{frameEl:t,components:w,editMode:f.editMode,currentExtensionId:S,editableRootPath:E}),P&&((P.isParamKey&&P.customKey||P.chipValue)&&xt?i.jsxs(i.Fragment,{children:[xt&&i.jsx(cg,{node:xt,spec:s,t:h,onApplyControl:Wh,box:P.box,scope:{colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:Ur(f.previewDevice,xt,f.previewCustomWidth)}}),i.jsx(t4,{box:P.box,node:xt,customKey:P.customKey??null,initialChipValue:P.chipValue,chipParamLabels:P.chipParamLabels,onKeyify:Zz,templateIdentifier:f.templateIdentifier,locale:f.locale,t:h,candidates:ln,onInsertBinding:Rz,onRemoveBinding:eM,onCommit:Jz,onCancel:Oh})]}):i.jsxs(i.Fragment,{children:[xt&&i.jsx(cg,{node:xt,spec:s,t:h,onApplyControl:Wh,box:P.box,scope:{colorScheme:f.previewColorScheme==="dark"?"dark":"base",breakpoint:xt?Ur(f.previewDevice,xt,f.previewCustomWidth):"base"}}),i.jsx(e4,{box:P.box,initialValue:P.initialValue,isCustomKey:P.isCustomKey,t:h,onCommit:Xz,onCancel:Oh,mirrorClassName:kl.className,mirrorStyle:kl.style,nodeEffectiveColor:kl.effectiveColor??P.nodeEffectiveColor,onOpenTranslations:Qz})]})),!D&&i.jsx(h7,{hoverBox:N,selectedBox:A,lockKind:C.selectedLockKind,navAffordance:C.selectedNavAffordance,onEditProps:ml,onDuplicate:Ae,onDelete:Ne,onSeparateBranch:Le?.mode==="separate"?q:void 0,separateBranchLabel:$e?.separate,onMergeBranch:Le?.mode==="merge"?ae:void 0,mergeBranchLabel:$e?.merge,definedDeviceBranches:be,onJumpToDevice:be.length>0?j:void 0,onLinkEditDestination:Ie,onEditBase:dt,onEditExtension:pt,extensionLabel:ze,baseLayoutLabel:C.selectedBaseLayout,dataSourceLabel:C.selectedDataSourceId,branchLabel:ee,onEditIteration:C.selectedIsIteration&&f.editMode!=="iteration_item"?ot:void 0,resizeEnabledAxes:rr.enabledAxes,onResizeHandlePointerDown:rr.enabledAxes.width||rr.enabledAxes.height?Gz:void 0,selectedName:Ce,onSelectParent:Z?se:void 0}),D&&B&&i.jsx("div",{"data-testid":"g7le-canvas-pick-hover","data-pick-has-id":B.hasId?"true":"false",style:{position:"absolute",left:B.box.left,top:B.box.top,width:B.box.width,height:B.box.height,pointerEvents:"none",zIndex:9998,boxSizing:"border-box",border:B.hasId?"2px solid #2563eb":"2px dashed #94a3b8",background:B.hasId?"rgba(37,99,235,0.10)":"repeating-linear-gradient(45deg, rgba(148,163,184,0.18) 0, rgba(148,163,184,0.18) 6px, transparent 6px, transparent 12px)",cursor:B.hasId?"pointer":"not-allowed"},children:!B.hasId&&i.jsx("span",{"data-testid":"g7le-canvas-pick-hover-hint",style:{position:"absolute",top:0,left:0,transform:"translateY(-100%)",background:"#475569",color:"#fff",fontSize:11,lineHeight:1.4,padding:"2px 6px",borderRadius:4,whiteSpace:"nowrap"},children:h("layout_editor.target_picker.needs_id")})}),!D&&i.jsx(x7,{selectedBox:qe,points:Se.points,onAddRequest:Re}),ho&&ft&&jh&&C.selectedLockKind==="none"&&i.jsx(ho,{node:ft.tableNode,params:ft.params,nodeBox:jh,cellBoxes:nM,selectedCellPath:ft.selectedCellRel??void 0,colorScheme:f.previewColorScheme==="dark"?"dark":"light",t:h,onPatchNode:oM,onInsertChild:ge,onRemoveChild:W=>{const X=Ze(W);if(!v||!X)return;let R=[];v.patchLayout(de=>(R=Ri({children:de},X).children??[],R)),b.push({actionKind:"remove",label:"inplace_remove",snapshot:R})},onRequestInlineEdit:W=>{ft&&G(`${ft.tablePath}.${W}`)}}),v&&i.jsx(bI,{frameEl:t,nesting:o,editMode:f.editMode,currentExtensionId:S,editableRootPath:E,components:w,patchLayout:v.patchLayout,pushHistory:le,selectedPath:C.selectedPath,onSelectPath:W=>C.handleSelect("",{dataset:{editorPath:W}}),onMovePath:W=>C.handleSelect("",{dataset:{editorPath:W}}),onRequestInlineEdit:G})]}):null}function pg(e,t){if(t.length===0)return null;const o=Ha({children:e},t);return o&&typeof o.name=="string"?o.name:null}function Ha(e,t){return We(e,t)}function an(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/[^a-zA-Z0-9_\-.]/g,t=>`\\${t}`)}function fg(e,t){if(!t)return null;const n=e.querySelector(`[data-editor-path="${an(t)}"]`);if(!n||typeof window.getComputedStyle!="function")return null;let o=n;const r=n.ownerDocument.createTreeWalker(n,NodeFilter.SHOW_TEXT);let s=r.nextNode();for(;s;){if((s.textContent??"").trim().length>0){const a=s.parentElement;if(a&&n.contains(a)){o=a;break}}s=r.nextNode()}try{return window.getComputedStyle(o).color||null}catch{return null}}function po(e,t){if(!t)return null;const n=e.querySelector(`[data-editor-path="${an(t)}"]`);if(n){const c=so(n,e);return c&&ao(c,e)?c:null}const o=e.querySelectorAll(`[data-editor-path^="${an(t)}.iteration."]`);let r=1/0,s=1/0,a=-1/0,l=-1/0,d=!1;return o.forEach(c=>{const p=c.dataset.editorPath??"";if(!new RegExp("\\.iteration\\.\\d+$").test(p))return;const f=so(c,e);f&&ao(f,e)&&(d=!0,r=Math.min(r,f.left),s=Math.min(s,f.top),a=Math.max(a,f.left+f.width),l=Math.max(l,f.top+f.height))}),d?{left:r,top:s,width:a-r,height:l-s,scale:1}:null}function d4({isEditMode:e,editorSpec:t,sampleOverride:n}){const o=t?.sampleData;return u.useMemo(()=>zm({spec:o,overrideSpec:n,onMatch:r=>{Fh(r)}}),[e,o,n])}const c4=Lt().ComponentRegistry;function u4(e){return e.split(/[-_]/).map(t=>t.charAt(0).toUpperCase()+t.slice(1).toLowerCase()).join("")}async function p4(e){const t=typeof window<"u"?window.localStorage?.getItem("auth_token"):null,n={Accept:"application/json"};t&&(n.Authorization=`Bearer ${t}`);const o=await fetch(`/api/admin/templates/${encodeURIComponent(e)}/editor-assets`,{headers:n,credentials:"same-origin"});if(!o.ok){const s=await o.json().catch(()=>null);throw Gr(o.status,s,"assets")}const r=await o.json();return r.data||r}async function f4(e){try{if(typeof fetch!="function")return 0;const t=await fetch(en(`/api/templates/${encodeURIComponent(e)}/config`,"json"),{credentials:"same-origin",headers:{Accept:"application/json"}});if(!t.ok)return 0;const n=await t.json().catch(()=>null),o=n?.data?.cache_version??n?.cache_version;return typeof o=="number"&&o>0?o:0}catch{return 0}}function g4(e){return new Promise((t,n)=>{if(typeof document>"u"){n(new Error("document undefined"));return}if(document.querySelector(`script[data-g7le-asset="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!1,r.setAttribute("data-g7le-asset",e),r.addEventListener("load",()=>t()),r.addEventListener("error",()=>n(new Error(`script load failed: ${e}`))),document.head.appendChild(r)})}async function h4(e){if(typeof document>"u")return;const t=`[data-g7le-asset="${e}"]`;if(document.querySelector(`link${t}, style${t}`))return;if(e.includes("/api/admin/"))try{const r=typeof window<"u"?window.localStorage?.getItem("auth_token"):null,s={Accept:"text/css"};r&&(s.Authorization=`Bearer ${r}`);const a=await fetch(e,{headers:s,credentials:"same-origin"});if(a.ok){const l=await a.text(),d=document.createElement("style");d.textContent=l,d.setAttribute("data-g7le-asset",e),document.head.appendChild(d);return}}catch{}const o=document.createElement("link");o.rel="stylesheet",o.href=e,o.setAttribute("data-g7le-asset",e),document.head.appendChild(o)}function y4(e,t,n="user"){const[o,r]=u.useState({componentRegistry:null,translationEngine:null,isReady:!1,error:null});return u.useEffect(()=>{let s=!1;return(async()=>{r(a=>({...a,isReady:!1,error:null}));try{const a=await p4(e);if(s)return;for(const y of a.css??[])if(await h4(y),s)return;const l=u4(e);if(!(typeof window<"u"&&typeof window[l]=="object")){for(const y of a.js??[])if(await g4(y),s)return}const c=c4.createIsolatedInstance();if(await c.loadComponents(e,n),s)return;const p=await f4(e);if(s)return;const f=new Pt({defaultLocale:t});if(p>0&&f.setCacheVersion(p),await f.loadTranslations(e,t),s)return;cd(f,e);try{const y=Pt.getInstance();p>y.getCacheVersion()&&y.setCacheVersion(p),await y.loadTranslations(e,t),cd(y,e)}catch{}if(s)return;r({componentRegistry:c,translationEngine:f,isReady:!0,error:null})}catch(a){if(s)return;const l=pp(a)?a:io(a,"assets");r({componentRegistry:null,translationEngine:null,isReady:!1,error:l})}})(),()=>{s=!0}},[e,t,n]),o}const gg=Lt().AuthManager;function b4(e){switch(e){case"unauthorized":return{icon:"🔒",tone:"blue",titleKey:"layout_editor.access_error.unauthorized.title",messageKey:"layout_editor.access_error.unauthorized.message",showRequiredPermissions:!0};case"forbidden":return{icon:"⛔",tone:"rose",titleKey:"layout_editor.access_error.forbidden.title",messageKey:"layout_editor.access_error.forbidden.message",showRequiredPermissions:!0};case"not_found":return{icon:"🗂️",tone:"slate",titleKey:"layout_editor.access_error.not_found.title",messageKey:"layout_editor.access_error.not_found.message",showRequiredPermissions:!1};case"server_error":return{icon:"🛠️",tone:"amber",titleKey:"layout_editor.access_error.server_error.title",messageKey:"layout_editor.access_error.server_error.message",showRequiredPermissions:!1};case"network":return{icon:"📡",tone:"amber",titleKey:"layout_editor.access_error.network.title",messageKey:"layout_editor.access_error.network.message",showRequiredPermissions:!1};default:return{icon:"⚠️",tone:"slate",titleKey:"layout_editor.access_error.unknown.title",messageKey:"layout_editor.access_error.unknown.message",showRequiredPermissions:!1}}}const m4={blue:{bg:"#eff6ff",border:"#bfdbfe",iconBg:"#dbeafe"},rose:{bg:"#fff1f2",border:"#fecdd3",iconBg:"#ffe4e6"},amber:{bg:"#fffbeb",border:"#fde68a",iconBg:"#fef3c7"},slate:{bg:"#f8fafc",border:"#e2e8f0",iconBg:"#f1f5f9"}};function v4({error:e}){return u.useEffect(()=>{if(e.kind!=="unauthorized"||typeof window>"u")return;const t=gg.getInstance(),n=`${window.location.pathname}${window.location.search}`,o=t.getLoginRedirectUrl("admin",n,"session_expired"),r=window.setTimeout(()=>{window.location.href=o},600);return()=>window.clearTimeout(r)},[e.kind]),null}function Fa({error:e}){const{t}=tt(),n=b4(e.kind),o=m4[n.tone],r=()=>{typeof window<"u"&&(window.location.href="/admin")},s=()=>{typeof window<"u"&&window.location.reload()},a=()=>{if(typeof window>"u")return;const l=gg.getInstance(),d=`${window.location.pathname}${window.location.search}`;window.location.href=l.getLoginRedirectUrl("admin",d)};return i.jsxs("div",{className:"g7le-access-error","data-testid":"g7le-access-error","data-error-kind":e.kind,style:{flex:1,display:"flex",alignItems:"center",justifyContent:"center",padding:24,background:"#f1f5f9"},children:[i.jsx(v4,{error:e}),i.jsxs("div",{style:{maxWidth:480,width:"100%",background:o.bg,border:`1px solid ${o.border}`,borderRadius:12,padding:"28px 32px",boxShadow:"0 2px 8px rgba(15, 23, 42, 0.05)",textAlign:"center"},children:[i.jsx("div",{style:{width:64,height:64,margin:"0 auto 16px",borderRadius:"50%",background:o.iconBg,display:"flex",alignItems:"center",justifyContent:"center",fontSize:32,lineHeight:1},"aria-hidden":"true",children:n.icon}),i.jsx("h2",{"data-testid":"g7le-access-error-title",style:{margin:"0 0 8px",fontSize:18,fontWeight:700,color:"#0f172a"},children:t(n.titleKey)}),i.jsx("p",{"data-testid":"g7le-access-error-message",style:{margin:"0 0 16px",fontSize:13,lineHeight:1.6,color:"#475569"},children:t(n.messageKey)}),n.showRequiredPermissions&&e.requiredPermissions&&i.jsxs("div",{"data-testid":"g7le-access-error-permissions",style:{display:"inline-flex",flexWrap:"wrap",gap:6,justifyContent:"center",padding:"8px 12px",marginBottom:16,background:"rgba(15, 23, 42, 0.04)",borderRadius:8,fontSize:12,color:"#475569"},children:[i.jsx("span",{style:{fontWeight:600},children:t("layout_editor.access_error.required_permission_label")}),e.requiredPermissions.split(/[,\s]+/).filter(Boolean).map(l=>i.jsx("code",{style:{padding:"2px 6px",background:"#fff",border:"1px solid #e2e8f0",borderRadius:4,fontFamily:"ui-monospace, SFMono-Regular, Menlo, monospace",fontSize:11,color:"#1e293b"},children:l},l))]}),e.message&&i.jsxs("details",{"data-testid":"g7le-access-error-detail",style:{marginBottom:16,fontSize:11,color:"#94a3b8"},children:[i.jsx("summary",{style:{cursor:"pointer"},children:t("layout_editor.access_error.detail_summary")}),i.jsxs("pre",{style:{marginTop:8,padding:8,background:"#fff",border:"1px solid #e2e8f0",borderRadius:6,overflowX:"auto",whiteSpace:"pre-wrap",wordBreak:"break-word",textAlign:"left",fontSize:11,color:"#475569"},children:[e.status>0?`HTTP ${e.status} `:"",e.message]})]}),i.jsxs("div",{style:{display:"flex",justifyContent:"center",gap:8,flexWrap:"wrap"},children:[e.kind==="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-signin",onClick:a,style:Ka,children:t("layout_editor.access_error.action.sign_in")}),e.kind==="forbidden"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home",onClick:r,style:Ka,children:t("layout_editor.access_error.action.go_home")}),(e.kind==="not_found"||e.kind==="server_error"||e.kind==="network"||e.kind==="unknown")&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-retry",onClick:s,style:Ka,children:t("layout_editor.access_error.action.retry")}),e.kind!=="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home-secondary",onClick:r,style:_4,children:t("layout_editor.access_error.action.go_admin_home")})]})]})]})}const Ka={padding:"8px 16px",background:"#2563eb",color:"#fff",border:"1px solid #1d4ed8",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},_4={padding:"8px 16px",background:"#fff",color:"#475569",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"};function x4(e){const t=window;if(typeof t>"u")return{getSnapshot:()=>({globalState:{},localState:{}}),restore:()=>{}};const n=t.G7Core||{},o={templateApp:t.__templateApp,g7CoreState:n.state,g7CoreDispatch:n.dispatch,g7CoreData:n.data,g7CoreModal:n.modal,g7CoreToast:n.toast};let r={...e.initialGlobalState??{}};r._local||(r._local={});const s=[],a=()=>{const c={globalState:{...r},localState:{...r._local??{}}};for(const p of s)try{p(r)}catch{}e.onChange?.(c)},l={},d={getGlobalState:()=>r,setGlobalState:(c,p)=>{typeof c=="function"?r=c(r):r={...r,...c},a()},onGlobalStateChange:c=>(s.push(c),()=>{const p=s.indexOf(c);p>=0&&s.splice(p,1)}),get globalState(){return r},getActionDispatcher:()=>e.actionDispatcher,getDataSource:c=>l[c]??null,setDataSource:(c,p)=>{l[c]=p},refetchDataSource:async c=>null,updateDataSourceItem:(c,p,f)=>l[c]??null,getLocale:()=>e.locale,changeLocale:c=>{},getRouter:()=>({navigate:()=>{},replace:()=>{},push:()=>{}}),updateQueryParams:()=>{}};return t.__templateApp=d,n&&typeof n.dispatch=="function"&&(n.dispatch=(c,p)=>{try{return e.actionDispatcher.executeAction(c,p??{})}catch{return}}),n&&typeof n.modal=="object"&&n.modal!==null&&(n.modal={...n.modal,open:()=>{},close:()=>{},closeAll:()=>{}}),n&&typeof n.toast=="object"&&n.toast!==null&&(n.toast={...n.toast,push:()=>{},dismiss:()=>{}}),{getSnapshot:()=>({globalState:{...r},localState:{...r._local??{}}}),restore:()=>{t.__templateApp===d&&(t.__templateApp=o.templateApp),t.G7Core&&(o.g7CoreState!==void 0&&(t.G7Core.state=o.g7CoreState),o.g7CoreDispatch!==void 0&&(t.G7Core.dispatch=o.g7CoreDispatch),o.g7CoreData!==void 0&&(t.G7Core.data=o.g7CoreData),o.g7CoreModal!==void 0&&(t.G7Core.modal=o.g7CoreModal),o.g7CoreToast!==void 0&&(t.G7Core.toast=o.g7CoreToast)),s.length=0}}}const w4=new Set(["id","userId","postId","orderId","productId","categoryId","commentId","noticeId","inquiryId","addressId","wishlistId","notificationId","roleId","menuId"]);function k4(e){if(!e)return{};const t={},n=/:([a-zA-Z_][a-zA-Z0-9_]*)/g;let o;for(;(o=n.exec(e))!==null;){const r=o[1];t[r]=S4(r)}return t}function S4(e){return w4.has(e)||/Id$|_id$/.test(e)?"1":"sample"}const C4=dr("LayoutEditor");function Xo(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function bi(e){if(Array.isArray(e))return e.map(t=>bi(t));if(Xo(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=bi(o);return t}return e}function hg(e,t="",n=new Set){for(const[o,r]of Object.entries(e)){const s=t?`${t}.${o}`:o;Xo(r)?hg(r,s,n):n.add(s)}return n}function yg(e,t,n,o,r,s=""){for(const[a,l]of Object.entries(t)){const d=s?`${s}.${a}`:a;if(n.has(d)){r(`[LayoutEditor] sampleGlobal key conflict: extension '${o}' override of core key '${d}' ignored`);continue}const c=e[a];Xo(l)&&Xo(c)?yg(c,l,n,o,r,d):e[a]=bi(l)}}function Va(e){const t=e.warn??(r=>C4.warn(r)),n=bi(e.coreSeed??{});e.isGuestOnly&&"currentUser"in n&&delete n.currentUser;const o=hg(n);for(const r of e.sources)Xo(r.sampleGlobal)&&yg(n,r.sampleGlobal,o,r.id,t);return e.isGuestOnly&&"currentUser"in n&&delete n.currentUser,n}function $4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")&&(t[n]=o);return t}function E4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")||(t[n]=o);return t}function I4(e){if(typeof e!="string")return null;const t=/^\s*\{\{\s*([\s\S]*?)\s*\}\}\s*$/.exec(e);if(!t)return null;let n=t[1];const o=n.indexOf("??");return o>=0&&(n=n.slice(0,o)),n=n.replace(/\?\./g,".").replace(/\s+/g,""),n===""||!/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(n)?null:n.split(".")}function A4(e,t){const n=I4(t);if(!n||n.length===0)return e;let o=e;for(let a=0;ao.map(r=>{if(!r||typeof r!="object")return r;const s=r;let a=s;if(typeof s.slot=="string"&&!t.has(s.slot)){const{slot:l,...d}=s;a={...d,__editorSlotName:l}}if(Array.isArray(a.children)){const l=n(a.children);l!==a.children&&(a={...a,children:l})}return a});return n(e)}function St(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function Xt(e){if(Array.isArray(e))return e.map(t=>Xt(t));if(St(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=Xt(o);return t}return e}function mi(e,t){for(const[n,o]of Object.entries(t)){const r=e[n];St(o)&&St(r)?mi(r,o):e[n]=Xt(o)}}function N4(e){const t=[];let n="";for(let o=0;o0&&(t.push(n),n="");const s=e[o+1];if(s==="'"||s==='"'){const l=e.indexOf(`${s}]`,o+2);if(l!==-1){t.push(e.slice(o+2,l)),o=l+1;continue}}const a=e.indexOf("]",o+1);if(a!==-1){t.push(e.slice(o+1,a)),o=a;continue}n+=r}else r==="."?n.length>0&&(t.push(n),n=""):n+=r}return n.length>0&&t.push(n),t.filter(o=>o.length>0)}function Ua(e,t,n){const o=N4(t);if(o.length===0)return;let r=e;for(let s=0;s0?t:void 0}const Ga="g7le:editor-t-ready",vg="data-g7le-vh-expanded";function M4(e,t){if(!e||!t)return;const n=t;e.querySelectorAll("*").forEach(r=>{const s=window.getComputedStyle(r),a=parseFloat(s.height),l=Number.isFinite(a)&&Math.abs(a-n)<=2,d=parseFloat(s.minHeight),c=Number.isFinite(d)&&Math.abs(d-n)<=2,p=s.overflowY==="auto"||s.overflowY==="scroll"||s.overflowY==="hidden"||s.overflow==="auto"||s.overflow==="scroll"||s.overflow==="hidden";l&&p?(r.style.setProperty("height","auto","important"),c&&r.style.setProperty("min-height","0","important"),r.style.setProperty("overflow","visible","important"),r.setAttribute(vg,"1")):(s.overflowY==="auto"||s.overflowY==="scroll")&&r.scrollHeight>r.clientHeight+4&&r.clientHeight>120&&(r.style.setProperty("overflow","visible","important"),r.style.setProperty("height","auto","important"),r.setAttribute(vg,"1"))})}const L4=[];function P4(e={}){const{manifest:t=null,nesting:n=null,componentPalette:o=null,spec:r=null,permissionCandidates:s,sampleGlobalSources:a=L4,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c}=e,{state:p}=Je(),f=Ft(),y=wp(),{document:g,isLoading:m,error:h}=f??y,v=u.useRef(null),[b,x]=u.useState(null),{deviceWidth:k,scale:w,colorScheme:_}=ja(),S=u.useMemo(()=>p.activeStateId?p.availableStates.find(Q=>Q.id===p.activeStateId)??null:null,[p.activeStateId,p.availableStates]),E=u.useMemo(()=>mg(S),[S]),C=d4({isEditMode:!0,editorSpec:r??void 0,sampleOverride:E}),{t:N}=tt(),{componentRegistry:$,translationEngine:A,isReady:z,error:D}=y4(p.templateIdentifier,p.locale),O=u.useRef(null);O.current===null&&(O.current=new vd);const M=u.useRef(null);if(M.current===null){M.current=new jE({navigate:()=>{}}),M.current.setPreviewMode(!0);try{const ie=window.__templateApp?.getActionDispatcher?.();if(ie&&typeof ie.getRegisteredHandlers=="function"){const _e=ie.getRegisteredHandlers();for(const ge of _e){if(!ge.includes("."))continue;const Ae=typeof ie.getHandler=="function"?ie.getHandler(ge):void 0;typeof Ae=="function"&&M.current.registerHandler(ge,Ae,{category:"plugin",source:"preview-canvas-cloned-from-host"})}}}catch{}}u.useEffect(()=>{if(!z)return;const Q=M.current;if(Q)try{const ie=window.G7Core?.getActionDispatcher?.();if(!ie||typeof ie.getRegisteredHandlers!="function")return;const _e=ie.getRegisteredHandlers();for(const ge of _e){if(!ge.includes(".")||typeof Q.getHandler=="function"&&Q.getHandler(ge))continue;const Ae=typeof ie.getHandler=="function"?ie.getHandler(ge):void 0;typeof Ae=="function"&&Q.registerHandler(ge,Ae,{category:"plugin",source:"preview-canvas-iife-reclone"})}}catch{}},[z]);const B=u.useMemo(()=>new HE({sampleProvider:C,actionDispatcher:M.current}),[C]),T=u.useMemo(()=>g?.raw?.meta?.guest_only===!0,[g]),H=u.useMemo(()=>Va({sources:a,isGuestOnly:T}),[a,T]),L=u.useMemo(()=>{const Q=O4(qa(S),A,{templateId:p.templateIdentifier,locale:p.locale}),ie=H._local,{global:_e,local:ge}=z4({globalBaseline:H,localBaseline:ie??{},patch:S?.initialState??null,formErrors:Q});return{..._e,_local:ge}},[H,S,A,p.templateIdentifier,p.locale]),I=u.useMemo(()=>!S||!(!!S.initialState?.local||!!qa(S))?null:{...L._local??{},_forceLocalInit:S.id,_merge:"deep"},[S,L]),P=Q=>typeof Q=="object"&&Q!==null&&!Array.isArray(Q),F=u.useMemo(()=>P(S?.initialState?.query)?S.initialState.query:null,[S]),G=u.useMemo(()=>P(S?.initialState?.route)?S.initialState.route:null,[S]),[V,Y]=u.useState(()=>Va({sources:[],isGuestOnly:!1})),Z=u.useRef(null);u.useEffect(()=>{const Q={...L},ie=x4({actionDispatcher:M.current,locale:p.locale,initialGlobalState:Q,onChange:ge=>{Y(ge.globalState)}});Z.current=ie;const _e=window.__templateApp;return _e?.setGlobalState&&M.current.setGlobalStateUpdater((ge,Ae)=>{_e.setGlobalState(ge,Ae)}),Y(Q),$l({kind:"applyPatch",activeStateId:p.activeStateId,routePath:p.selectedRoute?.path??null,hasInitialPatch:!!S?.initialState,hasFormErrors:!!qa(S),hasSampleOverride:!!mg(S)}),()=>{ie.restore(),Z.current=null}},[p.locale,L]),u.useEffect(()=>{if(!A)return;const Q=window.G7Core;if(!Q||typeof Q.t!="function")return;const ie=Q.t,_e=p.templateIdentifier,ge=p.locale;return Q.t=(Ae,Ne)=>{try{const Le=Ne?"|"+Object.entries(Ne).map(([ae,pe])=>`${ae}=${pe}`).join("|"):void 0,q=A.translate(Ae,{templateId:_e,locale:ge},Le);if(q!==Ae&&!q.startsWith("$t:"))return q}catch{}return ie(Ae,Ne)},typeof window<"u"&&window.dispatchEvent(new CustomEvent(Ga)),()=>{window.G7Core?.t&&window.G7Core.t!==ie&&(window.G7Core.t=ie)}},[A,p.templateIdentifier,p.locale]);const se=u.useMemo(()=>{const Q=g?.raw?.components??[];return p.editMode!=="base"?Q:D4(Q)},[g,p.editMode]),re=g?.raw?.__editability,U=g?.raw?.__extensionId,ee=u.useMemo(()=>p.editMode!=="extension"||re!=="ok"?[]:typeof U!="number"?[]:B4(se,U),[p.editMode,re,U,se]),[fe,K]=u.useState(null),[ne,me]=u.useState(!1),Ce=g?.layoutName??null,ze=u.useMemo(()=>{const Q=g?.raw?.scripts??[];return Array.isArray(Q)?Q.map(ie=>ie&&typeof ie.src=="string"?ie.src:"").join("|"):""},[g]);u.useEffect(()=>{me(!1)},[Ce,ze]),u.useEffect(()=>{let Q=!1;const ie=g?.raw?.scripts??[];if(!Array.isArray(ie)||ie.length===0){me(!0);return}const _e=[];for(const ge of ie){if(!ge?.src||typeof ge.src!="string")continue;const Ae=ge.id?`script#${CSS.escape(ge.id)}, script[data-g7le-canvas-script="${ge.src}"]`:`script[data-g7le-canvas-script="${ge.src}"], script[src="${ge.src}"]`,Ne=window.document.querySelector(Ae);if(Ne){const Le=Ne.hasAttribute("data-g7le-canvas-script");if(Ne.hasAttribute("data-g7le-script-loaded")||!Le)continue;_e.push(new Promise(ae=>{if(Ne.hasAttribute("data-g7le-script-loaded")){ae();return}Ne.addEventListener("load",()=>ae()),Ne.addEventListener("error",()=>ae())}));continue}_e.push(new Promise(Le=>{const q=window.document.createElement("script");q.src=ge.src,q.async=!1,ge.id&&(q.id=ge.id),q.setAttribute("data-g7le-canvas-script",ge.src);const ae=()=>{q.setAttribute("data-g7le-script-loaded","1"),Le()};q.addEventListener("load",ae),q.addEventListener("error",ae),window.document.head.appendChild(q)}))}if(_e.length===0){me(!0);return}return Promise.all(_e).then(()=>{Q||requestAnimationFrame(()=>{requestAnimationFrame(()=>{Q||me(!0)})})}),()=>{Q=!0}},[g]);const Se=u.useMemo(()=>g?.raw?.data_sources??[],[g]),qe=u.useMemo(()=>g?.raw?.computed??{},[g]),[De,Pe]=u.useState({}),Qe=u.useMemo(()=>{if(p.editMode!=="iteration_item")return De;const Q=g?.iterationContext;if(!Q)return De;const ie=g?.raw?.components??[],_e=We({children:ie},Q.sourceIndexPath),ge=_e&&_e.iteration&&typeof _e.iteration.source=="string"?_e.iteration.source:null;return ge?A4(De,ge):De},[p.editMode,De,g]),[Me,Re]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const Q=()=>Re(ie=>ie+1);return window.addEventListener(kt,Q),()=>window.removeEventListener(kt,Q)},[]);const ve=u.useMemo(()=>k4(p.selectedRoute?.path??null),[p.selectedRoute?.path]);if(u.useEffect(()=>{let Q=!1;return(async()=>{if(Se.length===0){Q||Pe({});return}try{const ie=await B.fetchDataSources(Se,ve,new URLSearchParams,void 0,void 0);Q||Pe(ie)}catch{Q||Pe({})}})(),()=>{Q=!0}},[Se,B,ve]),u.useEffect(()=>{const Q=b;if(!Q||typeof window>"u")return;let ie=0,_e=0,ge=0;const Ae=()=>M4(Q,window.innerHeight);ie=window.requestAnimationFrame(()=>{_e=window.requestAnimationFrame(Ae)});let Ne=null;return typeof MutationObserver<"u"&&(Ne=new MutationObserver(()=>{ge||(ge=window.requestAnimationFrame(()=>{ge=0,Ae()}))}),Ne.observe(Q,{childList:!0,subtree:!0})),()=>{window.cancelAnimationFrame(ie),window.cancelAnimationFrame(_e),ge&&window.cancelAnimationFrame(ge),Ne&&Ne.disconnect()}},[b,se,ne,k,De]),u.useEffect(()=>{const Q=b;if(!Q||typeof window>"u"||p.editMode!=="extension"||re!=="ok"||ee.length===0){K(null);return}let ie=0,_e=0;const ge=ae=>{if(!ae)return!1;const pe=ae.getBoundingClientRect();return pe.width>0&&pe.height>0},Ae=ae=>{const pe=Q.querySelector(`[data-editor-path="${ae}"]`);if(ge(pe))return!0;const $e=Q.querySelectorAll(`[data-editor-path^="${ae}.children."]`);for(const be of Array.from($e))if(ge(be))return!0;return!1},Ne=()=>{K(ee.some(Ae))};ie=window.requestAnimationFrame(()=>{_e=window.requestAnimationFrame(Ne)});let Le=null,q=0;return typeof MutationObserver<"u"&&(Le=new MutationObserver(()=>{q||(q=window.requestAnimationFrame(()=>{q=0,Ne()}))}),Le.observe(Q,{childList:!0,subtree:!0})),()=>{window.cancelAnimationFrame(ie),window.cancelAnimationFrame(_e),q&&window.cancelAnimationFrame(q),Le&&Le.disconnect()}},[b,p.editMode,re,ee,ne,De]),u.useEffect(()=>{if(typeof window>"u"||p.editMode!=="base")return;const Q=window.document,ie="g7le-base-slot-style";if(Q.getElementById(ie))return;const _e=Q.createElement("style");_e.id=ie;const ge=N("layout_editor.preview.slot_area_label");return _e.textContent=`.g7le-preview-frame [data-editor-slot]{position:relative;min-height:96px;outline:2px dashed #94a3b8;outline-offset:-2px;}.g7le-preview-frame [data-editor-slot]::after{content:'${ge.replace(/'/g,"\\'")} · ' attr(data-editor-slot);position:absolute;top:8px;left:50%;transform:translateX(-50%);font-size:12px;line-height:1;color:#64748b;background:rgba(241,245,249,0.92);border:1px solid #cbd5e1;border-radius:9999px;padding:4px 12px;pointer-events:none;white-space:nowrap;}`,Q.head.appendChild(_e),()=>{Q.getElementById(ie)?.remove()}},[p.editMode,N]),!(p.editMode==="extension"||p.editMode==="iteration_item")&&(p.selectedRoute?.layoutName===null||p.selectedRoute===null))return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--empty","data-testid":"g7le-preview-empty",style:Jo,children:N("layout_editor.preview.empty")});if(m)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--loading","data-testid":"g7le-preview-loading",style:Jo,children:N("layout_editor.preview.loading")});if(h)return i.jsx(Fa,{error:h});if(D)return i.jsx(Fa,{error:D});if(!z||$===null||A===null)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--assets-loading","data-testid":"g7le-preview-assets-loading",style:Jo,children:N("layout_editor.preview.assets_loading")});if(se.length===0)return i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--empty-layout","data-testid":"g7le-preview-empty-layout",style:Jo,children:N("layout_editor.preview.empty_layout")});const he=p.editMode==="extension"?re==="no-injection"?"no-injection":re==="ok"&&ee.length>0&&fe!==!0?"gated-no-state":null:null;return ne?i.jsx("div",{className:"g7le-preview-canvas","data-testid":"g7le-preview-canvas",style:{overflow:"visible",background:"#f1f5f9",padding:24,display:"flex",justifyContent:"center",alignItems:"flex-start",minHeight:"calc(100vh - var(--g7le-header-h, 0px))",boxSizing:"border-box"},children:i.jsxs("div",{className:"g7le-preview-frame-wrapper","data-testid":"g7le-preview-frame-wrapper",style:{position:"relative",width:k,transform:`scale(${w})`,transformOrigin:"top center"},children:[i.jsx("div",{className:_==="dark"?"g7le-preview-frame g7le-preview-dark":"g7le-preview-frame","data-testid":"g7le-preview-frame","data-color-scheme":_,ref:Q=>{v.current=Q,x(Q)},style:{width:k,background:"#fff",border:"1px solid #cbd5e1",boxShadow:"0 1px 3px rgba(0,0,0,0.05)",minHeight:400,position:"relative",transform:"translateZ(0)",overflow:"hidden"},children:i.jsx(WE,{overrideWidth:k,children:se.map((Q,ie)=>i.jsx(BE,{componentDef:Q,dataContext:{...Qe,...$4(V),_global:E4(V),_computedDefinitions:qe,...I?{_localInit:I,_local:{...De._local??{},...V._local??{}}}:{},route:W4({...De.route??{},...ve,path:p.selectedRoute?.path??""},G),query:{...De.query??{},...F??{}}},translationContext:{templateId:p.templateIdentifier,locale:p.locale},registry:$,bindingEngine:O.current,translationEngine:Pt.getInstance(),actionDispatcher:M.current,isEditMode:!0,isRootRenderer:ie===0,componentPath:`${ie}`,onComponentSelect:(_e,ge)=>{},onComponentHover:(_e,ge)=>{}},`${Q.id??`preview-${ie}`}:t${Me}${p.editMode==="extension"?`:s${ne?1:0}`:""}`))})}),i.jsx("div",{className:"g7le-overlay-layer","data-testid":"g7le-overlay-layer",style:{position:"absolute",left:0,top:0,width:k,height:b?b.getBoundingClientRect().height/(w||1):"100%",overflow:"visible",pointerEvents:"none"},children:i.jsx(l4,{frameEl:b,manifest:t,nesting:n,componentPalette:o,spec:r,permissionCandidates:s,resolveRouteMatch:l,onNavigateToDestination:d,onSessionAddedPathsChange:c})}),he!==null&&i.jsx("div",{className:"g7le-preview-canvas--extension-fallback","data-testid":"g7le-preview-extension-fallback","data-fallback-reason":he,style:{position:"absolute",left:0,top:0,right:0,bottom:0,minHeight:b?b.getBoundingClientRect().height/(w||1):400,background:"#f1f5f9",display:"flex",alignItems:"flex-start",justifyContent:"center",padding:24,boxSizing:"border-box",pointerEvents:"auto",zIndex:50},children:i.jsxs("div",{className:"g7le-extension-fallback-card",style:{maxWidth:440,textAlign:"center",position:"sticky",top:"calc(40vh - var(--g7le-header-h, 0px))"},children:[i.jsx("p",{className:"g7le-extension-fallback-title",style:{fontWeight:600,marginBottom:8,color:"#0f172a"},children:N(he==="no-injection"?"layout_editor.preview.extension_no_injection_title":"layout_editor.preview.extension_gated_title")}),i.jsx("p",{className:"g7le-extension-fallback-desc",style:{color:"#475569",lineHeight:1.6},children:he==="no-injection"?N("layout_editor.preview.extension_no_injection_desc"):p.availableStates.length>1?N("layout_editor.preview.extension_gated_desc_with_states"):N("layout_editor.preview.extension_gated_desc")})]})})]})}):i.jsx("div",{className:"g7le-preview-canvas g7le-preview-canvas--scripts-loading","data-testid":"g7le-preview-scripts-loading",style:Jo,children:N("layout_editor.preview.loading")})}function O4(e,t,n){if(!e)return;if(!t)return e;const o=s=>{if(typeof s=="string"&&s.startsWith("$t:"))try{const a=s.slice(3),l=a.indexOf("|");if(l===-1)return t.translate(a,n);const d=a.slice(0,l),c=a.slice(l);return t.translate(d,n,c)}catch{return s}return Array.isArray(s)?s.map(o):s},r={};for(const[s,a]of Object.entries(e))r[s]=o(a);return r}function B4(e,t){const n=[],o=(r,s)=>{Array.isArray(r)&&r.forEach((a,l)=>{if(!a||typeof a!="object")return;const d=s?`${s}.children.${l}`:`${l}`,c=a.__source;if(c?.kind==="extension"&&c.extensionId===t){n.push(d);return}Array.isArray(a.children)&&o(a.children,d)})};return o(e,""),n}function W4(e,t){if(!t)return e;const n={...e};for(const[o,r]of Object.entries(t))r===null?delete n[o]:n[o]=r;return n}const Jo={flex:1,display:"flex",alignItems:"center",justifyContent:"center",padding:24,background:"#f1f5f9",color:"#64748b",fontSize:13},j4={desktop:"layout_editor.device.desktop",tablet:"layout_editor.device.tablet",mobile:"layout_editor.device.mobile",portable:"layout_editor.device.portable"};function H4(){const{device:e,zoom:t,customWidth:n,setDevice:o,setZoom:r,setCustomWidth:s,colorScheme:a,setColorScheme:l}=ja(),{t:d}=tt(),c=Vf();return i.jsxs("div",{className:"g7le-device-toolbar","data-testid":"g7le-device-toolbar",style:{display:"inline-flex",alignItems:"center",gap:8,padding:"4px 8px",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6},children:[i.jsxs("div",{role:"group","aria-label":d("layout_editor.device.group_label"),children:[c.map(p=>{const f=e===p,y=j4[p],g=y?d(y):p;return i.jsx("button",{type:"button","data-testid":`g7le-device-${p}`,"aria-pressed":f,onClick:()=>o(p),style:{padding:"2px 8px",marginRight:2,fontSize:12,cursor:"pointer",background:f?"#0f172a":"#f8fafc",color:f?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:g},p)}),(()=>{const p=e==="custom";return i.jsx("button",{type:"button","data-testid":"g7le-device-custom","aria-pressed":p,onClick:()=>o("custom"),style:{padding:"2px 8px",marginRight:2,fontSize:12,cursor:"pointer",background:p?"#0f172a":"#f8fafc",color:p?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:d("layout_editor.device.custom")})})()]}),e==="custom"&&i.jsxs("div",{style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("label",{htmlFor:"g7le-custom-width-input",style:{fontSize:11,color:"#475569"},children:d("layout_editor.device.custom_width_label")}),i.jsx("input",{id:"g7le-custom-width-input",type:"number",min:lg,max:dg,step:10,value:n,"data-testid":"g7le-custom-width-input","aria-label":d("layout_editor.device.custom_width_label"),onChange:p=>{const f=parseInt(p.currentTarget.value,10);Number.isFinite(f)&&s(f)},style:{width:72,padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4}}),i.jsx("span",{style:{fontSize:11,color:"#475569"},children:"px"})]}),i.jsx("div",{role:"group","aria-label":d("layout_editor.toolbar.color_scheme_group"),style:{display:"inline-flex",gap:2},children:["light","dark"].map(p=>{const f=a===p;return i.jsx("button",{type:"button","data-testid":`g7le-toolbar-scheme-${p}`,"aria-pressed":f,onClick:()=>l(p),style:{padding:"2px 8px",fontSize:12,cursor:"pointer",background:f?"#0f172a":"#f8fafc",color:f?"#fff":"#0f172a",border:"1px solid #cbd5e1",borderRadius:4},children:d(p==="light"?"layout_editor.toolbar.color_scheme_light":"layout_editor.toolbar.color_scheme_dark")},p)})}),i.jsxs("div",{style:{display:"inline-flex",alignItems:"center",gap:4},children:[i.jsx("span",{style:{fontSize:11,color:"#475569"},children:d("layout_editor.zoom.label")}),i.jsx("input",{type:"range",min:.5,max:1,step:.05,value:t,"data-testid":"g7le-zoom-slider","aria-label":d("layout_editor.zoom.aria_label"),onChange:p=>r(parseFloat(p.currentTarget.value)),style:{width:100}}),i.jsxs("span",{style:{fontSize:11,color:"#475569",minWidth:36,textAlign:"right"},children:[Math.round(t*100),"%"]})]}),i.jsx(jb,{})]})}function F4(){const{t:e}=tt(),{state:t,dispatch:n}=Je(),{availableStates:o,activeStateId:r}=t,[,s]=u.useState(0);u.useEffect(()=>{if(typeof window>"u")return;const p=()=>s(y=>y+1),f=window.requestAnimationFrame(p);return window.addEventListener(Ga,p),()=>{window.cancelAnimationFrame(f),window.removeEventListener(Ga,p)}},[]);const a=u.useCallback((p,f)=>{if(!p)return f;if(!p.startsWith("$t:"))return p;const y=p.slice(3),g=window.G7Core;if(g&&typeof g.t=="function"){const h=g.t(y);if(h&&h!==y&&!h.startsWith("$t:"))return h}const m=e(y);return m&&m!==y?m:f},[e]);if(!Array.isArray(o)||o.length<=1)return null;const l=p=>{const f=p.target.value;n({type:"SET_ACTIVE_STATE",activeStateId:f}),$l({kind:"change",activeStateId:f,routePath:t.selectedRoute?.path??null,availableCount:o.length})},d=o.find(p=>p.id===r)??o[0],c=d?.description?a(d.description,""):"";return i.jsxs("div",{className:"g7le-state-switcher","data-testid":"g7le-state-switcher","data-active-state":r??"",style:{display:"inline-flex",alignItems:"center",gap:6,padding:"4px 8px",background:"#fff",border:"1px solid #cbd5e1",borderRadius:6},children:[i.jsx("span",{style:{fontSize:12,fontWeight:600,color:"#475569",whiteSpace:"nowrap"},children:e("layout_editor.toolbar.state")}),i.jsx("select",{"data-testid":"g7le-state-switcher-select","aria-label":e("layout_editor.toolbar.state"),value:r??o[0].id,onChange:l,style:{fontSize:12,padding:"2px 6px",border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",color:"#0f172a",cursor:"pointer",outline:"none"},children:o.map(p=>i.jsx("option",{value:p.id,children:a(p.label,p.id)},p.id))}),c&&i.jsx("span",{"data-testid":"g7le-state-switcher-description",style:{fontSize:11,color:"#94a3b8",whiteSpace:"nowrap"},children:c})]})}function K4(e,t,n){if(e==="extension"){if(typeof n=="string"&&n.length>0){const o=Ya(n,"__base__/");return o?{kind:"base",match:o}:{kind:"route",match:n}}return null}if(!t)return null;switch(e){case"route":return{kind:"route",match:t};case"base":{const o=Ya(t,"__base__/");return o?{kind:"base",match:o}:null}case"modal":{const o=Ya(t,"__modal__/");return o?{kind:"modal",match:o}:null}default:return null}}function V4(e,t){if(!t||!Array.isArray(e)||e.length===0)return[];const n=[];for(const o of e)if(q4(o.scope,t))for(const r of o.items??[])r&&typeof r.id=="string"&&r.id.length>0&&n.push(r);return n}function U4(e){if(e.length===0)return null;const t=e.find(n=>n.default===!0);return t?t.id:e[0].id}function q4(e,t){if(!e||e.kind!==t.kind)return!1;const n=e.match;return typeof n!="string"||n.length===0?!1:t.kind==="route"?G4(t.match,n):n===t.match}function G4(e,t){if(t===e)return!0;if(!t.includes("*"))return!1;const n=t.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,"[^/]+");return new RegExp(`^${n}$`).test(e)}function Ya(e,t){return e.startsWith(t)?e.slice(t.length):""}const Y4="__conngroup__/modals/",X4="__conngroup__/extensions/";function J4(e){return e.meta?.editor_label?{label:e.meta.editor_label,labelSource:"editor_label"}:e.meta?.title?{label:e.meta.title,labelSource:"title"}:{label:e.path,labelSource:"path"}}function Z4(e){switch(e.source.kind){case"template":return"template";case"module":return`module:${e.source.identifier??""}`;case"plugin":return`plugin:${e.source.identifier??""}`;case"core":return null;default:return null}}function Q4(e,t){if(e.meta?.parent&&t.has(e.meta.parent))return e.meta.parent;const n=e.path.split("/").filter(Boolean);for(let o=n.length-1;o>=1;o--){const r="/"+n.slice(0,o).join("/");if(r!==e.path&&t.has(r))return r}return null}function R4(e){const t=new Map;for(const n of e)n.layout&&t.set(n.layout,(t.get(n.layout)??0)+1);return t}function eD(e){const t=new Map;for(const n of e){if(!n.hostLayout)continue;const o=t.get(n.hostLayout)??[];o.push(n),t.set(n.hostLayout,o)}return t}function tD(e){const t=new Map;for(const n of e)for(const o of n.extensions)for(const r of o.hostLayouts??[]){if(!r)continue;const s=t.get(r)??[];s.push({extension:o,group:n}),t.set(r,s)}return t}function nD(e,t){return{path:`__modal__/${e.hostLayout}/${e.modalId}`,layoutName:e.hostLayout,label:e.label??e.modalId,labelSource:e.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"modal",modalId:e.modalId,modalHostLayout:e.hostLayout,connectedHostRoutePath:t,children:[]}}function oD(e,t){const{extension:n,group:o}=e,r=o.sourceType==="plugin"?"🔌":"🧩";return{path:`__extension__/${n.id}`,layoutName:null,label:`${r} ${o.sourceLabel} · ${n.targetName}`,labelSource:"path",source:{kind:o.sourceType,identifier:o.sourceIdentifier},kind:"extension",extensionId:String(n.id),extensionType:n.extensionType,extensionPriority:n.priority,extensionTargetName:n.targetName,isModified:n.isModified,isInactive:!n.isActive,connectedHostRoutePath:t,children:[]}}function _g(e,t,n){if(e.kind!=="route"&&e.kind!=="base"||!e.layoutName)return;const o=[],r=t.get(e.layoutName)??[];r.length>0&&o.push({path:`${Y4}${e.path}`,layoutName:null,label:`$t:layout_editor.chrome.route_tree.connected.modals|count=${r.length}`,labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:r.map(a=>nD(a,e.path))});const s=n.get(e.layoutName)??[];s.length>0&&o.push({path:`${X4}${e.path}`,layoutName:null,label:`$t:layout_editor.chrome.route_tree.connected.extensions|count=${s.length}`,labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:s.map(a=>oD(a,e.path))}),o.length>0&&(e.children=[...o,...e.children??[]])}function rD(e){const t=[],n=new Set(e.routes.map(o=>o.path));for(const o of e.routes){const r=Z4(o);if(!r)continue;const{label:s,labelSource:a}=J4(o),l=Q4(o,n),d={path:o.path,layoutName:o.layout??null,label:s,labelSource:a,icon:o.meta?.icon??null,source:o.source,isRedirect:!!o.redirect,isHidden:!!o.meta?.hidden,kind:"route",children:[]};t.push({node:d,parentKey:l,groupKey:r})}return t}function iD(e,t){if(e==="template")return"$t:layout_editor.chrome.route_tree.group.template";if(e.startsWith("module:")){const n=e.slice(7);return`$t:layout_editor.chrome.route_tree.group.module|name=${t.moduleDisplayNames[n]??n}`}if(e.startsWith("plugin:")){const n=e.slice(7);return`$t:layout_editor.chrome.route_tree.group.plugin|name=${t.pluginDisplayNames[n]??n}`}return e}function sD(e,t){R4(e.routes);const n=eD(e.modals),o=tD(e.extensionGroups??[]),r=new Map;for(const d of t)r.has(d.groupKey)||r.set(d.groupKey,[]),r.get(d.groupKey).push(d);const s=Array.from(r.keys()).sort((d,c)=>{const p=g=>g==="template"?1:g.startsWith("module:")?2:g.startsWith("plugin:")?3:4,f=p(d),y=p(c);return f!==y?f-y:d.localeCompare(c)}),a=[];if(e.baseLayouts.length>0){const d=e.baseLayouts.map(c=>({path:`__base__/${c.layoutName}`,layoutName:c.layoutName,label:c.label??c.layoutName,labelSource:c.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"base",children:[]}));for(const c of d)_g(c,n,o);a.push({path:"__group__/base",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.base",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}for(const d of s){const c=r.get(d),p=new Map;for(const y of c)p.set(y.node.path,y);const f=[];for(const y of c)if(y.parentKey&&p.has(y.parentKey)){const g=p.get(y.parentKey);g.node.children=g.node.children??[],g.node.children.push(y.node)}else f.push(y.node);for(const y of c)_g(y.node,n,o);a.push({path:`__group__/${d}`,layoutName:null,label:iD(d,e),labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:f})}if(e.modals.length>0){const d=e.modals.map(c=>({path:`__modal__/${c.hostLayout}/${c.modalId}`,layoutName:c.hostLayout,label:c.label??c.modalId,labelSource:c.label?"editor_label":"path",source:{kind:"core",identifier:null},kind:"modal",modalId:c.modalId,modalHostLayout:c.hostLayout,children:[]}));a.push({path:"__group__/modal",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.modal",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}const l=e.extensionGroups??[];if(l.length>0){const d=l.map(c=>{const p=c.sourceType==="plugin"?"🔌":"🧩",f=c.extensions.map(y=>({path:`__extension__/${y.id}`,layoutName:null,label:`${c.sourceLabel} · ${y.targetName}`,labelSource:"path",source:{kind:c.sourceType,identifier:c.sourceIdentifier},kind:"extension",extensionId:String(y.id),extensionType:y.extensionType,extensionPriority:y.priority,extensionTargetName:y.targetName,isModified:y.isModified,isInactive:!y.isActive,children:[]}));return{path:`__extgroup__/${c.sourceType}/${c.sourceIdentifier}`,layoutName:null,label:`${p} ${c.sourceLabel}`,labelSource:"path",source:{kind:c.sourceType,identifier:c.sourceIdentifier},kind:"route",children:f}});a.push({path:"__group__/extension",layoutName:null,label:"$t:layout_editor.chrome.route_tree.group.extension",labelSource:"editor_label",source:{kind:"core",identifier:null},kind:"route",children:d})}return a}function aD(e){const t=rD(e);return sD(e,t)}const On=dr("useEditorRoutes");function lD(e,t){if(typeof e=="string")return e.length>0?e:null;if(e&&typeof e=="object"){const n=e[t];if(typeof n=="string"&&n.length>0)return n;if(typeof e.en=="string"&&e.en.length>0)return e.en;for(const o of Object.values(e))if(typeof o=="string"&&o.length>0)return o}return null}function xg(e,t){const n={};if(typeof window>"u")return n;const r=window.G7Config?.[e];if(!Array.isArray(r))return n;for(const s of r)s&&typeof s.identifier=="string"&&(n[s.identifier]=lD(s.display_name,t)??s.identifier);return n}function dD(e){const t=new vd,n=typeof window<"u"?window.G7Config:void 0,o={_global:{settings:n?.settings??{},plugins:n?.plugins??{},modules:n?.modules??{},appConfig:n?.appConfig??{}}};return e.map(r=>{const s={...r};if(typeof s.path=="string"&&s.path.includes("{{")){const a=s.path;let l=t.resolveBindings(s.path,o);l=l.replace(/\/\/+/g,"/")||"/",l.includes("{{")&&(On.warn("편집기 route path 표현식 평가 실패 — fallback 적용",{originalPath:a}),l=a.replace(/\{\{[^}]+\}\}/g,"").replace(/\/\/+/g,"/")||"/"),s.path=l}if(typeof s.redirect=="string"&&s.redirect.includes("{{")){const a=s.redirect;let l=t.resolveBindings(a,o);l=l.replace(/\/\/+/g,"/")||"/",l.includes("{{")&&(On.warn("편집기 route redirect 표현식 평가 실패 — fallback 적용",{originalRedirect:a}),l=a.replace(/\{\{[^}]+\}\}/g,"").replace(/\/\/+/g,"/")||"/"),s.redirect=l}return s})}function cD(e){const{dispatch:t}=Je(),{translationEngine:n,translationContext:o}=tt(),{templateIdentifier:r,cacheVersion:s}=e;u.useEffect(()=>{let a=!1;const l=s??(typeof window<"u"?window.G7Config?.cache_version:void 0)??"",d=l!==""?`?v=${encodeURIComponent(String(l))}`:"",c=en(`/api/admin/templates/${encodeURIComponent(r)}/editor/routes`,"json",null,d.replace(/^[?&]+/,"")),p=o?.locale??"ko",f=n&&r!==o?.templateId?n.loadTranslations(r,p).then(()=>{On.log("편집 대상 lang 로드 완료",{targetTemplate:r,locale:p})}).catch(h=>{On.warn("편집 대상 lang 로드 실패 — 라우트 라벨 fallback 사용",h)}):Promise.resolve(),y=(()=>{if(typeof fetch!="function")return Promise.reject(io(new Error("fetch unavailable"),"routes"));try{const h=fetch(c,{credentials:"same-origin",headers:je()});return!h||typeof h.then!="function"?Promise.reject(io(new Error("fetch returned non-Promise (test stub?)"),"routes")):h.then(async v=>{if(!v.ok){const b=await v.json().catch(()=>null);throw Gr(v.status,b,"routes")}return v.json()})}catch(h){return Promise.reject(h)}})(),g=`/api/admin/templates/${encodeURIComponent(r)}/layout-extensions${d}`,m=(()=>{if(typeof fetch!="function")return Promise.resolve(null);try{const h=fetch(g,{credentials:"same-origin",headers:je()});return!h||typeof h.then!="function"?Promise.resolve(null):h.then(async v=>v.ok?await v.json():null).catch(()=>null)}catch{return Promise.resolve(null)}})();return Promise.all([y,f,m]).then(([h,,v])=>{if(a)return;if(!h||!h.success||!Array.isArray(h.data?.routes)){On.warn("routes 응답 형식이 비정상 — empty tree 로 둠",h),t({type:"SET_ROUTES_ERROR",error:{kind:"unknown",status:200,message:"routes 응답 형식이 비정상입니다.",source:"routes"}});return}const b=dD(h.data.routes),x=xg("activeModules",p),k=xg("activePlugins",p),w=(h.data.base_layouts??[]).map(A=>({layoutName:A.layout_name,label:A.label??void 0})),_=(h.data.modals??[]).map(A=>({modalId:A.modal_id,hostLayout:A.host_layout,label:A.label??void 0})),S=(v?.success&&Array.isArray(v.data)?v.data:[]).map(A=>({sourceIdentifier:A.source_identifier,sourceType:A.source_type,sourceLabel:A.source_label,extensions:(A.extensions??[]).map(z=>({id:z.id,extensionType:z.extension_type,targetName:z.target_name,priority:z.priority,isActive:z.is_active,isModified:z.is_modified,hostLayouts:Array.isArray(z.host_layouts)?z.host_layouts:[],currentVersion:typeof z.current_version=="number"?z.current_version:void 0}))})),E=aD({routes:b,modals:_,baseLayouts:w,extensionGroups:S,moduleDisplayNames:x,pluginDisplayNames:k});t({type:"SET_ROUTE_TREE",tree:E});const C=h.data.layout_versions,N={};if(C&&typeof C=="object"&&!Array.isArray(C))for(const[A,z]of Object.entries(C))typeof z=="number"&&Number.isFinite(z)&&(N[A]=z);t({type:"SET_LAYOUT_VERSIONS",versions:N});const $={};for(const A of S)for(const z of A.extensions){const D=z.currentVersion;typeof D=="number"&&Number.isFinite(D)&&($[String(z.id)]=D)}t({type:"SET_EXTENSION_VERSIONS",versions:$}),On.log("routes 로드 완료",{count:b.length,groups:E.length})}).catch(h=>{if(a)return;On.error("routes 로드 실패",h);const v=pp(h)?h:io(h,"routes");t({type:"SET_ROUTES_ERROR",error:v})}),()=>{a=!0}},[r,s,t,n,o?.locale,o?.templateId])}function wg(e,t,n,o=[]){const r=t?kg(o,t):null,s=r!==null?{path:r.path,layoutName:r.layoutName}:void 0;if(e.startsWith("__base__/")){const a=e.slice(9);if(a)return n({type:"ENTER_BASE_EDIT",layoutName:a,returnRoute:s}),!0}else if(e.startsWith("__modal__/")){const a=e.slice(10);return a&&!a.includes("/")&&t?(n({type:"ENTER_MODAL_EDIT",modalId:a,hostLayout:t,returnRoute:s}),!0):!1}else if(e.startsWith("__extension__/")){const a=e.slice(14);if(a&&!a.includes("/"))return n({type:"ENTER_EXTENSION_EDIT",extensionId:a,returnRoute:s}),!0}else if(e.startsWith("__iteration__/")){const a=e.slice(14);return a&&t?(n({type:"ENTER_ITERATION_ITEM_EDIT",sourcePath:a,hostLayout:t,returnRoute:s}),!0):!1}return!1}function kg(e,t){for(const n of e){if(n.kind==="route"&&n.layoutName===t)return n;if(n.children&&n.children.length>0){const o=kg(n.children,t);if(o)return o}}return null}function Xa(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=Xa(n.children,t);if(o)return o}}return null}function uD(e){const{state:t,dispatch:n}=Je(),o=u.useRef(!1),r=u.useRef(t.editMode);r.current=t.editMode,u.useEffect(()=>{if(o.current||t.routeTree.length===0||typeof window>"u")return;const a=rp(window.location.search);if(a){const c=op(window.location.search),p=wg(a,c,n,t.routeTree);if(o.current=!0,p)return}const l=np(window.location.search);if(!l){o.current=!0;return}const d=Xa(t.routeTree,l);d&&n({type:"SELECT_ROUTE",route:{path:d.path,layoutName:d.layoutName}}),o.current=!0},[t.routeTree,n]),u.useEffect(()=>{if(typeof window>"u")return;const a=()=>{const l=rp(window.location.search);if(l){const p=op(window.location.search);if(wg(l,p,n,t.routeTree))return}if(r.current!=="route")switch(r.current){case"base":n({type:"EXIT_BASE_EDIT"});return;case"modal":n({type:"EXIT_MODAL_EDIT"});return;case"extension":n({type:"EXIT_EXTENSION_EDIT"});return;case"iteration_item":n({type:"EXIT_ITERATION_ITEM_EDIT"});return}const d=np(window.location.search);if(!d){n({type:"SELECT_ROUTE",route:null});return}const c=Xa(t.routeTree,d);c&&n({type:"SELECT_ROUTE",route:{path:c.path,layoutName:c.layoutName}})};return window.addEventListener("popstate",a),()=>{window.removeEventListener("popstate",a)}},[t.routeTree,n]);const s=u.useRef("route");u.useEffect(()=>{if(typeof window>"u"||!window.history?.replaceState||t.routeTree.length===0)return;const a=Zs(e,t.selectedRoute?.path??null,t.selectedRoute?.layoutName??null),l=`${window.location.pathname}${window.location.search}`,d=s.current;if(s.current=t.editMode,l===a)return;d==="route"&&t.editMode!=="route"&&window.history.pushState?window.history.pushState(window.history.state,"",a):window.history.replaceState(window.history.state,"",a)},[t.selectedRoute,t.editMode,t.routeTree,e])}function pD(){u.useEffect(()=>{if(typeof document>"u")return;const e=document.documentElement;let t=e.classList.contains("dark");const n=()=>{e.classList.contains("dark")&&(t=!0,e.classList.remove("dark"))};n();let o=null;return typeof MutationObserver<"u"&&(o=new MutationObserver(n),o.observe(e,{attributes:!0,attributeFilter:["class"]})),()=>{o?.disconnect(),t&&e.classList.add("dark")}},[])}function fD(){u.useEffect(()=>{if(typeof document>"u"||!document.body)return;const e=document.body,t=()=>{e.style.overflow==="hidden"&&(e.style.overflow=""),e.style.overflowY==="hidden"&&(e.style.overflowY="")};t();let n=null;return typeof MutationObserver<"u"&&(n=new MutationObserver(t),n.observe(e,{attributes:!0,attributeFilter:["style"]})),()=>{n?.disconnect()}},[])}function gD(e){if(typeof e!="string")return null;const t="__extension__/";if(!e.startsWith(t))return null;const n=e.slice(t.length);return n===""||n.includes("/")?null:n}function hD(e){if(Array.isArray(e.components))return e.components;if(Array.isArray(e.injections)){const t=[];return e.injections.forEach((n,o)=>{if(n&&Array.isArray(n.components))for(const r of n.components)t.push({...r,__injectionIndex:o})}),t}return[]}function yD(e,t){if(Array.isArray(e.components)){const n=t.map(o=>Ja(o));return{...e,components:n}}if(Array.isArray(e.injections)){const n=e.injections.map(o=>({...o}));n.forEach(o=>{Array.isArray(o.components)&&(o.components=[])});for(const o of t){const r=o.__injectionIndex,s=typeof r=="number"&&r>=0&&rJa(n))),t}function Sg(e,t){const n=[],o=r=>{if(Array.isArray(r))for(const s of r){if(!s||typeof s!="object")continue;const a=s.__source;if(a?.kind==="extension"&&a.extensionId===t){n.push(s);continue}Array.isArray(s.children)&&o(s.children)}};return o(e),n}function Cg(e,t){const o=Sg(e,t)[0]?.__source;return o&&o.kind==="extension"&&o.extensionId===t?o:{kind:"extension",extensionId:t}}function Za(e,t){for(const n of e){const o=n?.__source;if(o?.kind==="extension"&&o.extensionId===t||Array.isArray(n.children)&&Za(n.children,t))return!0}return!1}function bD(){const{state:e}=Je(),t=e.templateIdentifier,n=e.editMode==="extension"?gD(e.selectedRoute?.path):null,o=e.editMode==="extension"&&typeof e.selectedRoute?.layoutName=="string"?e.selectedRoute.layoutName:null,[r,s]=u.useState(null),[a,l]=u.useState(!1),[d,c]=u.useState(null),[p,f]=u.useState(!1),[y,g]=u.useState(0),[m,h]=u.useState(null),v=u.useRef({}),b=u.useRef({kind:"extension",extensionId:0}),x=u.useRef(null);u.useEffect(()=>{x.current=r},[r]),u.useEffect(()=>{h(null)},[n]);const k=u.useCallback(async A=>{const z=window.G7Config?.cache_version??0,D=en(`/api/layouts/${encodeURIComponent(t)}/${A}`,"json",`${z}.${xp()}`,"with_source_meta=1"),O=sr(),M={Accept:"application/json"};O&&(M.Authorization=`Bearer ${O}`);const B=await fetch(D,{headers:M,credentials:"same-origin"});if(!B.ok)return null;const T=await B.json().catch(()=>null);return(T&&(T.data||T))??{}},[t]),w=u.useCallback(async()=>{if(!n){s(null),c(null),f(!1);return}l(!0),c(null);try{const A=window.G7Config?.cache_version??0,z=`/api/admin/templates/${encodeURIComponent(t)}/layout-extensions/${encodeURIComponent(n)}?v=${A}`,D=await fetch(z,{headers:je(),credentials:"same-origin"}),O=await D.json().catch(()=>null);if(!D.ok){c(Gr(D.status,O,"layout")),s(null),f(!1);return}const M=(O&&(O.data||O))??{};let B={};const T=M.content;if(typeof T=="string")try{B=JSON.parse(T)}catch{B={}}else T&&typeof T=="object"&&(B=T);const H=M.extension_type==="overlay"?"overlay":"extension_point",L=typeof M.lock_version=="number"?M.lock_version:0,I=Array.isArray(M?.host_layouts)?M.host_layouts.filter(re=>typeof re=="string"):[],P=o??(I.length===1?I[0]:m&&I.includes(m)?m:null),F=Number(n);v.current=B;let V=hD(B),Y={},Z="pending";const se={kind:"extension",extensionId:F};if(b.current=se,P){const re=await k(P);if(re){Y=re;const U=Array.isArray(re.components)?re.components:[],ee=Cg(U,F);if(b.current=ee,Za(U,F))V=U,Z="ok";else{const K=(Array.isArray(re.modals)?re.modals:[]).filter(ne=>ne&&typeof ne=="object"&&Za([ne],F));if(K.length>0){const ne=K.map(me=>({...me,props:{...me.props??{},isOpen:!0}}));V=[...U,...ne],b.current=Cg(K,F),Z="ok"}else V=U,Z="no-injection"}}}s({extensionId:F,content:B,extensionType:H,components:V,lockVersion:L,hostLayouts:I,hostLayoutName:P,hostRaw:Y,editability:Z}),f(!1),st({op:"load",layoutName:`extension:${n}`,editMode:"extension",statusCode:D.status,timestamp:Date.now()})}catch(A){c(io(A,"layout")),s(null),f(!1)}finally{l(!1)}},[t,n,m,o,k]);u.useEffect(()=>{w()},[w]);const _=u.useCallback(A=>{h(A)},[]),S=u.useCallback(A=>{s(z=>z&&{...z,components:A}),f(!0),st({op:"patch",layoutName:`extension:${x.current?.extensionId??""}`,editMode:"extension",isDirty:!0,timestamp:Date.now()})},[]),E=u.useCallback(A=>{s(z=>z&&{...z,components:A(z.components)}),f(!0)},[]),C=u.useCallback(async()=>{const A=x.current;if(!A)return{kind:"guard_no_document"};const z=`/api/admin/templates/${encodeURIComponent(t)}/layout-extensions/${A.extensionId}`,D=Sg(A.components,A.extensionId),O=D.length>0?D:A.components,M=yD(v.current,O);st({op:"save",layoutName:`extension:${A.extensionId}`,editMode:"extension",saveTarget:"layout_extension",endpoint:z,isDirty:p,timestamp:Date.now()});let B;try{B=await fetch(z,{method:"PUT",headers:je({"Content-Type":"application/json"}),credentials:"same-origin",body:JSON.stringify({content:M,expected_lock_version:A.lockVersion})})}catch(H){return{kind:"network_error",message:H instanceof Error?H.message:"network error"}}const T=await B.json().catch(()=>null);if(st({op:"save_response",layoutName:`extension:${A.extensionId}`,editMode:"extension",endpoint:z,statusCode:B.status,conflict:B.status===409?{currentVersion:T?.errors?.current_version??T?.current_version,yourVersion:T?.errors?.your_version??T?.your_version}:void 0,timestamp:Date.now()}),B.ok){const H=T?.data??{},L=typeof H.lock_version=="number"?H.lock_version:A.lockVersion+1;s(P=>P&&{...P,lockVersion:L}),f(!1),g(P=>P+1);const I=typeof H.current_version=="number"?H.current_version:void 0;return{kind:"success",newLockVersion:L,newContentVersion:I,savedExtensionId:String(A.extensionId)}}return B.status===409?{kind:"concurrent_modification",currentVersion:T?.current_version??-1,yourVersion:T?.your_version??A.lockVersion}:B.status===422?{kind:"validation_failed",status:422,errors:T?.errors??null}:{kind:"network_error",message:T?.message??`HTTP ${B.status}`}},[t,p]),N=u.useCallback(async()=>{na(),await w()},[w]),$=!!r&&r.hostLayouts.length>1&&r.hostLayoutName===null;return{document:r,isLoading:a,error:d,isDirty:p,saveSuccessCounter:y,setLayoutComponents:S,patchLayout:E,save:C,reload:N,needsHostPicker:$,selectHost:_}}const $g=new Set;function mD(e,t,n){const r=e&&Object.keys(e).length>0?e:t,s=Array.isArray(r.data_sources)?r.data_sources:[],a=Array.isArray(t.data_sources)?t.data_sources:[],l=new Set,d=[];for(const c of[...s,...a]){const p=c&&typeof c=="object"?c.id:void 0,f=typeof p=="string"?p:`__anon_${d.length}`;l.has(f)||(l.add(f),d.push(c))}return{...r,components:n,...d.length>0?{data_sources:d}:{},lock_version:void 0}}function vD(e){return{document:e.document?{layoutName:`extension:${e.document.extensionId}`,raw:{...mD(e.document.hostRaw,e.document.content,e.document.components),__editability:e.document.editability,__extensionId:e.document.extensionId},lockVersion:e.document.lockVersion}:null,isLoading:e.isLoading,error:e.error,isDirty:e.isDirty,saveSuccessCounter:e.saveSuccessCounter,reload:e.reload,patchLayout:e.patchLayout,setLayoutComponents:e.setLayoutComponents,patchDocumentRaw:()=>{},dirtyKeys:$g,dirtyLayoutNames:$g,invalidateCurrentCache:()=>{},reloadCounter:0,save:()=>e.save(),markDirty:()=>{}}}function _D({hostLayouts:e,extensionLabel:t,onSelect:n}){const{t:o}=tt();return i.jsx("div",{className:"g7le-host-picker__backdrop","data-testid":"g7le-host-picker",role:"dialog","aria-modal":"true","aria-label":o("layout_editor.host_picker.title"),style:{position:"fixed",inset:0,zIndex:9500,display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(15, 23, 42, 0.55)"},children:i.jsxs("div",{className:"g7le-host-picker__panel",style:{width:"min(520px, 92vw)",maxHeight:"80vh",overflow:"auto",background:"#ffffff",color:"#0f172a",borderRadius:12,boxShadow:"0 20px 48px rgba(15, 23, 42, 0.28)",padding:"20px 22px"},children:[i.jsx("h2",{className:"g7le-host-picker__title",style:{margin:"0 0 6px",fontSize:16,fontWeight:700},children:o("layout_editor.host_picker.title")}),i.jsx("p",{className:"g7le-host-picker__desc",style:{margin:"0 0 16px",fontSize:13,color:"#475569",lineHeight:1.6},children:t?o("layout_editor.host_picker.description_with_label",{label:t}):o("layout_editor.host_picker.description")}),i.jsx("ul",{className:"g7le-host-picker__list",style:{listStyle:"none",margin:0,padding:0,display:"grid",gap:8},children:e.map(r=>i.jsx("li",{children:i.jsxs("button",{type:"button",className:"g7le-host-picker__item","data-testid":"g7le-host-picker-item","data-host":r,onClick:()=>n(r),style:{width:"100%",textAlign:"left",padding:"12px 14px",border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc",cursor:"pointer",fontSize:13,fontWeight:600,color:"#0f172a",display:"flex",alignItems:"center",gap:8},children:[i.jsx("span",{"aria-hidden":!0,style:{fontSize:14},children:"🖼"}),i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:r}),i.jsx("span",{"aria-hidden":!0,style:{color:"#94a3b8"},children:"→"})]})},r))})]})})}function xD(e,t){const[n,o]=u.useState(!1),[r,s]=u.useState(null),a=u.useCallback(async l=>{if(!t||!l)return{kind:"no_document"};o(!0),s(null);try{const d=mo(l),c=`/api/admin/templates/${encodeURIComponent(e)}/layouts/${t}/preview`,p=await fetch(c,{method:"POST",credentials:"same-origin",headers:je({"Content-Type":"application/json"}),body:JSON.stringify({content:JSON.stringify(d)})}),f=await p.json().catch(()=>null);if(!p.ok){const h=f?.message??`HTTP ${p.status}`;return s(h),{kind:"network_error",message:h}}const y=f?.data,g=typeof y?.token=="string"?y.token:"",m=typeof y?.preview_url=="string"&&y.preview_url?y.preview_url:g?`/preview/${g}`:"";return m?(window.G7Core?.dispatch?.({handler:"openWindow",params:{path:m}}),{kind:"success",previewUrl:m,token:g}):(s("invalid preview response"),{kind:"network_error",message:"invalid preview response"})}catch(d){const c=d instanceof Error?d.message:"network error";return s(c),{kind:"network_error",message:c}}finally{o(!1)}},[e,t]);return{isCreating:n,error:r,createPreview:a}}const ce="layout_editor.action_recipe",bn={setState:{label:`$t:${ce}.set_state.label`,params:[{key:"target",label:`$t:${ce}.set_state.param_target`,widget:"select",options:[{value:"local"},{value:"global"}]},{key:"state",label:`$t:${ce}.set_state.param_state`,widget:"state-key-value"},{key:"merge",label:`$t:${ce}.set_state.param_merge`,widget:"toggle"}],build:{handler:"setState",params:{target:"{{target}}",merge:"{{merge}}","...":"{{state}}"}}},loadFromLocalStorage:{label:`$t:${ce}.load_local.label`,params:[{key:"key",label:`$t:${ce}.load_local.param_key`,widget:"text",required:!0},{key:"stateKey",label:`$t:${ce}.load_local.param_state_key`,widget:"state-key-picker"},{key:"defaultValue",label:`$t:${ce}.load_local.param_default`,widget:"text"}],build:{handler:"loadFromLocalStorage",params:{key:"{{key}}",stateKey:"{{stateKey}}",defaultValue:"{{defaultValue}}"}}},saveToLocalStorage:{label:`$t:${ce}.save_local.label`,params:[{key:"key",label:`$t:${ce}.save_local.param_key`,widget:"text",required:!0},{key:"value",label:`$t:${ce}.save_local.param_value`,widget:"data-chip"}],build:{handler:"saveToLocalStorage",params:{key:"{{key}}",value:"{{value}}"}}},navigate:{label:`$t:${ce}.navigate.label`,params:[{key:"path",label:`$t:${ce}.navigate.param_path`,widget:"page-picker",required:!0},{key:"query",label:`$t:${ce}.navigate.param_query`,widget:"key-value"},{key:"mergeQuery",label:`$t:${ce}.navigate.param_merge_query`,widget:"toggle"},{key:"scroll",label:`$t:${ce}.navigate.param_scroll`,widget:"select",options:[{value:"top"},{value:"preserve"},{value:"none"}]},{key:"replace",label:`$t:${ce}.navigate.param_replace`,widget:"toggle"},{key:"transition_overlay_target",label:`$t:${ce}.navigate.param_overlay_target`,widget:"component-target-picker",dependsOn:{param:"replace",equals:!0}}],build:{handler:"navigate",params:{path:"{{path}}",query:"{{query}}",mergeQuery:"{{mergeQuery}}",scroll:"{{scroll}}",replace:"{{replace}}",transition_overlay_target:"{{transition_overlay_target}}"}}},openWindow:{label:`$t:${ce}.open_window.label`,params:[{key:"target",label:`$t:${ce}.open_window.param_target`,widget:"page-picker",required:!0},{key:"windowTarget",label:`$t:${ce}.open_window.param_window_target`,widget:"select",options:[{value:"_blank"},{value:"_self"}]}],build:{handler:"openWindow",target:"{{target}}",params:{windowTarget:"{{windowTarget}}"}}},navigateBack:{label:`$t:${ce}.navigate_back.label`,params:[],build:{handler:"navigateBack"}},navigateForward:{label:`$t:${ce}.navigate_forward.label`,params:[],build:{handler:"navigateForward"}},replaceUrl:{label:`$t:${ce}.replace_url.label`,params:[{key:"path",label:`$t:${ce}.replace_url.param_path`,widget:"page-picker"},{key:"query",label:`$t:${ce}.replace_url.param_query`,widget:"key-value"}],build:{handler:"replaceUrl",params:{path:"{{path}}",query:"{{query}}"}}},toast:{label:`$t:${ce}.toast.label`,params:[{key:"message",label:`$t:${ce}.toast.param_message`,widget:"i18n-text",required:!0},{key:"type",label:`$t:${ce}.toast.param_type`,widget:"select",options:[{value:"info"},{value:"success"},{value:"warning"},{value:"error"}]},{key:"duration",label:`$t:${ce}.toast.param_duration`,widget:"number"},{key:"icon",label:`$t:${ce}.toast.param_icon`,widget:"text"}],build:{handler:"toast",params:{message:"{{message}}",type:"{{type}}",duration:"{{duration}}",icon:"{{icon}}"}}},openModal:{label:`$t:${ce}.open_modal.label`,params:[{key:"target",label:`$t:${ce}.open_modal.param_target`,widget:"modal-picker",required:!0}],build:{handler:"openModal",target:"{{target}}"}},closeModal:{label:`$t:${ce}.close_modal.label`,params:[],build:{handler:"closeModal"}},showAlert:{label:`$t:${ce}.show_alert.label`,params:[{key:"target",label:`$t:${ce}.show_alert.param_message`,widget:"i18n-text",required:!0}],build:{handler:"showAlert",target:"{{target}}"}},setError:{label:`$t:${ce}.set_error.label`,params:[{key:"target",label:`$t:${ce}.set_error.param_message`,widget:"i18n-text"},{key:"stateTarget",label:`$t:${ce}.set_error.param_state_target`,widget:"select",options:[{value:"local"},{value:"global"}]}],build:{handler:"setError",target:"{{target}}",params:{stateTarget:"{{stateTarget}}"}}},refetchDataSource:{label:`$t:${ce}.refetch.label`,params:[{key:"dataSourceId",label:`$t:${ce}.refetch.param_data_source`,widget:"datasource-picker",required:!0},{key:"sync",label:`$t:${ce}.refetch.param_sync`,widget:"toggle"}],build:{handler:"refetchDataSource",params:{dataSourceId:"{{dataSourceId}}",sync:"{{sync}}"}}},appendDataSource:{label:`$t:${ce}.append_data.label`,params:[{key:"dataSourceId",label:`$t:${ce}.append_data.param_data_source`,widget:"datasource-picker",required:!0},{key:"dataPath",label:`$t:${ce}.append_data.param_data_path`,widget:"text"},{key:"newData",label:`$t:${ce}.append_data.param_new_data`,widget:"data-chip"}],build:{handler:"appendDataSource",params:{dataSourceId:"{{dataSourceId}}",dataPath:"{{dataPath}}",newData:"{{newData}}"}}},updateDataSource:{label:`$t:${ce}.update_data.label`,params:[{key:"dataSourceId",label:`$t:${ce}.update_data.param_data_source`,widget:"datasource-picker",required:!0},{key:"data",label:`$t:${ce}.update_data.param_data`,widget:"data-chip"},{key:"merge",label:`$t:${ce}.update_data.param_merge`,widget:"toggle"}],build:{handler:"updateDataSource",params:{dataSourceId:"{{dataSourceId}}",data:"{{data}}",merge:"{{merge}}"}}},scrollIntoView:{label:`$t:${ce}.scroll.label`,params:[{key:"selector",label:`$t:${ce}.scroll.param_selector`,widget:"text",required:!0},{key:"behavior",label:`$t:${ce}.scroll.param_behavior`,widget:"select",options:[{value:"smooth"},{value:"auto"}]},{key:"block",label:`$t:${ce}.scroll.param_block`,widget:"select",options:[{value:"start"},{value:"center"},{value:"end"},{value:"nearest"}]}],build:{handler:"scrollIntoView",params:{selector:"{{selector}}",behavior:"{{behavior}}",block:"{{block}}"}}},login:{label:`$t:${ce}.login.label`,params:[{key:"body",label:`$t:${ce}.login.param_body`,widget:"key-value"}],build:{handler:"login",params:{body:"{{body}}"}}},logout:{label:`$t:${ce}.logout.label`,params:[{key:"target",label:`$t:${ce}.logout.param_target`,widget:"text"}],build:{handler:"logout",target:"{{target}}"}},setLocale:{label:`$t:${ce}.set_locale.label`,params:[{key:"target",label:`$t:${ce}.set_locale.param_target`,widget:"locale-picker",required:!0}],build:{handler:"setLocale",target:"{{target}}"}},emitEvent:{label:`$t:${ce}.emit_event.label`,params:[{key:"event",label:`$t:${ce}.emit_event.param_event`,widget:"text",required:!0},{key:"data",label:`$t:${ce}.emit_event.param_data`,widget:"data-chip"}],build:{handler:"emitEvent",params:{event:"{{event}}",data:"{{data}}"}}},apiCall:{label:`$t:${ce}.api_call.label`,params:[{key:"target",label:`$t:${ce}.api_call.param_target`,widget:"page-picker",required:!0},{key:"method",label:`$t:${ce}.api_call.param_method`,widget:"select",options:[{value:"GET"},{value:"POST"},{value:"PUT"},{value:"PATCH"},{value:"DELETE"}]},{key:"body",label:`$t:${ce}.api_call.param_body`,widget:"key-value"},{key:"query",label:`$t:${ce}.api_call.param_query`,widget:"key-value"},{key:"identity_target_email",label:`$t:${ce}.api_call.param_identity_target_email`,widget:"data-chip"},{key:"identity_target_phone",label:`$t:${ce}.api_call.param_identity_target_phone`,widget:"data-chip"},{key:"onSuccess",label:`$t:${ce}.api_call.param_on_success`,widget:"action-list"},{key:"onError",label:`$t:${ce}.api_call.param_on_error`,widget:"action-list"}],build:{handler:"apiCall",target:"{{target}}",identity_target:{email:"{{identity_target_email}}",phone:"{{identity_target_phone}}"},onSuccess:"{{onSuccess}}",onError:"{{onError}}",params:{method:"{{method}}",body:"{{body}}",query:"{{query}}"}}},conditions:{label:`$t:${ce}.conditions.label`,params:[{key:"branches",label:`$t:${ce}.conditions.param_branches`,widget:"branch-list"}],build:{handler:"conditions",conditions:"{{branches}}"}},sequence:{label:`$t:${ce}.sequence.label`,params:[{key:"actions",label:`$t:${ce}.sequence.param_actions`,widget:"action-list"}],build:{handler:"sequence",params:{actions:"{{actions}}"}}},parallel:{label:`$t:${ce}.parallel.label`,params:[{key:"actions",label:`$t:${ce}.parallel.param_actions`,widget:"action-list"}],build:{handler:"parallel",params:{actions:"{{actions}}"}}},switch:{label:`$t:${ce}.switch.label`,params:[{key:"value",label:`$t:${ce}.switch.param_value`,widget:"data-chip"},{key:"cases",label:`$t:${ce}.switch.param_cases`,widget:"action-list",advanced:!0}],build:{handler:"switch",params:{value:"{{value}}",cases:"{{cases}}"}}},suppress:{label:`$t:${ce}.suppress.label`,params:[],build:{handler:"suppress"}}};function wD(){return{actionRecipes:bn,initActionRecipes:bn,errorRecipes:{navigate:bn.navigate,openModal:bn.openModal,toast:bn.toast,setState:bn.setState,sequence:bn.sequence,parallel:bn.parallel,showErrorPage:{label:`$t:${ce}.show_error_page.label`,params:[{key:"target",label:`$t:${ce}.show_error_page.param_target`,widget:"select",options:[{value:"content"},{value:"full"}]}],build:{handler:"showErrorPage",params:{target:"{{target}}"}}}}}}function Qa(e,t){return typeof e.label=="string"?e.label.startsWith("$t:")?t(e.label.slice(3)):e.label:String(e.value)}function vi(e){return Array.isArray(e.options)?e.options:[]}function kD({control:e,value:t,onChange:n,t:o}){const r=vi(e);return i.jsx("div",{className:"g7le-widget g7le-widget--segmented","data-testid":"g7le-widget-segmented",style:LD,children:r.map(s=>{const a=s.value===t;return i.jsx("button",{type:"button","data-testid":`g7le-segment-${String(s.value)}`,"data-active":a?"true":"false",onClick:()=>n(a?void 0:s.value),style:{...KD,background:a?"#2563eb":"#fff",color:a?"#fff":"#0f172a",borderColor:a?"#2563eb":"#cbd5e1"},children:Qa(s,o)},String(s.value))})})}function SD({control:e,value:t,onChange:n,t:o}){const r=Array.isArray(e.scale)?e.scale:[],s=r.findIndex(l=>l===t),a=t!=null;return i.jsxs("div",{className:"g7le-widget g7le-widget--slider","data-testid":"g7le-widget-slider",style:Ig,children:[i.jsxs("label",{style:Ra,children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-slider-enabled",checked:a,onChange:l=>{l.target.checked?n(r.length>0?r[Math.max(0,s===-1?0:s)]:void 0):n(void 0)}}),i.jsx("span",{style:{fontSize:12,color:"#64748b"},children:o("layout_editor.control.use")})]}),i.jsx("input",{type:"range","data-testid":"g7le-slider-range",min:0,max:Math.max(0,r.length-1),step:1,disabled:!a||r.length===0,value:s===-1?0:s,onChange:l=>{const d=Number(l.target.value);n(r[d])},style:{flex:1}}),i.jsx("span",{"data-testid":"g7le-slider-value",style:{fontSize:12,color:"#0f172a",minWidth:48,textAlign:"right"},children:a?String(t):o("layout_editor.control.default")})]})}function CD({control:e,value:t,onChange:n,t:o}){const r=vi(e);return i.jsxs("select",{className:"g7le-widget g7le-widget--select","data-testid":"g7le-widget-select",value:t==null?"":String(t),onChange:s=>{const a=s.target.value;if(a===""){n(void 0);return}const l=r.find(d=>String(d.value)===a);n(l?l.value:a)},style:VD,children:[i.jsx("option",{value:"",children:o("layout_editor.control.default")}),r.map(s=>i.jsx("option",{value:String(s.value),children:Qa(s,o)},String(s.value)))]})}function $D({control:e,value:t,onChange:n}){const o=vi(e),r=o[1]?.value??!0,s=o[0]?.value??void 0,a=t===r;return i.jsx("button",{type:"button",className:"g7le-widget g7le-widget--toggle","data-testid":"g7le-widget-toggle",role:"switch","aria-checked":a,onClick:()=>n(a?s:r),style:{...UD,background:a?"#2563eb":"#cbd5e1"},children:i.jsx("span",{style:{...qD,transform:a?"translateX(18px)":"translateX(0)"}})})}function ED({control:e,value:t,onChange:n,t:o,freeValueDisabled:r}){const s=vi(e),a=t==null?"":String(t),[l,d]=u.useState(a);u.useEffect(()=>{d(a)},[a]);const c=p=>{const f=p.trim();if(f===""){n(void 0);return}n(f)};return i.jsxs("div",{className:"g7le-widget g7le-widget--dimension","data-testid":"g7le-widget-dimension",style:PD,children:[r?i.jsx("div",{"data-testid":"g7le-dimension-free-disabled",style:HD,children:o("layout_editor.property_modal.dark_preset_only")}):i.jsxs("div",{style:OD,children:[i.jsx("input",{type:"text","data-testid":"g7le-dimension-input",value:l,placeholder:o("layout_editor.control.dimension.placeholder"),onChange:p=>d(p.target.value),onBlur:p=>c(p.target.value),onKeyDown:p=>{p.key==="Enter"&&(p.preventDefault(),c(p.target.value))},style:BD}),i.jsx("button",{type:"button","data-testid":"g7le-dimension-clear",title:o("layout_editor.control.default"),onClick:()=>{d(""),n(void 0)},style:WD,children:o("layout_editor.control.default")})]}),s.length>0&&i.jsx("div",{style:jD,"data-testid":"g7le-dimension-chips",children:s.map(p=>{const f=String(p.value),y=f===a;return i.jsx("button",{type:"button","data-testid":`g7le-dimension-chip-${f}`,"data-active":y?"true":"false",onClick:()=>{d(f),n(f)},style:{...FD,background:y?"#2563eb":"#fff",color:y?"#fff":"#0f172a",borderColor:y?"#2563eb":"#cbd5e1"},children:Qa(p,o)},f)})})]})}function ID(e){return[{key:"t",infix:`${e}t-`,labelKey:"layout_editor.control.spacing.top"},{key:"r",infix:`${e}r-`,labelKey:"layout_editor.control.spacing.right"},{key:"b",infix:`${e}b-`,labelKey:"layout_editor.control.spacing.bottom"},{key:"l",infix:`${e}l-`,labelKey:"layout_editor.control.spacing.left"}]}function AD(e,t,n,o){const r={all:-1,sides:{}};if(typeof e!="string"||e.trim()==="")return r;const s=e.split(/\s+/).filter(a=>a.length>0);for(const a of s){const l=n.find(d=>a.startsWith(d.infix));if(l){r.sides[l.key]=o.indexOf(a.slice(l.infix.length));continue}if(a.startsWith(`${t}x-`)){const d=o.indexOf(a.slice(`${t}x-`.length));r.sides.l=d,r.sides.r=d;continue}if(a.startsWith(`${t}y-`)){const d=o.indexOf(a.slice(`${t}y-`.length));r.sides.t=d,r.sides.b=d;continue}a.startsWith(`${t}-`)&&(r.all=o.indexOf(a.slice(`${t}-`.length)))}return r}function TD({control:e,value:t,onChange:n,t:o}){const r=typeof e.spacingPrefix=="string"?e.spacingPrefix:"p",s=(Array.isArray(e.scale)?e.scale:[]).map(b=>String(b)),a=ID(r),l=AD(t,r,a,s),d=Object.values(l.sides).some(b=>b>=0),c=l.all>=0||d,[p,f]=u.useState(d);u.useEffect(()=>{d&&f(!0)},[d]);const y=Math.min(4,Math.max(0,s.length-1)),g=b=>{const x=[];for(const k of a){const w=b[k.key];w!==void 0&&w>=0&&x.push(`${k.infix}${s[w]}`)}n(x.length>0?x.join(" "):void 0)},m=b=>{n(b>=0?`${r}-${s[Math.max(0,Math.min(b,s.length-1))]}`:void 0)},h=()=>({...l.sides}),v=(b,x,k,w,_)=>{const S=k>=0;return i.jsxs("div",{style:MD,"data-testid":b,children:[i.jsxs("label",{style:{...Ra,minWidth:64},children:[i.jsx("input",{type:"checkbox","data-testid":`${b}-enabled`,checked:S,onChange:E=>_(E.target.checked)}),i.jsx("span",{style:{fontSize:12,color:"#0f172a"},children:o(x)})]}),i.jsx("input",{type:"range","data-testid":`${b}-range`,min:0,max:Math.max(0,s.length-1),step:1,disabled:!S||s.length===0,value:k<0?0:k,onChange:E=>w(Number(E.target.value)),style:{flex:1}}),i.jsx("span",{"data-testid":`${b}-value`,style:{fontSize:12,color:S?"#0f172a":"#94a3b8",minWidth:28,textAlign:"right"},children:S?s[k]:"–"})]},b)};return i.jsxs("div",{className:"g7le-widget g7le-widget--spacing","data-testid":"g7le-widget-spacing",style:DD,children:[i.jsxs("div",{style:ND,children:[i.jsxs("div",{style:zD,"data-testid":"g7le-spacing-mode",children:[i.jsx("button",{type:"button","data-testid":"g7le-spacing-mode-all","data-active":p?"false":"true",onClick:()=>{f(!1),d&&n(void 0)},style:{...Eg,background:p?"#fff":"#2563eb",color:p?"#0f172a":"#fff",borderColor:p?"#cbd5e1":"#2563eb"},children:o("layout_editor.control.spacing.mode_all")}),i.jsx("button",{type:"button","data-testid":"g7le-spacing-mode-sides","data-active":p?"true":"false",onClick:()=>{if(f(!0),l.all>=0){const b={};for(const x of a)b[x.key]=l.all;g(b)}},style:{...Eg,background:p?"#2563eb":"#fff",color:p?"#fff":"#0f172a",borderColor:p?"#2563eb":"#cbd5e1"},children:o("layout_editor.control.spacing.mode_sides")})]}),i.jsx("span",{"data-testid":"g7le-spacing-value",style:{fontSize:11,color:"#64748b",minWidth:80,textAlign:"right"},children:c?String(t):o("layout_editor.control.default")})]}),p?i.jsx("div",{"data-testid":"g7le-spacing-sides",style:{display:"flex",flexDirection:"column",gap:4},children:a.map(b=>v(`g7le-spacing-side-${b.key}`,b.labelKey,l.sides[b.key]??-1,x=>{const k=h();k[b.key]=x,g(k)},x=>{const k=h();x?k[b.key]=l.sides[b.key]>=0?l.sides[b.key]:y:delete k[b.key],g(k)}))}):i.jsxs("div",{style:Ig,children:[i.jsxs("label",{style:{...Ra,minWidth:64},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-spacing-all-enabled",checked:l.all>=0,onChange:b=>m(b.target.checked?y:-1)}),i.jsx("span",{style:{fontSize:12,color:"#64748b"},children:o("layout_editor.control.spacing.all")})]}),i.jsx("input",{type:"range","data-testid":"g7le-spacing-all-range",min:0,max:Math.max(0,s.length-1),step:1,disabled:l.all<0||s.length===0,value:l.all<0?0:l.all,onChange:b=>m(Number(b.target.value)),style:{flex:1}}),i.jsx("span",{"data-testid":"g7le-spacing-all-value",style:{fontSize:12,color:l.all>=0?"#0f172a":"#94a3b8",minWidth:28,textAlign:"right"},children:l.all>=0?s[l.all]:"–"})]})]})}const DD={display:"flex",flexDirection:"column",gap:6,width:"100%"},ND={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},zD={display:"inline-flex",gap:0,border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},Eg={padding:"3px 10px",fontSize:11,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},MD={display:"flex",alignItems:"center",gap:8,width:"100%"},LD={display:"inline-flex",gap:0,border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},PD={display:"flex",flexDirection:"column",gap:6,width:"100%"},OD={display:"flex",gap:6,alignItems:"center"},BD={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,minWidth:80},WD={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer",whiteSpace:"nowrap"},jD={display:"flex",flexWrap:"wrap",gap:4},HD={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"2px 0"},FD={padding:"3px 9px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:999,background:"#fff",cursor:"pointer"},KD={padding:"4px 10px",fontSize:12,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},Ig={display:"flex",alignItems:"center",gap:8,width:"100%"},Ra={display:"inline-flex",alignItems:"center",gap:4},VD={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",minWidth:120},UD={position:"relative",width:38,height:20,borderRadius:10,border:"none",cursor:"pointer",padding:2,display:"inline-flex",alignItems:"center",transition:"background 120ms ease"},qD={display:"block",width:16,height:16,borderRadius:8,background:"#fff",transition:"transform 120ms ease"},GD=["#0f172a","#334155","#64748b","#94a3b8","#e2e8f0","#ffffff","#dc2626","#ea580c","#d97706","#16a34a","#0891b2","#2563eb","#7c3aed","#db2777"],Ag=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function YD(e){return Array.isArray(e.options)?e.options.filter(t=>typeof t.value=="string").map(t=>({value:t.value,swatch:typeof t.swatch=="string"?t.swatch:void 0,label:typeof t.label=="string"?t.label:void 0})):[]}function XD({control:e,value:t,onChange:n,t:o,freeValueDisabled:r}){const s=YD(e),a=s.length>0,l=typeof t=="string"&&s.some(g=>g.value===t)?t:"",d=typeof t=="string"&&Ag.test(t)?t:"",[c,p]=u.useState(d);u.useEffect(()=>{p(d)},[d]);const f=g=>{const m=g.trim();if(m===""){n(void 0);return}const h=m.startsWith("#")?m:`#${m}`;Ag.test(h)&&n(h)},y=GD;return i.jsxs("div",{className:"g7le-widget g7le-widget--color","data-testid":"g7le-widget-color",style:JD,children:[r?i.jsx("div",{"data-testid":"g7le-color-free-disabled",style:nN,children:o("layout_editor.property_modal.dark_preset_only")}):i.jsxs("div",{style:ZD,children:[i.jsxs("label",{style:QD,children:[i.jsx("span",{"data-testid":"g7le-color-swatch",style:{...RD,background:d||"transparent",backgroundImage:d?void 0:"linear-gradient(45deg,#e2e8f0 25%,transparent 25%,transparent 75%,#e2e8f0 75%),linear-gradient(45deg,#e2e8f0 25%,#fff 25%,#fff 75%,#e2e8f0 75%)",backgroundSize:"8px 8px",backgroundPosition:"0 0, 4px 4px"}}),i.jsx("input",{type:"color","data-testid":"g7le-color-native",value:d||"#000000",onChange:g=>n(g.target.value),style:{width:0,height:0,opacity:0,position:"absolute"}})]}),i.jsx("input",{type:"text","data-testid":"g7le-color-hex",value:c,placeholder:o("layout_editor.control.color_hex_placeholder"),onChange:g=>p(g.target.value),onBlur:g=>f(g.target.value),onKeyDown:g=>{g.key==="Enter"&&f(g.target.value)},style:eN}),i.jsx("button",{type:"button","data-testid":"g7le-color-clear",onClick:()=>n(void 0),style:tN,children:o("layout_editor.control.default")})]}),a?i.jsx("div",{style:Tg,"data-testid":"g7le-color-token-presets",children:s.map(g=>{const m=l===g.value,h=g.label?g.label.startsWith("$t:")?o(g.label.slice(3)):g.label:g.value;return i.jsx("button",{type:"button","aria-label":h,title:h,"data-testid":`g7le-color-token-${g.value}`,"data-active":m?"true":"false",onClick:()=>n(m?void 0:g.value),style:{...Dg,background:g.swatch||"#e2e8f0",outline:m?"2px solid #2563eb":"none"}},g.value)})}):!r&&i.jsx("div",{style:Tg,"data-testid":"g7le-color-presets",children:y.map(g=>i.jsx("button",{type:"button","aria-label":g,"data-testid":`g7le-color-preset-${g}`,onClick:()=>n(g),style:{...Dg,background:g,outline:d.toLowerCase()===g.toLowerCase()?"2px solid #2563eb":"none"}},g))})]})}const JD={display:"flex",flexDirection:"column",gap:8},ZD={display:"flex",alignItems:"center",gap:6},QD={position:"relative",display:"inline-flex",cursor:"pointer"},RD={width:24,height:24,borderRadius:4,border:"1px solid #cbd5e1",display:"inline-block"},eN={flex:1,padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,fontFamily:"monospace"},tN={padding:"4px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",color:"#64748b"},Tg={display:"grid",gridTemplateColumns:"repeat(7, 1fr)",gap:4},Dg={width:"100%",height:18,borderRadius:4,border:"1px solid #cbd5e1",cursor:"pointer",padding:0},nN={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"2px 0"},Zo=[{value:"fill",labelKey:"layout_editor.control.background_image.mode_fill",size:"cover",repeat:"no-repeat"},{value:"fit",labelKey:"layout_editor.control.background_image.mode_fit",size:"contain",repeat:"no-repeat"},{value:"tile",labelKey:"layout_editor.control.background_image.mode_tile",size:"auto",repeat:"repeat"}];function oN(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function Ng(e){return Zo.find(n=>n.size===e.size&&n.repeat===e.repeat)?.value??"fill"}function rN({value:e,onChange:t,t:n}){const o=oN(e),{state:r}=Je(),s=yo(),[a,l]=u.useState(!1),[d,c]=u.useState(null),[p,f]=u.useState(o.url??""),[y,g]=u.useState([]),m=u.useRef(null),h=r.templateIdentifier,v=r.selectedRoute?.layoutName??"";u.useEffect(()=>{f(o.url??"")},[o.url]);const b=u.useCallback(async()=>{if(!h||!v){g([]);return}const E=await Dl(h,v);E.ok&&g(E.data)},[h,v]);u.useEffect(()=>{b()},[b]);const x=E=>{if(!E){t(void 0);return}const C=Zo.find(N=>N.value===Ng(o))??Zo[0];t({url:E,size:o.size??C.size,repeat:o.repeat??C.repeat,position:o.position??"center"})},k=E=>{const C=Zo.find(N=>N.value===E);!C||!o.url||t({url:o.url,size:C.size,repeat:C.repeat,position:o.position??"center"})},w=async E=>{const C=E.target.files?.[0];if(!C)return;l(!0),c(null);const N=await Nl(h,v,C);N.ok?(x(N.data.url),b()):c(n("layout_editor.control.background_image.upload_failed")),l(!1),m.current&&(m.current.value="")},_=async E=>{(await zl(E.id)).ok&&g(N=>N.filter($=>$.id!==E.id))},S=()=>{const E=s.open({ariaLabel:n("layout_editor.attachment_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(Ml,{templateIdentifier:h,layoutName:v,t:n,onSelect:C=>{x(C),s.close(E)},onChanged:()=>{b()},onClose:()=>s.close(E)})})};return i.jsxs("div",{className:"g7le-widget g7le-widget--image","data-testid":"g7le-widget-image",style:iN,children:[o.url&&i.jsx("div",{"data-testid":"g7le-image-preview",style:{...sN,backgroundImage:`url(${o.url})`,backgroundSize:o.size??"cover",backgroundRepeat:o.repeat??"no-repeat",backgroundPosition:o.position??"center"}}),i.jsxs("div",{style:aN,children:[i.jsxs("label",{style:lN,children:[a?"…":n("layout_editor.control.background_image.upload"),i.jsx("input",{ref:m,type:"file",accept:"image/*","data-testid":"g7le-image-file",onChange:w,disabled:a,style:{display:"none"}})]}),i.jsx("button",{type:"button","data-testid":"g7le-image-clear",onClick:()=>t(void 0),style:dN,children:n("layout_editor.control.background_image.clear")})]}),i.jsx("input",{type:"text","data-testid":"g7le-image-url",placeholder:"https://… / URL",value:p,onChange:E=>f(E.target.value),onBlur:E=>x(E.target.value.trim()||void 0),style:cN}),i.jsx("div",{style:uN,"data-testid":"g7le-image-modes",children:Zo.map(E=>{const C=Ng(o)===E.value;return i.jsx("button",{type:"button","data-testid":`g7le-image-mode-${E.value}`,"data-active":C?"true":"false",disabled:!o.url,onClick:()=>k(E.value),style:{...pN,background:C&&o.url?"#2563eb":"#fff",color:C&&o.url?"#fff":"#0f172a"},children:n(E.labelKey)},E.value)})}),i.jsxs("div",{style:fN,"data-testid":"g7le-image-gallery",children:[i.jsxs("div",{style:gN,children:[i.jsx("span",{style:{fontSize:11,color:"#64748b"},children:n("layout_editor.attachment_manager.recent")}),i.jsxs("button",{type:"button","data-testid":"g7le-image-manage",onClick:S,style:hN,children:["🖼 ",n("layout_editor.attachment_manager.manage_link")]})]}),y.length>0?i.jsx("div",{style:yN,"data-testid":"g7le-image-thumbs",children:y.slice(0,8).map(E=>i.jsxs("div",{style:bN,"data-testid":`g7le-image-thumb-${E.id}`,children:[i.jsx("button",{type:"button",title:E.original_name,onClick:()=>x(E.url),style:{...mN,backgroundImage:`url(${E.url})`},"data-testid":`g7le-image-thumb-use-${E.id}`}),i.jsx("button",{type:"button",title:n("layout_editor.attachment_manager.delete"),onClick:()=>_(E),style:vN,"data-testid":`g7le-image-thumb-del-${E.id}`,children:"✕"})]},E.id))}):i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:n("layout_editor.attachment_manager.empty")})]}),d&&i.jsx("span",{"data-testid":"g7le-image-error",style:{fontSize:11,color:"#dc2626"},children:d})]})}const iN={display:"flex",flexDirection:"column",gap:6},sN={width:"100%",height:80,borderRadius:6,border:"1px solid #cbd5e1"},aN={display:"flex",gap:6},lN={padding:"5px 10px",fontSize:12,border:"1px solid #2563eb",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",display:"inline-flex",alignItems:"center"},dN={padding:"5px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#64748b",cursor:"pointer"},cN={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},uN={display:"inline-flex",border:"1px solid #cbd5e1",borderRadius:6,overflow:"hidden"},pN={padding:"4px 10px",fontSize:12,border:"none",borderRight:"1px solid #e2e8f0",cursor:"pointer"},fN={display:"flex",flexDirection:"column",gap:4,border:"1px solid #e2e8f0",borderRadius:6,padding:6,marginTop:2},gN={display:"flex",alignItems:"center",justifyContent:"space-between"},hN={fontSize:11,color:"#2563eb",background:"transparent",border:"none",cursor:"pointer",padding:0},yN={display:"flex",gap:6,flexWrap:"wrap"},bN={position:"relative",width:44,height:44},mN={width:44,height:44,borderRadius:6,border:"1px solid #cbd5e1",backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",padding:0},vN={position:"absolute",top:-6,right:-6,width:16,height:16,borderRadius:"50%",border:"none",background:"#dc2626",color:"#fff",fontSize:9,lineHeight:"16px",cursor:"pointer",padding:0};function _N(e){return Array.isArray(e)&&e.every(t=>t!==null&&typeof t=="object"&&!Array.isArray(t))}function xN(e){return typeof e=="string"&&/\{\{.*\}\}/.test(e)}function wN({value:e,onChange:t,t:n,bindingCandidates:o}){if(xN(e))return i.jsx("div",{className:"g7le-widget g7le-widget--options-list","data-testid":"g7le-widget-options-list-bound",style:TN,children:n("layout_editor.list_editor.bound_degraded")});const r=_N(e)?e:[],s=y=>{t(y.length===0?void 0:y)},a=(y,g)=>{const m=r.map((h,v)=>v===y?{...h,...g}:h);s(m)},l=y=>{s(r.filter((g,m)=>m!==y))},d=(y,g)=>{const m=y+g;if(m<0||m>=r.length)return;const h=[...r];[h[y],h[m]]=[h[m],h[y]],s(h)},c=()=>{s([...r,{value:"",label:""}])},p=y=>typeof y.label=="string"?y.label:y.label==null?"":String(y.label),f=y=>y.value===void 0||y.value===null?"":String(y.value);return i.jsxs("div",{className:"g7le-widget g7le-widget--options-list","data-testid":"g7le-widget-options-list",style:kN,children:[r.length===0&&i.jsx("div",{"data-testid":"g7le-options-empty",style:AN,children:n("layout_editor.list_editor.empty")}),r.map((y,g)=>i.jsxs("div",{"data-testid":`g7le-options-row-${g}`,style:SN,children:[i.jsxs("div",{style:zg,children:[i.jsx("span",{style:Mg,children:n("layout_editor.list_editor.option_value")}),i.jsx("input",{type:"text","data-testid":`g7le-options-value-${g}`,value:f(y),placeholder:n("layout_editor.list_editor.option_value"),onChange:m=>a(g,{value:m.target.value}),style:$N})]}),i.jsxs("div",{style:zg,children:[i.jsx("span",{style:Mg,children:n("layout_editor.list_editor.option_label")}),i.jsx("div",{"data-testid":`g7le-options-label-${g}`,style:{flex:1,minWidth:0},children:i.jsx(vt,{value:p(y),onChange:m=>a(g,{label:m??""}),t:n,placeholder:n("layout_editor.list_editor.option_label"),testidPrefix:`g7le-options-label-i18n-${g}`,candidates:o,enableExpressionTree:!0,expressionTreeCollapsible:!0})})]}),i.jsxs("div",{style:CN,children:[i.jsx("button",{type:"button","data-testid":`g7le-options-up-${g}`,title:n("layout_editor.list_editor.move_up"),disabled:g===0,onClick:()=>d(g,-1),style:Lg,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-options-down-${g}`,title:n("layout_editor.list_editor.move_down"),disabled:g===r.length-1,onClick:()=>d(g,1),style:Lg,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-options-remove-${g}`,title:n("layout_editor.list_editor.remove"),onClick:()=>l(g),style:EN,children:"✕"})]})]},g)),i.jsx("button",{type:"button","data-testid":"g7le-options-add",onClick:c,style:IN,children:n("layout_editor.list_editor.add")})]})}const kN={display:"flex",flexDirection:"column",gap:8,width:"100%"},SN={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0,paddingBottom:6,borderBottom:"1px solid #f1f5f9"},zg={display:"flex",gap:6,alignItems:"center",width:"100%",minWidth:0},Mg={flexShrink:0,width:36,fontSize:11,color:"#94a3b8",textAlign:"right"},CN={display:"flex",gap:4,alignItems:"center",justifyContent:"flex-end"},$N={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},Lg={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},EN={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},IN={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},AN={fontSize:11,color:"#94a3b8",fontStyle:"italic"},TN={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"},Pg=/[^A-Za-z0-9\-_:.]/g;function DN(e){return typeof e=="string"&&Ui(e)}function Og(e){return Ui(e)?In(e).map(t=>t.kind==="binding"?t.raw:t.raw.replace(Pg,"")).join(""):e.replace(Pg,"")}const NN={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4,fontFamily:"monospace"},zN={marginTop:4,fontSize:11,color:"#94a3b8"},MN={display:"flex",gap:6,alignItems:"center"},LN={flexShrink:0,padding:"5px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:4,background:"#f8fafc",cursor:"pointer",whiteSpace:"nowrap"};function PN({value:e,onChange:t,t:n,bindingCandidates:o}){const r=e==null?"":String(e),s=DN(e),a=!!o&&o.length>0,[l,d]=u.useState(!1),c=p=>{const f=Og(p);t(f===""?void 0:f)};return s||l?i.jsxs("div",{"data-testid":"g7le-core-id-chip",children:[i.jsx(bs,{value:r,onChange:c,t:n,candidates:o,onDone:()=>d(!1),testidPrefix:"g7le-core-id-chip-input"}),i.jsx("div",{style:zN,children:n("layout_editor.core_props.id.binding_hint")})]}):i.jsx("div",{children:i.jsxs("div",{style:MN,children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-id","data-testid":"g7le-widget-core-id",value:r,onChange:p=>{const f=Og(p.target.value);t(f===""?void 0:f)},spellCheck:!1,autoComplete:"off",style:NN}),a&&i.jsxs("button",{type:"button","data-testid":"g7le-core-id-add-data",onClick:()=>d(!0),style:LN,title:n("layout_editor.core_props.id.add_data"),children:["🔗 ",n("layout_editor.core_props.id.add_data")]})]})})}const ON=/[^A-Za-z0-9.\-_]/g;function BN(e){return typeof e=="string"&&/\{\{[\s\S]*?\}\}/.test(e)}function WN(e){return e.replace(ON,"")}const Bg={width:"100%",padding:"6px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:4,fontFamily:"monospace",boxSizing:"border-box"},jN={...Bg,background:"#f1f5f9",color:"#64748b",cursor:"not-allowed"},Wg={marginTop:4,fontSize:11,color:"#94a3b8"};function HN({value:e,onChange:t,t:n}){const o=e==null?"":String(e);return BN(e)?i.jsxs("div",{"data-testid":"g7le-core-datakey-bound",children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-datakey-bound","data-testid":"g7le-widget-core-datakey",value:o,readOnly:!0,disabled:!0,style:jN}),i.jsx("div",{style:Wg,children:n("layout_editor.core_props.dataKey.bound_degraded")})]}):i.jsxs("div",{children:[i.jsx("input",{type:"text",className:"g7le-widget g7le-widget--core-datakey","data-testid":"g7le-widget-core-datakey",value:o,placeholder:n("layout_editor.core_props.dataKey.placeholder"),onChange:s=>{const a=WN(s.target.value);t(a===""?void 0:a)},spellCheck:!1,autoComplete:"off",style:Bg}),i.jsx("div",{style:Wg,children:n("layout_editor.core_props.dataKey.hint")})]})}let jg=!1;function FN(){jg||(Xe("segmented",kD),Xe("slider",SD),Xe("select",CD),Xe("dimension",ED),Xe("spacing",TD),Xe("toggle",$D),Xe("color",XD),Xe("image",rN),Xe("tag-input",ys),Xe("page-picker",$w),Xe("datasource-picker",Oc),Xe("binding-picker",Oc),Xe("binding-list",Aw),Xe("state-key-picker",Es),Xe("modal-picker",Ew),Xe("i18n-text",Iw),Xe("text",Tw),Xe("options-list",wN),Xe("core-id",PN),Xe("core-datakey",HN),Xe("component-target-picker",PT),jg=!0)}function Qo(e){return Array.isArray(e.children)?e.children:[]}function Ro(e){if(typeof e.text=="string")return[];let t=null;const n=(o,r)=>{const s=Qo(o);for(let a=0;ad===o?er(l,r,n):l);return{...e,children:a}}function Hg(e,t){const n=r=>typeof r.props?.[t]=="string";if(n(e))return[];const o=(r,s)=>{const a=Qo(r);for(let l=0;lc===r?Fg(d,s,n,o):d);return{...e,children:l}}function KN(e){return Array.isArray(e.children)?e.children:[]}function VN(e){const t=e?.itemFields;if(!Array.isArray(t))return[{kind:"text"}];const n=[];for(const o of t){if(!o||typeof o!="object")continue;const r=o;r.kind==="text"?n.push({kind:"text",label:typeof r.label=="string"?r.label:void 0}):r.kind==="prop"&&typeof r.prop=="string"&&r.prop.length>0&&n.push({kind:"prop",prop:r.prop,label:typeof r.label=="string"?r.label:void 0})}return n.length>0?n:[{kind:"text"}]}function el(e,t){if(!(typeof e!="string"||e.length===0))return e.startsWith("$t:")?t(e.slice(3)):e}function Kg({node:e,params:t,spec:n,manifest:o,t:r,onPatchNode:s,templateIdentifier:a,candidates:l}){const d=typeof t?.childComponent=="string"?t.childComponent:null,c=t?.childTemplate&&typeof t.childTemplate=="object"?t.childTemplate:null,p=VN(t),f=el(typeof t?.childLabel=="string"?t.childLabel:void 0,r)??d??"",y=KN(e),g=u.useCallback(_=>{s({...e,children:_})},[e,s]),m=u.useCallback(()=>{if(c)return JSON.parse(JSON.stringify(c));if(!d)return null;const _=n?.componentPalette?.entries?.[d];if(_?.defaultNode&&typeof _.defaultNode=="object")return JSON.parse(JSON.stringify(_.defaultNode));const S=o?.components??{};let E=null;for(const C of Object.values(S))if(Array.isArray(C)){const N=C.find($=>$.name===d);if(N){E=N;break}}return E?kf(E):{type:"basic",name:d}},[c,d,n,o]),h=u.useCallback(()=>{const _=m();if(_){if(_.text==="")_.text=r("layout_editor.list_editor.new_item");else{const S=Ro(_);if(S!==null&&S.length>0){const E=_i(_,S);if(E&&E.text===""){const C=er(_,S,r("layout_editor.list_editor.new_item"));g([...y,C]);return}}}g([...y,_])}},[m,g,y,r]),v=u.useCallback(_=>{g(y.filter((S,E)=>E!==_))},[g,y]),b=u.useCallback((_,S)=>{const E=_+S;if(E<0||E>=y.length)return;const C=[...y];[C[_],C[E]]=[C[E],C[_]],g(C)},[g,y]),x=u.useCallback((_,S)=>{if(typeof S!="string"||S.length===0)return;const E=y[_];if(!E)return;const C=Ro(E);if(C===null)return;const N=y.map(($,A)=>A===_?er($,C,S):$);g(N)},[y,g]),k=u.useCallback((_,S,E)=>{if(typeof E!="string"||E.length===0)return;const C=y[_];if(!C)return;const N=Hg(C,S);if(N===null)return;const $=y.map((A,z)=>z===_?Fg(A,N,S,E):A);g($)},[y,g]);if(!d&&!c)return i.jsx("div",{className:"g7le-node-editor g7le-node-editor--children","data-testid":"g7le-children-editor-misconfigured",style:qg,children:r("layout_editor.list_editor.no_child_component")});const w=p.length>1||p.some(_=>_.label);return i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--children","data-testid":"g7le-children-editor",style:UN,children:[i.jsx("div",{style:qN,children:r("layout_editor.list_editor.items_title")}),y.length===0&&i.jsx("div",{"data-testid":"g7le-children-empty",style:qg,children:r("layout_editor.list_editor.empty")}),y.map((_,S)=>{const E=[];for(const N of p)if(N.kind==="text"){const $=Ro(_),A=$!==null?_i(_,$):null;A&&typeof A.text=="string"&&E.push({key:"text",caption:el(N.label,r)??(w?r("layout_editor.list_editor.item_label"):void 0),value:A.text,onChange:z=>x(S,z),testid:`g7le-children-i18n-${S}`})}else if(N.kind==="prop"&&N.prop){const $=Hg(_,N.prop);if($!==null){const z=_i(_,$)?.props?.[N.prop];E.push({key:`prop-${N.prop}`,caption:el(N.label,r)??N.prop,value:typeof z=="string"?z:"",onChange:D=>k(S,N.prop,D),testid:`g7le-children-prop-${N.prop}-${S}`})}}const C=typeof _?.name=="string"?_.name:r("layout_editor.list_editor.item");return i.jsxs("div",{"data-testid":`g7le-children-row-${S}`,style:GN,children:[E.length>0?E.map(N=>i.jsx("div",{style:Vg,children:i.jsxs("div",{"data-testid":N.key==="text"?`g7le-children-text-${S}`:`g7le-children-field-${N.key}-${S}`,style:YN,children:[N.caption!==void 0&&i.jsx("div",{"data-testid":`g7le-children-caption-${N.key}-${S}`,style:XN,children:N.caption}),i.jsx(vt,{value:N.value,onChange:N.onChange,t:r,placeholder:r("layout_editor.list_editor.item_text"),testidPrefix:N.testid,candidates:l,enableExpressionTree:!0,expressionTreeCollapsible:!0})]})},N.key)):i.jsx("div",{style:Vg,children:i.jsx("span",{"data-testid":`g7le-children-label-${S}`,style:ZN,children:C})}),i.jsxs("div",{style:JN,children:[i.jsx("button",{type:"button","data-testid":`g7le-children-up-${S}`,title:r("layout_editor.list_editor.move_up"),disabled:S===0,onClick:()=>b(S,-1),style:Ug,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-children-down-${S}`,title:r("layout_editor.list_editor.move_down"),disabled:S===y.length-1,onClick:()=>b(S,1),style:Ug,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-children-remove-${S}`,title:r("layout_editor.list_editor.remove"),onClick:()=>v(S),style:QN,children:"✕"})]})]},S)}),i.jsx("button",{type:"button","data-testid":"g7le-children-add",onClick:h,style:RN,children:r("layout_editor.list_editor.add_child",{component:f})})]})}const UN={display:"flex",flexDirection:"column",gap:6,width:"100%",marginBottom:12},qN={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},GN={display:"flex",flexDirection:"column",gap:4,width:"100%",minWidth:0,paddingBottom:6,borderBottom:"1px solid #f1f5f9"},Vg={display:"flex",gap:4,alignItems:"flex-start",width:"100%",minWidth:0},YN={flex:1,minWidth:0},XN={fontSize:11,color:"#64748b",marginBottom:2},JN={display:"flex",gap:4,alignItems:"center",justifyContent:"flex-end"},ZN={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,color:"#475569",fontStyle:"italic",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},Ug={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},QN={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},RN={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},qg={fontSize:11,color:"#94a3b8",fontStyle:"italic"},fo={rowContainer:"Tbody",row:"Tr",cell:"Td",headerCell:"Th",colSpanProp:"colSpan",rowSpanProp:"rowSpan"};function ht(e){return{rowContainer:e?.rowContainer??fo.rowContainer,row:e?.row??fo.row,cell:e?.cell??fo.cell,headerCell:e?.headerCell??fo.headerCell,colSpanProp:e?.colSpanProp??fo.colSpanProp,rowSpanProp:e?.rowSpanProp??fo.rowSpanProp}}function Bn(e){return e?Array.isArray(e.children)?e.children:[]:[]}function Gg(e,t){const n=e.props?.[t],o=typeof n=="number"?n:typeof n=="string"?parseInt(n,10):NaN;return Number.isFinite(o)&&o>=1?Math.floor(o):1}function Be(e,t){const n=ht(t),o=[],r=Bn(e),s=[];r.forEach((f,y)=>{(Bn(f).some(h=>h.name===n.row)||f.name===n.rowContainer||f.name==="Thead"||f.name==="Tfoot")&&s.push({container:f,containerIndex:y,isHeader:f.name==="Thead"})}),s.length===0&&r.some(f=>f.name===n.row)&&s.push({container:e,containerIndex:-1,isHeader:!1});const a=[],l=[];let d=0,c=0;const p=f=>{for(;a.length<=f;)a.push([]);return a[f]};s.forEach((f,y)=>{Bn(f.container).filter(m=>m.name===n.row).forEach((m,h)=>{const v=[],b=p(d);let x=0;Bn(m).filter(w=>w.name===n.cell||w.name===n.headerCell).forEach((w,_)=>{for(;b[x];)x++;const S=Gg(w,n.colSpanProp),E=Gg(w,n.rowSpanProp),C={cell:w,sectionIndex:y,rowIndexInSection:h,cellIndexInRow:_,gridRow:d,gridCol:x,colSpan:S,rowSpan:E,isHeader:w.name===n.headerCell};v.push(C);for(let N=0;N=0?(a.push(s.containerIndex),l=Bn(e)[s.containerIndex]??e):l=e;let d=-1,c=-1,p=null;const f=Bn(l);for(let m=0;m=r.gridRow&&t=r.gridCol&&nr){s=!0;break}if(s)break}s||t.add(r)}const n=Array.from(t).sort((r,s)=>r-s),o=[];for(let r=0;re.rows.find(l=>(l.cells[0]?.gridRow??-1)===a)?.sectionIndex??e.rows[a]?.sectionIndex??0,o=new Set([0,t]);for(let a=1;aa){l=!0;break}if(l)break}l||o.add(a)}const r=Array.from(o).sort((a,l)=>a-l),s=[];for(let a=0;at>=a.start&&t=o.length?{ok:!1,reason:"boundary"}:o[r].sectionIndex!==o[s].sectionIndex?{ok:!1,reason:"section"}:{ok:!0}}function wi(e,t,n){const o=Yg(e),r=o.findIndex(a=>t>=a.start&&t=o.length?{ok:!1,reason:"boundary"}:{ok:!0}}function Ge(e){return e?Array.isArray(e.children)?e.children:[]:[]}function mn(e,t,n){const o={...e.props??{}};n<=1?delete o[t]:o[t]=n;const r={...e,props:o};return Object.keys(o).length===0&&delete r.props,r}function ki(e,t,n=!1){const o={type:"basic",name:n?e.headerCell:e.cell},r=t?.props?.className;return typeof r=="string"&&r&&(o.props={className:r}),o.text="",o}function tr(e,t){return e.name===t.cell||e.name===t.headerCell}function nt(e,t,n,o){const r=t.sections[n];if(!r)return e;if(r.containerIndex===-1)return o(e);const s=Ge(e).map((a,l)=>l===r.containerIndex?o(a):a);return{...e,children:s}}function yt(e,t,n,o){let r=-1;const s=Ge(e).map(a=>a.name===t&&(r++,r===n)?o(a):a);return{...e,children:s}}function Si(e,t,n){let o=-1;const r=Ge(e);for(let s=0;s0?nt(e,r,r.sections.length-1,x=>({...x,children:[...Ge(x),b]})):{...e,children:[...Ge(e),b]}}const s=Math.max(-1,Math.min(n,r.rows.length-1)),a=s<0?r.rows[0]:r.rows[s],l=a.cells[0]?.gridRow??s,d=s<0?0:l+1,c=a.sectionIndex,p=s<0?0:a.rowIndexInSection+1,f=[],y=[];let g=0;for(;g0?Ue(r,d-1,g):null;b&&b.gridRow+b.rowSpan>d&&b.rowSpan>1?(y.push(b),g+=b.colSpan):(f.push(ki(o)),g+=1)}let m=e;for(const b of y)m=nt(m,r,b.sectionIndex,x=>yt(x,o.row,b.rowIndexInSection,k=>({...k,children:Ge(k).map((w,_)=>_===b.cellIndexInRow?mn(w,o.rowSpanProp,b.rowSpan+1):w)})));const h={type:"basic",name:o.row,children:f},v=Be(m,t);return m=nt(m,v,c,b=>{const x=Ge(b);let k=0,w=x.length;for(let _=0;_=r.rows.length||r.rows.length<=1)return e;const s=r.rows[n],a=s.cells[0]?.gridRow??n;let l=e;const d=new Set;for(let p=0;pyt(y,o.row,f.rowIndexInSection,g=>({...g,children:Ge(g).map((m,h)=>h===f.cellIndexInRow?mn(m,o.rowSpanProp,f.rowSpan-1):m)})))),p+=f?f.colSpan:1}const c=Be(l,t);return l=nt(l,c,s.sectionIndex,p=>{let f=-1;const y=Ge(p).filter(g=>g.name===o.row?(f++,f!==s.rowIndexInSection):!0);return{...p,children:y}}),l}function Zg(e,t,n,o){const r=ht(t),s=Be(e,t);if(!xi(s,n,o).ok)return e;const a=Xg(s),l=a.findIndex(h=>n>=h.start&&ns.rows.filter(v=>{const b=v.cells[0]?.gridRow??-1;return v.sectionIndex===f&&b>=h.start&&bv.rowIndexInSection),g=y(c),m=y(p);return g.length===0||m.length===0?e:nt(e,s,f,h=>{const v=[...Ge(h)],b=[];v.forEach((M,B)=>{M.name===r.row&&b.push(B)});const x=g.map(M=>b[M]).filter(M=>M!==void 0),k=m.map(M=>b[M]).filter(M=>M!==void 0);if(x.length===0||k.length===0)return h;const w=x.map(M=>v[M]),_=k.map(M=>v[M]),S=Math.min(...x)n>=m.start&&nw.gridCol>=f.start&&w.gridColw.gridCol>=y.start&&w.gridColw.cellIndexInRow),x=v.map(w=>w.cellIndexInRow),k=Be(g,t);g=nt(g,k,m.sectionIndex,w=>yt(w,r.row,m.rowIndexInSection,_=>{const S=[...Ge(_)],E=b.map(M=>Si(_,r,M)).filter(M=>M>=0),C=x.map(M=>Si(_,r,M)).filter(M=>M>=0);if(E.length===0||C.length===0)return _;const N=E.map(M=>S[M]),$=C.map(M=>S[M]),A=Math.min(...E,...C),z=Math.max(...E,...C),D=[...$,...N],O=[...S.slice(0,A),...D,...S.slice(z+1)];return{..._,children:O}}))}return g}function Rg(e,t,n){const o=ht(t),r=Be(e,t);if(r.rows.length===0||r.colCount===0)return nl(e,t,-1);const s=Math.max(0,Math.min(n+1,r.colCount));let a=e;const l=new Set;for(const d of r.rows){const c=d.cells[0]?.gridRow??0,p=s>0?Ue(r,c,s-1):null,f=s1){if(!l.has(p.cell)){l.add(p.cell);const h=Be(a,t);a=nt(a,h,p.sectionIndex,v=>yt(v,o.row,p.rowIndexInSection,b=>({...b,children:Ge(b).map((x,k)=>k===p.cellIndexInRow?mn(x,o.colSpanProp,p.colSpan+1):x)})))}continue}if(p&&p.gridRow0&&d.cells.every(h=>h.isHeader),m=Be(a,t);a=nt(a,m,d.sectionIndex,h=>yt(h,o.row,d.rowIndexInSection,v=>{const b=[...Ge(v)];let x;if(y<0)x=b.findIndex(k=>tr(k,o)),x<0&&(x=b.length);else{const k=Si(v,o,y);x=k<0?b.length:k+1}return b.splice(x,0,ki(o,void 0,g)),{...v,children:b}}))}return a}function eh(e,t,n){const o=ht(t),r=Be(e,t);if(n<0||n>=r.colCount||r.colCount<=1)return e;let s=e;const a=new Set;for(const l of r.rows){const d=l.cells[0]?.gridRow??0,c=Ue(r,d,n);if(!(!c||a.has(c.cell)||c.gridRow1){const p=Be(s,t);s=nt(s,p,c.sectionIndex,f=>yt(f,o.row,c.rowIndexInSection,y=>({...y,children:Ge(y).map((g,m)=>m===c.cellIndexInRow?mn(g,o.colSpanProp,c.colSpan-1):g)})))}else{const p=Be(s,t);s=nt(s,p,c.sectionIndex,f=>yt(f,o.row,c.rowIndexInSection,y=>{let g=-1;const m=Ge(y).filter(h=>tr(h,o)?(g++,g!==c.cellIndexInRow):!0);return{...y,children:m}}))}}return s}function ol(e,t,n,o,r,s){const a=ht(t),l=Be(e,t),d=Math.min(n,r),c=Math.max(n,r),p=Math.min(o,s),f=Math.max(o,s);if(d===c&&p===f)return{table:e,ok:!1,reason:"single"};if(!l.valid)return{table:e,ok:!1,reason:"invalid-grid"};const y=new Set;for(let b=d;b<=c;b++)for(let x=p;x<=f;x++){const k=Ue(l,b,x);if(!k)return{table:e,ok:!1,reason:"hole"};if(k.gridRowc||k.gridCol+k.colSpan-1>f)return{table:e,ok:!1,reason:"not-rectangular"};y.add(k)}const g=Ue(l,d,p),m=Array.from(y).filter(b=>b!==g);let h=nt(e,l,g.sectionIndex,b=>yt(b,a.row,g.rowIndexInSection,x=>({...x,children:Ge(x).map((k,w)=>{if(w!==g.cellIndexInRow)return k;let _=mn(k,a.colSpanProp,f-p+1);return _=mn(_,a.rowSpanProp,c-d+1),_})})));const v=new Map;for(const b of m){const x=`${b.sectionIndex}:${b.rowIndexInSection}`,k=v.get(x)??[];k.push(b),v.set(x,k)}for(const[b,x]of v){const k=parseInt(b.split(":")[0],10),w=x[0].rowIndexInSection,_=new Set(x.map(E=>E.cellIndexInRow)),S=Be(h,t);h=nt(h,S,k,E=>yt(E,a.row,w,C=>{let N=-1;const $=Ge(C).filter(A=>tr(A,a)?(N++,!_.has(N)):!0);return{...C,children:$}}))}return{table:h,ok:!0}}function th(e,t,n,o){const r=ht(t),s=Be(e,t),a=Ue(s,n,o);if(!a||a.colSpan<=1&&a.rowSpan<=1)return e;const l=a.gridRow,d=a.gridCol,c=a.gridRow+a.rowSpan-1,p=a.gridCol+a.colSpan-1,f=a.isHeader,y=typeof a.cell.props?.className=="string"?a.cell.props.className:void 0;let g=nt(e,s,a.sectionIndex,m=>yt(m,r.row,a.rowIndexInSection,h=>({...h,children:Ge(h).map((v,b)=>{if(b!==a.cellIndexInRow)return v;let x=mn(v,r.colSpanProp,1);return x=mn(x,r.rowSpanProp,1),x})})));for(let m=l;m<=c;m++)for(let h=d;h<=p;h++){if(m===l&&h===d)continue;const v=Be(g,t),b=v.rows.find(w=>w.cells.some(_=>_.gridRow===m))??v.rows[m];if(!b)continue;let x=-1;for(const w of b.cells)w.gridRow===m&&w.gridColyt(w,r.row,b.rowIndexInSection,_=>{const S=[...Ge(_)];let E;if(x<0)E=S.findIndex(C=>tr(C,r)),E<0&&(E=S.length);else{const C=Si(_,r,x);E=C<0?S.length:C+1}return S.splice(E,0,ki(r,k,f)),{..._,children:S}}))}return g}function Ci(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>{if(f!==l.cellIndexInRow)return p;const y={...p.props??{}};r.trim()===""?delete y.className:y.className=r;const g={...p,props:y};return Object.keys(y).length===0&&delete g.props,g})}))):e}function Jt(e,t,n,o,r,s){const a=ht(t),l=Be(e,t),d=Ue(l,n,o);return d?nt(e,l,d.sectionIndex,c=>yt(c,a.row,d.rowIndexInSection,p=>({...p,children:Ge(p).map((f,y)=>{if(y!==d.cellIndexInRow)return f;const g={...f.props??{}},m={...g.style??{}};!s||s.trim()===""?delete m[r]:m[r]=s,Object.keys(m).length===0?delete g.style:g.style=m;const h={...f,props:g};return Object.keys(g).length===0&&delete h.props,h})}))):e}function go(e,t,n,o,r,s,a){const l=ht(t),d=Be(e,t),c=Ue(d,n,o);if(!c)return e;const p=a==="dark";return nt(e,d,c.sectionIndex,f=>yt(f,l.row,c.rowIndexInSection,y=>({...y,children:Ge(y).map((g,m)=>{if(m!==c.cellIndexInRow)return g;const h={...g.props??{}},v=typeof h.className=="string"?h.className:"",b=lC(v,s,r,p);b===""?delete h.className:h.className=b;const x={...g,props:h};return Object.keys(h).length===0&&delete x.props,x})})))}function nh(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>f===l.cellIndexInRow?{...p,text:r}:p)}))):e}function oh(e,t,n,o,r){const s=ht(t),a=Be(e,t),l=Ue(a,n,o);return l?nt(e,a,l.sectionIndex,d=>yt(d,s.row,l.rowIndexInSection,c=>({...c,children:Ge(c).map((p,f)=>f===l.cellIndexInRow?r:p)}))):e}const rh=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function e8(e){return e.split(/\s+/).filter(Boolean)}function ih(e,t){for(const n of t)if(e===n||/^-?\d+$/.test(e.slice(n.length+1))&&e.startsWith(n+"-"))return!0;return!1}function sh({className:e,catalog:t,t:n,onChange:o,colorScheme:r="light",onPresetToken:s,colorStyle:a,onColorStyle:l,disabled:d}){const c=t?.sides??[],p=t?.widths??[],f=t?.colors??[],y=r==="dark",g=u.useMemo(()=>c.map($=>$.prefix),[c]),m=u.useMemo(()=>new Set(f.map($=>$.token)),[f]),h=u.useMemo(()=>e8(e),[e]),v=u.useMemo(()=>Vr(e,y),[e,y]),b=u.useMemo(()=>v.find($=>m.has($))??null,[v,m]),x=u.useMemo(()=>{const $=new Set;for(const A of c)h.some(D=>D===A.prefix||D.startsWith(A.prefix+"-")&&/^-?\d+$/.test(D.slice(A.prefix.length+1)))&&$.add(A.value);return $},[h,c]),k=u.useMemo(()=>{const $=h.find(z=>ih(z,g));if(!$)return"none";const A=p.filter(z=>z.value!=="none");for(const z of c){if($===z.prefix)return A.find(D=>(D.suffix??"")==="")?.value??"thin";if($.startsWith(z.prefix+"-")){const D=$.slice(z.prefix.length),O=A.find(M=>(M.suffix??"")===D);if(O)return O.value}}return"thin"},[h,g,c,p]);if(!t||c.length===0||p.length===0)return null;function w($){return $.filter(A=>!ih(A,g))}function _($,A){const z=w(h),D=p.find(B=>B.value===A);if(!D||A==="none"||$.size===0)return z.join(" ").trim();const O=D.suffix??"",M=[...z];for(const B of c)$.has(B.value)&&M.push(O?`${B.prefix}${O}`:B.prefix);return M.join(" ").trim()}const S=$=>{const A=new Set(x);A.has($)?A.delete($):($==="all"?A.clear():A.delete("all"),A.add($));const z=k==="none"?p.find(D=>D.value!=="none")?.value??"thin":k;o(_(A,z))},E=$=>{const A=x.size===0?new Set(["all"]):x;o(_(A,$))},C=$=>{s?.($)},N=($,A)=>$&&$.startsWith("$t:")?n($.slice(3)):$||A;return i.jsxs("div",{className:"g7le-cell-border","data-testid":"g7le-cell-border",style:t8,children:[i.jsx("div",{style:n8,children:i.jsx("span",{style:o8,children:n("layout_editor.table_editor.cell_border")})}),i.jsx("div",{style:rl,"data-testid":"g7le-cell-border-widths",children:p.map($=>i.jsx("button",{type:"button",disabled:d,"data-testid":`g7le-cell-border-width-${$.value}`,"aria-pressed":k===$.value,onClick:()=>E($.value),style:k===$.value?ah:il,children:N($.label,$.value)},$.value))}),k!=="none"&&i.jsx("div",{style:rl,"data-testid":"g7le-cell-border-sides",children:c.map($=>i.jsx("button",{type:"button",disabled:d,"data-testid":`g7le-cell-border-side-${$.value}`,"aria-pressed":x.has($.value),onClick:()=>S($.value),style:x.has($.value)?ah:il,children:N($.label,$.value)},$.value))}),k!=="none"&&(f.length>0||l)&&i.jsx(i.Fragment,{children:i.jsxs("div",{style:rl,"data-testid":"g7le-cell-border-colors",children:[f.map($=>{const A=b===$.token;return i.jsx("button",{type:"button",disabled:d,title:N($.label,$.value),"data-testid":`g7le-cell-border-color-${$.value}`,"aria-pressed":A,onClick:()=>C($.token),style:{...lh,background:$.swatch??"#fff",outline:A?"2px solid #2563eb":"1px solid #cbd5e1"}},$.value)}),l&&(()=>{const $=!!a&&f.some(z=>z.swatch&&z.swatch.toLowerCase()===a.toLowerCase()),A=!y&&!!a&&!$;return i.jsx("label",{title:n(y?"layout_editor.table_editor.dark_free_color_hint":"layout_editor.table_inplace.custom_color"),"data-testid":"g7le-cell-border-color-custom",style:{...lh,position:"relative",overflow:"hidden",display:"inline-flex",alignItems:"center",justifyContent:"center",background:A&&rh.test(a)?a:"conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",outline:A?"2px solid #2563eb":"1px solid #cbd5e1",opacity:y?.4:1,cursor:y?"not-allowed":"pointer"},children:i.jsx("input",{type:"color",disabled:d||y,"data-testid":"g7le-cell-border-color-picker",value:a&&rh.test(a)?a:"#000000",onChange:z=>l(z.target.value),style:{position:"absolute",width:0,height:0,opacity:0}})})})()]})})]})}const t8={display:"flex",flexDirection:"column",gap:6},n8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},o8={fontSize:12,fontWeight:600,color:"#475569"},rl={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},il={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},ah={...il,border:"1px solid #2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},lh={width:22,height:22,borderRadius:5,padding:0,cursor:"pointer",boxSizing:"border-box"},dh=/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function ch({colorStyle:e,className:t,catalog:n,t:o,colorScheme:r="light",onPresetToken:s,onCustomColor:a,onClear:l,disabled:d}){const c=n?.colors??[],p=r==="dark",f=e??"",y=Vr(t??"",p),g=c.find(x=>x.token&&y.includes(x.token))?.token??null,m=(x,k)=>x&&x.startsWith("$t:")?o(x.slice(3)):x||k,h=!g&&(p||f.trim()===""),v=!!f&&c.some(x=>x.swatch&&x.swatch.toLowerCase()===f.toLowerCase()),b=!p&&!!f&&!v;return i.jsxs("div",{className:"g7le-cell-fill","data-testid":"g7le-cell-fill",style:r8,children:[i.jsx("div",{style:i8,children:i.jsx("span",{style:s8,children:o("layout_editor.table_editor.cell_fill")})}),i.jsxs("div",{style:a8,"data-testid":"g7le-cell-fill-colors",children:[i.jsx("button",{type:"button",disabled:d,title:o("layout_editor.table_editor.cell_fill_none"),"data-testid":"g7le-cell-fill-none","aria-pressed":h,onClick:()=>l?.(),style:{...sl,background:"repeating-conic-gradient(#e2e8f0 0% 25%, #ffffff 0% 50%) 50% / 10px 10px",outline:h?"2px solid #2563eb":"1px solid #cbd5e1"}}),c.map(x=>{const k=!!x.token&&g===x.token,w=!p&&!!x.swatch&&f.toLowerCase()===(x.swatch??"").toLowerCase(),_=k||w,S=d||p&&!x.token;return i.jsx("button",{type:"button",disabled:S,title:m(x.label,x.value),"data-testid":`g7le-cell-fill-color-${x.value}`,"aria-pressed":_,onClick:()=>{x.token&&s?s(x.token):!p&&x.swatch&&a&&a(x.swatch)},style:{...sl,background:x.swatch??"#fff",outline:_?"2px solid #2563eb":"1px solid #cbd5e1",opacity:S?.4:1,cursor:S?"not-allowed":"pointer"}},x.value)}),a&&i.jsx("label",{title:o(p?"layout_editor.table_editor.dark_free_color_hint":"layout_editor.table_editor.cell_fill_custom"),"data-testid":"g7le-cell-fill-custom",style:{...sl,position:"relative",overflow:"hidden",display:"inline-flex",alignItems:"center",justifyContent:"center",background:b&&dh.test(f)?f:"conic-gradient(red, yellow, lime, aqua, blue, magenta, red)",outline:b?"2px solid #2563eb":"1px solid #cbd5e1",opacity:p?.4:1,cursor:p?"not-allowed":"pointer"},children:i.jsx("input",{type:"color",disabled:d||p,"data-testid":"g7le-cell-fill-color-picker",value:f&&dh.test(f)?f:"#ffffff",onChange:x=>a(x.target.value),style:{position:"absolute",width:0,height:0,opacity:0}})})]})]})}const r8={display:"flex",flexDirection:"column",gap:6},i8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},s8={fontSize:12,fontWeight:600,color:"#475569"},a8={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},sl={width:22,height:22,borderRadius:5,padding:0,cursor:"pointer",boxSizing:"border-box"};function l8(e){const t=/^(\d+)px$/.exec(e.trim());return t?parseInt(t[1],10):null}function uh({paddingStyle:e,catalog:t,t:n,onChange:o,disabled:r}){const s=t?.steps??[],a=e??"",l=l8(a),d=(h,v)=>h&&h.startsWith("$t:")?n(h.slice(3)):h||v,c=s.some(h=>(h.px??0)===(l??-1)),p=l!==null&&!c?String(l):"",[f,y]=u.useState(p);u.useEffect(()=>{y(p)},[e]);const g=h=>{o(h&&h>0?`${h}px`:""),y("")},m=h=>{const v=parseInt(h,10);Number.isFinite(v)&&v>=0&&o(v>0?`${v}px`:"")};return i.jsxs("div",{className:"g7le-cell-padding","data-testid":"g7le-cell-padding",style:d8,children:[i.jsx("div",{style:c8,children:n("layout_editor.table_editor.cell_padding")}),i.jsxs("div",{style:u8,"data-testid":"g7le-cell-padding-steps",children:[s.map(h=>{const v=(h.px??0)===(l??-1)||!h.px&&a.trim()==="";return i.jsx("button",{type:"button",disabled:r,"data-testid":`g7le-cell-padding-step-${h.value}`,"aria-pressed":v,onClick:()=>g(h.px),style:v?p8:ph,children:d(h.label,h.value)},h.value)}),i.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:3},children:[i.jsx("input",{type:"number",min:0,disabled:r,"data-testid":"g7le-cell-padding-free",value:f,placeholder:n("layout_editor.table_editor.cell_padding_custom"),onChange:h=>y(h.target.value),onBlur:()=>f.trim()!==""&&m(f),onKeyDown:h=>{h.key==="Enter"&&f.trim()!==""&&m(f)},style:f8}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},children:"px"})]})]})]})}const d8={display:"flex",flexDirection:"column",gap:6},c8={fontSize:12,fontWeight:600,color:"#475569"},u8={display:"flex",gap:4,flexWrap:"wrap",alignItems:"center"},ph={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},p8={...ph,border:"1px solid #2563eb",background:"#eff6ff",color:"#1d4ed8",fontWeight:600},f8={width:48,padding:"3px 5px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,boxSizing:"border-box"};function fh({colorScheme:e,onChange:t,t:n,disabled:o}){const r=e==="dark";return i.jsxs("div",{className:"g7le-color-scheme-tabs","data-testid":"g7le-color-scheme-tabs",style:g8,children:[i.jsxs("div",{style:h8,children:[i.jsx("span",{style:y8,children:n("layout_editor.table_editor.cell_color")}),i.jsxs("div",{style:b8,children:[i.jsx("button",{type:"button",disabled:o,"data-testid":"g7le-color-scheme-light","aria-pressed":!r,onClick:()=>t("light"),style:r?al:gh,children:n("layout_editor.property_modal.scope.scheme_light")}),i.jsx("button",{type:"button",disabled:o,"data-testid":"g7le-color-scheme-dark","aria-pressed":r,onClick:()=>t("dark"),style:r?gh:al,children:n("layout_editor.property_modal.scope.scheme_dark")})]})]}),r&&i.jsx("div",{style:m8,"data-testid":"g7le-color-scheme-dark-hint",children:n("layout_editor.table_editor.dark_free_color_hint")})]})}const g8={display:"flex",flexDirection:"column",gap:3},h8={display:"flex",alignItems:"center",justifyContent:"space-between",gap:8},y8={fontSize:12,fontWeight:700,color:"#0f172a"},b8={display:"flex",gap:2},al={padding:"2px 9px",fontSize:11,border:"none",borderBottom:"2px solid transparent",background:"transparent",color:"#94a3b8",cursor:"pointer"},gh={...al,color:"#1d4ed8",borderBottom:"2px solid #2563eb",fontWeight:600},m8={fontSize:10,color:"#94a3b8",fontStyle:"italic"},ll="dark:";function hh(e){return typeof e=="string"?e.split(/\s+/).filter(Boolean):[]}function yh(e,t){return t==="dark"?e.filter(n=>n.startsWith(ll)).map(n=>n.slice(ll.length)):e.filter(n=>!n.startsWith(ll))}const v8={none:0,thin:1,medium:2,thick:4},bh={top:{w:"borderTopWidth",s:"borderTopStyle",c:"borderTopColor"},bottom:{w:"borderBottomWidth",s:"borderBottomStyle",c:"borderBottomColor"},left:{w:"borderLeftWidth",s:"borderLeftStyle",c:"borderLeftColor"},right:{w:"borderRightWidth",s:"borderRightStyle",c:"borderRightColor"}},mh=["top","bottom","left","right"];function _8(e,t,n="light"){if(!e||!t)return{};const o=t.sides??[],r=t.widths??[],s=t.colors??[],a=hh(e.props?.className);if(a.length===0&&!e.props?.style)return{};const l=[...r].filter(b=>b.value!=="none").sort((b,x)=>(x.suffix??"").length-(b.suffix??"").length),d={};for(const b of o)for(const x of l){const k=x.suffix?`${b.prefix}${x.suffix}`:b.prefix;if(a.includes(k)){d[b.value]=x.value;break}}const c=e.props?.style??{},p=yh(a,n),f=s.find(b=>p.includes(b.token))?.swatch??"",g=(n==="light"&&typeof c.borderColor=="string"?c.borderColor:"")||f||"#94a3b8",m=b=>{const x=bh[b]?.c,k=x?c[x]:void 0;return typeof k=="string"&&k?k:g},h={},v=(b,x)=>{const k=bh[b];if(!k)return;const w=v8[x]??1;h[k.w]=`${w}px`,h[k.s]=w>0?"solid":"none",w>0&&(h[k.c]=m(b))};if(d.all)for(const b of mh)v(b,d.all);for(const b of mh)d[b]&&v(b,d[b]);return h}function x8(e,t,n="light"){if(!e?.props)return{};const o=e.props.style??{},r={},s=hh(e.props.className),a=t?.colors?.find(c=>c.token&&yh(s,n).includes(c.token))?.swatch??"",d=(n==="light"&&typeof o.backgroundColor=="string"?o.backgroundColor:"")||a;return d&&(r.backgroundColor=d),typeof o.padding=="string"&&o.padding&&(r.padding=o.padding),r}const w8=/^\s*\$t:(custom\.[a-zA-Z0-9._-]+)\s*$/,k8=/^\s*\$t:([a-zA-Z0-9._-]+)\s*$/,S8=/\{\{.*?\}\}/;function vh(e,t,n){const o=e?.text;if(typeof o!="string")return{display:"",editable:!1,isKey:!1};const r=mt(o);if(r&&r.key.startsWith("custom.")){let a="";try{const l=Pt.getInstance().translate(r.key,{templateId:t,locale:n});a=l&&l!==r.key?l:""}catch{a=""}return{display:a,editable:!0,isKey:!0}}if(S8.test(o))return{display:o,editable:!1,isKey:!1};const s=k8.exec(o);if(s){const a=s[1];let l="";try{const d=Pt.getInstance().translate(a,{templateId:t,locale:n});l=d&&d!==a?d:""}catch{l=""}return{display:l,editable:!0,isKey:!0}}return{display:o,editable:!0,isKey:!1}}function dl(e){const t=Ro(e);return t===null?{node:null,path:null}:{node:_i(e,t),path:t}}function C8(e){if(!e||typeof e.text!="string")return null;const t=w8.exec(e.text);if(t)return t[1];const n=mt(e.text);return n&&n.key.startsWith("custom.")?n.key:null}function $8({node:e,params:t,t:n,onPatchNode:o,templateIdentifier:r,candidates:s}){const{state:a}=Je(),l=a.selectedRoute?.layoutName??null,d=a.locale,c=r??a.templateIdentifier,p=u.useMemo(()=>t&&typeof t=="object"?t:null,[t]);u.useMemo(()=>ht(p),[p]);const f=u.useMemo(()=>Be(e,p),[e,p]),y=u.useMemo(()=>{const q=p?.cellBorder;return q&&typeof q=="object"?q:null},[p]),g=u.useMemo(()=>{const q=p?.cellBackground;return q&&typeof q=="object"?q:null},[p]),m=u.useMemo(()=>{const q=p?.cellPadding;return q&&typeof q=="object"?q:null},[p]),[h,v]=u.useState(null),[b,x]=u.useState(null),[k,w]=u.useState({}),[_,S]=u.useState(!1),[E,C]=u.useState(null),[N,$]=u.useState(null),[A,z]=u.useState("light"),D=u.useCallback(q=>o(q),[o]),O=u.useMemo(()=>h?Ue(f,h.r,h.c):null,[f,h]),M=N!==null?N:h?.c??null,B=E!==null?E:h?.r??null,T=u.useMemo(()=>{const q=(y?.sides??[]).find($e=>$e.value==="all")??(y?.sides??[])[0],ae=(y?.widths??[]).find($e=>$e.value!=="none");if(!q||!ae)return[];const pe=ae.suffix??"";return[pe?`${q.prefix}${pe}`:q.prefix]},[y]),H=u.useCallback((q,ae,pe)=>{if(T.length===0)return q;const $e=(y?.sides??[]).map(J=>J.prefix),be=Ue(Be(q,p),ae,pe),oe=(typeof be?.cell?.props?.className=="string"?be.cell.props.className:"").split(/\s+/).filter(Boolean);return oe.some(J=>$e.some(le=>J===le||J.startsWith(le+"-")&&/^-?\d+$/.test(J.slice(le.length+1))))?q:Ci(q,p,ae,pe,[...oe,...T].join(" ").trim())},[T,y,p]),L=u.useCallback(q=>{if(!h)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c),j=new Set,oe=[];for(let J=ae;J<=pe;J++)for(let le=$e;le<=be;le++)j.add(`${J}:${le}`),oe.push({r:J,c:le});let ue=e;for(const J of oe){if(ue=H(ue,J.r,J.c),ue=Jt(ue,p,J.r,J.c,"borderColor",q),J.r>0&&!j.has(`${J.r-1}:${J.c}`)){const le=Ue(Be(ue,p),J.r-1,J.c);le&&(ue=Jt(ue,p,le.gridRow,le.gridCol,"borderBottomColor",q))}if(J.c>0&&!j.has(`${J.r}:${J.c-1}`)){const le=Ue(Be(ue,p),J.r,J.c-1);le&&(ue=Jt(ue,p,le.gridRow,le.gridCol,"borderRightColor",q))}}D(ue)},[h,b,e,p,D,H]),I=u.useCallback(q=>L(q),[L]),P=u.useMemo(()=>(y?.colors??[]).map(q=>q.token).filter(Boolean),[y]),F=u.useCallback(q=>{if(!h||P.length===0)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c);let j=e;for(let oe=ae;oe<=pe;oe++)for(let ue=$e;ue<=be;ue++)j=H(j,oe,ue),j=go(j,p,oe,ue,q,P,A);D(j)},[h,b,e,p,D,H,P,A]),G=u.useMemo(()=>{if(A==="dark")return"";const q=O?.cell.props?.style;return typeof q?.borderColor=="string"?q.borderColor:""},[O,A]),V=u.useCallback((q,ae)=>{if(!h)return;const pe=Math.min(h.r,b?.r??h.r),$e=Math.max(h.r,b?.r??h.r),be=Math.min(h.c,b?.c??h.c),j=Math.max(h.c,b?.c??h.c);let oe=e;for(let ue=pe;ue<=$e;ue++)for(let J=be;J<=j;J++)oe=Jt(oe,p,ue,J,q,ae);D(oe)},[h,b,e,p,D]),Y=u.useMemo(()=>(g?.colors??[]).map(q=>q.token).filter(q=>!!q),[g]),Z=u.useCallback(()=>h?{r0:Math.min(h.r,b?.r??h.r),r1:Math.max(h.r,b?.r??h.r),c0:Math.min(h.c,b?.c??h.c),c1:Math.max(h.c,b?.c??h.c)}:null,[h,b]),se=u.useCallback(q=>{const ae=Z();if(!ae||Y.length===0)return;let pe=e;for(let $e=ae.r0;$e<=ae.r1;$e++)for(let be=ae.c0;be<=ae.c1;be++)pe=go(pe,p,$e,be,q,Y,A);D(pe)},[Z,e,p,D,Y,A]),re=u.useCallback(q=>V("backgroundColor",q),[V]),U=u.useCallback(()=>{const q=Z();if(!q)return;let ae=e;for(let pe=q.r0;pe<=q.r1;pe++)for(let $e=q.c0;$e<=q.c1;$e++)ae=go(ae,p,pe,$e,void 0,Y,A),A==="light"&&(ae=Jt(ae,p,pe,$e,"backgroundColor",void 0));D(ae)},[Z,e,p,D,Y,A]),ee=u.useCallback(q=>V("padding",q),[V]),fe=u.useMemo(()=>{if(A==="dark")return"";const q=O?.cell.props?.style;return typeof q?.backgroundColor=="string"?q.backgroundColor:""},[O,A]),K=u.useMemo(()=>typeof O?.cell.props?.className=="string"?O.cell.props.className:"",[O]),ne=u.useMemo(()=>{const q=O?.cell.props?.style;return typeof q?.padding=="string"?q.padding:""},[O]),me=u.useCallback(q=>q==="section"?n("layout_editor.table_editor.move_blocked_section"):"",[n]),Ce=u.useCallback((q,ae)=>xi(f,q,ae),[f]),ze=u.useCallback((q,ae)=>wi(f,q,ae),[f]),Se=u.useCallback(q=>D(nl(e,p,q)),[e,p,D]),qe=u.useCallback(q=>{D(Jg(e,p,q)),v(null),x(null)},[e,p,D]),De=u.useCallback((q,ae)=>D(Zg(e,p,q,ae)),[e,p,D]),Pe=u.useCallback(q=>D(Rg(e,p,q)),[e,p,D]),Qe=u.useCallback(q=>{D(eh(e,p,q)),v(null),x(null)},[e,p,D]),Me=u.useCallback((q,ae)=>D(Qg(e,p,q,ae)),[e,p,D]),Re=u.useCallback(()=>{if(!h||!b)return;const q=ol(e,p,h.r,h.c,b.r,b.c);q.ok&&(D(q.table),v({r:Math.min(h.r,b.r),c:Math.min(h.c,b.c)}),x(null))},[e,p,h,b,D]),ve=u.useCallback(()=>{h&&(D(th(e,p,h.r,h.c)),x(null))},[e,p,h,D]),te=u.useCallback(q=>{if(!h)return;const ae=Math.min(h.r,b?.r??h.r),pe=Math.max(h.r,b?.r??h.r),$e=Math.min(h.c,b?.c??h.c),be=Math.max(h.c,b?.c??h.c);let j=e;for(let oe=ae;oe<=pe;oe++)for(let ue=$e;ue<=be;ue++)j=Ci(j,p,oe,ue,q);D(j)},[e,p,h,b,D]),he=u.useCallback(async(q,ae)=>{const pe=`${q}:${ae}`,$e=k[pe];if($e===void 0)return;const be=Ue(f,q,ae);if(!be)return;const{node:j,path:oe}=dl(be.cell);if(oe===null)return;const ue=vh(j,c,d);if(ue.editable){if($e===ue.display){w(J=>{const le={...J};return delete le[pe],le});return}S(!0);try{const J=C8(j);if(J)await yr(c,J,d,$e),await tn(c,d);else{const le=await qn(c,l,d,$e);if(le.kind==="ok"&&le.resource){const Ie=`$t:${le.resource.translation_key}`;if(oe.length===0)D(nh(e,p,q,ae,Ie));else{const dt=er(be.cell,oe,Ie);D(oh(e,p,q,ae,dt))}await tn(c,d)}}}finally{S(!1),w(J=>{const le={...J};return delete le[pe],le})}}},[k,f,c,d,l,e,p,D]),Q=u.useMemo(()=>{if(!O)return null;const{node:q,path:ae}=dl(O.cell);return ae===null?null:{value:typeof q?.text=="string"?q.text:"",editable:!0}},[O]),ie=u.useCallback(q=>{if(typeof q!="string"||q.length===0||!h)return;const ae=Ue(f,h.r,h.c);if(!ae)return;const pe=Ro(ae.cell);if(pe!==null)if(pe.length===0)D(nh(e,p,h.r,h.c,q));else{const $e=er(ae.cell,pe,q);D(oh(e,p,h.r,h.c,$e))}},[h,f,e,p,D]),_e=u.useCallback((q,ae,pe)=>{pe?x({r:q,c:ae}):(v({r:q,c:ae}),x(null))},[]),ge=u.useMemo(()=>Array.from({length:f.colCount},(q,ae)=>ae),[f.colCount]),Ae=!!(h&&b&&(h.r!==b.r||h.c!==b.c)),Ne=!!(O&&(O.colSpan>1||O.rowSpan>1)),Le=O&&typeof O.cell.props?.className=="string"?O.cell.props.className:"";return i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--table","data-testid":"g7le-table-editor",style:E8,children:[i.jsx("div",{style:I8,children:n("layout_editor.table_editor.title")}),i.jsx("div",{style:{overflowX:"auto"},children:i.jsxs("table",{"data-testid":"g7le-table-editor-grid",style:A8,children:[i.jsx("thead",{children:i.jsxs("tr",{children:[i.jsx("th",{style:T8}),ge.map(q=>{const ae=ze(q,-1),pe=ze(q,1),$e=M===q;return i.jsx("th",{style:D8,"data-testid":`g7le-table-colgutter-${q}`,onMouseEnter:()=>$(q),onMouseLeave:()=>$(be=>be===q?null:be),children:i.jsxs("div",{style:{...z8,visibility:$e?"visible":"hidden"},children:[i.jsx("button",{type:"button",title:ae.ok?n("layout_editor.table_editor.move_col_left"):me(ae.reason)||n("layout_editor.table_editor.move_col_left"),disabled:!ae.ok||_,onClick:()=>Me(q,-1),"data-testid":`g7le-table-col-left-${q}`,style:ae.ok?vn:$i,children:"←"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.add_col"),disabled:_,onClick:()=>Pe(q),"data-testid":`g7le-table-col-add-${q}`,style:vn,children:"+"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.remove_col"),disabled:f.colCount<=1||_,onClick:()=>Qe(q),"data-testid":`g7le-table-col-remove-${q}`,style:_h,children:"✕"}),i.jsx("button",{type:"button",title:pe.ok?n("layout_editor.table_editor.move_col_right"):me(pe.reason)||n("layout_editor.table_editor.move_col_right"),disabled:!pe.ok||_,onClick:()=>Me(q,1),"data-testid":`g7le-table-col-right-${q}`,style:pe.ok?vn:$i,children:"→"})]})},q)})]})}),i.jsx("tbody",{children:f.rows.map((q,ae)=>{const pe=q.cells[0]?.gridRow??ae,$e=B===pe;return i.jsxs("tr",{"data-testid":`g7le-table-row-${pe}`,onMouseEnter:()=>C(pe),onMouseLeave:()=>C(be=>be===pe?null:be),children:[i.jsx("td",{style:N8,"data-testid":`g7le-table-rowgutter-${pe}`,children:(()=>{const be=Ce(pe,-1),j=Ce(pe,1);return i.jsxs("div",{style:{...M8,visibility:$e?"visible":"hidden"},children:[i.jsx("button",{type:"button",title:be.ok?n("layout_editor.table_editor.move_row_up"):me(be.reason)||n("layout_editor.table_editor.move_row_up"),disabled:!be.ok||_,onClick:()=>De(pe,-1),"data-testid":`g7le-table-row-up-${pe}`,style:be.ok?vn:$i,children:"↑"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.add_row"),disabled:_,onClick:()=>Se(pe),"data-testid":`g7le-table-row-add-${pe}`,style:vn,children:"+"}),i.jsx("button",{type:"button",title:n("layout_editor.table_editor.remove_row"),disabled:f.rows.length<=1||_,onClick:()=>qe(pe),"data-testid":`g7le-table-row-remove-${pe}`,style:_h,children:"✕"}),i.jsx("button",{type:"button",title:j.ok?n("layout_editor.table_editor.move_row_down"):me(j.reason)||n("layout_editor.table_editor.move_row_down"),disabled:!j.ok||_,onClick:()=>De(pe,1),"data-testid":`g7le-table-row-down-${pe}`,style:j.ok?vn:$i,children:"↓"})]})})()}),q.cells.map(be=>{const j=`${be.gridRow}:${be.gridCol}`,{node:oe,path:ue}=dl(be.cell),J=vh(oe,c,d),le=ue!==null&&J.editable,Ie=ue===null,dt=k[j],pt=dt!==void 0?dt:J.display,ot=h&&h.r===be.gridRow&&h.c===be.gridCol,Oe=h&&b&&be.gridRow>=Math.min(h.r,b.r)&&be.gridRow<=Math.max(h.r,b.r)&&be.gridCol>=Math.min(h.c,b.c)&&be.gridCol<=Math.max(h.c,b.c);return i.jsx("td",{colSpan:be.colSpan,rowSpan:be.rowSpan,"data-testid":`g7le-table-cell-${be.gridRow}-${be.gridCol}`,onClick:He=>_e(be.gridRow,be.gridCol,He.shiftKey),style:{...L8,..._8(be.cell,y,A),...x8(be.cell,g,A),...be.isHeader?P8:{},...ot?O8:{},...Oe&&!ot?B8:{}},children:le?i.jsx("input",{type:"text","data-testid":`g7le-table-cell-input-${be.gridRow}-${be.gridCol}`,value:pt,placeholder:n("layout_editor.table_editor.cell_text"),disabled:_,onMouseDown:He=>{He.shiftKey&&He.preventDefault(),_e(be.gridRow,be.gridCol,He.shiftKey)},onClick:He=>He.stopPropagation(),onChange:He=>w(Ye=>({...Ye,[j]:He.target.value})),onBlur:()=>{he(be.gridRow,be.gridCol)},style:W8}):Ie?i.jsx("span",{"data-testid":`g7le-table-cell-struct-${be.gridRow}-${be.gridCol}`,style:xh,children:n("layout_editor.table_editor.structural_cell")}):i.jsx("span",{style:xh,children:J.display})},j)})]},ae)})})]})}),i.jsxs("button",{type:"button","data-testid":"g7le-table-add-row-bottom",onClick:()=>Se(f.rows.length-1),disabled:_,style:j8,children:["+ ",n("layout_editor.table_editor.add_row")]}),i.jsxs("div",{style:H8,"data-testid":"g7le-table-cell-tools",children:[i.jsx("button",{type:"button","data-testid":"g7le-table-merge",onClick:Re,disabled:!Ae||_,style:wh,children:n("layout_editor.table_editor.merge_cells")}),i.jsx("button",{type:"button","data-testid":"g7le-table-unmerge",onClick:ve,disabled:!Ne||_,style:wh,children:n("layout_editor.table_editor.unmerge_cells")})]}),O&&Q&&i.jsxs("div",{style:K8,"data-testid":"g7le-table-cell-i18n-section",children:[i.jsx("div",{style:V8,children:n("layout_editor.table_editor.cell_i18n_label")}),i.jsx(vt,{value:Q.value,onChange:ie,t:n,placeholder:n("layout_editor.table_editor.cell_text"),testidPrefix:"g7le-table-cell-i18n",candidates:s,enableExpressionTree:!0,expressionTreeCollapsible:!0})]}),O&&i.jsxs("div",{style:F8,"data-testid":"g7le-table-cell-color-section",children:[i.jsx(fh,{colorScheme:A,onChange:z,t:n,disabled:_}),y&&i.jsx("div",{style:cl,"data-testid":"g7le-table-cell-border-row",children:i.jsx(sh,{className:Le,catalog:y,t:n,onChange:te,colorScheme:A,onPresetToken:F,colorStyle:G,onColorStyle:I,disabled:_})}),i.jsx("div",{style:cl,"data-testid":"g7le-table-cell-fill-row",children:i.jsx(ch,{colorStyle:fe,className:K,catalog:g,t:n,colorScheme:A,onPresetToken:se,onCustomColor:re,onClear:U,disabled:_})})]}),O&&i.jsx("div",{style:cl,"data-testid":"g7le-table-cell-padding-row",children:i.jsx(uh,{paddingStyle:ne,catalog:m,t:n,onChange:ee,disabled:_})}),O&&!y&&i.jsxs("label",{style:U8,"data-testid":"g7le-table-cell-border-row",children:[i.jsx("span",{style:q8,children:n("layout_editor.table_editor.cell_border")}),i.jsx("input",{type:"text","data-testid":"g7le-table-cell-border-input",value:Le,placeholder:n("layout_editor.table_editor.cell_class_placeholder"),onChange:q=>te(q.target.value),disabled:_,style:G8})]}),!f.valid&&i.jsx("div",{"data-testid":"g7le-table-grid-warning",style:Y8,children:n("layout_editor.table_editor.grid_invalid")}),i.jsx("div",{style:X8,children:n("layout_editor.table_editor.select_hint")})]})}const E8={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},I8={fontSize:12,fontWeight:700,color:"#0f172a"},A8={borderCollapse:"collapse",width:"auto"},T8={width:28,background:"transparent",border:"none"},D8={padding:2,border:"none",background:"transparent"},N8={padding:2,border:"none",background:"transparent",verticalAlign:"middle"},z8={display:"flex",gap:2,justifyContent:"center"},M8={display:"grid",gridTemplateColumns:"repeat(2, 16px)",gap:2,justifyContent:"center"},vn={width:16,height:16,lineHeight:"14px",padding:0,fontSize:10,border:"1px solid #cbd5e1",borderRadius:4,background:"#fff",color:"#475569",cursor:"pointer"},_h={...vn,border:"1px solid #fecaca",color:"#dc2626"},$i={...vn,cursor:"not-allowed",opacity:.4,color:"#94a3b8",borderColor:"#e2e8f0"},L8={border:"1px solid #cbd5e1",padding:0,width:64,minWidth:64,maxWidth:120,height:24,background:"#fff",cursor:"pointer"},P8={background:"#f1f5f9",fontWeight:600},O8={outline:"2px solid #2563eb",outlineOffset:-2},B8={background:"#dbeafe"},W8={width:"100%",boxSizing:"border-box",padding:"2px 5px",fontSize:11,height:22,lineHeight:"18px",border:"none",background:"transparent",outline:"none"},xh={display:"block",padding:"2px 5px",fontSize:10,color:"#64748b",fontStyle:"italic",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:110},j8={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},H8={display:"flex",gap:6},wh={padding:"4px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},F8={display:"flex",flexDirection:"column",gap:8,borderTop:"1px solid #f1f5f9",paddingTop:8},K8={display:"flex",flexDirection:"column",gap:6,borderTop:"1px solid #f1f5f9",paddingTop:8},V8={fontSize:12,fontWeight:700,color:"#0f172a"},cl={display:"flex",flexDirection:"column",gap:6},U8={display:"flex",alignItems:"center",gap:6},q8={fontSize:12,color:"#475569",minWidth:60},G8={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},Y8={fontSize:11,color:"#b45309",background:"#fffbeb",border:"1px solid #fde68a",borderRadius:6,padding:"4px 6px"},X8={fontSize:11,color:"#94a3b8"},J8=/\{\{.*?\}\}/;function Z8(e,t,n){const r=(e.props??{})[t];return Array.isArray(r)?r:r===void 0&&n?JSON.parse(JSON.stringify(n)):[]}function Q8(e){return typeof e=="string"&&J8.test(e)}function ul(e,t,n){return e?e.startsWith("$t:")?t(e.slice(3)):e:n}function R8(e,t,n){return typeof e=="object"&&e!==null?e[t.key]:n?e:void 0}function ez(e,t,n,o){if(typeof e=="object"&&e!==null){const r={...e};return o===void 0?delete r[t.key]:r[t.key]=o,r}return n?o:{[t.key]:o}}function kh({node:e,params:t,t:n,onPatchNode:o,templateIdentifier:r,candidates:s}){const a=typeof t?.arrayProp=="string"?t.arrayProp:null,l=Array.isArray(t?.fields)?t.fields:[],d=ul(typeof t?.itemLabel=="string"?t.itemLabel:void 0,n,n("layout_editor.array_editor.item")),c=t?.newItem,p=c&&typeof c=="object"?c:null,f=typeof c=="string"||typeof c=="number"?c:null,y=(l.find(S=>S.primary)??l[0])?.key,g=(e.props??{})[a??""],m=Array.isArray(t?.defaultItems)?t.defaultItems:null,h=a?Z8(e,a,m):[],v=u.useCallback(S=>{if(!a)return;const E={...e.props??{}};E[a]=S,o({...e,props:E})},[a,e,o]),b=u.useCallback(()=>{if(p)return JSON.parse(JSON.stringify(p));if(f!==null)return f;const S={};for(const E of l)(E.widget??"text")==="boolean"?S[E.key]=!1:S[E.key]="";return S},[p,f,l]),x=u.useCallback(()=>{v([...h,b()])},[v,h,b]),k=u.useCallback(S=>{v(h.filter((E,C)=>C!==S))},[v,h]),w=u.useCallback((S,E)=>{const C=S+E;if(C<0||C>=h.length)return;const N=[...h];[N[S],N[C]]=[N[C],N[S]],v(N)},[v,h]),_=u.useCallback((S,E,C)=>{const N=E.key===y,$=h.map((A,z)=>z===S?ez(A,E,N,C):A);v($)},[h,v,y]);return a?Q8(g)||g!==void 0&&!Array.isArray(g)?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor-bound",style:dz,children:n("layout_editor.array_editor.bound_degraded")}):i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor",style:tz,children:[i.jsx("div",{style:nz,children:n("layout_editor.array_editor.items_title")}),h.length===0&&i.jsx("div",{"data-testid":"g7le-array-empty",style:Ch,children:n("layout_editor.array_editor.empty")}),h.map((S,E)=>i.jsxs("div",{"data-testid":`g7le-array-row-${E}`,style:oz,children:[i.jsxs("div",{style:rz,children:[i.jsxs("span",{style:iz,children:["#",E+1]}),i.jsx("div",{style:{flex:1}}),i.jsx("button",{type:"button","data-testid":`g7le-array-up-${E}`,title:n("layout_editor.array_editor.move_up"),disabled:E===0,onClick:()=>w(E,-1),style:Sh,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-array-down-${E}`,title:n("layout_editor.array_editor.move_down"),disabled:E===h.length-1,onClick:()=>w(E,1),style:Sh,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-array-remove-${E}`,title:n("layout_editor.array_editor.remove"),onClick:()=>k(E),style:az,children:"✕"})]}),l.map(C=>{const N=C.widget??"text",$=C.key===y,A=ul(C.label,n,C.key),z=R8(S,C,$),D=`g7le-array-field-${E}-${C.key}`;if(N==="boolean")return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsx("input",{type:"checkbox","data-testid":D,checked:z===!0,onChange:M=>_(E,C,M.target.checked)})]},C.key);if(N==="select"){const M=Array.isArray(C.options)?C.options:[];return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsxs("select",{"data-testid":D,value:z==null?"":String(z),onChange:B=>_(E,C,B.target.value===""?void 0:B.target.value),style:nr,children:[i.jsx("option",{value:"",children:"—"}),M.map((B,T)=>i.jsx("option",{value:String(B.value),children:ul(B.label,n,String(B.value))},T))]})]},C.key)}if(N==="i18n-text"){const M=typeof z=="string"?z:z==null?"":String(z);return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsx("div",{"data-testid":D,style:{flex:1,minWidth:0},children:i.jsx(vt,{value:M,onChange:B=>_(E,C,B===""?void 0:B),t:n,placeholder:A,testidPrefix:`${D}-i18n`,candidates:s,enableExpressionTree:!0,expressionTreeCollapsible:!0})})]},C.key)}if(N==="number"){const M=z==null?"":String(z);return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsx("input",{type:"number","data-testid":D,value:M,onChange:B=>{const T=B.target.value;_(E,C,T===""?void 0:Number(T))},style:nr})]},C.key)}if(N==="color"){const M=typeof z=="string"?z:"",B=/^#[0-9a-fA-F]{6}$/.test(M)?M:"#000000";return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsx("input",{type:"color","data-testid":`${D}-swatch`,value:B,onChange:T=>_(E,C,T.target.value),style:sz}),i.jsx("input",{type:"text","data-testid":D,value:M,placeholder:"#7C3AED",onChange:T=>_(E,C,T.target.value===""?void 0:T.target.value),style:nr})]},C.key)}if(N==="number-list"){const M=Array.isArray(z)?z.join(", "):"";return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsx("input",{type:"text","data-testid":D,value:M,placeholder:n("layout_editor.array_editor.number_list_placeholder"),onChange:B=>{const T=B.target.value.split(",").map(H=>H.trim()).filter(H=>H!=="").map(H=>Number(H)).filter(H=>!Number.isNaN(H));_(E,C,T)},style:nr})]},C.key)}const O=z==null?"":String(z);return i.jsxs("label",{style:Wn,children:[i.jsx("span",{style:jn,children:A}),i.jsx("input",{type:"text","data-testid":D,value:O,placeholder:N==="icon"?n("layout_editor.array_editor.icon_placeholder"):A,onChange:M=>_(E,C,M.target.value===""?void 0:M.target.value),style:nr})]},C.key)})]},E)),i.jsx("button",{type:"button","data-testid":"g7le-array-add",onClick:x,style:lz,children:n("layout_editor.array_editor.add",{item:d})})]}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array","data-testid":"g7le-array-editor-misconfigured",style:Ch,children:n("layout_editor.array_editor.no_array_prop")})}const tz={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},nz={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},oz={display:"flex",flexDirection:"column",gap:4,padding:8,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},rz={display:"flex",gap:4,alignItems:"center"},iz={fontSize:11,fontWeight:700,color:"#64748b"},Wn={display:"flex",flexWrap:"wrap",gap:6,alignItems:"center"},jn={fontSize:11,color:"#475569",minWidth:64,flexShrink:0},nr={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},sz={width:32,height:28,padding:0,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",cursor:"pointer",flexShrink:0},Sh={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},az={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},lz={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},Ch={fontSize:11,color:"#94a3b8",fontStyle:"italic"},dz={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"};function cz(e,t){return e?e.startsWith("$t:")?t(e.slice(3)):e:null}function uz(e){const{params:t,t:n}=e,o=Array.isArray(t?.groups)?t.groups:[];return o.length===0?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array-group","data-testid":"g7le-array-group-misconfigured",style:hz,children:n("layout_editor.array_editor.no_array_prop")}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--array-group","data-testid":"g7le-array-group-editor",style:pz,children:o.map((r,s)=>{const a=cz(r.title,n),{title:l,...d}=r;return i.jsxs("div",{"data-testid":`g7le-array-group-${r.arrayProp??s}`,style:fz,children:[a&&i.jsx("div",{style:gz,children:a}),i.jsx(kh,{...e,params:d})]},r.arrayProp??s)})})}const pz={display:"flex",flexDirection:"column",gap:16,width:"100%",marginBottom:12},fz={display:"flex",flexDirection:"column",gap:4},gz={fontSize:12,fontWeight:700,color:"#1e293b",borderBottom:"1px solid #e2e8f0",paddingBottom:4},hz={fontSize:11,color:"#94a3b8",fontStyle:"italic"},yz=/\{\{.*?\}\}/;function bz(e){return typeof e=="string"&&yz.test(e)}function mz(e,t,n){return e?e.startsWith("$t:")?t(e.slice(3)):e:n}function vz(e){const{node:t,params:n,t:o,onPatchNode:r}=e,s=typeof n?.arrayProp=="string"?n.arrayProp:null,a=typeof n?.idField=="string"?n.idField:"id",l=typeof n?.cellChildrenProp=="string"?n.cellChildrenProp:"cellChildren",d=typeof n?.cellChildComponent=="string"?n.cellChildComponent:"Div",c=mz(typeof n?.itemLabel=="string"?n.itemLabel:void 0,o,o("layout_editor.array_editor.item")),[p,f]=u.useState(null),y=s?(t.props??{})[s]:void 0,g=Array.isArray(y)?y:[],m=u.useCallback(S=>{if(!s)return;const E={...t.props??{}};E[s]=S,r({...t,props:E})},[s,t,r]),h=u.useCallback(()=>{const S=n?.newItem;return S&&typeof S=="object"?JSON.parse(JSON.stringify(S)):{[a]:"",[l]:[]}},[n,a,l]),v=u.useCallback(()=>{m([...g,h()])},[m,g,h]),b=u.useCallback(S=>{m(g.filter((E,C)=>C!==S)),f(null)},[m,g]),x=u.useCallback((S,E)=>{const C=S+E;if(C<0||C>=g.length)return;const N=[...g];[N[S],N[C]]=[N[C],N[S]],m(N)},[m,g]),k=u.useCallback((S,E)=>{const C=g.map((N,$)=>$===S?{...N,[a]:E}:N);m(C)},[g,m,a]),w=u.useCallback(S=>({type:"composite",name:"CardGridCell",children:Array.isArray(S[l])?S[l]:[]}),[l]),_=u.useCallback(S=>E=>{const C=Array.isArray(E.children)?E.children:[],N=g.map(($,A)=>A===S?{...$,[l]:C}:$);m(N)},[g,m,l]);return s?bz(y)||y!==void 0&&!Array.isArray(y)?i.jsx("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-bound",style:Tz,children:o("layout_editor.array_editor.bound_degraded")}):i.jsxs("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-editor",style:_z,children:[i.jsx("div",{style:xz,children:o("layout_editor.cell_tree_editor.columns_title")}),g.length===0&&i.jsx("div",{"data-testid":"g7le-cell-tree-empty",style:Eh,children:o("layout_editor.array_editor.empty")}),g.map((S,E)=>{const C=typeof S[a]=="string"?S[a]:"",N=p===E;return i.jsxs("div",{"data-testid":`g7le-cell-tree-row-${E}`,style:wz,children:[i.jsxs("div",{style:kz,children:[i.jsxs("span",{style:Sz,children:["#",E+1]}),i.jsx("input",{type:"text","data-testid":`g7le-cell-tree-id-${E}`,value:C,placeholder:o("layout_editor.cell_tree_editor.id_placeholder"),onChange:$=>k(E,$.target.value),style:Cz}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-up-${E}`,title:o("layout_editor.array_editor.move_up"),disabled:E===0,onClick:()=>x(E,-1),style:$h,children:"↑"}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-down-${E}`,title:o("layout_editor.array_editor.move_down"),disabled:E===g.length-1,onClick:()=>x(E,1),style:$h,children:"↓"}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-remove-${E}`,title:o("layout_editor.array_editor.remove"),onClick:()=>b(E),style:$z,children:"✕"})]}),i.jsx("button",{type:"button","data-testid":`g7le-cell-tree-toggle-${E}`,onClick:()=>f(N?null:E),style:Ez,children:o(N?"layout_editor.cell_tree_editor.collapse_cells":"layout_editor.cell_tree_editor.expand_cells")}),N&&i.jsx("div",{"data-testid":`g7le-cell-tree-cells-${E}`,style:Iz,children:i.jsx(Kg,{...e,node:w(S),params:{childComponent:d},onPatchNode:_(E)})})]},E)}),i.jsx("button",{type:"button","data-testid":"g7le-cell-tree-add",onClick:v,style:Az,children:o("layout_editor.cell_tree_editor.add_column",{item:c})})]}):i.jsx("div",{className:"g7le-node-editor g7le-node-editor--cell-tree","data-testid":"g7le-cell-tree-misconfigured",style:Eh,children:o("layout_editor.array_editor.no_array_prop")})}const _z={display:"flex",flexDirection:"column",gap:8,width:"100%",marginBottom:12},xz={fontSize:12,fontWeight:700,color:"#0f172a",marginBottom:2},wz={display:"flex",flexDirection:"column",gap:6,padding:8,border:"1px solid #e2e8f0",borderRadius:8,background:"#f8fafc"},kz={display:"flex",gap:4,alignItems:"center"},Sz={fontSize:11,fontWeight:700,color:"#64748b",flexShrink:0},Cz={flex:1,minWidth:0,padding:"4px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6},$h={padding:"2px 6px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"},$z={padding:"2px 6px",fontSize:12,border:"1px solid #fecaca",borderRadius:6,background:"#fff",color:"#dc2626",cursor:"pointer"},Ez={padding:"3px 8px",fontSize:11,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#2563eb",cursor:"pointer",alignSelf:"flex-start"},Iz={paddingLeft:8,borderLeft:"2px solid #e2e8f0"},Az={padding:"4px 8px",fontSize:12,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",alignSelf:"flex-start"},Eh={fontSize:11,color:"#94a3b8",fontStyle:"italic"},Tz={fontSize:11,color:"#b45309",fontStyle:"italic",padding:"4px 2px"};function Dz(e,t,n){const o=Be(e,t);if(!n||n.length===0)return{measured:[],grid:o};const r=[];for(const s of o.rows)for(const a of s.cells){const d=tl(e,a,t).map(p=>`children.${p}`).join("."),c=n.find(p=>p.path===d||p.path.endsWith("."+d));c&&r.push({ref:a,box:{top:c.top,left:c.left,width:c.width,height:c.height}})}return{measured:r,grid:o}}const pl=26,fl=50,gl=6,Ih=44,Ah=18,Th="g7le-table-inplace-empty-cell-style";function Nz(){if(typeof document>"u"||document.getElementById(Th))return;const e=document.createElement("style");e.id=Th,e.textContent=".g7le-preview-frame td[data-editor-name],.g7le-preview-frame th[data-editor-name]{min-width:56px;height:38px;}.g7le-preview-frame table[data-editor-name]{min-width:120px;}",document.head.appendChild(e)}function zz({node:e,params:t,nodeBox:n,cellBoxes:o,selectedCellPath:r,colorScheme:s,t:a,onPatchNode:l,onRequestInlineEdit:d}){const c=u.useMemo(()=>t&&typeof t=="object"?t:null,[t]),{measured:p,grid:f}=u.useMemo(()=>Dz(e,c,o),[e,c,o]),[y,g]=u.useState(null),[m,h]=u.useState(null),v=u.useMemo(()=>{if(!r)return null;for(const j of f.rows)for(const oe of j.cells){const ue=tl(e,oe,c).map(J=>`children.${J}`).join(".");if(r===ue||r.startsWith(ue+"."))return{r:oe.gridRow,c:oe.gridCol}}return null},[r,f,e,c]),b=u.useMemo(()=>y&&Ue(f,y.r,y.c)?y:v,[y,v,f]),[x,k]=u.useState(!1),[w,_]=u.useState(s==="dark"?"dark":"light"),[S,E]=u.useState(null),[C,N]=u.useState(null),$=u.useRef(null),A=u.useCallback(()=>{$.current!==null&&(clearTimeout($.current),$.current=null)},[]),z=u.useCallback(()=>{A(),$.current=setTimeout(()=>{E(null),N(null),$.current=null},240)},[A]);u.useEffect(()=>()=>A(),[A]),u.useEffect(()=>{Nz()},[]);const D=u.useCallback(j=>l(j),[l]),O=u.useMemo(()=>b?Ue(f,b.r,b.c):null,[f,b]),M=u.useMemo(()=>O&&typeof O.cell.props?.className=="string"?O.cell.props.className:"",[O]),B=u.useCallback(j=>D(nl(e,c,j)),[e,c,D]),T=u.useCallback(j=>D(Jg(e,c,j)),[e,c,D]),H=u.useCallback((j,oe)=>D(Zg(e,c,j,oe)),[e,c,D]),L=u.useCallback(j=>D(Rg(e,c,j)),[e,c,D]),I=u.useCallback(j=>D(eh(e,c,j)),[e,c,D]),P=u.useCallback((j,oe)=>D(Qg(e,c,j,oe)),[e,c,D]),F=u.useCallback(j=>{if(!O)return;const oe=O.gridRow,ue=O.gridCol,J=j==="down"?oe+O.rowSpan:oe+O.rowSpan-1,le=j==="right"?ue+O.colSpan:ue+O.colSpan-1;if(J>=f.rows.length||le>=f.colCount)return;const Ie=ol(e,c,oe,ue,J,le);Ie.ok&&D(Ie.table)},[O,f,e,c,D]),G=!!(O&&O.gridCol+O.colSpan!b||!m||!Ue(f,m.r,m.c)?null:{r0:Math.min(b.r,m.r),c0:Math.min(b.c,m.c),r1:Math.max(b.r,m.r),c1:Math.max(b.c,m.c)},[b,m,f]),Z=u.useMemo(()=>{if(!Y)return[];const j=new Set,oe=[];for(const ue of f.rows)for(const J of ue.cells)if(J.gridRow>=Y.r0&&J.gridRow<=Y.r1&&J.gridCol>=Y.c0&&J.gridCol<=Y.c1){const le=`${J.gridRow}:${J.gridCol}`;j.has(le)||(j.add(le),oe.push({r:J.gridRow,c:J.gridCol}))}return oe},[Y,f]),se=Z.length>1,re=u.useCallback(()=>{if(!Y)return;const j=ol(e,c,Y.r0,Y.c0,Y.r1,Y.c1);j.ok&&(D(j.table),h(null))},[Y,e,c,D]),U=u.useCallback(()=>{b&&D(th(e,c,b.r,b.c))},[e,c,b,D]),ee=u.useMemo(()=>{const j=c?.cellBorder;return j&&typeof j=="object"?j:null},[c]),fe=u.useMemo(()=>{const j=c?.cellBackground;return j&&typeof j=="object"?j:null},[c]),K=u.useMemo(()=>{const j=c?.cellPadding;return j&&typeof j=="object"?j:null},[c]),ne=u.useMemo(()=>{if(x){const j=new Set,oe=[];for(const ue of f.rows)for(const J of ue.cells){const le=`${J.gridRow}:${J.gridCol}`;j.has(le)||(j.add(le),oe.push({r:J.gridRow,c:J.gridCol}))}return oe}return se?Z:b?[{r:b.r,c:b.c}]:[]},[x,se,Z,b,f]),me=u.useCallback(j=>{if(ne.length===0)return;let oe=e;for(const ue of ne){const J=(ee?.colors??[]).map(Ie=>Ie.token),le=j.split(/\s+/).filter(Ie=>Ie&&!J.includes(Ie)).join(" ");oe=Ci(oe,c,ue.r,ue.c,le)}D(oe)},[e,c,ne,ee,D]),Ce=u.useMemo(()=>{const j=(ee?.sides??[]).find(J=>J.value==="all")??(ee?.sides??[])[0],oe=(ee?.widths??[]).find(J=>J.value!=="none");if(!j||!oe)return[];const ue=oe.suffix??"";return[ue?`${j.prefix}${ue}`:j.prefix]},[ee]),ze=u.useCallback((j,oe,ue)=>{if(Ce.length===0)return j;const J=(ee?.sides??[]).map(Oe=>Oe.prefix),le=Ue(Be(j,c),oe,ue),dt=(typeof le?.cell?.props?.className=="string"?le.cell.props.className:"").split(/\s+/).filter(Boolean);if(dt.some(Oe=>J.some(He=>Oe===He||Oe.startsWith(He+"-")&&/^-?\d+$/.test(Oe.slice(He.length+1)))))return j;const ot=[...dt,...Ce].join(" ").trim();return Ci(j,c,oe,ue,ot)},[Ce,ee,c]),Se=u.useCallback((j,oe)=>{if(j.length===0)return;const ue=new Set(j.map(le=>`${le.r}:${le.c}`));let J=e;for(const le of j){if(J=ze(J,le.r,le.c),J=Jt(J,c,le.r,le.c,"borderColor",oe),le.r>0&&!ue.has(`${le.r-1}:${le.c}`)){const Ie=Ue(Be(J,c),le.r-1,le.c);Ie&&(J=Jt(J,c,Ie.gridRow,Ie.gridCol,"borderBottomColor",oe))}if(le.c>0&&!ue.has(`${le.r}:${le.c-1}`)){const Ie=Ue(Be(J,c),le.r,le.c-1);Ie&&(J=Jt(J,c,Ie.gridRow,Ie.gridCol,"borderRightColor",oe))}}D(J)},[e,c,D,ze]),qe=u.useMemo(()=>(ee?.colors??[]).map(j=>j.token).filter(Boolean),[ee]),De=u.useCallback(j=>{if(ne.length===0||qe.length===0)return;let oe=e;for(const ue of ne)oe=ze(oe,ue.r,ue.c),oe=go(oe,c,ue.r,ue.c,j,qe,w);D(oe)},[ne,e,c,D,ze,qe,w]),Pe=u.useCallback(j=>Se(ne,j),[Se,ne]),Qe=u.useMemo(()=>{if(w==="dark")return"";const j=O?.cell.props?.style;return typeof j?.borderColor=="string"?j.borderColor:""},[O,w]),Me=u.useCallback((j,oe)=>{if(ne.length===0)return;let ue=e;for(const J of ne)ue=Jt(ue,c,J.r,J.c,j,oe);D(ue)},[ne,e,c,D]),Re=u.useMemo(()=>(fe?.colors??[]).map(j=>j.token).filter(j=>!!j),[fe]),ve=u.useCallback(j=>{if(ne.length===0||Re.length===0)return;let oe=e;for(const ue of ne)oe=go(oe,c,ue.r,ue.c,j,Re,w);D(oe)},[ne,e,c,D,Re,w]),te=u.useCallback(j=>Me("backgroundColor",j),[Me]),he=u.useCallback(()=>{if(ne.length===0)return;let j=e;for(const oe of ne)j=go(j,c,oe.r,oe.c,void 0,Re,w),w==="light"&&(j=Jt(j,c,oe.r,oe.c,"backgroundColor",void 0));D(j)},[ne,e,c,D,Re,w]),Q=u.useCallback(j=>Me("padding",j),[Me]),ie=u.useMemo(()=>{if(w==="dark")return"";const j=O?.cell.props?.style;return typeof j?.backgroundColor=="string"?j.backgroundColor:""},[O,w]),_e=u.useMemo(()=>typeof O?.cell.props?.className=="string"?O.cell.props.className:"",[O]),ge=u.useMemo(()=>{const j=O?.cell.props?.style;return typeof j?.padding=="string"?j.padding:""},[O]),Ae=u.useCallback(j=>{const oe=j.currentTarget,ue=oe.style.pointerEvents;oe.style.pointerEvents="none";const J=document.elementFromPoint(j.clientX,j.clientY);oe.style.pointerEvents=ue;const le=J?.closest?.("[data-dnd-handle-path]");le&&le.dispatchEvent(new PointerEvent("pointerdown",{bubbles:!0,cancelable:!0,clientX:j.clientX,clientY:j.clientY,pointerId:j.pointerId,pointerType:j.pointerType,button:j.button,isPrimary:!0}))},[]),Ne=u.useMemo(()=>{const j=new Map;for(const oe of p)j.has(oe.ref.gridCol)||j.set(oe.ref.gridCol,oe.box);return j},[p]),Le=u.useMemo(()=>{const j=new Map;for(const oe of p)j.has(oe.ref.gridRow)||j.set(oe.ref.gridRow,oe.box);return j},[p]);if(p.length===0)return null;const q=!!(O&&(O.colSpan>1||O.rowSpan>1)),ae=C!==null?C:b?.c??null,pe=S!==null?S:b?.r??null,$e=n.top-pl-gl-Ih,be=n.left-fl-gl-Ih;return i.jsxs("div",{className:"g7le-table-inplace","data-testid":"g7le-table-inplace",style:{position:"absolute",inset:0,pointerEvents:"none",zIndex:c7},children:[p.map(j=>{const oe=b&&b.r===j.ref.gridRow&&b.c===j.ref.gridCol,ue=Y&&j.ref.gridRow>=Y.r0&&j.ref.gridRow<=Y.r1&&j.ref.gridCol>=Y.c0&&j.ref.gridCol<=Y.c1;return i.jsx("div",{"data-testid":`g7le-inplace-cell-${j.ref.gridRow}-${j.ref.gridCol}`,role:"button",tabIndex:-1,"aria-label":a("layout_editor.table_inplace.select_cell"),onMouseEnter:()=>{A(),E(j.ref.gridRow),N(j.ref.gridCol)},onMouseLeave:()=>{z()},onPointerDown:J=>Ae(J),onClick:J=>{if(J.stopPropagation(),J.shiftKey&&(y||b))h({r:j.ref.gridRow,c:j.ref.gridCol});else{g({r:j.ref.gridRow,c:j.ref.gridCol}),h(null);const le=tl(e,j.ref,c).map(Ie=>`children.${Ie}`).join(".");d?.(le)}},style:{position:"absolute",top:j.box.top,left:j.box.left,width:j.box.width,height:j.box.height,minWidth:Ah,minHeight:Ah,boxSizing:"border-box",pointerEvents:"auto",cursor:"pointer",outline:oe?"2px solid #2563eb":"none",outlineOffset:-2,background:ue&&!oe?"rgba(37,99,235,0.14)":"transparent"}},`cell-${j.ref.gridRow}-${j.ref.gridCol}`)}),i.jsx("div",{"data-testid":"g7le-inplace-col-rail",onMouseEnter:A,onMouseLeave:z,style:{position:"absolute",top:$e,left:n.left,width:n.width,height:pl,pointerEvents:"none"},children:ae!==null&&Ne.has(ae)&&(()=>{const j=ae,oe=Ne.get(j),ue=wi(f,j,-1),J=wi(f,j,1),le=oe.left-n.left;return i.jsxs("div",{"data-testid":`g7le-inplace-colgutter-${j}`,onMouseEnter:()=>{A(),N(j)},onMouseLeave:z,style:{position:"absolute",top:0,left:le,width:oe.width,minWidth:96,height:pl,display:"flex",gap:3,justifyContent:"center",alignItems:"center",pointerEvents:"auto"},children:[i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_col_left"),disabled:!ue.ok,onClick:()=>P(j,-1),"data-testid":`g7le-inplace-col-left-${j}`,style:ue.ok?_n:Ei,children:"←"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.add_col"),onClick:()=>L(j),"data-testid":`g7le-inplace-col-add-${j}`,style:_n,children:"+"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.remove_col"),disabled:f.colCount<=1,onClick:()=>I(j),"data-testid":`g7le-inplace-col-remove-${j}`,style:Dh,children:"✕"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_col_right"),disabled:!J.ok,onClick:()=>P(j,1),"data-testid":`g7le-inplace-col-right-${j}`,style:J.ok?_n:Ei,children:"→"})]},`colgut-${j}`)})()}),i.jsx("div",{"data-testid":"g7le-inplace-row-rail",style:{position:"absolute",top:n.top,left:be,width:fl,height:n.height,pointerEvents:"none"},children:pe!==null&&Le.has(pe)&&(()=>{const j=pe,oe=Le.get(j),ue=xi(f,j,-1),J=xi(f,j,1),le=oe.top-n.top;return i.jsxs("div",{"data-testid":`g7le-inplace-rowgutter-${j}`,onMouseEnter:()=>{A(),E(j)},onMouseLeave:z,style:{position:"absolute",top:le,left:0,width:fl,height:oe.height,minHeight:48,display:"grid",gridTemplateColumns:"repeat(2, 22px)",gridAutoRows:"22px",gap:3,justifyContent:"center",alignContent:"center",pointerEvents:"auto"},children:[i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_row_up"),disabled:!ue.ok,onClick:()=>H(j,-1),"data-testid":`g7le-inplace-row-up-${j}`,style:ue.ok?_n:Ei,children:"↑"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.add_row"),onClick:()=>B(j),"data-testid":`g7le-inplace-row-add-${j}`,style:_n,children:"+"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.remove_row"),disabled:f.rows.length<=1,onClick:()=>T(j),"data-testid":`g7le-inplace-row-remove-${j}`,style:Dh,children:"✕"}),i.jsx("button",{type:"button",title:a("layout_editor.table_inplace.move_row_down"),disabled:!J.ok,onClick:()=>H(j,1),"data-testid":`g7le-inplace-row-down-${j}`,style:J.ok?_n:Ei,children:"↓"})]},`rowgut-${j}`)})()}),i.jsxs("div",{"data-testid":"g7le-inplace-corner-add",style:{position:"absolute",top:n.top+n.height+gl,left:n.left,display:"flex",flexDirection:"column",gap:6,pointerEvents:"auto",minWidth:200,maxWidth:Math.max(240,n.width),background:"#fff",border:"1px solid #e2e8f0",borderRadius:8,padding:8,boxShadow:"0 2px 8px rgba(0,0,0,0.12)"},children:[i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap"},children:[i.jsxs("button",{type:"button","data-testid":"g7le-inplace-add-row-bottom",onClick:()=>B(f.rows.length-1),style:Nh,children:["+ ",a("layout_editor.table_inplace.add_row")]}),i.jsxs("button",{type:"button","data-testid":"g7le-inplace-add-col-right",onClick:()=>L(f.colCount-1),style:Nh,children:["+ ",a("layout_editor.table_inplace.add_col")]})]}),!b&&i.jsx("div",{"data-testid":"g7le-inplace-select-hint",style:{fontSize:11,color:"#94a3b8",borderTop:"1px solid #f1f5f9",paddingTop:6},children:a("layout_editor.table_inplace.select_cell_hint")}),b&&i.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6,borderTop:"1px solid #f1f5f9",paddingTop:6},children:[se&&i.jsx("div",{"data-testid":"g7le-inplace-range-count",style:{fontSize:11,color:"#2563eb"},children:a("layout_editor.table_inplace.range_selected",{count:Z.length})}),i.jsxs("div",{style:{display:"flex",gap:6,flexWrap:"wrap",alignItems:"center"},children:[se?i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-range",onClick:re,style:or,children:a("layout_editor.table_inplace.merge_range")}):i.jsxs(i.Fragment,{children:[i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-right",disabled:!G,onClick:()=>F("right"),style:G?or:hl,children:a("layout_editor.table_inplace.merge_right")}),i.jsx("button",{type:"button","data-testid":"g7le-inplace-merge-down",disabled:!V,onClick:()=>F("down"),style:V?or:hl,children:a("layout_editor.table_inplace.merge_down")})]}),i.jsx("button",{type:"button","data-testid":"g7le-inplace-unmerge",disabled:!q,onClick:U,style:q?or:hl,children:a("layout_editor.table_inplace.unmerge")})]}),i.jsxs("div",{"data-testid":"g7le-inplace-cell-color",style:{borderTop:"1px solid #f1f5f9",paddingTop:6,display:"flex",flexDirection:"column",gap:6},children:[i.jsxs("label",{style:{display:"flex",alignItems:"center",gap:6,fontSize:11,color:"#475569",cursor:"pointer"},children:[i.jsx("input",{type:"checkbox","data-testid":"g7le-inplace-border-all-toggle",checked:x,onChange:j=>k(j.target.checked)}),a("layout_editor.table_inplace.border_apply_all")]}),i.jsx(fh,{colorScheme:w,onChange:_,t:a}),ee&&i.jsx("div",{"data-testid":"g7le-inplace-cell-border",children:i.jsx(sh,{className:x?"":M,catalog:ee,t:a,onChange:me,colorScheme:w,onPresetToken:De,colorStyle:x?"":Qe,onColorStyle:Pe})}),i.jsx("div",{"data-testid":"g7le-inplace-cell-fill",children:i.jsx(ch,{colorStyle:x?"":ie,className:x?"":_e,catalog:fe,t:a,colorScheme:w,onPresetToken:ve,onCustomColor:te,onClear:he})})]}),i.jsx("div",{"data-testid":"g7le-inplace-cell-padding",style:{borderTop:"1px solid #f1f5f9",paddingTop:6},children:i.jsx(uh,{paddingStyle:x?"":ge,catalog:K,t:a,onChange:Q})})]})]})]})}const _n={width:22,height:22,lineHeight:"20px",padding:0,fontSize:13,border:"1px solid #cbd5e1",borderRadius:5,background:"#fff",color:"#475569",cursor:"pointer",boxShadow:"0 1px 3px rgba(0,0,0,0.18)"},Dh={..._n,border:"1px solid #fecaca",color:"#dc2626"},Ei={..._n,cursor:"not-allowed",opacity:.4,color:"#94a3b8",borderColor:"#e2e8f0"},or={padding:"4px 10px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer",boxShadow:"0 1px 2px rgba(0,0,0,0.12)"},hl={...or,cursor:"not-allowed",opacity:.4},Nh={padding:"3px 8px",fontSize:11,border:"1px dashed #94a3b8",borderRadius:6,background:"#fff",color:"#475569",cursor:"pointer"};let zh=!1;function Mz(){zh||(Pn("children",Kg),Pn("table",$8),Pn("array",kh),Pn("array-group",uz),Pn("array-cell-tree",vz),Oa("table",zz),zh=!0)}function Lz(){if(typeof window>"u")return;const e=window.G7Core;if(!e)return;const t=e.layoutEditor,n=Array.isArray(t?.__queue)?t.__queue:[],o=Array.isArray(t?.__readyCallbacks)?t.__readyCallbacks:[];e.layoutEditor={registerWidget:Xe,registerNodeEditor:Pn,registerCanvasOverlay:Oa,onReady:r=>{typeof r=="function"&&r()}};for(const r of n){const[s,...a]=r;try{s==="widget"?Xe(a[0],a[1]):s==="nodeEditor"?Pn(a[0],a[1]):s==="canvasOverlay"&&Oa(a[0],a[1])}catch{}}for(const r of o)try{r()}catch{}}const Pz=1280;FN(),Mz(),Lz();async function Oz(e){if(typeof fetch!="function")return[];try{const t=await fetch(en(`/api/admin/templates/${encodeURIComponent(e)}/editor/permission-candidates`,"json"),{credentials:"same-origin",headers:je()});if(!t.ok)return[];const o=(await t.json().catch(()=>null))?.data?.permissions;if(!Array.isArray(o))return[];const r=[];for(const s of o)if(s&&typeof s=="object"){const a=s.key,l=s.name;typeof a=="string"&&a.length>0&&r.push({value:a,label:typeof l=="string"&&l?l:a})}return r}catch{return[]}}function Bz({templateIdentifier:e}){cD({templateIdentifier:e}),uD(e);const{state:t,dispatch:n}=Je(),{t:o}=tt(),r=yo(),s=wp(),a=bD(),l=t.selectedRoute?.layoutName??null,d=xD(e,l),c=t.editMode==="extension",p=u.useMemo(()=>c?vD(a):s,[c,a,s]),[f,y]=u.useState(null),[g,m]=u.useState(null),[h,v]=u.useState([]),[b,x]=u.useState("user"),[k,w]=u.useState(null),[_,S]=u.useState(null),[E,C]=u.useState([]);u.useEffect(()=>{let te=!1;return(async()=>{try{const he=await fetch(en(`/api/templates/${encodeURIComponent(e)}/components`,"json"),{credentials:"same-origin",headers:{Accept:"application/json"}});if(!he.ok)return;const Q=await he.json().catch(()=>null);!te&&Q&&typeof Q=="object"&&y(Q)}catch{}})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{const{spec:he,sampleGlobalSources:Q}=await Zy({templateIdentifier:e,coreSeed:wD()});te||(m(he),v(Q),jz(e,he,Q))})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{if(typeof fetch=="function")try{const he=await fetch(`/api/admin/templates/${encodeURIComponent(e)}`,{credentials:"same-origin",headers:je()});if(!he.ok)return;const ie=(await he.json().catch(()=>null))?.data,_e=ie?.type;te||((_e==="admin"||_e==="user")&&x(_e),typeof ie?.name=="string"&&ie.name&&w(ie.name),typeof ie?.version=="string"&&ie.version&&S(ie.version))}catch{}})(),()=>{te=!0}},[e]),u.useEffect(()=>{let te=!1;return(async()=>{if(typeof fetch=="function")try{const he=await fetch("/api/admin/templates/?per_page=100&page=1",{credentials:"same-origin",headers:je()});if(!he.ok)return;const ie=(await he.json().catch(()=>null))?.data?.data;if(!te&&Array.isArray(ie)){const _e=[];for(const ge of ie){if(!ge||typeof ge!="object")continue;const Ae=ge.identifier;if(typeof Ae!="string"||!Ae||ge.is_pending===!0)continue;const Le=ge.type==="admin"?"admin":"user",q=ge.name,ae=ge.version;_e.push({identifier:Ae,name:typeof q=="string"?q:Ae,version:typeof ae=="string"?ae:"",type:Le})}C(_e)}}catch{}})(),()=>{te=!0}},[e]);const N=g?.nesting??null,$=t.selectedRoute?.path??null,A=t.editMode,z=t.selectedRoute?.layoutName??(c?a.document?.hostLayoutName??null:null),D=t.routeTree,O=u.useMemo(()=>{if(A!=="extension"||!z)return null;const te=bl(D,z);return te||`__base__/${z}`},[A,z,D]),M=c&&t.returnRoute===null&&!!z;u.useEffect(()=>{if(!M||!z)return;const te=bl(D,z);te&&n({type:"SET_RETURN_ROUTE",route:{path:te,layoutName:z}})},[M,z,D,n]),u.useEffect(()=>{const te=K4(A,$,O),he=V4(g?.states?.groups,te);n({type:"SET_AVAILABLE_STATES",states:he,activeStateId:U4(he)})},[g,A,$,O,n]);const[B,T]=u.useState([]);u.useEffect(()=>{let te=!1;return(async()=>{const he=await Oz(e);te||T(he)})(),()=>{te=!0}},[e]),pD(),fD();const H=u.useCallback(te=>{const he=t.routeTree,Q=te.split("?")[0]?.split("#")[0]??te;return Wz(he,Q)?"route_in_tree":"route_not_in_tree"},[t.routeTree]),L=u.useCallback(te=>{const he=te.split("?")[0]?.split("#")[0]??te,Q=yl(t.routeTree,he);Q&&(p.isDirty&&!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.dirty_guard.message")))||n({type:"SELECT_ROUTE",route:{path:Q.path,layoutName:Q.layoutName}}))},[t.routeTree,p.isDirty,n]),I=u.useRef([]),P=u.useCallback(te=>{I.current=te},[]),[F,G]=u.useState(null),V=u.useCallback(()=>G(null),[]),Y=u.useRef(null),Z=u.useRef(null);u.useEffect(()=>{const te=Z.current,he=Y.current;if(!te||!he||typeof ResizeObserver>"u")return;const Q=()=>{he.style.setProperty("--g7le-header-h",`${Math.round(te.getBoundingClientRect().height)}px`)};Q();const ie=new ResizeObserver(Q);return ie.observe(te),()=>ie.disconnect()},[]);const se=u.useCallback(()=>{p.reload()},[p]),re=u.useCallback(()=>{p.isDirty&&!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.dirty_guard.message")))||window.G7Core?.dispatch?.({handler:"openWindow",params:{path:`/admin/templates/${b}`,target:"_self"}})},[p.isDirty,o,b]),U=u.useCallback(te=>{if(!te||te===e||p.isDirty&&!(typeof window<"u"?window.confirm(o("layout_editor.chrome.dirty_guard.message")):!0))return;const he=Zs(te);window.G7Core?.dispatch?.({handler:"openWindow",params:{path:he,target:"_self"}})},[e,p.isDirty,o]),ee=u.useCallback(()=>{const te=TE(e,t.selectedRoute?.path??null);window.G7Core?.dispatch?.({handler:"openWindow",params:{path:te}})},[e,t.selectedRoute]),fe=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"";if(!te)return;const he=r.open({ariaLabel:o("layout_editor.attachment_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(Ml,{templateIdentifier:e,layoutName:te,t:o,onClose:()=>r.close(he)})})},[r,e,t.selectedRoute,o]),K=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"";if(!te)return;const he=By(s.document?.raw??{}),Q=r.open({ariaLabel:o("layout_editor.translation_manager.title"),width:720,maxHeightRatio:.82,content:u.createElement(sm,{templateIdentifier:e,layoutName:te,t:o,referencedKeys:he,onClose:()=>r.close(Q)})})},[r,e,t.selectedRoute,o,s.document]),ne=u.useCallback((te,he)=>{const Q=window.G7Core;if(Q&&typeof Q.t=="function"){const ie=Q.t(te,he);if(ie&&ie!==te&&!ie.startsWith("$t:"))return ie}return o(te,he)},[o]),me=u.useCallback(()=>{if(!t.selectedRoute||t.editMode==="extension"||t.editMode==="iteration_item")return;const te=r.open({ariaLabel:o("layout_editor.page_settings.title",{name:t.selectedRoute.path??""}),width:1080,maxHeightRatio:.88,closeOnEscape:!1,content:u.createElement(l$,{templateIdentifier:e,spec:g,t:o,resolveLabel:ne,permissionCandidates:B,onClose:()=>r.close(te)})})},[r,t.selectedRoute,t.editMode,o,ne,e,g,B]),Ce=u.useCallback(async()=>{const te=await d.createPreview(s.document?.raw??null);if(te.kind!=="success"){const he=te.kind==="network_error"?te.message:o("layout_editor.preview_action.no_document");typeof window<"u"&&window.alert(he)}},[d,s.document,o]),ze=u.useCallback(()=>{const te=t.selectedRoute?.layoutName??"",he=t.selectedRoute?.path??"",Q=t.editMode==="extension"&&he.startsWith("__extension__/")?he.slice(14):null,ie=Q?{kind:"extension",extensionId:Q}:te?{kind:"layout",layoutName:te}:null;if(!ie)return;const _e=r.open({ariaLabel:o("layout_editor.version_history.title"),width:640,maxHeightRatio:.82,content:u.createElement(q$,{templateIdentifier:e,target:ie,t:o,onRestored:async ge=>{typeof ge=="number"&&(ie.kind==="extension"?n({type:"SET_EXTENSION_VERSION",extensionId:ie.extensionId,version:ge}):n({type:"SET_LAYOUT_VERSION",layoutName:ie.layoutName,version:ge})),await p.reload()},onClose:()=>r.close(_e)})})},[r,e,t.selectedRoute,t.editMode,o,p,n]),Se=p.dirtyKeys.size>0||p.isDirty;u.useEffect(()=>{if(!Se)return;const te=he=>{he.preventDefault(),he.returnValue=""};return window.addEventListener("beforeunload",te),()=>window.removeEventListener("beforeunload",te)},[Se]);const qe=u.useCallback(()=>{!p.isDirty||!(!(typeof window<"u")||window.confirm(o("layout_editor.chrome.reset_guard.message")))||p.reload()},[p,o]),De=u.useCallback(async()=>{const te=await p.save({sessionAddedPaths:I.current});G(te),te.kind==="success"&&typeof te.newContentVersion=="number"&&(te.savedExtensionId?n({type:"SET_EXTENSION_VERSION",extensionId:te.savedExtensionId,version:te.newContentVersion}):te.savedLayoutName&&n({type:"SET_LAYOUT_VERSION",layoutName:te.savedLayoutName,version:te.newContentVersion}))},[p,n]),Pe=u.useCallback(()=>{const te=r.open({ariaLabel:o("layout_editor.shortcuts.title"),width:560,maxHeightRatio:.85,content:u.createElement(tE,{t:o,onClose:()=>r.close(te)})})},[r,o]),Qe=u.useMemo(()=>({save:()=>{De()},exit:re,editCode:ee,reset:qe,translations:K,addElement:()=>{t.selectedRoute&&n({type:"TOGGLE_PALETTE"})},help:Pe,preview:()=>{t.selectedRoute?.layoutName&&Ce()}}),[De,re,ee,qe,K,t.selectedRoute,n,Pe,Ce]),[Me,Re]=u.useState(!1);u.useEffect(()=>{const te=window.setInterval(()=>{Re(!!window.__g7LayoutEditorHasSelection)},200);return()=>window.clearInterval(te)},[]),Ru({handlers:Qe,hasSelection:Me});const ve=p;return t.routesError?i.jsx("div",{className:"g7le-chrome g7le-chrome--routes-error","data-testid":"g7le-chrome-routes-error",style:{position:"fixed",inset:0,display:"flex",background:"#f8fafc",color:"#0f172a",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif',fontSize:13,lineHeight:1.5,zIndex:9e3},children:i.jsx(Fa,{error:t.routesError})}):i.jsx("div",{className:"g7le-chrome","data-testid":"g7le-chrome",ref:Y,style:{position:"static",minHeight:"100vh",minWidth:Pz,background:"#f8fafc",color:"#0f172a",fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif',fontSize:13,lineHeight:1.5,zIndex:9e3},children:i.jsxs(sy,{value:ve,children:[c&&a.needsHostPicker&&a.document&&i.jsx(_D,{hostLayouts:a.document.hostLayouts,extensionLabel:null,onSelect:a.selectHost}),i.jsxs("div",{className:"g7le-chrome__header","data-testid":"g7le-chrome-header",ref:Z,style:{position:"sticky",top:0,zIndex:20,background:"#f8fafc"},children:[i.jsx(gy,{onSave:()=>{De()},isDirty:p.isDirty,onExit:re,onEditCode:ee,onManageImages:fe,onManageTranslations:K,onPageSettings:me,onReset:qe,onShowShortcuts:Pe,onPreview:Ce,onShowVersions:ze,templateName:k,templateVersion:_,templateList:E,onSwitchTemplate:U}),i.jsx(bE,{result:F,onDismiss:V,onLoadLatest:se}),i.jsxs("div",{className:"g7le-chrome__device-toolbar","data-testid":"g7le-chrome-device-toolbar",style:{display:"flex",justifyContent:"center",padding:"6px 12px",background:"#f1f5f9",borderBottom:"1px solid #e2e8f0"},children:[i.jsx(H4,{}),i.jsx(F4,{})]})]}),i.jsxs("div",{className:"g7le-chrome__body",style:{display:"grid",gridTemplateColumns:"auto 1fr",alignItems:"stretch"},children:[i.jsx(OE,{}),i.jsx(P4,{manifest:f,nesting:N,componentPalette:g?.componentPalette??null,spec:g,permissionCandidates:B,sampleGlobalSources:h,resolveRouteMatch:H,onNavigateToDestination:L,onSessionAddedPathsChange:P})]}),i.jsx(dy,{})]})})}function yl(e,t){for(const n of e){if(n.kind==="route"&&n.path===t)return n;if(n.children&&n.children.length>0){const o=yl(n.children,t);if(o)return o}}return null}function Wz(e,t){return yl(e,t)!==null}function bl(e,t){for(const n of e){if(n.kind==="route"&&n.layoutName===t&&typeof n.path=="string")return n.path;if(n.children&&n.children.length>0){const o=bl(n.children,t);if(o)return o}}return null}function jz(e,t,n){const o=[];Va({sources:n,isGuestOnly:!1,warn:s=>{const a=/extension '([^']+)' override of core key '([^']+)'/.exec(s);a&&o.push(`${a[1]}:${a[2]}`)}});const r=t?.sampleData?.byDataSourceId?Object.keys(t.sampleData.byDataSourceId).length:0;Xh({templateIdentifier:e,mergedSources:[...n.map(s=>({kind:s.kind,id:s.id}))],blockCounts:{controls:t?.controls?Object.keys(t.controls).length:0,componentCapabilities:t?.componentCapabilities?Object.keys(t.componentCapabilities).length:0,actionRecipes:t?.actionRecipes?Object.keys(t.actionRecipes).length:0,conditionRecipes:t?.conditionRecipes?Object.keys(t.conditionRecipes).length:0,paletteGroups:t?.componentPalette?.groups?.length??0,stateGroups:t?.states?.groups?.length??0,sampleDataIds:r},sampleGlobalSourceCount:n.length,sampleGlobalConflicts:o,timestamp:Date.now()})}function Mh({templateIdentifier:e,initialLocale:t}){return u.useEffect(()=>()=>{Hh(),Kh(),Uh(),qh(),Gh(),Yh(),Jh(),Zh(),Qh()},[]),i.jsx(iy,{templateIdentifier:e,initialLocale:t,children:i.jsx(ly,{children:i.jsx(Bz,{templateIdentifier:e})})})}if(typeof window<"u"){const e=window.G7Core||(window.G7Core={});if(e.__LayoutEditorChrome=Mh,typeof e.__onChromeReady=="function")try{e.__onChromeReady()}catch{}}dn.LayoutEditorChrome=Mh,Object.defineProperty(dn,Symbol.toStringTag,{value:"Module"})})(this.G7LayoutEditor=this.G7LayoutEditor||{},ReactJSXRuntime,React,ReactDOM); -//# sourceMappingURL=layout-editor.min.js.map diff --git a/public/build/core/template-engine.min.js b/public/build/core/template-engine.min.js index 8415f566..d1a82997 100644 --- a/public/build/core/template-engine.min.js +++ b/public/build/core/template-engine.min.js @@ -241,4 +241,3 @@ Error generating stack: `+c.message+` } } `)(a)}catch(r){B.warn(`Expression evaluation failed: ${e}`,r);return}}async executeInitActions(e,a={}){const{getActionDispatcher:r}=await Promise.resolve().then(()=>za),i=r();if(!i){B.warn("ActionDispatcher not available for init_actions");return}B.log("Executing init_actions:",e.map(d=>d.handler)),B.log("Init actions dataContext:",a),B.log("Init actions dataContext._global:",a._global);const l=e.map(d=>d.handler).filter(d=>d.includes("."));l.length>0&&await this.waitForHandlers(i,l);let u={...a};for(const d of e)try{const f={type:"click",handler:d.handler,target:d.target,params:d.params,resultTo:d.resultTo,onSuccess:d.onSuccess,onError:d.onError,if:d.if,conditions:d.conditions,auth_mode:d.auth_mode,auth_required:d.auth_required},g=i.createHandler(f,u),m=new Event("init");await g(m),B.log(`Init action executed: ${d.handler}`);const b=this.globalState;u={...u,_global:{...b},_local:b._local||u._local||{}},B.log(`Data context refreshed after ${d.handler}:`,{cartKey:u._global?.cartKey})}catch(f){B.error(`Failed to execute init action: ${d.handler}`,f)}}async waitForHandlers(e,a,r=5e3){const i=Date.now(),l=50,u=()=>a.every(f=>e.customHandlers?.has(f));if(u()){B.log("All module handlers already registered");return}const d=df().getFailedJsAssets();if(d.length>0){const f=d.some(b=>b==="module"||b==="plugin"),g=a.filter(b=>!e.customHandlers?.has(b)),m=g.length>0&&g.every(b=>d.some(v=>b.startsWith(`${v}.`)));if(f||m){B.warn("Extension asset load failed — not waiting for handlers that will never register:",{pending:g,failedAssets:d});return}}return B.log("Waiting for module handlers:",a),new Promise(f=>{const g=()=>{if(u()){B.log("All module handlers now registered"),f();return}if(Date.now()-i>=r){const m=a.filter(b=>!e.customHandlers?.has(b));B.warn("Timeout waiting for handlers:",m),f();return}setTimeout(g,l)};g()})}};$(Ca,"LOCALE_STORAGE_KEY","g7_locale"),$(Ca,"CACHE_VERSION_STORAGE_KEY","g7_cache_version");let So=Ca;function hf(o){const e=new So(o);return o.websocket&&(hc.configure(o.websocket),B.log("WebSocket 설정 완료")),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{e.init(),window.__templateApp=e}):(e.init(),window.__templateApp=e),e}typeof window<"u"&&(window.G7Core=window.G7Core||{},window.G7Core.initTemplateApp=hf,window.G7Core.dataSource={refetch:async(o,e)=>{const a=window.__templateApp;if(!a){B.warn("TemplateApp not initialized (G7Core.dataSource.refetch)");return}return a.refetchDataSource(o,e)},get:o=>{const e=window.__templateApp;if(!e){B.warn("TemplateApp not initialized (G7Core.dataSource.get)");return}return e.getDataSource(o)}});function K_(o={}){const{checked:e,value:a,name:r,type:i="checkbox"}=o,u=a!==void 0?a:i==="checkbox"||i==="radio"?e??!1:String(e??""),d={checked:e??!1,value:u,name:r??"",type:i};return{target:d,currentTarget:d,preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new Event("change"),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:"change",isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{}}}function W_(o={}){const{button:e=0,clientX:a=0,clientY:r=0}=o;return{button:e,clientX:a,clientY:r,preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new MouseEvent("click"),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:"click",isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{},target:document.createElement("div"),currentTarget:document.createElement("div")}}function Y_(){return{preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new Event("submit"),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:"submit",isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{},target:document.createElement("form"),currentTarget:document.createElement("form")}}function X_(o,e="keydown"){return{key:o,code:o.length===1?`Key${o.toUpperCase()}`:o,preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new KeyboardEvent(e,{key:o}),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:e,isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{},target:document.createElement("div"),currentTarget:document.createElement("div"),altKey:!1,ctrlKey:!1,metaKey:!1,shiftKey:!1,repeat:!1}}const J_={justify:/^justify-(start|end|center|between|around|evenly)$/,items:/^items-(start|end|center|baseline|stretch)$/,content:/^content-(start|end|center|between|around|evenly|stretch)$/,self:/^self-(auto|start|end|center|stretch|baseline)$/,flex:/^flex-(row|row-reverse|col|col-reverse|wrap|wrap-reverse|nowrap|1|auto|initial|none)$/,grow:/^(grow|grow-0)$/,shrink:/^(shrink|shrink-0)$/,basis:/^basis-/,order:/^(order-|-)order-/,gap:/^gap(-x|-y)?-/,gridCols:/^grid-cols-/,gridRows:/^grid-rows-/,colSpan:/^col-(span-|start-|end-)/,rowSpan:/^row-(span-|start-|end-)/,display:/^(block|inline-block|inline|flex|inline-flex|table|inline-table|table-caption|table-cell|table-column|table-column-group|table-footer-group|table-header-group|table-row-group|table-row|flow-root|grid|inline-grid|contents|list-item|hidden)$/,position:/^(static|fixed|absolute|relative|sticky)$/,inset:/^(inset|top|right|bottom|left)-/,zIndex:/^z-/,width:/^w-/,minWidth:/^min-w-/,maxWidth:/^max-w-/,height:/^h-/,minHeight:/^min-h-/,maxHeight:/^max-h-/,padding:/^p[xytblr]?-/,margin:/^-?m[xytblr]?-/,space:/^space-(x|y)-/,fontSize:/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,fontWeight:/^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,fontStyle:/^(italic|not-italic)$/,fontFamily:/^font-(sans|serif|mono)/,textAlign:/^text-(left|center|right|justify|start|end)$/,textColor:/^text-(inherit|current|transparent|black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-/,textDecoration:/^(underline|overline|line-through|no-underline)$/,textTransform:/^(uppercase|lowercase|capitalize|normal-case)$/,lineHeight:/^leading-/,letterSpacing:/^tracking-/,textOverflow:/^(truncate|text-ellipsis|text-clip)$/,whitespace:/^whitespace-/,wordBreak:/^break-/,bgColor:/^bg-(inherit|current|transparent|black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-/,bgGradient:/^bg-gradient-/,bgSize:/^bg-(auto|cover|contain)$/,bgPosition:/^bg-(bottom|center|left|left-bottom|left-top|right|right-bottom|right-top|top)$/,bgRepeat:/^bg-(repeat|no-repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/,bgAttachment:/^bg-(fixed|local|scroll)$/,bgClip:/^bg-clip-/,bgOrigin:/^bg-origin-/,borderWidth:/^border(-[xytblr])?(-0|-2|-4|-8)?$/,borderColor:/^border-(inherit|current|transparent|black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-/,borderStyle:/^border-(solid|dashed|dotted|double|hidden|none)$/,borderRadius:/^rounded(-[tblrse]{1,2})?(-none|-sm|-md|-lg|-xl|-2xl|-3xl|-full)?$/,ringWidth:/^ring(-0|-1|-2|-4|-8|-inset)?$/,ringColor:/^ring-(inherit|current|transparent|black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-/,ringOffset:/^ring-offset-/,shadow:/^shadow(-sm|-md|-lg|-xl|-2xl|-inner|-none)?$/,opacity:/^opacity-/,mixBlend:/^mix-blend-/,bgBlend:/^bg-blend-/,blur:/^blur(-none|-sm|-md|-lg|-xl|-2xl|-3xl)?$/,brightness:/^brightness-/,contrast:/^contrast-/,grayscale:/^grayscale(-0)?$/,hueRotate:/^-?hue-rotate-/,invert:/^invert(-0)?$/,saturate:/^saturate-/,sepia:/^sepia(-0)?$/,backdropBlur:/^backdrop-blur-/,backdropBrightness:/^backdrop-brightness-/,backdropContrast:/^backdrop-contrast-/,backdropGrayscale:/^backdrop-grayscale-/,backdropHueRotate:/^backdrop-hue-rotate-/,backdropInvert:/^backdrop-invert-/,backdropOpacity:/^backdrop-opacity-/,backdropSaturate:/^backdrop-saturate-/,backdropSepia:/^backdrop-sepia-/,scale:/^scale(-x|-y)?-/,rotate:/^-?rotate-/,translate:/^-?translate-[xy]-/,skew:/^-?skew-[xy]-/,transformOrigin:/^origin-/,transition:/^transition(-none|-all|-colors|-opacity|-shadow|-transform)?$/,duration:/^duration-/,ease:/^ease-(linear|in|out|in-out)$/,delay:/^delay-/,animate:/^animate-/,cursor:/^cursor-/,userSelect:/^select-/,pointerEvents:/^pointer-events-/,resize:/^resize(-none|-x|-y)?$/,scrollBehavior:/^scroll-(auto|smooth)$/,touchAction:/^touch-/,overflow:/^overflow(-x|-y)?-(auto|hidden|clip|visible|scroll)$/,overscroll:/^overscroll(-x|-y)?-(auto|contain|none)$/,visibility:/^(visible|invisible|collapse)$/,aspectRatio:/^aspect-/,columns:/^columns-/,breakAfter:/^break-after-/,breakBefore:/^break-before-/,breakInside:/^break-inside-/,boxDecorationBreak:/^box-decoration-/,boxSizing:/^box-(border|content)$/,float:/^float-(right|left|none)$/,clear:/^clear-(left|right|both|none)$/,isolation:/^(isolate|isolation-auto)$/,objectFit:/^object-(contain|cover|fill|none|scale-down)$/,objectPosition:/^object-/};function oy(o){const e=o.replace(/^(dark:|hover:|focus:|active:|disabled:|group-hover:|sm:|md:|lg:|xl:|2xl:)+/,"");for(const[a,r]of Object.entries(J_))if(r.test(e))return a;return null}function ly(o){const e=o.match(/^((dark:|hover:|focus:|active:|disabled:|group-hover:|sm:|md:|lg:|xl:|2xl:)+)/);return e?e[1]:""}function Q_(o,e){if(!e||e.trim()==="")return o;if(!o||o.trim()==="")return e;const a=o.split(/\s+/).filter(Boolean),r=e.split(/\s+/).filter(Boolean),i=new Map;for(const u of r){const d=oy(u);if(d){const f=ly(u),g=`${d}:${f}`;i.set(g,u)}}return[...a.filter(u=>{const d=oy(u);if(!d)return!0;const f=ly(u),g=`${d}:${f}`;return!i.has(g)}),...r].join(" ")}function Z_(o){return Object.entries(o).filter(([,e])=>e).map(([e])=>e).join(" ")}function eA(...o){return o.filter(e=>typeof e=="string"&&e.length>0).join(" ")}const we=ct("G7CoreGlobals");function tA(){window.React=Ye,window.ReactDOM={...Qu,createPortal:Ar.createPortal,unstable_batchedUpdates:o=>o()},window.ReactJSXRuntime=J0,we.log("전역 객체 window.React, window.ReactDOM, window.ReactJSXRuntime에 노출됨")}function nA(o){const e=new Map,a=Tr.getInstance();o.componentEvent={on:(r,i)=>(e.has(r)||e.set(r,new Set),e.get(r).add(i),a.trackEventSubscribe(r),()=>{e.get(r)?.delete(i),a.trackEventUnsubscribe(r)}),emit:async(r,i)=>{const l=e.get(r),u=l?.size??0;if(!l||l.size===0)return a.trackEventEmit(r,i,0),[];let d,f=[];try{f=await Promise.all(Array.from(l).map(async g=>{try{return await g(i)}catch(m){throw we.error(`componentEvent: Error in listener for "${r}":`,m),m}}))}catch(g){d=g instanceof Error?g:new Error(String(g))}if(a.trackEventEmit(r,i,u,f,d),d)throw d;return f},off:r=>{e.delete(r),a.trackEventOff(r)},clear:()=>{e.clear(),a.trackEventClear()}},we.log("전역 객체 window.G7Core.componentEvent에 노출됨")}function aA(o){o.__runtime||(o.__runtime={DynamicRenderer:Ha,ComponentRegistry:da,TranslationEngine:Cr,DataSourceManager:va,dataSourceManager:I_,DataBindingEngine:wn,dataBindingEngine:XS,ActionDispatcher:lo,TranslationReactContext:nf,TranslationProvider:uc,useTranslation:Fm,ResponsiveContext:Md,ResponsiveProvider:Jl,useResponsive:$d,responsiveManager:ui,BREAKPOINT_PRESETS:Dl,AuthManager:ha,createLogger:ct,G7DevToolsCore:Tr},we.log("전역 객체 window.G7Core.__runtime(코어 런타임 표면)에 노출됨"))}function rA(o){if(o.layoutEditor&&o.layoutEditor.__isStub!==!0||o.layoutEditor&&o.layoutEditor.__isStub===!0)return;const e=[],a=[];o.layoutEditor={__isStub:!0,__queue:e,__readyCallbacks:a,registerWidget:(r,i)=>e.push(["widget",r,i]),registerNodeEditor:(r,i)=>e.push(["nodeEditor",r,i]),registerCanvasOverlay:(r,i)=>e.push(["canvasOverlay",r,i]),onReady:r=>{typeof r=="function"&&a.push(r)}},we.log("전역 객체 window.G7Core.layoutEditor 예약 접수함(stub) 노출됨")}function iA(o,e){o.useTranslation=Fm,o.t=(a,r)=>{const i=e.getState();if(!i.translationEngine||!i.translationContext)return a;if(r){const l="|"+Object.entries(r).map(([u,d])=>`${u}=${d}`).join("|");return i.translationEngine.translate(a,i.translationContext,l)}return i.translationEngine.translate(a,i.translationContext)},we.log("전역 객체 window.G7Core.useTranslation에 노출됨")}function sA(o,e){o.createChangeEvent=K_,o.createClickEvent=W_,o.createSubmitEvent=Y_,o.createKeyboardEvent=X_,o.uuid=()=>typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,a=>{const r=Math.random()*16|0;return(a==="x"?r:r&3|8).toString(16)}),o.createLogger=ct,we.log("전역 객체 window.G7Core.createLogger에 노출됨"),o.renderItemChildren=(a,r,i,l,u)=>{const d=e.getState(),m={_global:window.__templateApp?.getGlobalState?.()||{},_local:r._local||{},_computed:r._computed||{},...r},b=new Set,v=(E,A)=>{const T=m._global?._remountKeys;return E&&T?.[E]&&!b.has(E)?(b.add(E),`${E}-remount-${T[E]}`):E||A},S={translationContext:d.translationContext,translationEngine:d.translationEngine,bindingEngine:d.bindingEngine,actionDispatcher:d.actionDispatcher,getRemountKey:v,...u};return Zs(a,m,i,l,S)},o.getComponentMap=()=>da.getInstance().getComponentMap(),o.renderComponentLayout=(a,r,i)=>{if(!a||!Array.isArray(a)||a.length===0)return null;const l=da.getInstance().getComponentMap(),u=e.getState(),f=window.__templateApp?.getGlobalState?.()||{},g={_global:f._global||{},_local:f._local||{},_computed:f._computed||{},...r},m=new Set,b=(v,S)=>{const E=g._global?._remountKeys;return v&&E?.[v]&&!m.has(v)?(m.add(v),`${v}-remount-${E[v]}`):v||S};return Zs(a,g,l,i,{translationContext:u.translationContext,translationEngine:u.translationEngine,bindingEngine:u.bindingEngine,actionDispatcher:u.actionDispatcher,getRemountKey:b})},o.renderExpandContent=a=>{const{children:r,row:i,expandContext:l,componentContext:u,keyPrefix:d}=a;if(!r||!Array.isArray(r)||r.length===0)return null;const f=a.componentMap||da.getInstance().getComponentMap(),g=e.getState(),m=o.state?.get?.()||{},b=u?.stateRef?.current??u?.state??{},v={...m._local||{},...b},S=u?.computedRef?.current??m._computed??{},E=g.bindingEngine??o.getDataBindingEngine?.(),A={...m,_local:v,_computed:S,row:i,item:i,$item:i};let T={};if(l&&typeof l=="object"&&E)try{for(const[D,U]of Object.entries(l))if(typeof U=="string"){const j=U.match(/^\{\{([^}]+)\}\}$/);if(j){const P=j[1].trim();try{T[D]=E.evaluateExpression(P,A,{skipCache:!0})}catch{T[D]=void 0}}else T[D]=E.resolveBindings(U,A,{skipCache:!0})}else T[D]=U}catch(D){we.warn("renderExpandContent: expandContext 평가 오류:",D)}const L={row:i,item:i,$item:i,_local:v,_global:m._global||{},_computed:m._computed||{},...T},M=new Set,R=(D,U)=>{const j=L._global?._remountKeys;return D&&j?.[D]&&!M.has(D)?(M.add(D),`${D}-remount-${j[D]}`):D||U};return Zs(r,L,f,d,{translationContext:g.translationContext,translationEngine:g.translationEngine??void 0,bindingEngine:g.bindingEngine??void 0,actionDispatcher:g.actionDispatcher,componentContext:u,getRemountKey:R})},o.$get=function(r,i,l=void 0){if(r==null)return l;const u=Array.isArray(i)?i:[i];if(u.length===0)return r;let d=r;for(const f of u){if(d==null||f==null)return l;d=d[f]}return d??l},we.log("전역 객체 window.G7Core.renderItemChildren에 노출됨"),we.log("전역 객체 window.G7Core.renderComponentLayout에 노출됨"),we.log("전역 객체 window.G7Core.renderExpandContent에 노출됨"),we.log("전역 객체 window.G7Core.$get에 노출됨")}function oA(o){o.dispatch=async(e,a)=>{const r=window.__templateApp;if(!r)return we.warn("G7Core.dispatch: TemplateApp이 초기화되지 않았습니다."),{success:!1,error:new Error("TemplateApp이 초기화되지 않았습니다.")};const i=r.getActionDispatcher?.();if(!i)return we.warn("G7Core.dispatch: ActionDispatcher를 찾을 수 없습니다."),{success:!1,error:new Error("ActionDispatcher를 찾을 수 없습니다.")};const l=r.getRouter?.(),u=r.getGlobalState?.(),d=r.setGlobalState?.bind(r),g=a?.componentContext||window.__g7ActionContext,m=window.__g7PendingLocalState;let b=g?.state??u;m&&g&&(b=m,we.log("[dispatch] Using __g7PendingLocalState for context.state:",m));let v=g?.data??u;m&&g?.data?._local&&(v={...g.data,_local:m},we.log("[dispatch] Updated context.data._local with pendingLocalState"));const S={navigate:l?(E,A)=>l.navigate(E,A):void 0,setState:g?.setState??d,state:b,data:v,_isDispatchFallbackContext:!g?.setState};if(e.debounce){const E=e.debounceKey||`dispatch-${e.handler}`,A=typeof e.debounce=="number"?e.debounce:e.debounce.delay;return i.debouncedCall(E,A,()=>{const{debounce:T,debounceKey:L,...M}=e;i.dispatchAction(M,S)}),{success:!0,debounced:!0}}try{return await i.dispatchAction(e,S)}catch(E){return we.error("G7Core.dispatch: 액션 실행 오류:",E),{success:!1,error:E instanceof Error?E:new Error(String(E))}}},we.log("전역 객체 window.G7Core.dispatch에 노출됨")}function wo(o){const e={};for(const[a,r]of Object.entries(o))if(a.includes(".")){const i=a.split(".");let l=e;for(let u=0;u0&&e.every(a=>/^\d+$/.test(a))}function uy(o,e){const a={...o};for(const r of Object.keys(e))r in a?a[r]!==null&&typeof a[r]=="object"&&!Array.isArray(a[r])&&e[r]!==null&&typeof e[r]=="object"&&!Array.isArray(e[r])&&(a[r]=uy(a[r],e[r])):a[r]=e[r];return a}function Zn(o,e){if(Array.isArray(o)&&!Array.isArray(e)&&lA(e)){const r=Object.keys(e).map(l=>parseInt(l,10));if(!((r.length>0?Math.max(...r):0)>=o.length+r.length+10)){const l=[...o];for(const[u,d]of Object.entries(e)){const f=parseInt(u,10);f>=0&&f=l.length&&(l[f]=d)}return l}}const a={...o};for(const[r,i]of Object.entries(e))i!==null&&typeof i=="object"&&!Array.isArray(i)?a[r]!==null&&typeof a[r]=="object"?a[r]=Zn(a[r],i):a[r]={...i}:a[r]=i;return a}function cA(o){o.state={get:()=>window.__templateApp?.getGlobalState?.()||{},set:(e,a)=>{const r=window.__templateApp;if(r?.setGlobalState&&r?.getGlobalState){const i=a?.merge||"deep",l=wo(e);let u;if(i==="replace")u=l;else if(i==="shallow")u={...r.getGlobalState(),...l};else{const d=r.getGlobalState();u=Zn(d,l)}r.setGlobalState(u,{render:a?.render})}else we.warn("G7Core.state.set: TemplateApp이 초기화되지 않았습니다.")},setLocal:(e,a)=>{const r=window.__templateApp;if(a?.render===!1&&!a?.selfManaged){const T=window.__g7AutoBindingPaths;T&&T.size>0&&cy(wo(e)).some(M=>T.has(M))&&(we.log("[setLocal] render:false + 자동바인딩 경로 겹침 감지 → render:true 자동 승격 (engine-v1.43.0)"),a={...a,render:!0})}if(a?.debounce){const T=r?.getActionDispatcher?.();if(T){const L=a.debounceKey||`setLocal-${Object.keys(e).join(",")}`,{debounce:M,debounceKey:R,...D}=a;T.debouncedCall(L,a.debounce,()=>{o.state.setLocal(e,D)});return}}const i=a?.scope??"current",l=a?.merge||"deep",u=window.__g7ActionContext,d=wo(e);if(i==="parent"||i==="root"){const T=window.__g7LayoutContextStack||[];if(T.length>0){const L=i==="parent"?T[T.length-1]:T[0];if(L?.setState){L.setState(M=>l==="replace"?d:l==="shallow"?{...M||{},...d}:Zn(M||{},d)),we.log(`[setLocal] scope=${i}: 타겟 컨텍스트에 상태 업데이트 (mergeMode=${l})`,d);return}}we.warn(`[setLocal] scope=${i}: 레이아웃 컨텍스트 스택이 비어있습니다. current로 폴백합니다.`)}const f=window.__g7PendingLocalState,g=r?.getGlobalState?.()?._local||{},v=!((window.__g7LayoutContextStack||[]).length>0)&&u?.state?u.state:void 0,S=v?uy(g,v):g,E=f||S;let A;if(l==="replace"?A=d:l==="shallow"?A={...E,...d}:A=Zn(E,d),r?.setGlobalState&&(r.setGlobalState({_local:A},{render:a?.render}),we.log(`[setLocal] globalLocal updated via setGlobalState (mergeMode=${l}, render=${a?.render??!0}):`,d)),window.__g7PendingLocalState=A,we.log("[setLocal] __g7PendingLocalState updated:",A),window.__g7SequenceLocalSync=A,window.__g7LastSetLocalSnapshot=A,l==="replace")window.__g7ForcedLocalFields=d;else{const T=window.__g7ForcedLocalFields||{};window.__g7ForcedLocalFields=Zn(T,d)}if(we.log("[setLocal] __g7ForcedLocalFields updated:",d),l==="replace")window.__g7SetLocalOverrideKeys=d;else{const T=window.__g7SetLocalOverrideKeys||{};window.__g7SetLocalOverrideKeys=Zn(T,d)}we.log("[setLocal] __g7SetLocalOverrideKeys updated:",d),u?.setState&&u.setState(T=>l==="replace"?d:l==="shallow"?{...T||{},...d}:Zn(T||{},d)),r?.setGlobalState||we.warn("G7Core.state.setLocal: TemplateApp이 없습니다.")},getLocal:()=>{const r=(window.__templateApp?.getGlobalState?.()||{})._local||{},i=window.__g7PendingLocalState;if(i)return Zn(r,i);const l=window.__g7LastSetLocalSnapshot;return l&&l!==r?Zn(r,l):r},update:e=>{const a=window.__templateApp;if(a?.getGlobalState&&a?.setGlobalState){const r=a.getGlobalState(),i=e(r);a.setGlobalState(i)}else we.warn("G7Core.state.update: TemplateApp이 초기화되지 않았습니다.")},subscribe:e=>{const a=window.__templateApp;return a?.onGlobalStateChange?a.onGlobalStateChange(e):(we.warn("G7Core.state.subscribe: TemplateApp이 초기화되지 않았습니다."),()=>{})},getDataSource:e=>window.__templateApp?.getDataSource?.(e),getIsolated:e=>e?window.__g7IsolatedStates?.[e]?.state??null:window.__g7ActionContext?.isolatedContext?.state??null,setIsolated:(e,a,r)=>{let i,l,u;typeof e=="string"?(i=e,l=a||{},u=r):(l=e,u=a&&typeof a=="object"&&"merge"in a?a:r);const d=u?.merge||"deep",f=wo(l);if(i){const b=window.__g7IsolatedStates?.[i];b?.mergeState?b.mergeState(f,d):we.warn(`G7Core.state.setIsolated: scopeId '${i}'를 찾을 수 없습니다.`);return}const g=window.__g7ActionContext;g?.isolatedContext?.mergeState?g.isolatedContext.mergeState(f,d):we.warn("G7Core.state.setIsolated: 액션 컨텍스트에 isolatedContext가 없습니다.")},getParent:()=>{const e=window.__g7LayoutContextStack||[];if(e.length===0)return we.log("[getParent] 레이아웃 컨텍스트 스택이 비어있습니다."),null;const a=e[e.length-1];if(!a)return null;const r=a.dataContext||{};return{_local:a.state||r._local||{},_global:r._global||o.state.get()||{},setState:a.setState}},setParentLocal:(e,a,r)=>{const i=window.__g7LayoutContextStack||[];if(i.length===0){we.warn("[setParentLocal] 레이아웃 컨텍스트 스택이 비어있습니다.");return}const l=i[i.length-1];if(!l?.setState){we.warn("[setParentLocal] 부모 컨텍스트에 setState가 없습니다.");return}let u,d;typeof e=="string"?(u={[e]:a},d=r):(u=e,d=a&&typeof a=="object"&&"merge"in a?a:r);const f=d?.merge||"deep",g=wo(u),m=l.state?._local!==void 0,b=m?l.state._local:l.state||{};let v;f==="replace"?v=g:f==="shallow"?v={...b,...g}:v=Zn(b,g),window.__g7PendingLocalState=v;const S=window.__templateApp;if(S?.setGlobalState&&S.setGlobalState({_local:v}),l.setState(v),m?l.state._local=v:l.state=v,l.dataContext&&(l.dataContext._local=v),f==="replace")window.__g7ForcedLocalFields=g;else{const E=window.__g7ForcedLocalFields||{};window.__g7ForcedLocalFields=Zn(E,g)}Dd()},setParentGlobal:(e,a)=>{let r;typeof e=="string"?r={[e]:a}:r=e,o.state.set(r),we.log("[setParentGlobal] 전역 상태 업데이트:",r)}},window.__g7IsolatedStates||(window.__g7IsolatedStates={}),o.state.getGlobal=o.state.get,o.state.setGlobal=o.state.set,we.log("전역 객체 window.G7Core.state에 노출됨")}function uA(o){o.dataSource={get:e=>window.__templateApp?.getDataSource?.(e),set:(e,a,r)=>{const i=window.__templateApp;i?.setDataSource?i.setDataSource(e,a,r):we.warn("G7Core.dataSource.set: TemplateApp이 초기화되지 않았습니다.")},refetch:async(e,a)=>{const r=window.__templateApp;if(r?.refetchDataSource)return r.refetchDataSource(e,a);we.warn("G7Core.dataSource.refetch: TemplateApp이 초기화되지 않았습니다.")},updateItem:(e,a,r,i,l)=>{const u=window.__templateApp;return u?.updateDataSourceItem?u.updateDataSourceItem(e,a,r,i,l):(we.warn("G7Core.dataSource.updateItem: TemplateApp이 초기화되지 않았습니다."),!1)},updateData:(e,a,r,i="append")=>{const l=window.__templateApp;if(!l?.getDataSource||!l?.setDataSource)return we.warn("G7Core.dataSource.updateData: TemplateApp이 초기화되지 않았습니다."),!1;const u=l.getDataSource(e);if(!u)return we.warn(`G7Core.dataSource.updateData: 데이터 소스 '${e}'를 찾을 수 없습니다.`),!1;let d,f=u,g=null;if(a){const b=a.split(".");g=b.pop();for(const v of b)if(f&&typeof f=="object"&&v in f)f=f[v];else return we.warn(`G7Core.dataSource.updateData: 경로 '${a}'를 찾을 수 없습니다.`),!1;d=f[g]}else d=u;if(!Array.isArray(d))return we.warn("G7Core.dataSource.updateData: 대상 경로의 데이터가 배열이 아닙니다."),!1;if(!Array.isArray(r))return we.warn("G7Core.dataSource.updateData: newData가 배열이 아닙니다."),!1;const m=i==="prepend"?[...r,...d]:[...d,...r];return a&&g?(f[g]=m,l.setDataSource(e,u,{merge:!1})):l.setDataSource(e,m,{merge:!1}),we.log(`G7Core.dataSource.updateData: '${e}'에 ${r.length}개 항목 ${i==="prepend"?"앞에":"뒤에"} 추가됨`),!0}},we.log("전역 객체 window.G7Core.dataSource에 노출됨")}function dA(o,e){o.locale={current:()=>window.__templateApp?.getLocale?.()||"ko",supported:()=>{const r=window.__templateApp?.globalState?.appConfig?.supportedLocales;return Array.isArray(r)&&r.length>0?r:e.getState().templateMetadata?.locales||["ko","en"]},change:async a=>{const r=window.__templateApp;r?.changeLocale?await r.changeLocale(a):we.warn("G7Core.locale.change: TemplateApp이 초기화되지 않았습니다.")}},we.log("전역 객체 window.G7Core.locale에 노출됨")}function fA(o){o.toast={show:(e,a)=>{o.dispatch({handler:"toast",params:{message:e,type:a?.type||"info",...a?.duration&&{duration:a.duration}}})},success:(e,a)=>{o.toast.show(e,{type:"success",duration:a})},error:(e,a)=>{o.toast.show(e,{type:"error",duration:a})},warning:(e,a)=>{o.toast.show(e,{type:"warning",duration:a})},info:(e,a)=>{o.toast.show(e,{type:"info",duration:a})}},we.log("전역 객체 window.G7Core.toast에 노출됨")}function hA(o){o.modal={open:e=>{o.dispatch({handler:"openModal",target:e})},close:e=>{o.dispatch({handler:"closeModal",...e&&{target:e}})},closeAll:()=>{o.dispatch({handler:"closeAllModals"})},isOpen:e=>{const a=o.state.get();return a._global?.activeModal===e||a._global?.modalStack?.includes(e)||!1},getStack:()=>o.state.get()._global?.modalStack||[]},we.log("전역 객체 window.G7Core.modal에 노출됨")}function gA(o){o.style={mergeClasses:Q_,conditionalClass:Z_,joinClasses:eA},we.log("전역 객체 window.G7Core.style에 노출됨")}function pA(o,e){const{webSocketManager:a}=e;o.websocket={manager:a,subscribe:(r,i,l,u)=>a.subscribe(r,i,l,u),unsubscribe:r=>a.unsubscribe(r),leaveChannel:r=>a.leaveChannel(r),disconnect:()=>a.disconnect(),isInitialized:()=>a.isInitialized(),getSubscriptionCount:()=>a.getSubscriptionCount()},we.log("전역 객체 window.G7Core.websocket에 노출됨")}function mA(o,e){const{transitionManager:a}=e;o.navigation={isPending:()=>a?.getIsPending?.()||!1,onComplete:r=>{if(!a)return r(),()=>{};let i=a.getIsPending();const l=a.subscribe(u=>{u?i=!0:i&&(r(),l())});return l}},we.log("전역 객체 window.G7Core.navigation에 노출됨")}function yA(o){o.plugin={getSettings:e=>window.G7Config?.plugins?.[e],get:(e,a,r)=>window.G7Config?.plugins?.[e]?.[a]??r,getAll:()=>window.G7Config?.plugins??{}},we.log("전역 객체 window.G7Core.plugin에 노출됨")}function bA(o){o.module={getSettings:e=>window.G7Config?.modules?.[e],get:(e,a,r)=>window.G7Config?.modules?.[e]?.[a]??r,getAll:()=>window.G7Config?.modules??{}},we.log("전역 객체 window.G7Core.module에 노출됨")}function vA(o,e){const{transitionManager:a,responsiveManager:r}=e;o.AuthManager=ha,o.api=$a(),o.TransitionManager=a,o.useTransitionState=nm,o.ResponsiveManager=r,o.useResponsive=$d,o.useControllableState=lC,o.shallowArrayEqual=cC,o.shallowObjectEqual=rm,o.updateQueryParams=async(i,l)=>{const u=window.__templateApp;if(u?.updateQueryParams)return u.updateQueryParams(i,l);we.warn("G7Core.updateQueryParams: TemplateApp이 초기화되지 않았습니다.")},we.log("전역 객체 window.G7Core.AuthManager에 노출됨"),we.log("전역 객체 window.G7Core.api에 노출됨"),we.log("전역 객체 window.G7Core.ResponsiveManager에 노출됨"),we.log("전역 객체 window.G7Core.useResponsive에 노출됨"),we.log("전역 객체 window.G7Core.useControllableState에 노출됨"),we.log("전역 객체 window.G7Core.updateQueryParams에 노출됨")}function SA(o,e){o.getSlotContext=()=>window.__slotContextValue??null,o.getDynamicRenderer=()=>window.__DynamicRenderer??null,o.getComponentRegistry=()=>e.getComponentRegistry?.()??da.getInstance(),o.getDataBindingEngine=()=>e.getDataBindingEngine?.()??e.getState().bindingEngine,o.getTranslationEngine=()=>e.getTranslationEngine?.()??e.getState().translationEngine,o.getActionDispatcher=()=>e.getActionDispatcher?.()??e.getState().actionDispatcher,we.log("전역 객체 window.G7Core 슬롯 API에 노출됨 (getSlotContext, getDynamicRenderer 등)")}function wA(o){o.identity={setLauncher:St.setLauncher.bind(St),handle:St.handle.bind(St),isIdentityRequired:St.isIdentityRequired.bind(St),hasLauncher:St.hasLauncher.bind(St),markDomainNoticeShown:St.markDomainNoticeShown.bind(St),redirectExternally:St.redirectExternally.bind(St),createDeferred:St.createDeferred.bind(St),resolveDeferred:St.resolveDeferred.bind(St),reset:St.reset.bind(St),redirectStashKey:_d},we.log("전역 객체 window.G7Core 본인인증(IDV) API에 노출됨 (identity)")}function CA(o){if(typeof window>"u")return;tA(),window.G7Core||(window.G7Core={});const e=window.G7Core;aA(e),nA(e),rA(e),vA(e,o),iA(e,o),sA(e,o),oA(e),cA(e),uA(e),dA(e,o),fA(e),hA(e),gA(e),pA(e,o),mA(e,o),yA(e),bA(e),SA(e,o),wA(e),we.log("G7Core 전역 객체 초기화 완료")}function EA(){const o=window.G7Core;if(!o){we.warn("G7Core가 초기화되지 않았습니다.");return}const e=()=>Tr.getInstance(),a={isEnabled:()=>{try{return e().isEnabled()}catch{return!1}},trackRender:r=>{try{e().isEnabled()&&e().trackRender(r)}catch{}},trackIteration:(r,i,l,u,d)=>{try{e().isEnabled()&&e().trackIteration(r,{source:i,itemVar:l,indexVar:u,sourceLength:d})}catch{}},trackIfCondition:(r,i,l,u)=>{try{e().isEnabled()&&e().trackIfCondition(r,i,l)}catch{}},trackMount:(r,i)=>{try{e().isEnabled()&&e().trackMount(r,{...i,id:r})}catch{}},trackUnmount:r=>{try{e().isEnabled()&&e().trackUnmount(r)}catch{}},trackExpressionEval:r=>{try{e().isEnabled()&&e().trackExpressionEval(r)}catch{}},trackBindingEval:r=>{try{e().isEnabled()&&e().trackBindingEval(r)}catch{}},recordCacheHit:()=>{try{e().isEnabled()&&e().recordCacheHit()}catch{}},recordCacheMiss:()=>{try{e().isEnabled()&&e().recordCacheMiss()}catch{}},trackHandlerRegistration:(r,i,l,u)=>{try{e().isEnabled()&&e().trackHandlerRegistration(r,i,l,u)}catch{}},trackHandlerUnregistration:r=>{try{e().isEnabled()&&e().trackHandlerUnregistration(r)}catch{}},logAction:r=>{try{e().isEnabled()&&e().logAction(r)}catch{}},trackRequest:(r,i)=>{try{return e().isEnabled()?e().trackRequest(r,i):""}catch{return""}},completeRequest:(r,i,l)=>{try{e().isEnabled()&&e().completeRequest(r,i,l)}catch{}},failRequest:(r,i)=>{try{e().isEnabled()&&e().failRequest(r,i)}catch{}},trackDataSourceDefinition:r=>{try{e().isEnabled()&&e().trackDataSourceDefinition(r)}catch{}},trackDataSourceLoading:r=>{try{e().isEnabled()&&e().trackDataSourceLoading(r)}catch{}},trackDataSourceLoaded:(r,i,l)=>{try{e().isEnabled()&&e().trackDataSourceLoaded(r,i,l)}catch{}},trackDataSourceError:(r,i)=>{try{e().isEnabled()&&e().trackDataSourceError(r,i)}catch{}},trackForm:(r,i,l)=>{try{e().isEnabled()&&e().trackForm(r,i,l)}catch{}},untrackForm:r=>{try{e().isEnabled()&&e().untrackForm(r)}catch{}},startStateChange:(r,i,l,u)=>{try{return e().isEnabled()?e().startStateChange(r,i,l,u):""}catch{return""}},completeStateChange:r=>{try{e().isEnabled()&&e().completeStateChange(r)}catch{}},trackComponentRender:(r,i,l,u,d,f)=>{try{e().isEnabled()&&e().trackComponentRender(r,i,l,u,d,f)}catch{}},updateLocalState:r=>{try{e().isEnabled()&&e().updateLocalState(r)}catch{}},updateComputedState:r=>{try{e().isEnabled()&&e().updateComputedState(r)}catch{}},updateParentContext:r=>{try{e().isEnabled()&&e().updateParentContext(r)}catch{}},trackComponentStateSource:(r,i,l,u)=>{try{e().isEnabled()&&e().trackComponentStateSource(r,i,l,u)}catch{}},trackDynamicState:(r,i)=>{try{e().isEnabled()&&e().trackDynamicState(r,i)}catch{}},trackContextFlow:(r,i,l,u,d,f)=>{try{e().isEnabled()&&e().trackContextFlow(r,i,l,u,d,f)}catch{}},trackComponentStyle:(r,i,l,u)=>{try{e().isEnabled()&&e().trackComponentStyle(r,i,l,u)}catch{}},trackAuthEvent:(r,i,l,u)=>{try{e().isEnabled()&&e().trackAuthEvent(r,i,l,u)}catch{}},trackAuthHeader:(r,i,l,u,d)=>{try{e().isEnabled()&&e().trackAuthHeader(r,i,l,u,d)}catch{}},trackLog:(r,i,l)=>{try{e().isEnabled()&&e().trackLog(r,i,l)}catch{}},trackAction:r=>{try{e().isEnabled()&&e().trackAction?.(r)}catch{}},trackDataSourceUpdate:r=>{try{e().isEnabled()&&e().trackDataSourceUpdate?.(r)}catch{}},startSequenceExecution:r=>{try{return e().isEnabled()?e().startSequenceExecution(r):""}catch{return""}},captureSequenceActionBefore:(r,i,l,u,d)=>{try{e().isEnabled()&&e().captureSequenceActionBefore(r,i,l,u,d)}catch{}},captureSequenceActionAfter:(r,i,l,u,d,f)=>{try{e().isEnabled()&&e().captureSequenceActionAfter(r,i,l,u,d,f)}catch{}},endSequenceExecution:(r,i)=>{try{e().isEnabled()&&e().endSequenceExecution(r,i)}catch{}},registerStateCaptureForHandler:(r,i,l)=>{try{e().isEnabled()&&e().registerStateCaptureForHandler(r,i,l)}catch{}},detectStaleClosure:(r,i,l,u,d)=>{try{return e().isEnabled()?e().detectStaleClosure(r,i,l,u,d):[]}catch{return[]}},trackStaleClosureWarning:r=>{try{e().isEnabled()&&e().trackStaleClosureWarning(r)}catch{}},trackModalOpen:r=>{try{e().isEnabled()&&e().trackModalOpen(r)}catch{}},trackModalClose:(r,i)=>{try{e().isEnabled()&&e().trackModalClose(r,i)}catch{}},trackModalStateChange:r=>{try{e().isEnabled()&&e().trackModalStateChange(r)}catch{}},trackNestedContext:r=>{try{return e().isEnabled()?e().trackNestedContext(r):""}catch{return""}},trackNestedContextAccess:(r,i)=>{try{e().isEnabled()&&e().trackNestedContextAccess(r,i)}catch{}},trackComputedProperty:(r,i,l,u,d,f,g)=>{try{e().isEnabled()&&e().trackComputedProperty(r,i,l,u,d,f,g)}catch{}},trackComputedRecalc:(r,i,l,u,d,f,g)=>{try{e().isEnabled()&&e().trackComputedRecalc(r,i,l,u,d,f,g)}catch{}},setNamedActionDefinitions:r=>{try{e().isEnabled()&&e().setNamedActionDefinitions(r)}catch{}},trackNamedActionRef:r=>{try{e().isEnabled()&&e().trackNamedActionRef(r)}catch{}}};o.devTools=a,we.log("G7Core.devTools 인터페이스 초기화 완료")}let Co=null;function _A(){const o=window.G7Core;return o?.__devtools?Promise.resolve(o.__devtools):Co||(Co=new Promise((e,a)=>{const r=window.G7Config?.coreDevToolsAsset||"/build/core/devtools.min.js",i="g7-devtools-bundle",l=()=>{const f=window.G7Core?.__devtools;f?e(f):a(new Error("DevTools 번들 로드됨 — 그러나 __devtools 미노출"))};window.G7Core=window.G7Core||{},window.G7Core.__onDevToolsReady=l;const u=document.getElementById(i);if(u){u.addEventListener("load",l,{once:!0}),u.addEventListener("error",()=>a(new Error(`DevTools 번들 로드 실패: ${r}`)),{once:!0});return}const d=document.createElement("script");d.id=i,d.src=r,d.async=!1,d.addEventListener("load",l,{once:!0}),d.addEventListener("error",()=>{Co=null,a(new Error(`DevTools 번들 로드 실패: ${r}`))},{once:!0}),document.head.appendChild(d)}),Co)}function AA(){const o=window.G7Core;if(!o){we.warn("G7Core가 초기화되지 않았습니다.");return}const e=Tr.getInstance();if(e.initialize(),!e.isEnabled()){window.G7DevTools={isEnabled:()=>!1,enable:()=>{we.log("환경설정 > 고급 설정 > 디버그 모드를 켜세요")}},ed(),we.log("G7DevTools 비활성화됨 (디버그 모드 꺼짐)");return}xA(o,e)}async function xA(o,e){let a;try{a=await _A()}catch(g){we.warn("DevTools 번들 로드 실패 — 최소 API 로 폴백:",g),window.G7DevTools={isEnabled:()=>!1,enable:()=>{}},ed();return}const{DiagnosticEngine:r,getServerConnector:i,getStyleTracker:l}=a;EA(),ed();try{l().enable(),we.log("StyleTracker 활성화됨")}catch(g){we.warn("StyleTracker 활성화 실패:",g)}const u=new r,d=i(),f={isEnabled:()=>e.isEnabled(),state:{get:()=>e.getState(),getHistory:()=>e.getStateHistory(),watch:(g,m)=>e.watchState(g,m)},actions:{getHistory:()=>e.getActionHistory(),watch:g=>e.watchActions(g),getMetrics:()=>e.getActionMetrics()},binding:{evaluate:g=>{const m=o.getDataBindingEngine?.();if(!m){we.warn("DataBindingEngine이 없습니다.");return}return m.evaluateExpression(g,e.getState())},getCacheStats:()=>e.getCacheStats(),clearStats:()=>{e.resetCacheStats(),we.log("캐시 통계 초기화됨")}},diagnose:{analyze:g=>u.analyze(g),suggestFix:g=>u.suggestFix(g),getCommonIssues:()=>u.getCommonIssues(),getRulesByCategory:g=>u.getRulesByCategory(g)},server:{dumpState:g=>d.dumpState(g),sendLog:g=>d.sendLog(g),sendError:(g,m)=>d.sendError(g,m),isConnected:()=>d.isConnected(),testConnection:()=>d.testConnection()},config:{isDebugMode:()=>e.isEnabled(),setLogLevel:g=>e.setLogLevel(g),setMaxHistory:g=>e.setMaxHistory(g)},lifecycle:{getMountedComponents:()=>e.getLifecycleInfo().mountedComponents,getOrphanedListeners:()=>e.getLifecycleInfo().orphanedListeners},performance:{getRenderCount:()=>e.getPerformanceInfo().renderCounts,getBindingEvalCount:()=>e.getPerformanceInfo().bindingEvalCount,getMemoryWarnings:()=>e.getPerformanceInfo().memoryWarnings,startProfiling:()=>e.startProfiling(),stopProfiling:()=>e.stopProfiling()},network:{getActiveRequests:()=>e.getNetworkInfo().activeRequests,getRequestHistory:()=>e.getNetworkInfo().requestHistory,getPendingDataSources:()=>e.getNetworkInfo().pendingDataSources},conditional:{getIfConditions:()=>e.getConditionalInfo().ifConditions,getIterations:()=>e.getConditionalInfo().iterations},form:{getForms:()=>e.getFormInfo()},websocket:{getInfo:()=>e.getWebSocketInfo()}};window.G7DevTools=f,o.devtools=f,we.log("G7DevTools 전역 객체 초기화 완료 (window.G7DevTools)"),TA(a.DevToolsPanel)}function TA(o){if(document.getElementById("g7-devtools-root")){we.log("DevToolsPanel 이미 렌더링됨");return}const e=document.createElement("div");e.id="g7-devtools-root",document.body.appendChild(e);try{Qu.createRoot(e).render(Ye.createElement(o)),we.log("DevToolsPanel UI 렌더링 완료")}catch(a){we.error("DevToolsPanel UI 렌더링 실패:",a)}}const Ze=ct("TemplateEngine"),se={templateId:null,locale:"ko",isInitialized:!1,reactRoot:null,containerId:null,currentLayoutJson:null,currentDataContext:{},translationContext:{templateId:"",locale:"ko"},registry:null,bindingEngine:null,translationEngine:null,actionDispatcher:null,templateMetadata:null};let Eo=!1;const _o=[];async function RA(o){try{Ze.log("템플릿 메타데이터 로드 중...",o);const e=o.split("-").map(r=>r.split("_").map(i=>i.charAt(0).toUpperCase()+i.slice(1)).join("")).join(""),a=window[e];return a?.templateMetadata?(Ze.log("템플릿 메타데이터 로드 완료 (전역 변수)",a.templateMetadata),a.templateMetadata):(Ze.warn(`템플릿 번들에 메타데이터가 없습니다. 전역 변수: ${e}`),{identifier:o,locales:["ko","en"],name:{ko:o,en:o},description:{ko:"",en:""},version:"1.0.0",type:"admin"})}catch(e){return Ze.error("템플릿 메타데이터 로드 실패",e),{identifier:o,locales:["ko","en"],name:{ko:o,en:o},description:{ko:"",en:""},version:"1.0.0",type:"admin"}}}function dy(){const e=(typeof window<"u"?window.__templateApp:void 0)?.globalState?.appConfig?.supportedLocales,a=se.templateMetadata?.locales;return{$locale:se.locale,$locales:Array.isArray(e)&&e.length>0?e:a||["ko","en"],$templateLocales:a||["ko","en"],$templateId:se.templateId}}async function Ao(o){try{if(Ze.log("템플릿 엔진 초기화 시작",o),se.isInitialized)throw new Error("템플릿 엔진이 이미 초기화되었습니다. destroyTemplate()을 먼저 호출하세요.");if(!o.templateId)throw new z_;Eo=o.debug??!1,typeof window<"u"&&(window.G7Config||(window.G7Config={}),window.G7Config.debug=Eo),AA(),se.templateId=o.templateId,se.locale=o.locale||"ko",Ze.log("엔진 인스턴스 생성 중..."),se.registry=da.getInstance(),se.bindingEngine=new wn,se.translationEngine=Cr.getInstance(),o.cacheVersion!==void 0&&o.cacheVersion>0&&se.translationEngine.setCacheVersion(o.cacheVersion),se.translationContext={templateId:o.templateId,locale:se.locale},se.actionDispatcher=new lo({},se.translationEngine,se.translationContext),eC(se.actionDispatcher),Ze.log("다국어 파일 로드 중...",o.templateId,se.locale);const e="en",a=[se.translationEngine.loadTranslations(o.templateId,se.locale).then(()=>{Ze.log(`다국어 파일 로드 완료: ${se.locale}`)}).catch(r=>{Ze.warn(`다국어 파일 로드 실패 (${se.locale}):`,r instanceof Error?r.message:r)})];se.locale!==e&&a.push(se.translationEngine.loadTranslations(o.templateId,e).then(()=>{Ze.log(`폴백 다국어 파일 로드 완료: ${e}`)}).catch(r=>{Ze.warn(`폴백 다국어 파일 로드 실패 (${e}):`,r instanceof Error?r.message:r)})),await Promise.allSettled(a),Ze.log("템플릿 메타데이터 로드 중...",o.templateId),se.templateMetadata=await RA(o.templateId),se.isInitialized=!0,Ze.log("템플릿 엔진 초기화 완료 (ComponentRegistry는 별도 로드)")}catch(e){throw Ze.error("템플릿 엔진 초기화 실패",e),se.isInitialized=!1,se.registry=null,se.bindingEngine=null,se.translationEngine=null,se.actionDispatcher=null,e}}let xo=null;function gf(){const o=window.G7Core;return o?.__LayoutEditorChrome?Promise.resolve(o.__LayoutEditorChrome):xo||(xo=new Promise((e,a)=>{const r=window.G7Config?.coreEditorAsset||"/build/core/layout-editor.min.js",i="g7-layout-editor-bundle",l=document.getElementById(i),u=()=>{const f=window.G7Core?.__LayoutEditorChrome;f?e(f):a(new Error("편집기 번들 로드됨 — 그러나 __LayoutEditorChrome 미노출"))};if(window.G7Core=window.G7Core||{},window.G7Core.__onChromeReady=u,l){l.addEventListener("load",u,{once:!0}),l.addEventListener("error",()=>a(new Error(`편집기 번들 로드 실패: ${r}`)),{once:!0});return}const d=document.createElement("script");d.id=i,d.src=r,d.async=!1,d.addEventListener("load",u,{once:!0}),d.addEventListener("error",()=>{xo=null,a(new Error(`편집기 번들 로드 실패: ${r}`))},{once:!0}),document.head.appendChild(d)}),xo)}async function ls(o){try{if(Ze.log("템플릿 렌더링 시작",o),!se.isInitialized)throw new Error("템플릿 엔진이 초기화되지 않았습니다. initTemplateEngine()을 먼저 호출하세요.");if(!o.containerId)throw new Error("containerId는 필수입니다.");if(!o.layoutJson)throw new Error("layoutJson은 필수입니다.");const e=document.getElementById(o.containerId);if(!e)throw new Error(`컨테이너를 찾을 수 없습니다: #${o.containerId}`);const a=dy();if(se.containerId=o.containerId,se.currentLayoutJson=o.layoutJson,se.currentDataContext={...a,...o.dataContext||{}},se.translationContext=o.translationContext||{templateId:se.templateId||"",locale:se.locale},se.reactRoot||(Ze.log("React Root 생성"),se.reactRoot=Qu.createRoot(e)),typeof window<"u"){const l=iy(window.location.pathname);if(l){Ze.log("레이아웃 편집기 모드 진입",l);let u;try{u=await gf()}catch(f){Ze.error("레이아웃 편집기 번들 로드 실패",f),cf.render(o.containerId,{title:"레이아웃 편집기 로드 실패",message:f instanceof Error?f.message:"편집기 번들을 불러오지 못했습니다.",icon:"fas fa-triangle-exclamation",showStack:!1,showReloadButton:!0,debug:Eo});return}const d=Ye.createElement(u,{templateIdentifier:l.templateIdentifier,initialLocale:se.locale});se.reactRoot.render(Ye.createElement(uc,{translationEngine:se.translationEngine,translationContext:se.translationContext,children:Ye.createElement(Od,{children:Ye.createElement(Jl,{children:Ye.createElement(af,{children:d})})})}));return}}const r=o.layoutJson.components||[];if(r.length===0){Ze.warn("렌더링할 컴포넌트가 없습니다.");return}const i=o.layoutJson.modals||[];if(Ze.log("modals 배열:",i),Ze.log("modals 개수:",i.length),Ze.log("DynamicRenderer로 렌더링 시작"),se.reactRoot.render(Ye.createElement(uc,{translationEngine:se.translationEngine,translationContext:se.translationContext},Ye.createElement(Od,null,Ye.createElement(Jl,null,Ye.createElement(af,null,[...r.map((l,u)=>{const d=se.currentLayoutJson?.layout_name||"";return Ye.createElement(Ha,{key:d&&!l._fromBase?`${l.id}_${d}`:l.id,componentDef:l,dataContext:se.currentDataContext,translationContext:se.translationContext,registry:se.registry,bindingEngine:se.bindingEngine,translationEngine:se.translationEngine,actionDispatcher:se.actionDispatcher,isRootRenderer:u===0,layoutKey:d})}),Ye.createElement(Jp,{key:"__modal_parent_context_provider"},i.map(l=>{const u=se.currentDataContext._global?.modalStack||[],f=u.includes(l.id)||se.currentDataContext._global?.activeModal===l.id,g=u.indexOf(l.id),m=g>=0?50+g:50,b=window.__g7LayoutContextStack||[],S=b[b.length-1]?.dataContext,E=Ye.createElement(Ha,{key:`modal_${l.id}_renderer`,componentDef:{...l,props:{...l.props,isOpen:f,style:{...l.props?.style,zIndex:m},onClose:se.actionDispatcher?.createHandler({type:"click",handler:"closeModal"},se.currentDataContext)}},dataContext:se.currentDataContext,translationContext:se.translationContext,registry:se.registry,bindingEngine:se.bindingEngine,translationEngine:se.translationEngine,actionDispatcher:se.actionDispatcher,parentDataContext:S});return l.data_sources&&l.data_sources.length>0?Ye.createElement(ay,{key:`modal_${l.id}`,isOpen:f,modalId:l.id,dataSources:l.data_sources,dataContext:se.currentDataContext,globalStateUpdater:se.actionDispatcher?.getGlobalStateUpdater(),bindingEngine:se.bindingEngine,debug:Eo,children:E}):E}))]))))),Ze.log("템플릿 렌더링 완료"),_o.length>0){Ze.log(`대기 중인 데이터 업데이트 ${_o.length}건 적용`);const l=[..._o];_o.length=0;for(const{data:u,options:d}of l)cs(u,d)}}catch(e){throw Ze.error("템플릿 렌더링 실패",e),e}}function cs(o,e){try{if(Ze.log("템플릿 데이터 업데이트 시작",Object.keys(o)),!se.isInitialized)throw new Error("템플릿 엔진이 초기화되지 않았습니다.");if(!se.reactRoot||!se.currentLayoutJson){Ze.log("React 미준비 - 데이터 업데이트 큐잉:",Object.keys(o)),_o.push({data:o,options:e});return}const a=dy(),r={...se.currentDataContext._global||{},...o._global||{}},i=sC(se.currentDataContext._localInit,o._localInit);se.currentDataContext={...a,...se.currentDataContext,...o,_global:r,_localInit:i},r._local!==void 0&&(se.currentDataContext._local=r._local),r._computed!==void 0&&(se.currentDataContext._computed=r._computed);const l=se.currentLayoutJson.components||[],u=se.currentLayoutJson.modals||[];Ze.log("updateTemplateData - modals 배열:",u),Ze.log("updateTemplateData - modals 개수:",u.length),Ze.log("updateTemplateData - activeModal:",se.currentDataContext._global?.activeModal);const d=()=>{se.reactRoot.render(Ye.createElement(uc,{translationEngine:se.translationEngine,translationContext:se.translationContext},Ye.createElement(Od,null,Ye.createElement(Jl,null,Ye.createElement(af,null,[...l.map((f,g)=>{const m=se.currentLayoutJson?.layout_name||"";return Ye.createElement(Ha,{key:m&&!f._fromBase?`${f.id}_${m}`:f.id,componentDef:f,dataContext:se.currentDataContext,translationContext:se.translationContext,registry:se.registry,bindingEngine:se.bindingEngine,translationEngine:se.translationEngine,actionDispatcher:se.actionDispatcher,isRootRenderer:g===0,layoutKey:m})}),Ye.createElement(Jp,{key:"__modal_parent_context_provider_update"},u.map(f=>{const g=se.currentDataContext._global?.modalStack||[],b=g.includes(f.id)||se.currentDataContext._global?.activeModal===f.id,v=g.indexOf(f.id),S=v>=0?50+v:50,E=window.__g7LayoutContextStack||[],T=E[E.length-1]?.dataContext,L=Ye.createElement(Ha,{key:`modal_${f.id}_renderer`,componentDef:{...f,props:{...f.props,isOpen:b,style:{...f.props?.style,zIndex:S},onClose:se.actionDispatcher?.createHandler({type:"click",handler:"closeModal"},se.currentDataContext)}},dataContext:se.currentDataContext,translationContext:se.translationContext,registry:se.registry,bindingEngine:se.bindingEngine,translationEngine:se.translationEngine,actionDispatcher:se.actionDispatcher,parentDataContext:T});return f.data_sources&&f.data_sources.length>0?Ye.createElement(ay,{key:`modal_${f.id}`,isOpen:b,modalId:f.id,dataSources:f.data_sources,dataContext:se.currentDataContext,globalStateUpdater:se.actionDispatcher?.getGlobalStateUpdater(),bindingEngine:se.bindingEngine,debug:Eo,children:L}):L}))])))))};e?.sync?(Ze.log("재렌더링 시작 (sync mode - 즉시 렌더링)"),d()):(Ze.log("재렌더링 시작 (with startTransition)"),N.startTransition(()=>{d()})),Ze.log("템플릿 데이터 업데이트 완료")}catch(a){throw Ze.error("템플릿 데이터 업데이트 실패",a),a}}function yc(){try{Ze.log("템플릿 정리 시작"),se.reactRoot&&(Ze.log("React Root 언마운트"),se.reactRoot.unmount(),se.reactRoot=null),se.templateId=null,se.locale="ko",se.isInitialized=!1,se.containerId=null,se.currentLayoutJson=null,se.currentDataContext={},se.translationContext={templateId:"",locale:"ko"},se.registry=null,se.bindingEngine=null,se.translationEngine=null,se.actionDispatcher=null,se.templateMetadata=null,Ze.log("템플릿 정리 완료")}catch(o){throw Ze.error("템플릿 정리 실패",o),o}}function Za(){return Object.freeze({...se})}function fy(){return se.actionDispatcher}const To={initTemplateEngine:Ao,renderTemplate:ls,updateTemplateData:cs,destroyTemplate:yc,getState:Za};typeof window<"u"&&(window.G7Core||(window.G7Core={}),window.G7Core.TemplateEngine=To,CA({getState:()=>({translationEngine:se.translationEngine,translationContext:se.translationContext,bindingEngine:se.bindingEngine,actionDispatcher:se.actionDispatcher,templateMetadata:se.templateMetadata}),transitionManager:Na,responsiveManager:ui,webSocketManager:hc}),Ze.log("전역 객체 window.G7Core.TemplateEngine에 노출됨"));const za=Object.freeze(Object.defineProperty({__proto__:null,DataSourceManager:va,LayoutLoader:tf,TemplateApp:So,TemplateEngine:To,default:To,destroyTemplate:yc,getActionDispatcher:fy,getState:Za,initTemplateApp:hf,initTemplateEngine:Ao,loadLayoutEditorBundle:gf,renderTemplate:ls,updateTemplateData:cs},Symbol.toStringTag,{value:"Module"}));Ft.DataSourceManager=va,Ft.LayoutLoader=tf,Ft.TemplateApp=So,Ft.TemplateEngine=To,Ft.default=To,Ft.destroyTemplate=yc,Ft.getActionDispatcher=fy,Ft.getState=Za,Ft.initTemplateApp=hf,Ft.initTemplateEngine=Ao,Ft.loadLayoutEditorBundle=gf,Ft.renderTemplate=ls,Ft.updateTemplateData=cs,Object.defineProperties(Ft,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})})(this.G7Core=this.G7Core||{}); -//# sourceMappingURL=template-engine.min.js.map diff --git a/routes/api.php b/routes/api.php index 325ad41f..25dad9f6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -707,6 +707,8 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin' Route::get('{user}', [AdminUserController::class, 'show'])->middleware('permission:admin,core.users.read')->name('api.admin.users.show'); Route::put('{user}', [AdminUserController::class, 'update'])->middleware('permission:admin,core.users.update')->name('api.admin.users.update'); Route::delete('{user}', [AdminUserController::class, 'destroy'])->middleware('permission:admin,core.users.delete')->name('api.admin.users.destroy'); + // 계정 잠금 해제 — 영구 잠금(무한대) 계정의 유일한 복구 경로 + Route::post('{user}/unlock', [AdminUserController::class, 'unlock'])->middleware('permission:admin,core.users.update')->name('api.admin.users.unlock'); }); // 스케줄 관리 diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index fe1b89f8..aa83ddb0 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -8,6 +8,9 @@ ### Added +- 회원 상세에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 로그인 실패가 누적돼 잠긴 계정, 특히 무기한 잠긴 계정을 관리자가 이 화면에서 바로 풀 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 보안에 비밀번호 최소 길이·특수문자 필수 항목을 추가하고, 로그인 시도 제한 항목에 IP 단위 제한 계산 방식 안내를 덧붙였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + - 일반 설정에 에셋 파일 서빙 방식 항목을 추가했습니다. 정적 파일 최적화 규칙(nginx `location ~* \.(js|css|json|...)$` 블록 등)이 확장자 주소를 가로채 화면이 뜨지 않을 때 확장자 없는 주소로 전환할 수 있습니다. "자동 감지" 버튼을 누르면 판정 결과가 항목 아래에 인라인으로 표시되며, 확장자를 가로채는 환경이면 원인이 되는 서버 규칙까지 안내합니다. 평소에는 자동으로 선택되므로 화면이 정상이면 바꿀 필요가 없습니다. - 저장된 에셋 파일 서빙 방식이 실제 서버 환경과 다르면 대시보드에서 안내합니다. 브라우저는 방식이 어긋나도 스스로 보정해 화면을 띄우지만 검색엔진 봇에는 보정이 적용되지 않으므로, 설정을 맞춰 두어야 검색 노출이 정상 유지됩니다. 안내에서 바로 환경설정으로 이동할 수 있습니다. - SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다. @@ -23,6 +26,7 @@ ### Fixed +- 회원 목록에서 검색을 실행할 때만 목록 로딩 표시가 나타나지 않던 문제를 수정했습니다. 정렬·표시 개수 변경·페이지 이동은 정상 표시되고 있었습니다. - 관리자 회원·역할·활동이력·알림 로그·모듈/플러그인·템플릿·스케줄 목록에서 상세나 등록/수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지던 문제를 수정했습니다. 새로고침 버튼도 걸어 둔 조건을 지우지 않고 보고 있던 목록을 그대로 다시 부릅니다. 초기화 버튼과 탭 전환은 종전처럼 조건을 비웁니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 회원 목록에서 회원을 열어 보거나 등록/수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지던 문제를 수정했습니다. 새로고침 버튼도 걸어 둔 조건을 지우지 않습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 회원 목록에서 뒤쪽 페이지를 보던 중 검색하면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-security-password-policy.test.ts b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-security-password-policy.test.ts new file mode 100644 index 00000000..f3bb1b2f --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-security-password-policy.test.ts @@ -0,0 +1,159 @@ +/** + * 보안 설정 탭 — 비밀번호 정책 입력 계약 테스트 (#493 C4) + * + * @description + * 비밀번호 최소 길이 입력의 경계값은 서버가 내려주는 `_meta.limits` 를 바인딩해야 한다. + * 리터럴로 두면 저장 규칙(`SaveSettingsRequest`)이 바뀌어도 화면이 따라오지 않아, + * 화면이 권하는 값이 저장 단계에서 422 가 되거나 반대로 저장 가능한 값을 입력조차 못 한다. + * + * 바인딩이 있다는 것만으로는 부족하다 — 참조하는 한계값 키가 서버 응답에 실제로 존재해야 + * 한다. `_meta.limits` 는 `config('core.settings_limits')` 를 그대로 실어 내리므로, 오타난 + * 키는 예외 없이 조용히 `undefined` 가 되어 항상 폴백으로 떨어진다. 그러면 바인딩이 있어도 + * 리터럴을 쓴 것과 결과가 같아지고, 이 계약이 막으려던 결함이 그대로 재현된다. + * 그래서 세 지점(설정 파일 · 서버 규칙 · 화면 바인딩)이 같은 키·같은 값을 가리키는지 대조한다. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import tabSecurity from '../../layouts/partials/admin_settings/_tab_security.json'; + +/** + * 저장소 루트를 위로 훑어 찾는다 (`_bundled` 여부로 깊이가 달라지므로 상대경로 고정 금지). + * + * @returns 저장소 루트 절대경로 + */ +function repositoryRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'config/core.php'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('config/core.php 를 가진 저장소 루트를 찾지 못했습니다.'); +} + +const root = repositoryRoot(); +const configSource = fs.readFileSync(path.join(root, 'config/core.php'), 'utf-8'); +const requestSource = fs.readFileSync( + path.join(root, 'app/Http/Requests/Settings/SaveSettingsRequest.php'), + 'utf-8', +); + +/** + * `config/core.php` 의 `settings_limits` 블록을 키 → 값으로 읽는다. + * + * @returns 한계값 키 맵 + */ +function configuredLimits(): Record { + const block = /'settings_limits'\s*=>\s*\[([\s\S]*?)\n\s*\],/.exec(configSource); + expect(block, "config/core.php 에서 'settings_limits' 블록을 찾지 못했습니다.").not.toBeNull(); + + const limits: Record = {}; + + for (const line of block![1].split('\n')) { + const matched = /'([a-z0-9_]+)'\s*=>\s*(-?[\d.]+)\s*,/.exec(line); + if (matched) { + limits[matched[1]] = Number(matched[2]); + } + } + + expect(Object.keys(limits).length).toBeGreaterThan(0); + + return limits; +} + +/** + * 레이아웃 트리에서 props.name 이 일치하는 Input 노드를 찾는다. + * + * @param node 탐색 시작 노드 + * @param fieldName 찾을 필드명 + * @returns 찾은 노드 또는 null + */ +function findByName(node: any, fieldName: string): any { + if (!node || typeof node !== 'object') return null; + + if (node.name === 'Input' && node.props?.name === fieldName) { + return node; + } + + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + const found = findByName(item, fieldName); + if (found) return found; + } + } else if (child && typeof child === 'object') { + const found = findByName(child, fieldName); + if (found) return found; + } + } + + return null; +} + +/** + * 화면 바인딩 표현식에서 참조 한계값 키와 폴백을 뽑는다. + * + * @param expression 레이아웃 min/max 표현식 + * @returns 한계값 키와 폴백 숫자 + */ +function parseBinding(expression: string): { limitKey: string; fallback: number } { + const matched = /_meta\??\.limits\??\.([a-z0-9_]+)\s*\?\?\s*(-?[\d.]+)/.exec(expression); + expect(matched, `한계값 바인딩 형태가 아닙니다: ${expression}`).not.toBeNull(); + + return { limitKey: matched![1], fallback: Number(matched![2]) }; +} + +describe('보안 설정 — 비밀번호 최소 길이', () => { + const limits = configuredLimits(); + const input = findByName(tabSecurity, 'security.password_min_length'); + + it('입력 필드가 존재한다', () => { + expect(input).not.toBeNull(); + expect(input.props.type).toBe('number'); + }); + + it.each([ + ['min', 'security_password_min_length_min'], + ['max', 'security_password_min_length_max'], + ])('%s 이 서버가 실제로 내려주는 한계값 키를 가리킨다', (bound, expectedKey) => { + const { limitKey } = parseBinding(String(input.props[bound])); + + // 오타난 키는 응답에 없어 항상 undefined → 영구 폴백. 바인딩 존재만으로는 못 잡는다. + expect(limits, `config/core.php 에 '${limitKey}' 가 없습니다 — 화면이 영구히 폴백으로 떨어집니다.`) + .toHaveProperty(limitKey); + expect(limitKey).toBe(expectedKey); + }); + + it.each([ + ['min', 'security_password_min_length_min'], + ['max', 'security_password_min_length_max'], + ])('%s 의 폴백이 설정 파일 값과 같다', (bound, key) => { + const { fallback } = parseBinding(String(input.props[bound])); + + expect(fallback).toBe(limits[key]); + }); + + it('서버 저장 규칙이 화면과 같은 한계값 키를 읽는다', () => { + const rule = /'security\.password_min_length'\s*=>\s*\[([\s\S]*?)\],/.exec(requestSource); + expect(rule, 'SaveSettingsRequest 에서 password_min_length 규칙을 찾지 못했습니다.').not.toBeNull(); + + for (const bound of ['min', 'max'] as const) { + const { limitKey } = parseBinding(String(input.props[bound])); + expect(rule![1]).toContain(`core.settings_limits.${limitKey}`); + } + }); + + it('특수문자 필수 토글이 함께 노출된다', () => { + const toggle = findByName(tabSecurity, 'security.require_password_special_char') + ?? JSON.stringify(tabSecurity).includes('security.require_password_special_char'); + + expect(toggle).toBeTruthy(); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx index d27d5434..70813f61 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx @@ -62,11 +62,15 @@ describe('환경설정 > SEO 탭 > Sitemap 생성 칸', () => { expect(field.props.type).toBe('number'); }); - it('분할 기준 칸의 허용 범위가 백엔드 검증 규칙(1000~50000)과 일치한다', () => { + // 허용 범위는 서버가 내려주는 한계값(_meta.limits)을 바인딩한다. 화면이 숫자를 직접 들면 + // 저장 규칙이 바뀔 때 따라오지 못해 "화면은 받는데 저장에서 422" 가 된다. + it('분할 기준 칸의 허용 범위가 서버 한계값을 바인딩한다', () => { const field = findFieldByName(seoTab, 'seo.sitemap_urls_per_file'); - expect(field.props.min).toBe(1000); - expect(field.props.max).toBe(50000); + expect(String(field.props.min)).toContain('_meta?.limits?.seo_sitemap_urls_per_file_min'); + expect(String(field.props.max)).toContain('_meta?.limits?.seo_sitemap_urls_per_file_max'); + expect(String(field.props.min)).toContain('?? 1000'); + expect(String(field.props.max)).toContain('?? 50000'); }); it('압축 칸이 seo.sitemap_gzip Toggle 로 존재한다', () => { @@ -130,8 +134,17 @@ describe('환경설정 > 고급 탭 > Sitemap 캐시 기준값 칸', () => { const advancedField = findFieldByName(advancedTab, 'advanced.seo_sitemap_cache_ttl'); const seoOverrideField = findFieldByName(seoTab, 'seo.sitemap_cache_ttl'); - expect(Number(advancedField.props.min)).toBe(Number(seoOverrideField.props.min)); - expect(Number(advancedField.props.max)).toBe(Number(seoOverrideField.props.max)); + // 두 칸 모두 서버 한계값 바인딩이므로 숫자 캐스팅은 NaN 이 된다(NaN === NaN 으로 통과해 + // 버리는 함정). 바인딩이 비어 있을 때 쓰는 폴백 숫자로 두 칸의 범위가 같은지 판정한다. + const fallbackOf = (value: unknown): number => { + const matched = /\?\?\s*([\d.]+)/.exec(String(value)); + expect(matched, `한계값 바인딩의 폴백을 찾지 못했습니다: ${String(value)}`).not.toBeNull(); + + return Number(matched![1]); + }; + + expect(fallbackOf(advancedField.props.min)).toBe(fallbackOf(seoOverrideField.props.min)); + expect(fallbackOf(advancedField.props.max)).toBe(fallbackOf(seoOverrideField.props.max)); }); it('Sitemap 캐시 칸이 읽기 전용 상태와 검증 오류를 형제 칸과 동일하게 반영한다', () => { diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-user-detail-unlock.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-user-detail-unlock.test.tsx new file mode 100644 index 00000000..9cc1a89e --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-user-detail-unlock.test.tsx @@ -0,0 +1,112 @@ +/** + * @file admin-user-detail-unlock.test.tsx + * @description 회원 상세 계정 잠금 상태 표시 + 관리자 잠금 해제 버튼 계약 검증 (C1) + * + * 테스트 대상: + * - templates/.../layouts/admin_user_detail.json (header_unlock_button / field_lock_status) + * + * 배경: 보안 설정의 잠금 시간 0(무한대)으로 잠긴 계정은 성공 로그인 자체가 불가하므로 + * 관리자 수동 해제 경로가 없으면 복구 불가가 된다. 화면에 해제 수단이 실제로 존재하는지 + * (그리고 잠기지 않은 계정에는 노출되지 않는지) 정적으로 고정한다. + */ + +import { describe, it, expect } from 'vitest'; +import { TranslationEngine } from '@core/template-engine/TranslationEngine'; +import userDetailLayout from '../../layouts/admin_user_detail.json'; +import adminKo from '../../lang/partial/ko/admin.json'; +import adminEn from '../../lang/partial/en/admin.json'; + +/** 레이아웃 트리에서 id 로 노드를 찾습니다. */ +function findById(node: unknown, id: string): any { + if (!node || typeof node !== 'object') return undefined; + const value = node as { id?: string; [key: string]: unknown }; + if (value.id === id) return value; + for (const child of Object.values(value)) { + if (Array.isArray(child)) { + for (const item of child) { + const found = findById(item, id); + if (found) return found; + } + continue; + } + const found = findById(child, id); + if (found) return found; + } + return undefined; +} + +describe('회원 상세 계정 잠금 해제 (C1)', () => { + it('잠금 해제 버튼이 잠긴 계정에서만 노출되어야 한다', () => { + const button = findById(userDetailLayout, 'header_unlock_button'); + + expect(button).toBeDefined(); + expect(button.if).toBe('{{user?.data?.is_locked === true}}'); + expect(button.props.disabled).toBe('{{user?.data?.abilities?.can_update !== true}}'); + }); + + it('잠금 해제 버튼이 관리자 해제 엔드포인트를 POST 로 호출해야 한다', () => { + const button = findById(userDetailLayout, 'header_unlock_button'); + const action = button.actions[0]; + + expect(action.handler).toBe('apiCall'); + // target 은 액션 top-level 이어야 한다 (params 내부면 URL 이 undefined 로 깨진다) + expect(action.target).toBe('/api/admin/users/{{route?.id}}/unlock'); + expect(action.params.method).toBe('POST'); + expect(action.auth_required).toBe(true); + }); + + it('해제 성공 시 상세 데이터를 다시 불러오고 성공 안내를 노출해야 한다', () => { + const button = findById(userDetailLayout, 'header_unlock_button'); + const onSuccess = button.actions[0].onSuccess; + + const refetch = onSuccess.find((a: any) => a.handler === 'refetchDataSource'); + expect(refetch).toBeDefined(); + // params.id 가 아니라 params.dataSourceId 여야 인식된다 + expect(refetch.params.dataSourceId).toBe('user'); + + const toast = onSuccess.find((a: any) => a.handler === 'toast'); + expect(toast).toBeDefined(); + expect(toast.params.message).toBe('$t:admin.users.detail.unlock_success'); + }); + + it('잠금 상태 필드가 영구/기한부/정상 3분기를 모두 표시해야 한다', () => { + const field = findById(userDetailLayout, 'field_lock_status'); + expect(field).toBeDefined(); + + const conditions = field.children + .filter((c: any) => typeof c.if === 'string') + .map((c: any) => c.if); + + expect(conditions).toContain('{{user?.data?.locked_permanently === true}}'); + expect(conditions).toContain( + '{{user?.data?.locked_permanently !== true && user?.data?.is_locked === true}}', + ); + expect(conditions).toContain('{{user?.data?.is_locked !== true}}'); + }); + + it('ko/en partial 에 잠금 해제 관련 키가 모두 정의되어야 한다', () => { + for (const pack of [adminKo, adminEn] as any[]) { + expect(pack.users.detail.unlock).toBeTruthy(); + expect(pack.users.detail.unlock_success).toBeTruthy(); + expect(pack.users.detail.locked_permanently_value).toBeTruthy(); + expect(pack.users.detail.locked_until_value).toBeTruthy(); + expect(pack.users.detail.not_locked_value).toBeTruthy(); + expect(pack.users.form.fields.lock_status).toBeTruthy(); + } + }); + + it('TranslationEngine 이 잠금 해제 키를 ko/en 으로 해석해야 한다', () => { + const engine = TranslationEngine.getInstance(); + const templateId = 'sirsoft-admin_basic'; + + (engine as any).translations.set(`${templateId}:ko`, { admin: adminKo }); + (engine as any).translations.set(`${templateId}:en`, { admin: adminEn }); + + expect(engine.translate('admin.users.detail.unlock', { templateId, locale: 'ko' })).toBe( + '잠금 해제', + ); + expect(engine.translate('admin.users.detail.unlock', { templateId, locale: 'en' })).toBe( + 'Unlock', + ); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/dist/js/components.iife.js b/templates/_bundled/sirsoft-admin_basic/dist/js/components.iife.js index bdeb4559..ad0a10a8 100644 --- a/templates/_bundled/sirsoft-admin_basic/dist/js/components.iife.js +++ b/templates/_bundled/sirsoft-admin_basic/dist/js/components.iife.js @@ -258,4 +258,3 @@ self.addEventListener('message', async (e) => { `,role:"option","aria-selected":_,"data-item-value":C.value,"data-item-index":N,"data-item-selected":_,children:c?.item?Dp(c.item,{item:C,index:N,isSelected:_},`item-${N}`):a||k(C,_)},C.value)})})})]})};function NE(t){const e=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(t);return e?{r:parseInt(e[1],16),g:parseInt(e[2],16),b:parseInt(e[3],16)}:null}const OE=({value:t,checked:e=!1,icon:n,label:r,variant:s="blue",customColor:o,className:i="",style:a,onChange:l,id:c,editorAttrs:d})=>{const[u,f]=v.useState(!1),g="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border cursor-pointer transition-all";if(o){const b=NE(o),S=document.documentElement.classList.contains("dark");let x;return e?x={backgroundColor:o,color:"#ffffff",borderColor:o,opacity:u?.85:1}:x=b?{backgroundColor:`rgba(${b.r}, ${b.g}, ${b.b}, ${u?S?.25:.12:S?.15:.06})`,color:o,borderColor:`rgba(${b.r}, ${b.g}, ${b.b}, ${S?.3:.2})`}:{},h.jsxs(rn,{className:`${g} ${i}`,style:{...x,...a},onMouseEnter:()=>f(!0),onMouseLeave:()=>f(!1),id:c,...d,children:[h.jsx(wt,{type:"checkbox",value:t,checked:e,onChange:E=>{l?.(E)},className:"hidden"}),n&&h.jsx(re,{name:n,className:"w-4 h-4"}),h.jsx(I,{children:r})]})}const y=e?{green:"bg-green-600 dark:bg-green-600 text-white dark:text-white border-green-700 dark:border-green-500 hover:bg-green-700 dark:hover:bg-green-500",orange:"bg-orange-600 dark:bg-orange-600 text-white dark:text-white border-orange-700 dark:border-orange-500 hover:bg-orange-700 dark:hover:bg-orange-500",yellow:"bg-yellow-600 dark:bg-yellow-600 text-white dark:text-white border-yellow-700 dark:border-yellow-500 hover:bg-yellow-700 dark:hover:bg-yellow-500",gray:"bg-gray-600 dark:bg-gray-500 text-white dark:text-white border-gray-700 dark:border-gray-400 hover:bg-gray-700 dark:hover:bg-gray-400",blue:"bg-blue-600 dark:bg-blue-600 text-white dark:text-white border-blue-700 dark:border-blue-500 hover:bg-blue-700 dark:hover:bg-blue-500"}[s]:{green:"bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30",orange:"bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800 hover:bg-orange-100 dark:hover:bg-orange-900/30",yellow:"bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 hover:bg-yellow-100 dark:hover:bg-yellow-900/30",gray:"bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600",blue:"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30"}[s];return h.jsxs(rn,{className:`${g} ${y} ${i}`,style:a,id:c,...d,children:[h.jsx(wt,{type:"checkbox",value:t,checked:e,onChange:b=>{l?.(b)},className:"hidden"}),n&&h.jsx(re,{name:n,className:"w-4 h-4"}),h.jsx(I,{children:r})]})},TE=({label:t,icon:e,iconPosition:n="left",position:r="right",className:s="",style:o,children:i})=>{const[a,l]=v.useState(!1),[c,d]=v.useState({top:0,left:0}),u=v.useRef(null),f=v.useRef(null),g=v.useCallback(()=>{if(!u.current)return;const p=u.current.getBoundingClientRect(),y=224;let b=r==="right"?p.right-y:p.left;const S=p.bottom+8;b<8&&(b=8),b+y>window.innerWidth-8&&(b=window.innerWidth-y-8),d({top:S,left:b})},[r]);v.useEffect(()=>{a&&g()},[a,g]),v.useEffect(()=>{if(!a)return;const p=()=>{l(!1)};return window.addEventListener("scroll",p,!0),window.addEventListener("resize",p),()=>{window.removeEventListener("scroll",p,!0),window.removeEventListener("resize",p)}},[a]),v.useEffect(()=>{const p=y=>{const b=y.target,S=u.current?.contains(b),x=f.current?.contains(b);!S&&!x&&l(!1)};return a&&document.addEventListener("mousedown",p),()=>{document.removeEventListener("mousedown",p)}},[a]);const m=a?On.createPortal(h.jsx("div",{ref:f,className:"fixed z-[9999] w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden",style:{top:c.top,left:c.left},onClick:()=>l(!1),children:i}),document.body):null;return h.jsxs(I,{className:`relative inline-block ${s}`,style:o,children:[h.jsxs(oe,{ref:u,onClick:()=>l(!a),className:"flex items-center gap-2",children:[n==="left"&&e&&h.jsx(re,{name:e,className:"w-4 h-4"}),t&&h.jsx(Q,{className:"text-sm font-medium",children:t}),n==="right"&&e&&h.jsx(re,{name:e,className:"w-4 h-4"})]}),m]})},AE=({label:t,icon:e,variant:n="default",disabled:r=!1,divider:s=!1,className:o="",style:i,onClick:a})=>{if(s)return h.jsx(I,{className:"h-px bg-gray-200 dark:bg-gray-700 my-1",style:i});const l=n==="danger"?"text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20":"text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",c=r?"opacity-50 cursor-not-allowed":"cursor-pointer",d=()=>{!r&&a&&a()};return h.jsxs(I,{onClick:d,className:`flex items-center gap-3 px-4 py-3 transition-colors ${l} ${c} ${o}`,style:i,children:[e&&h.jsx(re,{name:e,className:"w-4 h-4"}),t&&h.jsx(Q,{className:"text-sm font-medium",children:t})]})},DE=()=>window.G7Core,$c=v.createContext(null),Ip=()=>{const t=v.useContext($c);if(!t){const e=DE(),n=e?.locale?.current?.()||"ko",r=e?.locale?.supported?.()||["ko","en"];return{activeLocale:n,getActiveLocale:()=>e?.locale?.current?.()||"ko",supportedLocales:r,isInsideMultilingualTabPanel:!1}}return{...t,isInsideMultilingualTabPanel:!0}},IE=()=>window.G7Core,LE=({value:t,onChange:e,placeholder:n,readOnly:r,disabled:s,hasError:o})=>{const{activeLocale:i,getActiveLocale:a}=Ip(),l=typeof t=="string"?{[i]:t}:t||{},c=v.useCallback(u=>{const f=a();e({...l,[f]:u.target.value})},[l,a,e]),d=o?"border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500":"border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500";return h.jsx(wt,{type:"text",value:l[i]||"",onChange:c,placeholder:n,readOnly:r,disabled:s,className:`w-full px-3 py-2 text-sm border ${d} rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2`})},PE=(t,e)=>{if(!e)return!1;const n=e.startsWith("!"),r=n?e.slice(1):e,s=!!t[r];return n?!s:s},$E=({column:t,item:e,index:n,onValueChange:r,readOnly:s,error:o})=>{const i=e[t.key],a=s||t.readOnly||PE(e,t.readOnlyCondition)||t.disabled,l=!!o,c=l?"border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500":"border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500",d=v.useCallback(f=>{r(t.key,f)},[t.key,r]),u=f=>h.jsxs(I,{className:"w-full",children:[f,l&&h.jsx(Q,{className:"text-xs text-red-500 dark:text-red-400 mt-1 block",children:o})]});switch(t.type){case"input":return u(h.jsx(wt,{type:"text",value:i||"",onChange:f=>d(f.target.value),placeholder:t.placeholder,readOnly:a,disabled:a,className:`w-full px-3 py-2 text-sm border ${c} rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${t.className||""}`}));case"number":return u(h.jsx(wt,{type:"number",value:i??"",onChange:f=>d(f.target.value?Number(f.target.value):null),placeholder:t.placeholder,min:t.min,max:t.max,step:t.step,readOnly:a,disabled:a,className:`w-full px-3 py-2 text-sm border ${c} rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${t.className||""}`}));case"select":return u(h.jsxs(Pr,{value:i??"",onChange:f=>d(f.target.value),disabled:a,className:`w-full px-3 py-2 text-sm border ${c} rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 ${t.className||""}`,children:[h.jsx("option",{value:"",children:"선택하세요"}),t.options?.map(f=>h.jsx("option",{value:f.value,disabled:f.disabled,children:f.label},f.value))]}));case"textarea":return u(h.jsx(Zn,{value:i||"",onChange:f=>d(f.target.value),placeholder:t.placeholder,readOnly:a,disabled:a,className:`w-full px-3 py-2 text-sm border ${c} rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 resize-none ${t.className||""}`,rows:2}));case"multilingual":return u(h.jsx(LE,{value:i,onChange:f=>d(f),placeholder:t.placeholder,readOnly:a,disabled:a,hasError:l}));case"custom":return t.render?h.jsx(h.Fragment,{children:t.render(e,n,d)}):h.jsx(Q,{className:"text-gray-500 dark:text-gray-400",children:"-"});default:return h.jsx(Q,{className:"text-gray-900 dark:text-white text-sm",children:String(i??"")})}},FE=(t,e,n)=>{if(!t)return;if(n==="multilingual"){const o=IE()?.locale?.current?.()||"ko",i=`${e}.${o}`;return t[i]?.[0]}return t[e]?.[0]},zE=({item:t,index:e,id:n,columns:r,enableDrag:s=!0,showIndex:o=!0,canRemove:i=!0,rowActions:a,readOnly:l=!1,onValueChange:c,onRemove:d,className:u="",level:f=0,errors:g})=>{const{attributes:m,listeners:p,setNodeRef:y,transform:b,transition:S,isDragging:x}=Di({id:n,disabled:!s||l}),E={transform:Ln.Transform.toString(b),transition:S,opacity:x?.5:1},k=v.useCallback((_,w)=>{c?.(_,w)},[c]),C=v.useCallback(_=>{_.onClick?.(t,e)},[t,e]),N=f>0?{paddingLeft:`${f*24}px`}:{};return h.jsxs(I,{ref:y,style:E,className:`flex items-center gap-2 py-2 px-2 border-b border-gray-100 dark:border-gray-700 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${x?"bg-blue-50 dark:bg-blue-900/20 shadow-lg rounded-lg z-10":""} ${u}`,children:[s&&!l&&h.jsx(I,{...m,...p,className:"flex-shrink-0 w-6 h-6 flex items-center justify-center cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400",style:N,children:h.jsx(re,{name:"grip-vertical",className:"w-4 h-4"})}),o&&h.jsx(I,{className:"flex-shrink-0 w-8 text-center text-sm text-gray-500 dark:text-gray-400 font-medium",children:e+1}),r.map(_=>{const w=FE(g,_.key,_.type);return h.jsx(I,{className:`flex-1 min-w-0 ${_.width||""}`,style:_.width?{flex:"none",width:_.width}:{},children:h.jsx($E,{column:_,item:t,index:e,onValueChange:k,readOnly:l,error:w})},_.key)}),a&&a.length>0&&!l&&h.jsx(I,{className:"flex-shrink-0 flex items-center gap-1",children:a.map(_=>{const w=typeof _.disabled=="function"?_.disabled(t,e):_.disabled;return h.jsx(oe,{type:"button",onClick:()=>C(_),disabled:w,className:`p-1.5 rounded transition-colors ${_.danger?"text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600":"text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300"} disabled:opacity-50 disabled:cursor-not-allowed`,title:_.label,children:_.icon?h.jsx(re,{name:_.icon,className:"w-4 h-4"}):h.jsx(Q,{className:"text-xs",children:_.label})},_.key)})}),i&&!a&&!l&&h.jsx(I,{className:"flex-shrink-0",children:h.jsx(oe,{type:"button",onClick:d,className:"p-1.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors",title:"삭제",children:h.jsx(re,{name:"minus",className:"w-4 h-4"})})})]})},Lp=()=>window.G7Core,Fc=()=>`item_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,VE=t=>{const e={_id:Fc()};return t.forEach(n=>{if(n.type==="multilingual"){const s=Lp()?.locale?.all?.()||["ko","en"],o={};s.forEach(i=>{o[i]=""}),e[n.key]=o}else n.type==="number"?e[n.key]=null:(n.type,e[n.key]="")}),e},HE=(t,e)=>{if(!t)return{};const n=`fields.${e}.`,r={};return Object.keys(t).forEach(s=>{if(s.startsWith(n)){const o=s.substring(n.length);r[o]=t[s]}}),r},UE=({items:t=[],columns:e,onChange:n,onAddItem:r,onRemoveItem:s,onReorder:o,addLabel:i="항목 추가",enableDrag:a=!0,showIndex:l=!0,minItems:c=0,maxItems:d,rowActions:u,emptyMessage:f="항목이 없습니다.",className:g="",headerClassName:m="",rowClassName:p="",name:y,readOnly:b=!1,createDefaultItem:S,childrenKey:x,itemIdKey:E="_id",errors:k,id:C,editorAttrs:N})=>{const _=v.useRef(null),w=v.useRef([]),M=v.useRef(null),O=v.useMemo(()=>{let j=!1;for(const U of t)if(U[E]==null){j=!0;break}return j?t.map(U=>U[E]!=null?U:{...U,[E]:Fc()}):t},[t,E]);w.current=O,v.useEffect(()=>{M.current=null},[O]);const L=v.useCallback(j=>String(j[E]??""),[E]);v.useEffect(()=>{if(y){const j=Lp();j?.form&&(_.current=j.form)}},[y]);const F=Wl(qr(Ys,{activationConstraint:{distance:8}}),qr(Gs,{coordinateGetter:Ii})),$=v.useMemo(()=>O.map(j=>L(j)),[O,L]),D=v.useCallback(j=>{const{active:U,over:B}=j;if(B&&U.id!==B.id){const G=M.current??w.current,V=G.map(Z=>String(Z[E]??"")),K=V.indexOf(String(U.id)),X=V.indexOf(String(B.id));if(K!==-1&&X!==-1){const Z=Qr([...G],K,X);M.current=Z,n?.(Z),o?.(Z)}}},[n,o,E]),A=v.useCallback((j,U,B)=>{const V=[...M.current??w.current];V[j]={...V[j],[U]:B},M.current=V,n?.(V)},[n]),T=v.useCallback(()=>{const j=M.current??w.current;if(d&&j.length>=d)return;if(r){r();return}const U=S?S():VE(e);U[E]||(U[E]=Fc());const B=[...j,U];M.current=B,n?.(B)},[d,r,S,e,E,n]),z=v.useCallback(j=>{const U=M.current??w.current;if(U.length<=c)return;const B=U[j];if(s){s(j,B);return}const G=U.filter((V,K)=>K!==j);M.current=G,n?.(G)},[c,s,n]),H=O.length>c,W=!d||O.lengthh.jsxs(I,{className:`flex-1 min-w-0 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase ${j.width||""}`,style:j.width?{flex:"none",width:j.width}:{},children:[j.label,j.required&&h.jsx(Q,{className:"text-red-500 dark:text-red-400 ml-0.5",children:"*"})]},j.key)),(u||!b&&H)&&h.jsx(I,{className:"flex-shrink-0 w-8"})]}),O.length>0?h.jsx(tc,{sensors:F,collisionDetection:Gl,onDragEnd:D,children:h.jsx(Ai,{items:$,strategy:nc,children:h.jsx(I,{className:"border-x border-gray-200 dark:border-gray-700",children:O.map((j,U)=>{const B=L(j),G=HE(k,U);return h.jsx(zE,{id:B,item:j,index:U,columns:e,enableDrag:a,showIndex:l,canRemove:H,rowActions:u,readOnly:b,onValueChange:(V,K)=>A(U,V,K),onRemove:()=>z(U),className:p,errors:G},B)})})})}):h.jsx(I,{className:"border-x border-gray-200 dark:border-gray-700 py-8 text-center text-gray-500 dark:text-gray-400 text-sm",children:f}),!b&&W&&h.jsx(I,{className:"relative border border-gray-200 dark:border-gray-700 border-t-0 rounded-b-lg",children:h.jsx(I,{className:"flex items-center justify-center py-2",children:h.jsxs(oe,{type:"button",onClick:T,className:"flex items-center gap-1.5 px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors",children:[h.jsx(re,{name:"plus",className:"w-4 h-4"}),h.jsx(Q,{children:i})]})})}),(b||!W)&&O.length>0&&h.jsx(I,{className:"border-x border-b border-gray-200 dark:border-gray-700 rounded-b-lg h-1"})]})},Pp=()=>window.G7Core,BE=()=>Pp()?.locale?.supported?.()??["ko","en"],jE=()=>Pp()?.locale?.current?.()??"ko",WE={ko:"한국어",en:"English",ja:"日本語",zh:"中文"},GE=({children:t,className:e="",style:n,variant:r="default",defaultLocale:s,onLocaleChange:o,locales:i})=>{const a=v.useMemo(()=>i&&i.length>0?i:BE(),[i]),[l,c]=v.useState(s||jE());v.useEffect(()=>{if(a.length>0&&!a.includes(l)){const p=a[0];c(p),o?.(p)}},[a,l,o]);const d=v.useRef(l);d.current=l;const u=v.useCallback(()=>d.current,[]),f=v.useCallback(p=>{c(p),o?.(p)},[o]),g=v.useMemo(()=>({activeLocale:l,getActiveLocale:u,supportedLocales:a}),[l,u,a]),m=p=>{const y=p===l,b="px-4 py-2 text-sm font-medium transition-all";return r==="pills"?y?`${b} bg-blue-600 text-white rounded-lg`:`${b} text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg`:r==="underline"?y?`${b} text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400`:`${b} text-gray-600 dark:text-gray-400 border-b-2 border-transparent hover:border-gray-300 dark:hover:border-gray-500`:y?`${b} text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 border-b-2 border-blue-600`:`${b} text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 border-b-2 border-transparent`};return h.jsxs(I,{className:e,style:n,children:[h.jsx(Lo,{className:"flex gap-1 border-b border-gray-200 dark:border-gray-700 mb-4",children:a.map(p=>h.jsx(oe,{type:"button",onClick:()=>f(p),className:m(p),children:h.jsx(Q,{children:WE[p]||p})},p))}),h.jsx($c.Provider,{value:g,children:t})]})},YE=({label:t,required:e=!1,error:n,helperText:r,className:s="",labelClassName:o="",children:i,horizontal:a=!1,labelWidth:l="w-1/3",id:c,editorAttrs:d})=>{const u=a?`flex items-start gap-4 ${s}`:`space-y-1 ${s}`,f=a?`${l} flex-shrink-0 pt-2`:"",g=a?"flex-1":"";return h.jsxs(I,{className:u,id:c,...d,children:[t&&h.jsx(I,{className:f,children:h.jsxs(rn,{className:`block text-sm font-medium text-gray-700 dark:text-gray-300 ${o}`,children:[t,e&&h.jsx("span",{className:"ml-1 text-red-500 dark:text-red-400",children:"*"})]})}),h.jsxs(I,{className:`${g} ${n?"form-field-error":""}`.trim(),children:[i,r&&!n&&h.jsx(I,{className:"mt-1 text-xs text-gray-500 dark:text-gray-400",children:r}),n&&h.jsx(I,{className:"mt-1 text-xs text-red-600 dark:text-red-400",children:n})]})]})};function io(t){return t+.5|0}const or=(t,e,n)=>Math.max(Math.min(t,n),e);function ao(t){return or(io(t*2.55),0,255)}function ir(t){return or(io(t*255),0,255)}function Wn(t){return or(io(t/2.55)/100,0,1)}function $p(t){return or(io(t*100),0,100)}const hn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},zc=[..."0123456789ABCDEF"],XE=t=>zc[t&15],KE=t=>zc[(t&240)>>4]+zc[t&15],Zi=t=>(t&240)>>4===(t&15),qE=t=>Zi(t.r)&&Zi(t.g)&&Zi(t.b)&&Zi(t.a);function ZE(t){var e=t.length,n;return t[0]==="#"&&(e===4||e===5?n={r:255&hn[t[1]]*17,g:255&hn[t[2]]*17,b:255&hn[t[3]]*17,a:e===5?hn[t[4]]*17:255}:(e===7||e===9)&&(n={r:hn[t[1]]<<4|hn[t[2]],g:hn[t[3]]<<4|hn[t[4]],b:hn[t[5]]<<4|hn[t[6]],a:e===9?hn[t[7]]<<4|hn[t[8]]:255})),n}const QE=(t,e)=>t<255?e(t):"";function JE(t){var e=qE(t)?XE:KE;return t?"#"+e(t.r)+e(t.g)+e(t.b)+QE(t.a,e):void 0}const RE=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Fp(t,e,n){const r=e*Math.min(n,1-n),s=(o,i=(o+t/30)%12)=>n-r*Math.max(Math.min(i-3,9-i,1),-1);return[s(0),s(8),s(4)]}function eM(t,e,n){const r=(s,o=(s+t/60)%6)=>n-n*e*Math.max(Math.min(o,4-o,1),0);return[r(5),r(3),r(1)]}function tM(t,e,n){const r=Fp(t,1,.5);let s;for(e+n>1&&(s=1/(e+n),e*=s,n*=s),s=0;s<3;s++)r[s]*=1-e-n,r[s]+=e;return r}function nM(t,e,n,r,s){return t===s?(e-n)/r+(e.5?d/(2-o-i):d/(o+i),l=nM(n,r,s,d,o),l=l*60+.5),[l|0,c||0,a]}function Hc(t,e,n,r){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,n,r)).map(ir)}function Uc(t,e,n){return Hc(Fp,t,e,n)}function rM(t,e,n){return Hc(tM,t,e,n)}function sM(t,e,n){return Hc(eM,t,e,n)}function zp(t){return(t%360+360)%360}function oM(t){const e=RE.exec(t);let n=255,r;if(!e)return;e[5]!==r&&(n=e[6]?ao(+e[5]):ir(+e[5]));const s=zp(+e[2]),o=+e[3]/100,i=+e[4]/100;return e[1]==="hwb"?r=rM(s,o,i):e[1]==="hsv"?r=sM(s,o,i):r=Uc(s,o,i),{r:r[0],g:r[1],b:r[2],a:n}}function iM(t,e){var n=Vc(t);n[0]=zp(n[0]+e),n=Uc(n),t.r=n[0],t.g=n[1],t.b=n[2]}function aM(t){if(!t)return;const e=Vc(t),n=e[0],r=$p(e[1]),s=$p(e[2]);return t.a<255?`hsla(${n}, ${r}%, ${s}%, ${Wn(t.a)})`:`hsl(${n}, ${r}%, ${s}%)`}const Vp={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Hp={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};function lM(){const t={},e=Object.keys(Hp),n=Object.keys(Vp);let r,s,o,i,a;for(r=0;r>16&255,o>>8&255,o&255]}return t}let Qi;function cM(t){Qi||(Qi=lM(),Qi.transparent=[0,0,0,0]);const e=Qi[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:e.length===4?e[3]:255}}const dM=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function uM(t){const e=dM.exec(t);let n=255,r,s,o;if(e){if(e[7]!==r){const i=+e[7];n=e[8]?ao(i):or(i*255,0,255)}return r=+e[1],s=+e[3],o=+e[5],r=255&(e[2]?ao(r):or(r,0,255)),s=255&(e[4]?ao(s):or(s,0,255)),o=255&(e[6]?ao(o):or(o,0,255)),{r,g:s,b:o,a:n}}}function fM(t){return t&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${Wn(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`)}const Bc=t=>t<=.0031308?t*12.92:Math.pow(t,1/2.4)*1.055-.055,ls=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function hM(t,e,n){const r=ls(Wn(t.r)),s=ls(Wn(t.g)),o=ls(Wn(t.b));return{r:ir(Bc(r+n*(ls(Wn(e.r))-r))),g:ir(Bc(s+n*(ls(Wn(e.g))-s))),b:ir(Bc(o+n*(ls(Wn(e.b))-o))),a:t.a+n*(e.a-t.a)}}function Ji(t,e,n){if(t){let r=Vc(t);r[e]=Math.max(0,Math.min(r[e]+r[e]*n,e===0?360:1)),r=Uc(r),t.r=r[0],t.g=r[1],t.b=r[2]}}function Up(t,e){return t&&Object.assign(e||{},t)}function Bp(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=ir(t[3]))):(e=Up(t,{r:0,g:0,b:0,a:1}),e.a=ir(e.a)),e}function gM(t){return t.charAt(0)==="r"?uM(t):oM(t)}class lo{constructor(e){if(e instanceof lo)return e;const n=typeof e;let r;n==="object"?r=Bp(e):n==="string"&&(r=ZE(e)||cM(e)||gM(e)),this._rgb=r,this._valid=!!r}get valid(){return this._valid}get rgb(){var e=Up(this._rgb);return e&&(e.a=Wn(e.a)),e}set rgb(e){this._rgb=Bp(e)}rgbString(){return this._valid?fM(this._rgb):void 0}hexString(){return this._valid?JE(this._rgb):void 0}hslString(){return this._valid?aM(this._rgb):void 0}mix(e,n){if(e){const r=this.rgb,s=e.rgb;let o;const i=n===o?.5:n,a=2*i-1,l=r.a-s.a,c=((a*l===-1?a:(a+l)/(1+a*l))+1)/2;o=1-c,r.r=255&c*r.r+o*s.r+.5,r.g=255&c*r.g+o*s.g+.5,r.b=255&c*r.b+o*s.b+.5,r.a=i*r.a+(1-i)*s.a,this.rgb=r}return this}interpolate(e,n){return e&&(this._rgb=hM(this._rgb,e._rgb,n)),this}clone(){return new lo(this.rgb)}alpha(e){return this._rgb.a=ir(e),this}clearer(e){const n=this._rgb;return n.a*=1-e,this}greyscale(){const e=this._rgb,n=io(e.r*.3+e.g*.59+e.b*.11);return e.r=e.g=e.b=n,this}opaquer(e){const n=this._rgb;return n.a*=1+e,this}negate(){const e=this._rgb;return e.r=255-e.r,e.g=255-e.g,e.b=255-e.b,this}lighten(e){return Ji(this._rgb,2,e),this}darken(e){return Ji(this._rgb,2,-e),this}saturate(e){return Ji(this._rgb,1,e),this}desaturate(e){return Ji(this._rgb,1,-e),this}rotate(e){return iM(this._rgb,e),this}}function Gn(){}const pM=(()=>{let t=0;return()=>t++})();function lt(t){return t==null}function _t(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return e.slice(0,7)==="[object"&&e.slice(-6)==="Array]"}function Qe(t){return t!==null&&Object.prototype.toString.call(t)==="[object Object]"}function gn(t){return(typeof t=="number"||t instanceof Number)&&isFinite(+t)}function $n(t,e){return gn(t)?t:e}function Je(t,e){return typeof t>"u"?e:t}const mM=(t,e)=>typeof t=="string"&&t.endsWith("%")?parseFloat(t)/100:+t/e,jp=(t,e)=>typeof t=="string"&&t.endsWith("%")?parseFloat(t)/100*e:+t;function ht(t,e,n){if(t&&typeof t.call=="function")return t.apply(n,e)}function at(t,e,n,r){let s,o,i;if(_t(t))for(o=t.length,s=0;st,x:t=>t.x,y:t=>t.y};function vM(t){const e=t.split("."),n=[];let r="";for(const s of e)r+=s,r.endsWith("\\")?r=r.slice(0,-1)+".":(n.push(r),r="");return n}function xM(t){const e=vM(t);return n=>{for(const r of e){if(r==="")break;n=n&&n[r]}return n}}function Cr(t,e){return(Gp[e]||(Gp[e]=xM(e)))(t)}function jc(t){return t.charAt(0).toUpperCase()+t.slice(1)}const fo=t=>typeof t<"u",ar=t=>typeof t=="function",Yp=(t,e)=>{if(t.size!==e.size)return!1;for(const n of t)if(!e.has(n))return!1;return!0};function wM(t){return t.type==="mouseup"||t.type==="click"||t.type==="contextmenu"}const ct=Math.PI,Ct=2*ct,ta=Number.POSITIVE_INFINITY,kM=ct/180,Et=ct/2,Sr=ct/4,Xp=ct*2/3,Kp=Math.log10,lr=Math.sign;function na(t,e,n){return Math.abs(t-e)s-o).pop(),e}function SM(t){return typeof t=="symbol"||typeof t=="object"&&t!==null&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}function ra(t){return!SM(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function _M(t,e){const n=Math.round(t);return n-e<=t&&n+e>=t}function EM(t,e,n){let r,s,o;for(r=0,s=t.length;rl&&c=Math.min(e,n)-r&&t<=Math.max(e,n)+r}function Wc(t,e,n){n=n||(i=>t[i]1;)o=s+r>>1,n(o)?s=o:r=o;return{lo:s,hi:r}}const Gc=(t,e,n,r)=>Wc(t,n,r?s=>{const o=t[s][e];return ot[s][e]Wc(t,n,r=>t[r][e]>=n);function AM(t,e,n){let r=0,s=t.length;for(;rr&&t[s-1]>n;)s--;return r>0||s{const r="_onData"+jc(n),s=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:!1,value(...o){const i=s.apply(this,o);return t._chartjs.listeners.forEach(a=>{typeof a[r]=="function"&&a[r](...o)}),i}})})}function Rp(t,e){const n=t._chartjs;if(!n)return;const r=n.listeners,s=r.indexOf(e);s!==-1&&r.splice(s,1),!(r.length>0)&&(Jp.forEach(o=>{delete t[o]}),delete t._chartjs)}function e0(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const t0=(function(){return typeof window>"u"?function(t){return t()}:window.requestAnimationFrame})();function n0(t,e){let n=[],r=!1;return function(...s){n=s,r||(r=!0,t0.call(window,()=>{r=!1,t.apply(e,n)}))}}function IM(t,e){let n;return function(...r){return e?(clearTimeout(n),n=setTimeout(t,e,r)):t.apply(this,r),e}}const Yc=t=>t==="start"?"left":t==="end"?"right":"center",Ht=(t,e,n)=>t==="start"?e:t==="end"?n:(e+n)/2,LM=(t,e,n,r)=>t===(r?"left":"right")?n:t==="center"?(e+n)/2:e,oa=t=>t===0||t===1,r0=(t,e,n)=>-(Math.pow(2,10*(t-=1))*Math.sin((t-e)*Ct/n)),s0=(t,e,n)=>Math.pow(2,-10*t)*Math.sin((t-e)*Ct/n)+1,ho={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>-Math.cos(t*Et)+1,easeOutSine:t=>Math.sin(t*Et),easeInOutSine:t=>-.5*(Math.cos(ct*t)-1),easeInExpo:t=>t===0?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>t===1?1:-Math.pow(2,-10*t)+1,easeInOutExpo:t=>oa(t)?t:t<.5?.5*Math.pow(2,10*(t*2-1)):.5*(-Math.pow(2,-10*(t*2-1))+2),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>oa(t)?t:r0(t,.075,.3),easeOutElastic:t=>oa(t)?t:s0(t,.075,.3),easeInOutElastic(t){return oa(t)?t:t<.5?.5*r0(t*2,.1125,.45):.5+.5*s0(t*2-1,.1125,.45)},easeInBack(t){return t*t*((1.70158+1)*t-1.70158)},easeOutBack(t){return(t-=1)*t*((1.70158+1)*t+1.70158)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?.5*(t*t*(((e*=1.525)+1)*t-e)):.5*((t-=2)*t*(((e*=1.525)+1)*t+e)+2)},easeInBounce:t=>1-ho.easeOutBounce(1-t),easeOutBounce(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:t=>t<.5?ho.easeInBounce(t*2)*.5:ho.easeOutBounce(t*2-1)*.5+.5};function o0(t){if(t&&typeof t=="object"){const e=t.toString();return e==="[object CanvasPattern]"||e==="[object CanvasGradient]"}return!1}function i0(t){return o0(t)?t:new lo(t)}function Xc(t){return o0(t)?t:new lo(t).saturate(.5).darken(.1).hexString()}const PM=["x","y","borderWidth","radius","tension"],$M=["color","borderColor","backgroundColor"];function FM(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:e=>e!=="onProgress"&&e!=="onComplete"&&e!=="fn"}),t.set("animations",{colors:{type:"color",properties:$M},numbers:{type:"number",properties:PM}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:e=>e|0}}}})}function zM(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}const a0=new Map;function VM(t,e){e=e||{};const n=t+JSON.stringify(e);let r=a0.get(n);return r||(r=new Intl.NumberFormat(t,e),a0.set(n,r)),r}function Kc(t,e,n){return VM(e,n).format(t)}const HM={values(t){return _t(t)?t:""+t},numeric(t,e,n){if(t===0)return"0";const r=this.chart.options.locale;let s,o=t;if(n.length>1){const c=Math.max(Math.abs(n[0].value),Math.abs(n[n.length-1].value));(c<1e-4||c>1e15)&&(s="scientific"),o=UM(t,n)}const i=Kp(Math.abs(o)),a=isNaN(i)?1:Math.max(Math.min(-1*Math.floor(i),20),0),l={notation:s,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(l,this.options.ticks.format),Kc(t,r,l)}};function UM(t,e){let n=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;return Math.abs(n)>=1&&t!==Math.floor(t)&&(n=t-Math.floor(t)),n}var l0={formatters:HM};function BM(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(e,n)=>n.lineWidth,tickColor:(e,n)=>n.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:l0.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:e=>!e.startsWith("before")&&!e.startsWith("after")&&e!=="callback"&&e!=="parser",_indexable:e=>e!=="borderDash"&&e!=="tickBorderDash"&&e!=="dash"}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:e=>e!=="backdropPadding"&&e!=="callback",_indexable:e=>e!=="backdropPadding"})}const Er=Object.create(null),qc=Object.create(null);function go(t,e){if(!e)return t;const n=e.split(".");for(let r=0,s=n.length;rr.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(r,s)=>Xc(s.backgroundColor),this.hoverBorderColor=(r,s)=>Xc(s.borderColor),this.hoverColor=(r,s)=>Xc(s.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e),this.apply(n)}set(e,n){return Zc(this,e,n)}get(e){return go(this,e)}describe(e,n){return Zc(qc,e,n)}override(e,n){return Zc(Er,e,n)}route(e,n,r,s){const o=go(this,e),i=go(this,r),a="_"+n;Object.defineProperties(o,{[a]:{value:o[n],writable:!0},[n]:{enumerable:!0,get(){const l=this[a],c=i[s];return Qe(l)?Object.assign({},c,l):Je(l,c)},set(l){this[a]=l}}})}apply(e){e.forEach(n=>n(this))}}var vt=new jM({_scriptable:t=>!t.startsWith("on"),_indexable:t=>t!=="events",hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[FM,zM,BM]);function WM(t){return!t||lt(t.size)||lt(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function c0(t,e,n,r,s){let o=e[s];return o||(o=e[s]=t.measureText(s).width,n.push(s)),o>r&&(r=o),r}function Mr(t,e,n){const r=t.currentDevicePixelRatio,s=n!==0?Math.max(n/2,.5):0;return Math.round((e-s)*r)/r+s}function d0(t,e){!e&&!t||(e=e||t.getContext("2d"),e.save(),e.resetTransform(),e.clearRect(0,0,t.width,t.height),e.restore())}function u0(t,e,n,r){f0(t,e,n,r,null)}function f0(t,e,n,r,s){let o,i,a,l,c,d,u,f;const g=e.pointStyle,m=e.rotation,p=e.radius;let y=(m||0)*kM;if(g&&typeof g=="object"&&(o=g.toString(),o==="[object HTMLImageElement]"||o==="[object HTMLCanvasElement]")){t.save(),t.translate(n,r),t.rotate(y),t.drawImage(g,-g.width/2,-g.height/2,g.width,g.height),t.restore();return}if(!(isNaN(p)||p<=0)){switch(t.beginPath(),g){default:s?t.ellipse(n,r,s/2,p,0,0,Ct):t.arc(n,r,p,0,Ct),t.closePath();break;case"triangle":d=s?s/2:p,t.moveTo(n+Math.sin(y)*d,r-Math.cos(y)*p),y+=Xp,t.lineTo(n+Math.sin(y)*d,r-Math.cos(y)*p),y+=Xp,t.lineTo(n+Math.sin(y)*d,r-Math.cos(y)*p),t.closePath();break;case"rectRounded":c=p*.516,l=p-c,i=Math.cos(y+Sr)*l,u=Math.cos(y+Sr)*(s?s/2-c:l),a=Math.sin(y+Sr)*l,f=Math.sin(y+Sr)*(s?s/2-c:l),t.arc(n-u,r-a,c,y-ct,y-Et),t.arc(n+f,r-i,c,y-Et,y),t.arc(n+u,r+a,c,y,y+Et),t.arc(n-f,r+i,c,y+Et,y+ct),t.closePath();break;case"rect":if(!m){l=Math.SQRT1_2*p,d=s?s/2:l,t.rect(n-d,r-l,2*d,2*l);break}y+=Sr;case"rectRot":u=Math.cos(y)*(s?s/2:p),i=Math.cos(y)*p,a=Math.sin(y)*p,f=Math.sin(y)*(s?s/2:p),t.moveTo(n-u,r-a),t.lineTo(n+f,r-i),t.lineTo(n+u,r+a),t.lineTo(n-f,r+i),t.closePath();break;case"crossRot":y+=Sr;case"cross":u=Math.cos(y)*(s?s/2:p),i=Math.cos(y)*p,a=Math.sin(y)*p,f=Math.sin(y)*(s?s/2:p),t.moveTo(n-u,r-a),t.lineTo(n+u,r+a),t.moveTo(n+f,r-i),t.lineTo(n-f,r+i);break;case"star":u=Math.cos(y)*(s?s/2:p),i=Math.cos(y)*p,a=Math.sin(y)*p,f=Math.sin(y)*(s?s/2:p),t.moveTo(n-u,r-a),t.lineTo(n+u,r+a),t.moveTo(n+f,r-i),t.lineTo(n-f,r+i),y+=Sr,u=Math.cos(y)*(s?s/2:p),i=Math.cos(y)*p,a=Math.sin(y)*p,f=Math.sin(y)*(s?s/2:p),t.moveTo(n-u,r-a),t.lineTo(n+u,r+a),t.moveTo(n+f,r-i),t.lineTo(n-f,r+i);break;case"line":i=s?s/2:Math.cos(y)*p,a=Math.sin(y)*p,t.moveTo(n-i,r-a),t.lineTo(n+i,r+a);break;case"dash":t.moveTo(n,r),t.lineTo(n+Math.cos(y)*(s?s/2:p),r+Math.sin(y)*p);break;case!1:t.closePath();break}t.fill(),e.borderWidth>0&&t.stroke()}}function h0(t,e,n){return n=n||.5,!e||t&&t.x>e.left-n&&t.xe.top-n&&t.y0&&o.strokeColor!=="";let l,c;for(t.save(),t.font=s.string,GM(t,o),l=0;l+t||0;function Rc(t,e){const n={},r=Qe(e),s=r?Object.keys(e):e,o=Qe(t)?r?i=>Je(t[i],t[e[i]]):i=>t[i]:()=>t;for(const i of s)n[i]=QM(o(i));return n}function g0(t){return Rc(t,{top:"y",right:"x",bottom:"y",left:"x"})}function cs(t){return Rc(t,["topLeft","topRight","bottomLeft","bottomRight"])}function pn(t){const e=g0(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Ut(t,e){t=t||{},e=e||vt.font;let n=Je(t.size,e.size);typeof n=="string"&&(n=parseInt(n,10));let r=Je(t.style,e.style);r&&!(""+r).match(qM)&&(console.warn('Invalid font style specified: "'+r+'"'),r=void 0);const s={family:Je(t.family,e.family),lineHeight:ZM(Je(t.lineHeight,e.lineHeight),n),size:n,style:r,weight:Je(t.weight,e.weight),string:""};return s.string=WM(s),s}function aa(t,e,n,r){let s,o,i;for(s=0,o=t.length;sn&&a===0?0:a+l;return{min:i(r,-Math.abs(o)),max:i(s,o)}}function ds(t,e){return Object.assign(Object.create(t),e)}function ed(t,e=[""],n,r,s=()=>t[0]){const o=n||t;typeof r>"u"&&(r=v0("_fallback",t));const i={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:r,_getTarget:s,override:a=>ed([a,...t],e,o,r)};return new Proxy(i,{deleteProperty(a,l){return delete a[l],delete a._keys,delete t[0][l],!0},get(a,l){return m0(a,l,()=>iN(l,e,t,a))},getOwnPropertyDescriptor(a,l){return Reflect.getOwnPropertyDescriptor(a._scopes[0],l)},getPrototypeOf(){return Reflect.getPrototypeOf(t[0])},has(a,l){return x0(a).includes(l)},ownKeys(a){return x0(a)},set(a,l,c){const d=a._storage||(a._storage=s());return a[l]=d[l]=c,delete a._keys,!0}})}function us(t,e,n,r){const s={_cacheable:!1,_proxy:t,_context:e,_subProxy:n,_stack:new Set,_descriptors:p0(t,r),setContext:o=>us(t,o,n,r),override:o=>us(t.override(o),e,n,r)};return new Proxy(s,{deleteProperty(o,i){return delete o[i],delete t[i],!0},get(o,i,a){return m0(o,i,()=>eN(o,i,a))},getOwnPropertyDescriptor(o,i){return o._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i)},getPrototypeOf(){return Reflect.getPrototypeOf(t)},has(o,i){return Reflect.has(t,i)},ownKeys(){return Reflect.ownKeys(t)},set(o,i,a){return t[i]=a,delete o[i],!0}})}function p0(t,e={scriptable:!0,indexable:!0}){const{_scriptable:n=e.scriptable,_indexable:r=e.indexable,_allKeys:s=e.allKeys}=t;return{allKeys:s,scriptable:n,indexable:r,isScriptable:ar(n)?n:()=>n,isIndexable:ar(r)?r:()=>r}}const RM=(t,e)=>t?t+jc(e):e,td=(t,e)=>Qe(e)&&t!=="adapters"&&(Object.getPrototypeOf(e)===null||e.constructor===Object);function m0(t,e,n){if(Object.prototype.hasOwnProperty.call(t,e)||e==="constructor")return t[e];const r=n();return t[e]=r,r}function eN(t,e,n){const{_proxy:r,_context:s,_subProxy:o,_descriptors:i}=t;let a=r[e];return ar(a)&&i.isScriptable(e)&&(a=tN(e,a,t,n)),_t(a)&&a.length&&(a=nN(e,a,t,i.isIndexable)),td(e,a)&&(a=us(a,s,o&&o[e],i)),a}function tN(t,e,n,r){const{_proxy:s,_context:o,_subProxy:i,_stack:a}=n;if(a.has(t))throw new Error("Recursion detected: "+Array.from(a).join("->")+"->"+t);a.add(t);let l=e(o,i||r);return a.delete(t),td(t,l)&&(l=nd(s._scopes,s,t,l)),l}function nN(t,e,n,r){const{_proxy:s,_context:o,_subProxy:i,_descriptors:a}=n;if(typeof o.index<"u"&&r(t))return e[o.index%e.length];if(Qe(e[0])){const l=e,c=s._scopes.filter(d=>d!==l);e=[];for(const d of l){const u=nd(c,s,t,d);e.push(us(u,o,i&&i[t],a))}}return e}function b0(t,e,n){return ar(t)?t(e,n):t}const rN=(t,e)=>t===!0?e:typeof t=="string"?Cr(e,t):void 0;function sN(t,e,n,r,s){for(const o of e){const i=rN(n,o);if(i){t.add(i);const a=b0(i._fallback,n,s);if(typeof a<"u"&&a!==n&&a!==r)return a}else if(i===!1&&typeof r<"u"&&n!==r)return null}return!1}function nd(t,e,n,r){const s=e._rootScopes,o=b0(e._fallback,n,r),i=[...t,...s],a=new Set;a.add(r);let l=y0(a,i,n,o||n,r);return l===null||typeof o<"u"&&o!==n&&(l=y0(a,i,o,l,r),l===null)?!1:ed(Array.from(a),[""],s,o,()=>oN(e,n,r))}function y0(t,e,n,r,s){for(;n;)n=sN(t,e,n,r,s);return n}function oN(t,e,n){const r=t._getTarget();e in r||(r[e]={});const s=r[e];return _t(s)&&Qe(n)?n:s||{}}function iN(t,e,n,r){let s;for(const o of e)if(s=v0(RM(o,t),n),typeof s<"u")return td(t,s)?nd(n,r,t,s):s}function v0(t,e){for(const n of e){if(!n)continue;const r=n[t];if(typeof r<"u")return r}}function x0(t){let e=t._keys;return e||(e=t._keys=aN(t._scopes)),e}function aN(t){const e=new Set;for(const n of t)for(const r of Object.keys(n).filter(s=>!s.startsWith("_")))e.add(r);return Array.from(e)}function rd(){return typeof window<"u"&&typeof document<"u"}function sd(t){let e=t.parentNode;return e&&e.toString()==="[object ShadowRoot]"&&(e=e.host),e}function la(t,e,n){let r;return typeof t=="string"?(r=parseInt(t,10),t.indexOf("%")!==-1&&(r=r/100*e.parentNode[n])):r=t,r}const ca=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function lN(t,e){return ca(t).getPropertyValue(e)}const cN=["top","right","bottom","left"];function Nr(t,e,n){const r={};n=n?"-"+n:"";for(let s=0;s<4;s++){const o=cN[s];r[o]=parseFloat(t[e+"-"+o+n])||0}return r.width=r.left+r.right,r.height=r.top+r.bottom,r}const dN=(t,e,n)=>(t>0||e>0)&&(!n||!n.shadowRoot);function uN(t,e){const n=t.touches,r=n&&n.length?n[0]:t,{offsetX:s,offsetY:o}=r;let i=!1,a,l;if(dN(s,o,t.target))a=s,l=o;else{const c=e.getBoundingClientRect();a=r.clientX-c.left,l=r.clientY-c.top,i=!0}return{x:a,y:l,box:i}}function Or(t,e){if("native"in t)return t;const{canvas:n,currentDevicePixelRatio:r}=e,s=ca(n),o=s.boxSizing==="border-box",i=Nr(s,"padding"),a=Nr(s,"border","width"),{x:l,y:c,box:d}=uN(t,n),u=i.left+(d&&a.left),f=i.top+(d&&a.top);let{width:g,height:m}=e;return o&&(g-=i.width+a.width,m-=i.height+a.height),{x:Math.round((l-u)/g*n.width/r),y:Math.round((c-f)/m*n.height/r)}}function fN(t,e,n){let r,s;if(e===void 0||n===void 0){const o=t&&sd(t);if(!o)e=t.clientWidth,n=t.clientHeight;else{const i=o.getBoundingClientRect(),a=ca(o),l=Nr(a,"border","width"),c=Nr(a,"padding");e=i.width-c.width-l.width,n=i.height-c.height-l.height,r=la(a.maxWidth,o,"clientWidth"),s=la(a.maxHeight,o,"clientHeight")}}return{width:e,height:n,maxWidth:r||ta,maxHeight:s||ta}}const cr=t=>Math.round(t*10)/10;function hN(t,e,n,r){const s=ca(t),o=Nr(s,"margin"),i=la(s.maxWidth,t,"clientWidth")||ta,a=la(s.maxHeight,t,"clientHeight")||ta,l=fN(t,e,n);let{width:c,height:d}=l;if(s.boxSizing==="content-box"){const f=Nr(s,"border","width"),g=Nr(s,"padding");c-=g.width+f.width,d-=g.height+f.height}return c=Math.max(0,c-o.width),d=Math.max(0,r?c/r:d-o.height),c=cr(Math.min(c,i,l.maxWidth)),d=cr(Math.min(d,a,l.maxHeight)),c&&!d&&(d=cr(c/2)),(e!==void 0||n!==void 0)&&r&&l.height&&d>l.height&&(d=l.height,c=cr(Math.floor(d*r))),{width:c,height:d}}function w0(t,e,n){const r=e||1,s=cr(t.height*r),o=cr(t.width*r);t.height=cr(t.height),t.width=cr(t.width);const i=t.canvas;return i.style&&(n||!i.style.height&&!i.style.width)&&(i.style.height=`${t.height}px`,i.style.width=`${t.width}px`),t.currentDevicePixelRatio!==r||i.height!==s||i.width!==o?(t.currentDevicePixelRatio=r,i.height=s,i.width=o,t.ctx.setTransform(r,0,0,r,0,0),!0):!1}const gN=(function(){let t=!1;try{const e={get passive(){return t=!0,!1}};rd()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch{}return t})();function k0(t,e){const n=lN(t,e),r=n&&n.match(/^(\d+)(\.\d+)?px$/);return r?+r[1]:void 0}const pN=function(t,e){return{x(n){return t+t+e-n},setWidth(n){e=n},textAlign(n){return n==="center"?n:n==="right"?"left":"right"},xPlus(n,r){return n-r},leftForLtr(n,r){return n-r}}},mN=function(){return{x(t){return t},setWidth(t){},textAlign(t){return t},xPlus(t,e){return t+e},leftForLtr(t,e){return t}}};function fs(t,e,n){return t?pN(e,n):mN()}function C0(t,e){let n,r;(e==="ltr"||e==="rtl")&&(n=t.canvas.style,r=[n.getPropertyValue("direction"),n.getPropertyPriority("direction")],n.setProperty("direction",e,"important"),t.prevTextDirection=r)}function S0(t,e){e!==void 0&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function da(t,e,n){return t.options.clip?t[n]:e[n]}function bN(t,e){const{xScale:n,yScale:r}=t;return n&&r?{left:da(n,e,"left"),right:da(n,e,"right"),top:da(r,e,"top"),bottom:da(r,e,"bottom")}:e}function yN(t,e){const n=e._clip;if(n.disabled)return!1;const r=bN(e,t.chartArea);return{left:n.left===!1?0:r.left-(n.left===!0?0:n.left),right:n.right===!1?t.width:r.right+(n.right===!0?0:n.right),top:n.top===!1?0:r.top-(n.top===!0?0:n.top),bottom:n.bottom===!1?t.height:r.bottom+(n.bottom===!0?0:n.bottom)}}class vN{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(e,n,r,s){const o=n.listeners[s],i=n.duration;o.forEach(a=>a({chart:e,initial:n.initial,numSteps:i,currentStep:Math.min(r-n.start,i)}))}_refresh(){this._request||(this._running=!0,this._request=t0.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(e=Date.now()){let n=0;this._charts.forEach((r,s)=>{if(!r.running||!r.items.length)return;const o=r.items;let i=o.length-1,a=!1,l;for(;i>=0;--i)l=o[i],l._active?(l._total>r.duration&&(r.duration=l._total),l.tick(e),a=!0):(o[i]=o[o.length-1],o.pop());a&&(s.draw(),this._notify(s,r,e,"progress")),o.length||(r.running=!1,this._notify(s,r,e,"complete"),r.initial=!1),n+=o.length}),this._lastDate=e,n===0&&(this._running=!1)}_getAnims(e){const n=this._charts;let r=n.get(e);return r||(r={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},n.set(e,r)),r}listen(e,n,r){this._getAnims(e).listeners[n].push(r)}add(e,n){!n||!n.length||this._getAnims(e).items.push(...n)}has(e){return this._getAnims(e).items.length>0}start(e){const n=this._charts.get(e);n&&(n.running=!0,n.start=Date.now(),n.duration=n.items.reduce((r,s)=>Math.max(r,s._duration),0),this._refresh())}running(e){if(!this._running)return!1;const n=this._charts.get(e);return!(!n||!n.running||!n.items.length)}stop(e){const n=this._charts.get(e);if(!n||!n.items.length)return;const r=n.items;let s=r.length-1;for(;s>=0;--s)r[s].cancel();n.items=[],this._notify(e,n,Date.now(),"complete")}remove(e){return this._charts.delete(e)}}var Xn=new vN;const _0="transparent",xN={boolean(t,e,n){return n>.5?e:t},color(t,e,n){const r=i0(t||_0),s=r.valid&&i0(e||_0);return s&&s.valid?s.mix(r,n).hexString():e},number(t,e,n){return t+(e-t)*n}};class wN{constructor(e,n,r,s){const o=n[r];s=aa([e.to,s,o,e.from]);const i=aa([e.from,o,s]);this._active=!0,this._fn=e.fn||xN[e.type||typeof i],this._easing=ho[e.easing]||ho.linear,this._start=Math.floor(Date.now()+(e.delay||0)),this._duration=this._total=Math.floor(e.duration),this._loop=!!e.loop,this._target=n,this._prop=r,this._from=i,this._to=s,this._promises=void 0}active(){return this._active}update(e,n,r){if(this._active){this._notify(!1);const s=this._target[this._prop],o=r-this._start,i=this._duration-o;this._start=r,this._duration=Math.floor(Math.max(i,e.duration)),this._total+=o,this._loop=!!e.loop,this._to=aa([e.to,n,s,e.from]),this._from=aa([e.from,s,n])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(e){const n=e-this._start,r=this._duration,s=this._prop,o=this._from,i=this._loop,a=this._to;let l;if(this._active=o!==a&&(i||n1?2-l:l,l=this._easing(Math.min(1,Math.max(0,l))),this._target[s]=this._fn(o,a,l)}wait(){const e=this._promises||(this._promises=[]);return new Promise((n,r)=>{e.push({res:n,rej:r})})}_notify(e){const n=e?"res":"rej",r=this._promises||[];for(let s=0;s{const o=e[s];if(!Qe(o))return;const i={};for(const a of n)i[a]=o[a];(_t(o.properties)&&o.properties||[s]).forEach(a=>{(a===s||!r.has(a))&&r.set(a,i)})})}_animateOptions(e,n){const r=n.options,s=CN(e,r);if(!s)return[];const o=this._createAnimations(s,r);return r.$shared&&kN(e.options.$animations,r).then(()=>{e.options=r},()=>{}),o}_createAnimations(e,n){const r=this._properties,s=[],o=e.$animations||(e.$animations={}),i=Object.keys(n),a=Date.now();let l;for(l=i.length-1;l>=0;--l){const c=i[l];if(c.charAt(0)==="$")continue;if(c==="options"){s.push(...this._animateOptions(e,n));continue}const d=n[c];let u=o[c];const f=r.get(c);if(u)if(f&&u.active()){u.update(f,d,a);continue}else u.cancel();if(!f||!f.duration){e[c]=d;continue}o[c]=u=new wN(f,e,c,d),s.push(u)}return s}update(e,n){if(this._properties.size===0){Object.assign(e,n);return}const r=this._createAnimations(e,n);if(r.length)return Xn.add(this._chart,r),!0}}function kN(t,e){const n=[],r=Object.keys(e);for(let s=0;s0||!n&&o<0)return s.index}return null}function A0(t,e){const{chart:n,_cachedMeta:r}=t,s=n._stacks||(n._stacks={}),{iScale:o,vScale:i,index:a}=r,l=o.axis,c=i.axis,d=MN(o,i,r),u=e.length;let f;for(let g=0;gn[r].axis===e).shift()}function TN(t,e){return ds(t,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}function AN(t,e,n){return ds(t,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:n,index:e,mode:"default",type:"data"})}function mo(t,e){const n=t.controller.index,r=t.vScale&&t.vScale.axis;if(r){e=e||t._parsed;for(const s of e){const o=s._stacks;if(!o||o[r]===void 0||o[r][n]===void 0)return;delete o[r][n],o[r]._visualValues!==void 0&&o[r]._visualValues[n]!==void 0&&delete o[r]._visualValues[n]}}}const ad=t=>t==="reset"||t==="none",D0=(t,e)=>e?t:Object.assign({},t),DN=(t,e,n)=>t&&!e.hidden&&e._stacked&&{keys:N0(n,!0),values:null};class hs{constructor(e,n){this.chart=e,this._ctx=e.ctx,this.index=n,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const e=this._cachedMeta;this.configure(),this.linkScales(),e._stacked=od(e.vScale,e),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(e){this.index!==e&&mo(this._cachedMeta),this.index=e}linkScales(){const e=this.chart,n=this._cachedMeta,r=this.getDataset(),s=(u,f,g,m)=>u==="x"?f:u==="r"?m:g,o=n.xAxisID=Je(r.xAxisID,id(e,"x")),i=n.yAxisID=Je(r.yAxisID,id(e,"y")),a=n.rAxisID=Je(r.rAxisID,id(e,"r")),l=n.indexAxis,c=n.iAxisID=s(l,o,i,a),d=n.vAxisID=s(l,i,o,a);n.xScale=this.getScaleForId(o),n.yScale=this.getScaleForId(i),n.rScale=this.getScaleForId(a),n.iScale=this.getScaleForId(c),n.vScale=this.getScaleForId(d)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(e){return this.chart.scales[e]}_getOtherScale(e){const n=this._cachedMeta;return e===n.iScale?n.vScale:n.iScale}reset(){this._update("reset")}_destroy(){const e=this._cachedMeta;this._data&&Rp(this._data,this),e._stacked&&mo(e)}_dataCheck(){const e=this.getDataset(),n=e.data||(e.data=[]),r=this._data;if(Qe(n)){const s=this._cachedMeta;this._data=EN(n,s)}else if(r!==n){if(r){Rp(r,this);const s=this._cachedMeta;mo(s),s._parsed=[]}n&&Object.isExtensible(n)&&DM(n,this),this._syncList=[],this._data=n}}addElements(){const e=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(e.dataset=new this.datasetElementType)}buildOrUpdateElements(e){const n=this._cachedMeta,r=this.getDataset();let s=!1;this._dataCheck();const o=n._stacked;n._stacked=od(n.vScale,n),n.stack!==r.stack&&(s=!0,mo(n),n.stack=r.stack),this._resyncElements(e),(s||o!==n._stacked)&&(A0(this,n._parsed),n._stacked=od(n.vScale,n))}configure(){const e=this.chart.config,n=e.datasetScopeKeys(this._type),r=e.getOptionScopes(this.getDataset(),n,!0);this.options=e.createResolver(r,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(e,n){const{_cachedMeta:r,_data:s}=this,{iScale:o,_stacked:i}=r,a=o.axis;let l=e===0&&n===s.length?!0:r._sorted,c=e>0&&r._parsed[e-1],d,u,f;if(this._parsing===!1)r._parsed=s,r._sorted=!0,f=s;else{_t(s[e])?f=this.parseArrayData(r,s,e,n):Qe(s[e])?f=this.parseObjectData(r,s,e,n):f=this.parsePrimitiveData(r,s,e,n);const g=()=>u[a]===null||c&&u[a]p||u=0;--f)if(!m()){this.updateRangeFromParsed(c,e,g,l);break}}return c}getAllParsedValues(e){const n=this._cachedMeta._parsed,r=[];let s,o,i;for(s=0,o=n.length;s=0&&ethis.getContext(r,s,n),p=c.resolveNamedOptions(f,g,m,u);return p.$shared&&(p.$shared=l,o[i]=Object.freeze(D0(p,l))),p}_resolveAnimations(e,n,r){const s=this.chart,o=this._cachedDataOpts,i=`animation-${n}`,a=o[i];if(a)return a;let l;if(s.options.animation!==!1){const d=this.chart.config,u=d.datasetAnimationScopeKeys(this._type,n),f=d.getOptionScopes(this.getDataset(),u);l=d.createResolver(f,this.getContext(e,r,n))}const c=new E0(s,l&&l.animations);return l&&l._cacheable&&(o[i]=Object.freeze(c)),c}getSharedOptions(e){if(e.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},e))}includeOptions(e,n){return!n||ad(e)||this.chart._animationsDisabled}_getSharedOptions(e,n){const r=this.resolveDataElementOptions(e,n),s=this._sharedOptions,o=this.getSharedOptions(r),i=this.includeOptions(n,o)||o!==s;return this.updateSharedOptions(o,n,r),{sharedOptions:o,includeOptions:i}}updateElement(e,n,r,s){ad(s)?Object.assign(e,r):this._resolveAnimations(n,s).update(e,r)}updateSharedOptions(e,n,r){e&&!ad(n)&&this._resolveAnimations(void 0,n).update(e,r)}_setStyle(e,n,r,s){e.active=s;const o=this.getStyle(n,s);this._resolveAnimations(n,r,s).update(e,{options:!s&&this.getSharedOptions(o)||o})}removeHoverStyle(e,n,r){this._setStyle(e,r,"active",!1)}setHoverStyle(e,n,r){this._setStyle(e,r,"active",!0)}_removeDatasetHoverStyle(){const e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!1)}_setDatasetHoverStyle(){const e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!0)}_resyncElements(e){const n=this._data,r=this._cachedMeta.data;for(const[a,l,c]of this._syncList)this[a](l,c);this._syncList=[];const s=r.length,o=n.length,i=Math.min(o,s);i&&this.parse(0,i),o>s?this._insertElements(s,o-s,e):o{for(c.length+=n,a=c.length-1;a>=i;a--)c[a]=c[a-n]};for(l(o),a=e;as-o))}return t._cache.$bar}function LN(t){const e=t.iScale,n=IN(e,t.type);let r=e._length,s,o,i,a;const l=()=>{i===32767||i===-32768||(fo(a)&&(r=Math.min(r,Math.abs(i-a)||r)),a=i)};for(s=0,o=n.length;s0?s[t-1]:null,a=tMath.abs(a)&&(l=a,c=i),e[n.axis]=c,e._custom={barStart:l,barEnd:c,start:s,end:o,min:i,max:a}}function I0(t,e,n,r){return _t(t)?FN(t,e,n,r):e[n.axis]=n.parse(t,r),e}function L0(t,e,n,r){const s=t.iScale,o=t.vScale,i=s.getLabels(),a=s===o,l=[];let c,d,u,f;for(c=n,d=n+r;c=n?1:-1)}function VN(t){let e,n,r,s,o;return t.horizontal?(e=t.base>t.x,n="left",r="right"):(e=t.based.controller.options.grouped),o=r.options.stacked,i=[],a=this._cachedMeta.controller.getParsed(n),l=a&&a[r.axis],c=d=>{const u=d._parsed.find(g=>g[r.axis]===l),f=u&&u[d.vScale.axis];if(lt(f)||isNaN(f))return!0};for(const d of s)if(!(n!==void 0&&c(d))&&((o===!1||i.indexOf(d.stack)===-1||o===void 0&&d.stack===void 0)&&i.push(d.stack),d.index===e))break;return i.length||i.push(void 0),i}_getStackCount(e){return this._getStacks(void 0,e).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const e=this.chart.scales,n=this.chart.options.indexAxis;return Object.keys(e).filter(r=>e[r].axis===n).shift()}_getAxis(){const e={},n=this.getFirstScaleIdForIndexAxis();for(const r of this.chart.data.datasets)e[Je(this.chart.options.indexAxis==="x"?r.xAxisID:r.yAxisID,n)]=!0;return Object.keys(e)}_getStackIndex(e,n,r){const s=this._getStacks(e,r),o=n!==void 0?s.indexOf(n):-1;return o===-1?s.length-1:o}_getRuler(){const e=this.options,n=this._cachedMeta,r=n.iScale,s=[];let o,i;for(o=0,i=n.data.length;osa(x,a,l,!0)?1:Math.max(E,E*n,k,k*n),m=(x,E,k)=>sa(x,a,l,!0)?-1:Math.min(E,E*n,k,k*n),p=g(0,c,u),y=g(Et,d,f),b=m(ct,c,u),S=m(ct+Et,d,f);r=(p-b)/2,s=(y-S)/2,o=-(p+b)/2,i=-(y+S)/2}return{ratioX:r,ratioY:s,offsetX:o,offsetY:i}}class bo extends hs{constructor(e,n){super(e,n),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(e,n){const r=this.getDataset().data,s=this._cachedMeta;if(this._parsing===!1)s._parsed=r;else{let o=l=>+r[l];if(Qe(r[e])){const{key:l="value"}=this._parsing;o=c=>+Cr(r[c],l)}let i,a;for(i=e,a=e+n;i0&&!isNaN(e)?Ct*(Math.abs(e)/n):0}getLabelAndValue(e){const n=this._cachedMeta,r=this.chart,s=r.data.labels||[],o=Kc(n._parsed[e],r.options.locale);return{label:s[e]||"",value:o}}getMaxBorderWidth(e){let n=0;const r=this.chart;let s,o,i,a,l;if(!e){for(s=0,o=r.data.datasets.length;se!=="spacing",_indexable:e=>e!=="spacing"&&!e.startsWith("borderDash")&&!e.startsWith("hoverBorderDash")}),Fe(bo,"overrides",{aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){const n=e.data,{labels:{pointStyle:r,textAlign:s,color:o,useBorderRadius:i,borderRadius:a}}=e.legend.options;return n.labels.length&&n.datasets.length?n.labels.map((l,c)=>{const u=e.getDatasetMeta(0).controller.getStyle(c);return{text:l,fillStyle:u.backgroundColor,fontColor:o,hidden:!e.getDataVisibility(c),lineDash:u.borderDash,lineDashOffset:u.borderDashOffset,lineJoin:u.borderJoinStyle,lineWidth:u.borderWidth,strokeStyle:u.borderColor,textAlign:s,pointStyle:r,borderRadius:i&&(a||u.borderRadius),index:c}}):[]}},onClick(e,n,r){r.chart.toggleDataVisibility(n.index),r.chart.update()}}}});function Tr(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class cd{constructor(e){Fe(this,"options");this.options=e||{}}static override(e){Object.assign(cd.prototype,e)}init(){}formats(){return Tr()}parse(){return Tr()}format(){return Tr()}add(){return Tr()}diff(){return Tr()}startOf(){return Tr()}endOf(){return Tr()}}var WN={_date:cd};function GN(t,e,n,r){const{controller:s,data:o,_sorted:i}=t,a=s._cachedMeta.iScale,l=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null;if(a&&e===a.axis&&e!=="r"&&i&&o.length){const c=a._reversePixels?TM:Gc;if(r){if(s._sharedOptions){const d=o[0],u=typeof d.getRange=="function"&&d.getRange(e);if(u){const f=c(o,e,n-u),g=c(o,e,n+u);return{lo:f.lo,hi:g.hi}}}}else{const d=c(o,e,n);if(l){const{vScale:u}=s._cachedMeta,{_parsed:f}=t,g=f.slice(0,d.lo+1).reverse().findIndex(p=>!lt(p[u.axis]));d.lo-=Math.max(0,g);const m=f.slice(d.hi).findIndex(p=>!lt(p[u.axis]));d.hi+=Math.max(0,m)}return d}}return{lo:0,hi:o.length-1}}function fa(t,e,n,r,s){const o=t.getSortedVisibleDatasetMetas(),i=n[e];for(let a=0,l=o.length;a{l[i]&&l[i](e[n],s)&&(o.push({element:l,datasetIndex:c,index:d}),a=a||l.inRange(e.x,e.y,s))}),r&&!a?[]:o}var qN={modes:{index(t,e,n,r){const s=Or(e,t),o=n.axis||"x",i=n.includeInvisible||!1,a=n.intersect?dd(t,s,o,r,i):ud(t,s,o,!1,r,i),l=[];return a.length?(t.getSortedVisibleDatasetMetas().forEach(c=>{const d=a[0].index,u=c.data[d];u&&!u.skip&&l.push({element:u,datasetIndex:c.index,index:d})}),l):[]},dataset(t,e,n,r){const s=Or(e,t),o=n.axis||"xy",i=n.includeInvisible||!1;let a=n.intersect?dd(t,s,o,r,i):ud(t,s,o,!1,r,i);if(a.length>0){const l=a[0].datasetIndex,c=t.getDatasetMeta(l).data;a=[];for(let d=0;dn.pos===e)}function V0(t,e){return t.filter(n=>z0.indexOf(n.pos)===-1&&n.box.axis===e)}function vo(t,e){return t.sort((n,r)=>{const s=e?r:n,o=e?n:r;return s.weight===o.weight?s.index-o.index:s.weight-o.weight})}function ZN(t){const e=[];let n,r,s,o,i,a;for(n=0,r=(t||[]).length;nc.box.fullSize),!0),r=vo(yo(e,"left"),!0),s=vo(yo(e,"right")),o=vo(yo(e,"top"),!0),i=vo(yo(e,"bottom")),a=V0(e,"x"),l=V0(e,"y");return{fullSize:n,leftAndTop:r.concat(o),rightAndBottom:s.concat(l).concat(i).concat(a),chartArea:yo(e,"chartArea"),vertical:r.concat(s).concat(l),horizontal:o.concat(i).concat(a)}}function H0(t,e,n,r){return Math.max(t[n],e[n])+Math.max(t[r],e[r])}function U0(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function eO(t,e,n,r){const{pos:s,box:o}=n,i=t.maxPadding;if(!Qe(s)){n.size&&(t[s]-=n.size);const u=r[n.stack]||{size:0,count:1};u.size=Math.max(u.size,n.horizontal?o.height:o.width),n.size=u.size/u.count,t[s]+=n.size}o.getPadding&&U0(i,o.getPadding());const a=Math.max(0,e.outerWidth-H0(i,t,"left","right")),l=Math.max(0,e.outerHeight-H0(i,t,"top","bottom")),c=a!==t.w,d=l!==t.h;return t.w=a,t.h=l,n.horizontal?{same:c,other:d}:{same:d,other:c}}function tO(t){const e=t.maxPadding;function n(r){const s=Math.max(e[r]-t[r],0);return t[r]+=s,s}t.y+=n("top"),t.x+=n("left"),n("right"),n("bottom")}function nO(t,e){const n=e.maxPadding;function r(s){const o={left:0,top:0,right:0,bottom:0};return s.forEach(i=>{o[i]=Math.max(e[i],n[i])}),o}return r(t?["left","right"]:["top","bottom"])}function xo(t,e,n,r){const s=[];let o,i,a,l,c,d;for(o=0,i=t.length,c=0;o{typeof p.beforeLayout=="function"&&p.beforeLayout()});const d=l.reduce((p,y)=>y.box.options&&y.box.options.display===!1?p:p+1,0)||1,u=Object.freeze({outerWidth:e,outerHeight:n,padding:s,availableWidth:o,availableHeight:i,vBoxMaxWidth:o/2/d,hBoxMaxHeight:i/2}),f=Object.assign({},s);U0(f,pn(r));const g=Object.assign({maxPadding:f,w:o,h:i,x:s.left,y:s.top},s),m=JN(l.concat(c),u);xo(a.fullSize,g,u,m),xo(l,g,u,m),xo(c,g,u,m)&&xo(l,g,u,m),tO(g),B0(a.leftAndTop,g,u,m),g.x+=g.w,g.y+=g.h,B0(a.rightAndBottom,g,u,m),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},at(a.chartArea,p=>{const y=p.box;Object.assign(y,t.chartArea),y.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})})}};class j0{acquireContext(e,n){}releaseContext(e){return!1}addEventListener(e,n,r){}removeEventListener(e,n,r){}getDevicePixelRatio(){return 1}getMaximumSize(e,n,r,s){return n=Math.max(0,n||e.width),r=r||e.height,{width:n,height:Math.max(0,s?Math.floor(n/s):r)}}isAttached(e){return!0}updateConfig(e){}}class rO extends j0{acquireContext(e){return e&&e.getContext&&e.getContext("2d")||null}updateConfig(e){e.options.animation=!1}}const ga="$chartjs",sO={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},W0=t=>t===null||t==="";function oO(t,e){const n=t.style,r=t.getAttribute("height"),s=t.getAttribute("width");if(t[ga]={initial:{height:r,width:s,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||"block",n.boxSizing=n.boxSizing||"border-box",W0(s)){const o=k0(t,"width");o!==void 0&&(t.width=o)}if(W0(r))if(t.style.height==="")t.height=t.width/(e||2);else{const o=k0(t,"height");o!==void 0&&(t.height=o)}return t}const G0=gN?{passive:!0}:!1;function iO(t,e,n){t&&t.addEventListener(e,n,G0)}function aO(t,e,n){t&&t.canvas&&t.canvas.removeEventListener(e,n,G0)}function lO(t,e){const n=sO[t.type]||t.type,{x:r,y:s}=Or(t,e);return{type:n,chart:e,native:t,x:r!==void 0?r:null,y:s!==void 0?s:null}}function pa(t,e){for(const n of t)if(n===e||n.contains(e))return!0}function cO(t,e,n){const r=t.canvas,s=new MutationObserver(o=>{let i=!1;for(const a of o)i=i||pa(a.addedNodes,r),i=i&&!pa(a.removedNodes,r);i&&n()});return s.observe(document,{childList:!0,subtree:!0}),s}function dO(t,e,n){const r=t.canvas,s=new MutationObserver(o=>{let i=!1;for(const a of o)i=i||pa(a.removedNodes,r),i=i&&!pa(a.addedNodes,r);i&&n()});return s.observe(document,{childList:!0,subtree:!0}),s}const wo=new Map;let Y0=0;function X0(){const t=window.devicePixelRatio;t!==Y0&&(Y0=t,wo.forEach((e,n)=>{n.currentDevicePixelRatio!==t&&e()}))}function uO(t,e){wo.size||window.addEventListener("resize",X0),wo.set(t,e)}function fO(t){wo.delete(t),wo.size||window.removeEventListener("resize",X0)}function hO(t,e,n){const r=t.canvas,s=r&&sd(r);if(!s)return;const o=n0((a,l)=>{const c=s.clientWidth;n(a,l),c{const l=a[0],c=l.contentRect.width,d=l.contentRect.height;c===0&&d===0||o(c,d)});return i.observe(s),uO(t,o),i}function fd(t,e,n){n&&n.disconnect(),e==="resize"&&fO(t)}function gO(t,e,n){const r=t.canvas,s=n0(o=>{t.ctx!==null&&n(lO(o,t))},t);return iO(r,e,s),s}class pO extends j0{acquireContext(e,n){const r=e&&e.getContext&&e.getContext("2d");return r&&r.canvas===e?(oO(e,n),r):null}releaseContext(e){const n=e.canvas;if(!n[ga])return!1;const r=n[ga].initial;["height","width"].forEach(o=>{const i=r[o];lt(i)?n.removeAttribute(o):n.setAttribute(o,i)});const s=r.style||{};return Object.keys(s).forEach(o=>{n.style[o]=s[o]}),n.width=n.width,delete n[ga],!0}addEventListener(e,n,r){this.removeEventListener(e,n);const s=e.$proxies||(e.$proxies={}),i={attach:cO,detach:dO,resize:hO}[n]||gO;s[n]=i(e,n,r)}removeEventListener(e,n){const r=e.$proxies||(e.$proxies={}),s=r[n];if(!s)return;({attach:fd,detach:fd,resize:fd}[n]||aO)(e,n,s),r[n]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(e,n,r,s){return hN(e,n,r,s)}isAttached(e){const n=e&&sd(e);return!!(n&&n.isConnected)}}function mO(t){return!rd()||typeof OffscreenCanvas<"u"&&t instanceof OffscreenCanvas?rO:pO}let Ar=(Ta=class{constructor(){Fe(this,"x");Fe(this,"y");Fe(this,"active",!1);Fe(this,"options");Fe(this,"$animations")}tooltipPosition(e){const{x:n,y:r}=this.getProps(["x","y"],e);return{x:n,y:r}}hasValue(){return ra(this.x)&&ra(this.y)}getProps(e,n){const r=this.$animations;if(!n||!r)return this;const s={};return e.forEach(o=>{s[o]=r[o]&&r[o].active()?r[o]._to:this[o]}),s}},Fe(Ta,"defaults",{}),Fe(Ta,"defaultRoutes"),Ta);function bO(t,e){const n=t.options.ticks,r=yO(t),s=Math.min(n.maxTicksLimit||r,r),o=n.major.enabled?xO(e):[],i=o.length,a=o[0],l=o[i-1],c=[];if(i>s)return wO(e,c,o,i/s),c;const d=vO(o,e,s);if(i>0){let u,f;const g=i>1?Math.round((l-a)/(i-1)):null;for(ma(e,c,d,lt(g)?0:a-g,a),u=0,f=i-1;us)return l}return Math.max(s,1)}function xO(t){const e=[];let n,r;for(n=0,r=t.length;nt==="left"?"right":t==="right"?"left":t,K0=(t,e,n)=>e==="top"||e==="left"?t[e]+n:t[e]-n,q0=(t,e)=>Math.min(e||t,t);function Z0(t,e){const n=[],r=t.length/e,s=t.length;let o=0;for(;oi+a)))return l}function _O(t,e){at(t,n=>{const r=n.gc,s=r.length/2;let o;if(s>e){for(o=0;or?r:n,r=s&&n>r?n:r,{min:$n(n,$n(r,n)),max:$n(r,$n(n,r))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const e=this.chart.data;return this.options.labels||(this.isHorizontal()?e.xLabels:e.yLabels)||e.labels||[]}getLabelItems(e=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(e))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){ht(this.options.beforeUpdate,[this])}update(e,n,r){const{beginAtZero:s,grace:o,ticks:i}=this.options,a=i.sampleSize;this.beforeUpdate(),this.maxWidth=e,this.maxHeight=n,this._margins=r=Object.assign({left:0,right:0,top:0,bottom:0},r),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+r.left+r.right:this.height+r.top+r.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=JM(this,o,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const l=a=o||r<=1||!this.isHorizontal()){this.labelRotation=s;return}const d=this._getLabelSizes(),u=d.widest.width,f=d.highest.height,g=Jt(this.chart.width-u,0,this.maxWidth);a=e.offset?this.maxWidth/r:g/(r-1),u+6>a&&(a=g/(r-(e.offset?.5:1)),l=this.maxHeight-ko(e.grid)-n.padding-Q0(e.title,this.chart.options.font),c=Math.sqrt(u*u+f*f),i=MM(Math.min(Math.asin(Jt((d.highest.height+6)/a,-1,1)),Math.asin(Jt(l/c,-1,1))-Math.asin(Jt(f/c,-1,1)))),i=Math.max(s,Math.min(o,i))),this.labelRotation=i}afterCalculateLabelRotation(){ht(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){ht(this.options.beforeFit,[this])}fit(){const e={width:0,height:0},{chart:n,options:{ticks:r,title:s,grid:o}}=this,i=this._isVisible(),a=this.isHorizontal();if(i){const l=Q0(s,n.options.font);if(a?(e.width=this.maxWidth,e.height=ko(o)+l):(e.height=this.maxHeight,e.width=ko(o)+l),r.display&&this.ticks.length){const{first:c,last:d,widest:u,highest:f}=this._getLabelSizes(),g=r.padding*2,m=Yn(this.labelRotation),p=Math.cos(m),y=Math.sin(m);if(a){const b=r.mirror?0:y*u.width+p*f.height;e.height=Math.min(this.maxHeight,e.height+b+g)}else{const b=r.mirror?0:p*u.width+y*f.height;e.width=Math.min(this.maxWidth,e.width+b+g)}this._calculatePadding(c,d,y,p)}}this._handleMargins(),a?(this.width=this._length=n.width-this._margins.left-this._margins.right,this.height=e.height):(this.width=e.width,this.height=this._length=n.height-this._margins.top-this._margins.bottom)}_calculatePadding(e,n,r,s){const{ticks:{align:o,padding:i},position:a}=this.options,l=this.labelRotation!==0,c=a!=="top"&&this.axis==="x";if(this.isHorizontal()){const d=this.getPixelForTick(0)-this.left,u=this.right-this.getPixelForTick(this.ticks.length-1);let f=0,g=0;l?c?(f=s*e.width,g=r*n.height):(f=r*e.height,g=s*n.width):o==="start"?g=n.width:o==="end"?f=e.width:o!=="inner"&&(f=e.width/2,g=n.width/2),this.paddingLeft=Math.max((f-d+i)*this.width/(this.width-d),0),this.paddingRight=Math.max((g-u+i)*this.width/(this.width-u),0)}else{let d=n.height/2,u=e.height/2;o==="start"?(d=0,u=e.height):o==="end"&&(d=n.height,u=0),this.paddingTop=d+i,this.paddingBottom=u+i}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){ht(this.options.afterFit,[this])}isHorizontal(){const{axis:e,position:n}=this.options;return n==="top"||n==="bottom"||e==="x"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(e){this.beforeTickToLabelConversion(),this.generateTickLabels(e);let n,r;for(n=0,r=e.length;n({width:i[w]||0,height:a[w]||0});return{first:_(0),last:_(n-1),widest:_(C),highest:_(N),widths:i,heights:a}}getLabelForValue(e){return e}getPixelForValue(e,n){return NaN}getValueForPixel(e){}getPixelForTick(e){const n=this.ticks;return e<0||e>n.length-1?null:this.getPixelForValue(n[e].value)}getPixelForDecimal(e){this._reversePixels&&(e=1-e);const n=this._startPixel+e*this._length;return OM(this._alignToPixels?Mr(this.chart,n,0):n)}getDecimalForPixel(e){const n=(e-this._startPixel)/this._length;return this._reversePixels?1-n:n}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:e,max:n}=this;return e<0&&n<0?n:e>0&&n>0?e:0}getContext(e){const n=this.ticks||[];if(e>=0&&ea*s?a/r:l/s:l*s0}_computeGridLineItems(e){const n=this.axis,r=this.chart,s=this.options,{grid:o,position:i,border:a}=s,l=o.offset,c=this.isHorizontal(),u=this.ticks.length+(l?1:0),f=ko(o),g=[],m=a.setContext(this.getContext()),p=m.display?m.width:0,y=p/2,b=function(A){return Mr(r,A,p)};let S,x,E,k,C,N,_,w,M,O,L,F;if(i==="top")S=b(this.bottom),N=this.bottom-f,w=S-y,O=b(e.top)+y,F=e.bottom;else if(i==="bottom")S=b(this.top),O=e.top,F=b(e.bottom)-y,N=S+y,w=this.top+f;else if(i==="left")S=b(this.right),C=this.right-f,_=S-y,M=b(e.left)+y,L=e.right;else if(i==="right")S=b(this.left),M=e.left,L=b(e.right)-y,C=S+y,_=this.left+f;else if(n==="x"){if(i==="center")S=b((e.top+e.bottom)/2+.5);else if(Qe(i)){const A=Object.keys(i)[0],T=i[A];S=b(this.chart.scales[A].getPixelForValue(T))}O=e.top,F=e.bottom,N=S+y,w=N+f}else if(n==="y"){if(i==="center")S=b((e.left+e.right)/2);else if(Qe(i)){const A=Object.keys(i)[0],T=i[A];S=b(this.chart.scales[A].getPixelForValue(T))}C=S-y,_=C-f,M=e.left,L=e.right}const $=Je(s.ticks.maxTicksLimit,u),D=Math.max(1,Math.ceil(u/$));for(x=0;x0&&(K-=G/2);break}j={left:K,top:V,width:G+U.width,height:B+U.height,color:D.backdropColor}}y.push({label:E,font:w,textOffset:L,options:{rotation:p,color:T,strokeColor:z,strokeWidth:H,textAlign:W,textBaseline:F,translation:[k,C],backdrop:j}})}return y}_getXAxisLabelAlignment(){const{position:e,ticks:n}=this.options;if(-Yn(this.labelRotation))return e==="top"?"left":"right";let s="center";return n.align==="start"?s="left":n.align==="end"?s="right":n.align==="inner"&&(s="inner"),s}_getYAxisLabelAlignment(e){const{position:n,ticks:{crossAlign:r,mirror:s,padding:o}}=this.options,i=this._getLabelSizes(),a=e+o,l=i.widest.width;let c,d;return n==="left"?s?(d=this.right+o,r==="near"?c="left":r==="center"?(c="center",d+=l/2):(c="right",d+=l)):(d=this.right-a,r==="near"?c="right":r==="center"?(c="center",d-=l/2):(c="left",d=this.left)):n==="right"?s?(d=this.left+o,r==="near"?c="right":r==="center"?(c="center",d-=l/2):(c="left",d-=l)):(d=this.left+a,r==="near"?c="left":r==="center"?(c="center",d+=l/2):(c="right",d=this.right)):c="right",{textAlign:c,x:d}}_computeLabelArea(){if(this.options.ticks.mirror)return;const e=this.chart,n=this.options.position;if(n==="left"||n==="right")return{top:0,left:this.left,bottom:e.height,right:this.right};if(n==="top"||n==="bottom")return{top:this.top,left:0,bottom:this.bottom,right:e.width}}drawBackground(){const{ctx:e,options:{backgroundColor:n},left:r,top:s,width:o,height:i}=this;n&&(e.save(),e.fillStyle=n,e.fillRect(r,s,o,i),e.restore())}getLineWidthForValue(e){const n=this.options.grid;if(!this._isVisible()||!n.display)return 0;const s=this.ticks.findIndex(o=>o.value===e);return s>=0?n.setContext(this.getContext(s)).lineWidth:0}drawGrid(e){const n=this.options.grid,r=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(e));let o,i;const a=(l,c,d)=>{!d.width||!d.color||(r.save(),r.lineWidth=d.width,r.strokeStyle=d.color,r.setLineDash(d.borderDash||[]),r.lineDashOffset=d.borderDashOffset,r.beginPath(),r.moveTo(l.x,l.y),r.lineTo(c.x,c.y),r.stroke(),r.restore())};if(n.display)for(o=0,i=s.length;o{this.draw(o)}}]:[{z:r,draw:o=>{this.drawBackground(),this.drawGrid(o),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:n,draw:o=>{this.drawLabels(o)}}]}getMatchingVisibleMetas(e){const n=this.chart.getSortedVisibleDatasetMetas(),r=this.axis+"AxisID",s=[];let o,i;for(o=0,i=n.length;o{const r=n.split("."),s=r.pop(),o=[t].concat(r).join("."),i=e[n].split("."),a=i.pop(),l=i.join(".");vt.route(o,s,l,a)})}function DO(t){return"id"in t&&"defaults"in t}class IO{constructor(){this.controllers=new ba(hs,"datasets",!0),this.elements=new ba(Ar,"elements"),this.plugins=new ba(Object,"plugins"),this.scales=new ba(gs,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...e){this._each("register",e)}remove(...e){this._each("unregister",e)}addControllers(...e){this._each("register",e,this.controllers)}addElements(...e){this._each("register",e,this.elements)}addPlugins(...e){this._each("register",e,this.plugins)}addScales(...e){this._each("register",e,this.scales)}getController(e){return this._get(e,this.controllers,"controller")}getElement(e){return this._get(e,this.elements,"element")}getPlugin(e){return this._get(e,this.plugins,"plugin")}getScale(e){return this._get(e,this.scales,"scale")}removeControllers(...e){this._each("unregister",e,this.controllers)}removeElements(...e){this._each("unregister",e,this.elements)}removePlugins(...e){this._each("unregister",e,this.plugins)}removeScales(...e){this._each("unregister",e,this.scales)}_each(e,n,r){[...n].forEach(s=>{const o=r||this._getRegistryForType(s);r||o.isForType(s)||o===this.plugins&&s.id?this._exec(e,o,s):at(s,i=>{const a=r||this._getRegistryForType(i);this._exec(e,a,i)})})}_exec(e,n,r){const s=jc(e);ht(r["before"+s],[],r),n[e](r),ht(r["after"+s],[],r)}_getRegistryForType(e){for(let n=0;no.filter(a=>!i.some(l=>a.plugin.id===l.plugin.id));this._notify(s(n,r),e,"stop"),this._notify(s(r,n),e,"start")}}function PO(t){const e={},n=[],r=Object.keys(zn.plugins.items);for(let o=0;o1&&J0(t[0].toLowerCase());if(r)return r}throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function R0(t,e,n){if(n[e+"AxisID"]===t)return{axis:e}}function BO(t,e){if(e.data&&e.data.datasets){const n=e.data.datasets.filter(r=>r.xAxisID===t||r.yAxisID===t);if(n.length)return R0(t,"x",n[0])||R0(t,"y",n[0])}return{}}function jO(t,e){const n=Er[t.type]||{scales:{}},r=e.scales||{},s=hd(t.type,e),o=Object.create(null);return Object.keys(r).forEach(i=>{const a=r[i];if(!Qe(a))return console.error(`Invalid scale configuration for scale: ${i}`);if(a._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${i}`);const l=gd(i,a,BO(i,t),vt.scales[a.type]),c=HO(l,s),d=n.scales||{};o[i]=uo(Object.create(null),[{axis:l},a,d[l],d[c]])}),t.data.datasets.forEach(i=>{const a=i.type||t.type,l=i.indexAxis||hd(a,e),d=(Er[a]||{}).scales||{};Object.keys(d).forEach(u=>{const f=VO(u,l),g=i[f+"AxisID"]||f;o[g]=o[g]||Object.create(null),uo(o[g],[{axis:f},r[g],d[u]])})}),Object.keys(o).forEach(i=>{const a=o[i];uo(a,[vt.scales[a.type],vt.scale])}),o}function em(t){const e=t.options||(t.options={});e.plugins=Je(e.plugins,{}),e.scales=jO(t,e)}function tm(t){return t=t||{},t.datasets=t.datasets||[],t.labels=t.labels||[],t}function WO(t){return t=t||{},t.data=tm(t.data),em(t),t}const nm=new Map,rm=new Set;function ya(t,e){let n=nm.get(t);return n||(n=e(),nm.set(t,n),rm.add(n)),n}const Co=(t,e,n)=>{const r=Cr(e,n);r!==void 0&&t.add(r)};class GO{constructor(e){this._config=WO(e),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(e){this._config.type=e}get data(){return this._config.data}set data(e){this._config.data=tm(e)}get options(){return this._config.options}set options(e){this._config.options=e}get plugins(){return this._config.plugins}update(){const e=this._config;this.clearCache(),em(e)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(e){return ya(e,()=>[[`datasets.${e}`,""]])}datasetAnimationScopeKeys(e,n){return ya(`${e}.transition.${n}`,()=>[[`datasets.${e}.transitions.${n}`,`transitions.${n}`],[`datasets.${e}`,""]])}datasetElementScopeKeys(e,n){return ya(`${e}-${n}`,()=>[[`datasets.${e}.elements.${n}`,`datasets.${e}`,`elements.${n}`,""]])}pluginScopeKeys(e){const n=e.id,r=this.type;return ya(`${r}-plugin-${n}`,()=>[[`plugins.${n}`,...e.additionalOptionScopes||[]]])}_cachedScopes(e,n){const r=this._scopeCache;let s=r.get(e);return(!s||n)&&(s=new Map,r.set(e,s)),s}getOptionScopes(e,n,r){const{options:s,type:o}=this,i=this._cachedScopes(e,r),a=i.get(n);if(a)return a;const l=new Set;n.forEach(d=>{e&&(l.add(e),d.forEach(u=>Co(l,e,u))),d.forEach(u=>Co(l,s,u)),d.forEach(u=>Co(l,Er[o]||{},u)),d.forEach(u=>Co(l,vt,u)),d.forEach(u=>Co(l,qc,u))});const c=Array.from(l);return c.length===0&&c.push(Object.create(null)),rm.has(n)&&i.set(n,c),c}chartOptionScopes(){const{options:e,type:n}=this;return[e,Er[n]||{},vt.datasets[n]||{},{type:n},vt,qc]}resolveNamedOptions(e,n,r,s=[""]){const o={$shared:!0},{resolver:i,subPrefixes:a}=sm(this._resolverCache,e,s);let l=i;if(XO(i,n)){o.$shared=!1,r=ar(r)?r():r;const c=this.createResolver(e,r,a);l=us(i,r,c)}for(const c of n)o[c]=l[c];return o}createResolver(e,n,r=[""],s){const{resolver:o}=sm(this._resolverCache,e,r);return Qe(n)?us(o,n,void 0,s):o}}function sm(t,e,n){let r=t.get(e);r||(r=new Map,t.set(e,r));const s=n.join();let o=r.get(s);return o||(o={resolver:ed(e,n),subPrefixes:n.filter(a=>!a.toLowerCase().includes("hover"))},r.set(s,o)),o}const YO=t=>Qe(t)&&Object.getOwnPropertyNames(t).some(e=>ar(t[e]));function XO(t,e){const{isScriptable:n,isIndexable:r}=p0(t);for(const s of e){const o=n(s),i=r(s),a=(i||o)&&t[s];if(o&&(ar(a)||YO(a))||i&&_t(a))return!0}return!1}var KO="4.5.1";const qO=["top","bottom","left","right","chartArea"];function om(t,e){return t==="top"||t==="bottom"||qO.indexOf(t)===-1&&e==="x"}function im(t,e){return function(n,r){return n[t]===r[t]?n[e]-r[e]:n[t]-r[t]}}function am(t){const e=t.chart,n=e.options.animation;e.notifyPlugins("afterRender"),ht(n&&n.onComplete,[t],e)}function ZO(t){const e=t.chart,n=e.options.animation;ht(n&&n.onProgress,[t],e)}function lm(t){return rd()&&typeof t=="string"?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const va={},cm=t=>{const e=lm(t);return Object.values(va).filter(n=>n.canvas===e).pop()};function QO(t,e,n){const r=Object.keys(t);for(const s of r){const o=+s;if(o>=e){const i=t[s];delete t[s],(n>0||o>e)&&(t[o+n]=i)}}}function JO(t,e,n,r){return!n||t.type==="mouseout"?null:r?e:t}let So=(ur=class{static register(...e){zn.add(...e),dm()}static unregister(...e){zn.remove(...e),dm()}constructor(e,n){const r=this.config=new GO(n),s=lm(e),o=cm(s);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const i=r.createResolver(r.chartOptionScopes(),this.getContext());this.platform=new(r.platform||mO(s)),this.platform.updateConfig(r);const a=this.platform.acquireContext(s,i.aspectRatio),l=a&&a.canvas,c=l&&l.height,d=l&&l.width;if(this.id=pM(),this.ctx=a,this.canvas=l,this.width=d,this.height=c,this._options=i,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new LO,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=IM(u=>this.update(u),i.resizeDelay||0),this._dataChanges=[],va[this.id]=this,!a||!l){console.error("Failed to create chart: can't acquire context from the given item");return}Xn.listen(this,"complete",am),Xn.listen(this,"progress",ZO),this._initialize(),this.attached&&this.update()}get aspectRatio(){const{options:{aspectRatio:e,maintainAspectRatio:n},width:r,height:s,_aspectRatio:o}=this;return lt(e)?n&&o?o:s?r/s:null:e}get data(){return this.config.data}set data(e){this.config.data=e}get options(){return this._options}set options(e){this.config.options=e}get registry(){return zn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():w0(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return d0(this.canvas,this.ctx),this}stop(){return Xn.stop(this),this}resize(e,n){Xn.running(this)?this._resizeBeforeDraw={width:e,height:n}:this._resize(e,n)}_resize(e,n){const r=this.options,s=this.canvas,o=r.maintainAspectRatio&&this.aspectRatio,i=this.platform.getMaximumSize(s,e,n,o),a=r.devicePixelRatio||this.platform.getDevicePixelRatio(),l=this.width?"resize":"attach";this.width=i.width,this.height=i.height,this._aspectRatio=this.aspectRatio,w0(this,a,!0)&&(this.notifyPlugins("resize",{size:i}),ht(r.onResize,[this,i],this),this.attached&&this._doResize(l)&&this.render())}ensureScalesHaveIDs(){const n=this.options.scales||{};at(n,(r,s)=>{r.id=s})}buildOrUpdateScales(){const e=this.options,n=e.scales,r=this.scales,s=Object.keys(r).reduce((i,a)=>(i[a]=!1,i),{});let o=[];n&&(o=o.concat(Object.keys(n).map(i=>{const a=n[i],l=gd(i,a),c=l==="r",d=l==="x";return{options:a,dposition:c?"chartArea":d?"bottom":"left",dtype:c?"radialLinear":d?"category":"linear"}}))),at(o,i=>{const a=i.options,l=a.id,c=gd(l,a),d=Je(a.type,i.dtype);(a.position===void 0||om(a.position,c)!==om(i.dposition))&&(a.position=i.dposition),s[l]=!0;let u=null;if(l in r&&r[l].type===d)u=r[l];else{const f=zn.getScale(d);u=new f({id:l,type:d,ctx:this.ctx,chart:this}),r[u.id]=u}u.init(a,e)}),at(s,(i,a)=>{i||delete r[a]}),at(r,i=>{mn.configure(this,i,i.options),mn.addBox(this,i)})}_updateMetasets(){const e=this._metasets,n=this.data.datasets.length,r=e.length;if(e.sort((s,o)=>s.index-o.index),r>n){for(let s=n;sn.length&&delete this._stacks,e.forEach((r,s)=>{n.filter(o=>o===r._dataset).length===0&&this._destroyDatasetMeta(s)})}buildOrUpdateControllers(){const e=[],n=this.data.datasets;let r,s;for(this._removeUnreferencedMetasets(),r=0,s=n.length;r{this.getDatasetMeta(n).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(e){const n=this.config;n.update();const r=this._options=n.createResolver(n.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!r.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins("beforeUpdate",{mode:e,cancelable:!0})===!1)return;const o=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let i=0;for(let c=0,d=this.data.datasets.length;c{c.reset()}),this._updateDatasets(e),this.notifyPlugins("afterUpdate",{mode:e}),this._layers.sort(im("z","_idx"));const{_active:a,_lastEvent:l}=this;l?this._eventHandler(l,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){at(this.scales,e=>{mn.removeBox(this,e)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const e=this.options,n=new Set(Object.keys(this._listeners)),r=new Set(e.events);(!Yp(n,r)||!!this._responsiveListeners!==e.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:e}=this,n=this._getUniformDataChanges()||[];for(const{method:r,start:s,count:o}of n){const i=r==="_removeElements"?-o:o;QO(e,s,i)}}_getUniformDataChanges(){const e=this._dataChanges;if(!e||!e.length)return;this._dataChanges=[];const n=this.data.datasets.length,r=o=>new Set(e.filter(i=>i[0]===o).map((i,a)=>a+","+i.splice(1).join(","))),s=r(0);for(let o=1;oo.split(",")).map(o=>({method:o[1],start:+o[2],count:+o[3]}))}_updateLayout(e){if(this.notifyPlugins("beforeLayout",{cancelable:!0})===!1)return;mn.update(this,this.width,this.height,e);const n=this.chartArea,r=n.width<=0||n.height<=0;this._layers=[],at(this.boxes,s=>{r&&s.position==="chartArea"||(s.configure&&s.configure(),this._layers.push(...s._layers()))},this),this._layers.forEach((s,o)=>{s._idx=o}),this.notifyPlugins("afterLayout")}_updateDatasets(e){if(this.notifyPlugins("beforeDatasetsUpdate",{mode:e,cancelable:!0})!==!1){for(let n=0,r=this.data.datasets.length;n=0;--n)this._drawDataset(e[n]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(e){const n=this.ctx,r={meta:e,index:e.index,cancelable:!0},s=yN(this,e);this.notifyPlugins("beforeDatasetDraw",r)!==!1&&(s&&Qc(n,s),e.controller.draw(),s&&Jc(n),r.cancelable=!1,this.notifyPlugins("afterDatasetDraw",r))}isPointInArea(e){return h0(e,this.chartArea,this._minPadding)}getElementsAtEventForMode(e,n,r,s){const o=qN.modes[n];return typeof o=="function"?o(this,e,r,s):[]}getDatasetMeta(e){const n=this.data.datasets[e],r=this._metasets;let s=r.filter(o=>o&&o._dataset===n).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:n&&n.order||0,index:e,_dataset:n,_parsed:[],_sorted:!1},r.push(s)),s}getContext(){return this.$context||(this.$context=ds(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(e){const n=this.data.datasets[e];if(!n)return!1;const r=this.getDatasetMeta(e);return typeof r.hidden=="boolean"?!r.hidden:!n.hidden}setDatasetVisibility(e,n){const r=this.getDatasetMeta(e);r.hidden=!n}toggleDataVisibility(e){this._hiddenIndices[e]=!this._hiddenIndices[e]}getDataVisibility(e){return!this._hiddenIndices[e]}_updateVisibility(e,n,r){const s=r?"show":"hide",o=this.getDatasetMeta(e),i=o.controller._resolveAnimations(void 0,s);fo(n)?(o.data[n].hidden=!r,this.update()):(this.setDatasetVisibility(e,r),i.update(o,{visible:r}),this.update(a=>a.datasetIndex===e?s:void 0))}hide(e,n){this._updateVisibility(e,n,!1)}show(e,n){this._updateVisibility(e,n,!0)}_destroyDatasetMeta(e){const n=this._metasets[e];n&&n.controller&&n.controller._destroy(),delete this._metasets[e]}_stop(){let e,n;for(this.stop(),Xn.remove(this),e=0,n=this.data.datasets.length;e{n.addEventListener(this,o,i),e[o]=i},s=(o,i,a)=>{o.offsetX=i,o.offsetY=a,this._eventHandler(o)};at(this.options.events,o=>r(o,s))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const e=this._responsiveListeners,n=this.platform,r=(l,c)=>{n.addEventListener(this,l,c),e[l]=c},s=(l,c)=>{e[l]&&(n.removeEventListener(this,l,c),delete e[l])},o=(l,c)=>{this.canvas&&this.resize(l,c)};let i;const a=()=>{s("attach",a),this.attached=!0,this.resize(),r("resize",o),r("detach",i)};i=()=>{this.attached=!1,s("resize",o),this._stop(),this._resize(0,0),r("attach",a)},n.isAttached(this.canvas)?a():i()}unbindEvents(){at(this._listeners,(e,n)=>{this.platform.removeEventListener(this,n,e)}),this._listeners={},at(this._responsiveListeners,(e,n)=>{this.platform.removeEventListener(this,n,e)}),this._responsiveListeners=void 0}updateHoverStyle(e,n,r){const s=r?"set":"remove";let o,i,a,l;for(n==="dataset"&&(o=this.getDatasetMeta(e[0].datasetIndex),o.controller["_"+s+"DatasetHoverStyle"]()),a=0,l=e.length;a{const a=this.getDatasetMeta(o);if(!a)throw new Error("No dataset found at index "+o);return{datasetIndex:o,element:a.data[i],index:i}});!Ri(r,n)&&(this._active=r,this._lastEvent=null,this._updateHoverStyles(r,n))}notifyPlugins(e,n,r){return this._plugins.notify(this,e,n,r)}isPluginEnabled(e){return this._plugins._cache.filter(n=>n.plugin.id===e).length===1}_updateHoverStyles(e,n,r){const s=this.options.hover,o=(l,c)=>l.filter(d=>!c.some(u=>d.datasetIndex===u.datasetIndex&&d.index===u.index)),i=o(n,e),a=r?e:o(e,n);i.length&&this.updateHoverStyle(i,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(e,n){const r={event:e,replay:n,cancelable:!0,inChartArea:this.isPointInArea(e)},s=i=>(i.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins("beforeEvent",r,s)===!1)return;const o=this._handleEvent(e,n,r.inChartArea);return r.cancelable=!1,this.notifyPlugins("afterEvent",r,s),(o||r.changed)&&this.render(),this}_handleEvent(e,n,r){const{_active:s=[],options:o}=this,i=n,a=this._getActiveElements(e,s,r,i),l=wM(e),c=JO(e,this._lastEvent,r,l);r&&(this._lastEvent=null,ht(o.onHover,[e,a,this],this),l&&ht(o.onClick,[e,a,this],this));const d=!Ri(a,s);return(d||n)&&(this._active=a,this._updateHoverStyles(a,s,n)),this._lastEvent=c,d}_getActiveElements(e,n,r,s){if(e.type==="mouseout")return[];if(!r)return n;const o=this.options.hover;return this.getElementsAtEventForMode(e,o.mode,o,s)}},Fe(ur,"defaults",vt),Fe(ur,"instances",va),Fe(ur,"overrides",Er),Fe(ur,"registry",zn),Fe(ur,"version",KO),Fe(ur,"getChart",cm),ur);function dm(){return at(So.instances,t=>t._plugins.invalidate())}function RO(t,e,n){const{startAngle:r,x:s,y:o,outerRadius:i,innerRadius:a,options:l}=e,{borderWidth:c,borderJoinStyle:d}=l,u=Math.min(c/i,Fn(r-n));if(t.beginPath(),t.arc(s,o,i-c/2,r+u/2,n-u/2),a>0){const f=Math.min(c/a,Fn(r-n));t.arc(s,o,a+c/2,n-f/2,r+f/2,!0)}else{const f=Math.min(c/2,i*Fn(r-n));if(d==="round")t.arc(s,o,f,n-ct/2,r+ct/2,!0);else if(d==="bevel"){const g=2*f*f,m=-g*Math.cos(n+ct/2)+s,p=-g*Math.sin(n+ct/2)+o,y=g*Math.cos(r+ct/2)+s,b=g*Math.sin(r+ct/2)+o;t.lineTo(m,p),t.lineTo(y,b)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}function eT(t,e,n){const{startAngle:r,pixelMargin:s,x:o,y:i,outerRadius:a,innerRadius:l}=e;let c=s/a;t.beginPath(),t.arc(o,i,a,r-c,n+c),l>s?(c=s/l,t.arc(o,i,l,n+c,r-c,!0)):t.arc(o,i,s,n+Et,r-Et),t.closePath(),t.clip()}function tT(t){return Rc(t,["outerStart","outerEnd","innerStart","innerEnd"])}function nT(t,e,n,r){const s=tT(t.options.borderRadius),o=(n-e)/2,i=Math.min(o,r*e/2),a=l=>{const c=(n-Math.min(o,l))*r/2;return Jt(l,0,Math.min(o,c))};return{outerStart:a(s.outerStart),outerEnd:a(s.outerEnd),innerStart:Jt(s.innerStart,0,i),innerEnd:Jt(s.innerEnd,0,i)}}function ps(t,e,n,r){return{x:n+t*Math.cos(e),y:r+t*Math.sin(e)}}function xa(t,e,n,r,s,o){const{x:i,y:a,startAngle:l,pixelMargin:c,innerRadius:d}=e,u=Math.max(e.outerRadius+r+n-c,0),f=d>0?d+r+n+c:0;let g=0;const m=s-l;if(r){const D=d>0?d-r:0,A=u>0?u-r:0,T=(D+A)/2,z=T!==0?m*T/(T+r):m;g=(m-z)/2}const p=Math.max(.001,m*u-n/ct)/u,y=(m-p)/2,b=l+y+g,S=s-y-g,{outerStart:x,outerEnd:E,innerStart:k,innerEnd:C}=nT(e,f,u,S-b),N=u-x,_=u-E,w=b+x/N,M=S-E/_,O=f+k,L=f+C,F=b+k/O,$=S-C/L;if(t.beginPath(),o){const D=(w+M)/2;if(t.arc(i,a,u,w,D),t.arc(i,a,u,D,M),E>0){const H=ps(_,M,i,a);t.arc(H.x,H.y,E,M,S+Et)}const A=ps(L,S,i,a);if(t.lineTo(A.x,A.y),C>0){const H=ps(L,$,i,a);t.arc(H.x,H.y,C,S+Et,$+Math.PI)}const T=(S-C/f+(b+k/f))/2;if(t.arc(i,a,f,S-C/f,T,!0),t.arc(i,a,f,T,b+k/f,!0),k>0){const H=ps(O,F,i,a);t.arc(H.x,H.y,k,F+Math.PI,b-Et)}const z=ps(N,b,i,a);if(t.lineTo(z.x,z.y),x>0){const H=ps(N,w,i,a);t.arc(H.x,H.y,x,b-Et,w)}}else{t.moveTo(i,a);const D=Math.cos(w)*u+i,A=Math.sin(w)*u+a;t.lineTo(D,A);const T=Math.cos(M)*u+i,z=Math.sin(M)*u+a;t.lineTo(T,z)}t.closePath()}function rT(t,e,n,r,s){const{fullCircles:o,startAngle:i,circumference:a}=e;let l=e.endAngle;if(o){xa(t,e,n,r,l,s);for(let c=0;c=ct&&g===0&&d!=="miter"&&RO(t,e,p),o||(xa(t,e,n,r,p,s),t.stroke())}class _o extends Ar{constructor(n){super();Fe(this,"circumference");Fe(this,"endAngle");Fe(this,"fullCircles");Fe(this,"innerRadius");Fe(this,"outerRadius");Fe(this,"pixelMargin");Fe(this,"startAngle");this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,n&&Object.assign(this,n)}inRange(n,r,s){const o=this.getProps(["x","y"],s),{angle:i,distance:a}=Qp(o,{x:n,y:r}),{startAngle:l,endAngle:c,innerRadius:d,outerRadius:u,circumference:f}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],s),g=(this.options.spacing+this.options.borderWidth)/2,m=Je(f,c-l),p=sa(i,l,c)&&l!==c,y=m>=Ct||p,b=_r(a,d+g,u+g);return y&&b}getCenterPoint(n){const{x:r,y:s,startAngle:o,endAngle:i,innerRadius:a,outerRadius:l}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],n),{offset:c,spacing:d}=this.options,u=(o+i)/2,f=(a+l+d+c)/2;return{x:r+Math.cos(u)*f,y:s+Math.sin(u)*f}}tooltipPosition(n){return this.getCenterPoint(n)}draw(n){const{options:r,circumference:s}=this,o=(r.offset||0)/4,i=(r.spacing||0)/2,a=r.circular;if(this.pixelMargin=r.borderAlign==="inner"?.33:0,this.fullCircles=s>Ct?Math.floor(s/Ct):0,s===0||this.innerRadius<0||this.outerRadius<0)return;n.save();const l=(this.startAngle+this.endAngle)/2;n.translate(Math.cos(l)*o,Math.sin(l)*o);const c=1-Math.sin(Math.min(ct,s||0)),d=o*c;n.fillStyle=r.backgroundColor,n.strokeStyle=r.borderColor,rT(n,this,d,i,a),sT(n,this,d,i,a),n.restore()}}Fe(_o,"id","arc"),Fe(_o,"defaults",{borderAlign:"center",borderColor:"#fff",borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1}),Fe(_o,"defaultRoutes",{backgroundColor:"backgroundColor"}),Fe(_o,"descriptors",{_scriptable:!0,_indexable:n=>n!=="borderDash"});function um(t,e){const{x:n,y:r,base:s,width:o,height:i}=t.getProps(["x","y","base","width","height"],e);let a,l,c,d,u;return t.horizontal?(u=i/2,a=Math.min(n,s),l=Math.max(n,s),c=r-u,d=r+u):(u=o/2,a=n-u,l=n+u,c=Math.min(r,s),d=Math.max(r,s)),{left:a,top:c,right:l,bottom:d}}function dr(t,e,n,r){return t?0:Jt(e,n,r)}function oT(t,e,n){const r=t.options.borderWidth,s=t.borderSkipped,o=g0(r);return{t:dr(s.top,o.top,0,n),r:dr(s.right,o.right,0,e),b:dr(s.bottom,o.bottom,0,n),l:dr(s.left,o.left,0,e)}}function iT(t,e,n){const{enableBorderRadius:r}=t.getProps(["enableBorderRadius"]),s=t.options.borderRadius,o=cs(s),i=Math.min(e,n),a=t.borderSkipped,l=r||Qe(s);return{topLeft:dr(!l||a.top||a.left,o.topLeft,0,i),topRight:dr(!l||a.top||a.right,o.topRight,0,i),bottomLeft:dr(!l||a.bottom||a.left,o.bottomLeft,0,i),bottomRight:dr(!l||a.bottom||a.right,o.bottomRight,0,i)}}function aT(t){const e=um(t),n=e.right-e.left,r=e.bottom-e.top,s=oT(t,n/2,r/2),o=iT(t,n/2,r/2);return{outer:{x:e.left,y:e.top,w:n,h:r,radius:o},inner:{x:e.left+s.l,y:e.top+s.t,w:n-s.l-s.r,h:r-s.t-s.b,radius:{topLeft:Math.max(0,o.topLeft-Math.max(s.t,s.l)),topRight:Math.max(0,o.topRight-Math.max(s.t,s.r)),bottomLeft:Math.max(0,o.bottomLeft-Math.max(s.b,s.l)),bottomRight:Math.max(0,o.bottomRight-Math.max(s.b,s.r))}}}}function pd(t,e,n,r){const s=e===null,o=n===null,a=t&&!(s&&o)&&um(t,r);return a&&(s||_r(e,a.left,a.right))&&(o||_r(n,a.top,a.bottom))}function lT(t){return t.topLeft||t.topRight||t.bottomLeft||t.bottomRight}function cT(t,e){t.rect(e.x,e.y,e.w,e.h)}function md(t,e,n={}){const r=t.x!==n.x?-e:0,s=t.y!==n.y?-e:0,o=(t.x+t.w!==n.x+n.w?e:0)-r,i=(t.y+t.h!==n.y+n.h?e:0)-s;return{x:t.x+r,y:t.y+s,w:t.w+o,h:t.h+i,radius:t.radius}}class wa extends Ar{constructor(e){super(),this.options=void 0,this.horizontal=void 0,this.base=void 0,this.width=void 0,this.height=void 0,this.inflateAmount=void 0,e&&Object.assign(this,e)}draw(e){const{inflateAmount:n,options:{borderColor:r,backgroundColor:s}}=this,{inner:o,outer:i}=aT(this),a=lT(i.radius)?ia:cT;e.save(),(i.w!==o.w||i.h!==o.h)&&(e.beginPath(),a(e,md(i,n,o)),e.clip(),a(e,md(o,-n,i)),e.fillStyle=r,e.fill("evenodd")),e.beginPath(),a(e,md(o,n)),e.fillStyle=s,e.fill(),e.restore()}inRange(e,n,r){return pd(this,e,n,r)}inXRange(e,n){return pd(this,e,null,n)}inYRange(e,n){return pd(this,null,e,n)}getCenterPoint(e){const{x:n,y:r,base:s,horizontal:o}=this.getProps(["x","y","base","horizontal"],e);return{x:o?(n+s)/2:n,y:o?r:(r+s)/2}}getRange(e){return e==="x"?this.width/2:this.height/2}}Fe(wa,"id","bar"),Fe(wa,"defaults",{borderSkipped:"start",borderWidth:0,borderRadius:0,inflateAmount:"auto",pointStyle:void 0}),Fe(wa,"defaultRoutes",{backgroundColor:"backgroundColor",borderColor:"borderColor"});const fm=(t,e)=>{let{boxHeight:n=e,boxWidth:r=e}=t;return t.usePointStyle&&(n=Math.min(n,e),r=t.pointStyleWidth||Math.min(r,e)),{boxWidth:r,boxHeight:n,itemHeight:Math.max(e,n)}},dT=(t,e)=>t!==null&&e!==null&&t.datasetIndex===e.datasetIndex&&t.index===e.index;class hm extends Ar{constructor(e){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,n,r){this.maxWidth=e,this.maxHeight=n,this._margins=r,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const e=this.options.labels||{};let n=ht(e.generateLabels,[this.chart],this)||[];e.filter&&(n=n.filter(r=>e.filter(r,this.chart.data))),e.sort&&(n=n.sort((r,s)=>e.sort(r,s,this.chart.data))),this.options.reverse&&n.reverse(),this.legendItems=n}fit(){const{options:e,ctx:n}=this;if(!e.display){this.width=this.height=0;return}const r=e.labels,s=Ut(r.font),o=s.size,i=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=fm(r,o);let c,d;n.font=s.string,this.isHorizontal()?(c=this.maxWidth,d=this._fitRows(i,o,a,l)+10):(d=this.maxHeight,c=this._fitCols(i,s,a,l)+10),this.width=Math.min(c,e.maxWidth||this.maxWidth),this.height=Math.min(d,e.maxHeight||this.maxHeight)}_fitRows(e,n,r,s){const{ctx:o,maxWidth:i,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.lineWidths=[0],d=s+a;let u=e;o.textAlign="left",o.textBaseline="middle";let f=-1,g=-d;return this.legendItems.forEach((m,p)=>{const y=r+n/2+o.measureText(m.text).width;(p===0||c[c.length-1]+y+2*a>i)&&(u+=d,c[c.length-(p>0?0:1)]=0,g+=d,f++),l[p]={left:0,top:g,row:f,width:y,height:s},c[c.length-1]+=y+a}),u}_fitCols(e,n,r,s){const{ctx:o,maxHeight:i,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.columnSizes=[],d=i-e;let u=a,f=0,g=0,m=0,p=0;return this.legendItems.forEach((y,b)=>{const{itemWidth:S,itemHeight:x}=uT(r,n,o,y,s);b>0&&g+x+2*a>d&&(u+=f+a,c.push({width:f,height:g}),m+=f+a,p++,f=g=0),l[b]={left:m,top:g,col:p,width:S,height:x},f=Math.max(f,S),g+=x+a}),u+=f,c.push({width:f,height:g}),u}adjustHitBoxes(){if(!this.options.display)return;const e=this._computeTitleHeight(),{legendHitBoxes:n,options:{align:r,labels:{padding:s},rtl:o}}=this,i=fs(o,this.left,this.width);if(this.isHorizontal()){let a=0,l=Ht(r,this.left+s,this.right-this.lineWidths[a]);for(const c of n)a!==c.row&&(a=c.row,l=Ht(r,this.left+s,this.right-this.lineWidths[a])),c.top+=this.top+e+s,c.left=i.leftForLtr(i.x(l),c.width),l+=c.width+s}else{let a=0,l=Ht(r,this.top+e+s,this.bottom-this.columnSizes[a].height);for(const c of n)c.col!==a&&(a=c.col,l=Ht(r,this.top+e+s,this.bottom-this.columnSizes[a].height)),c.top=l,c.left+=this.left+s,c.left=i.leftForLtr(i.x(c.left),c.width),l+=c.height+s}}isHorizontal(){return this.options.position==="top"||this.options.position==="bottom"}draw(){if(this.options.display){const e=this.ctx;Qc(e,this),this._draw(),Jc(e)}}_draw(){const{options:e,columnSizes:n,lineWidths:r,ctx:s}=this,{align:o,labels:i}=e,a=vt.color,l=fs(e.rtl,this.left,this.width),c=Ut(i.font),{padding:d}=i,u=c.size,f=u/2;let g;this.drawTitle(),s.textAlign=l.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=c.string;const{boxWidth:m,boxHeight:p,itemHeight:y}=fm(i,u),b=function(C,N,_){if(isNaN(m)||m<=0||isNaN(p)||p<0)return;s.save();const w=Je(_.lineWidth,1);if(s.fillStyle=Je(_.fillStyle,a),s.lineCap=Je(_.lineCap,"butt"),s.lineDashOffset=Je(_.lineDashOffset,0),s.lineJoin=Je(_.lineJoin,"miter"),s.lineWidth=w,s.strokeStyle=Je(_.strokeStyle,a),s.setLineDash(Je(_.lineDash,[])),i.usePointStyle){const M={radius:p*Math.SQRT2/2,pointStyle:_.pointStyle,rotation:_.rotation,borderWidth:w},O=l.xPlus(C,m/2),L=N+f;f0(s,M,O,L,i.pointStyleWidth&&m)}else{const M=N+Math.max((u-p)/2,0),O=l.leftForLtr(C,m),L=cs(_.borderRadius);s.beginPath(),Object.values(L).some(F=>F!==0)?ia(s,{x:O,y:M,w:m,h:p,radius:L}):s.rect(O,M,m,p),s.fill(),w!==0&&s.stroke()}s.restore()},S=function(C,N,_){po(s,_.text,C,N+y/2,c,{strikethrough:_.hidden,textAlign:l.textAlign(_.textAlign)})},x=this.isHorizontal(),E=this._computeTitleHeight();x?g={x:Ht(o,this.left+d,this.right-r[0]),y:this.top+d+E,line:0}:g={x:this.left+d,y:Ht(o,this.top+E+d,this.bottom-n[0].height),line:0},C0(this.ctx,e.textDirection);const k=y+d;this.legendItems.forEach((C,N)=>{s.strokeStyle=C.fontColor,s.fillStyle=C.fontColor;const _=s.measureText(C.text).width,w=l.textAlign(C.textAlign||(C.textAlign=i.textAlign)),M=m+f+_;let O=g.x,L=g.y;l.setWidth(this.width),x?N>0&&O+M+d>this.right&&(L=g.y+=k,g.line++,O=g.x=Ht(o,this.left+d,this.right-r[g.line])):N>0&&L+k>this.bottom&&(O=g.x=O+n[g.line].width+d,g.line++,L=g.y=Ht(o,this.top+E+d,this.bottom-n[g.line].height));const F=l.x(O);if(b(F,L,C),O=LM(w,O+m+f,x?O+M:this.right,e.rtl),S(l.x(O),L,C),x)g.x+=M+d;else if(typeof C.text!="string"){const $=c.lineHeight;g.y+=gm(C,$)+d}else g.y+=k}),S0(this.ctx,e.textDirection)}drawTitle(){const e=this.options,n=e.title,r=Ut(n.font),s=pn(n.padding);if(!n.display)return;const o=fs(e.rtl,this.left,this.width),i=this.ctx,a=n.position,l=r.size/2,c=s.top+l;let d,u=this.left,f=this.width;if(this.isHorizontal())f=Math.max(...this.lineWidths),d=this.top+c,u=Ht(e.align,u,this.right-f);else{const m=this.columnSizes.reduce((p,y)=>Math.max(p,y.height),0);d=c+Ht(e.align,this.top,this.bottom-m-e.labels.padding-this._computeTitleHeight())}const g=Ht(a,u,u+f);i.textAlign=o.textAlign(Yc(a)),i.textBaseline="middle",i.strokeStyle=n.color,i.fillStyle=n.color,i.font=r.string,po(i,n.text,g,d,r)}_computeTitleHeight(){const e=this.options.title,n=Ut(e.font),r=pn(e.padding);return e.display?n.lineHeight+r.height:0}_getLegendItemAt(e,n){let r,s,o;if(_r(e,this.left,this.right)&&_r(n,this.top,this.bottom)){for(o=this.legendHitBoxes,r=0;ro.length>i.length?o:i)),e+n.size/2+r.measureText(s).width}function hT(t,e,n){let r=t;return typeof e.text!="string"&&(r=gm(e,n)),r}function gm(t,e){const n=t.text?t.text.length:0;return e*n}function gT(t,e){return!!((t==="mousemove"||t==="mouseout")&&(e.onHover||e.onLeave)||e.onClick&&(t==="click"||t==="mouseup"))}var pm={id:"legend",_element:hm,start(t,e,n){const r=t.legend=new hm({ctx:t.ctx,options:n,chart:t});mn.configure(t,r,n),mn.addBox(t,r)},stop(t){mn.removeBox(t,t.legend),delete t.legend},beforeUpdate(t,e,n){const r=t.legend;mn.configure(t,r,n),r.options=n},afterUpdate(t){const e=t.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(t,e){e.replay||t.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(t,e,n){const r=e.datasetIndex,s=n.chart;s.isDatasetVisible(r)?(s.hide(r),e.hidden=!0):(s.show(r),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:t=>t.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:n,pointStyle:r,textAlign:s,color:o,useBorderRadius:i,borderRadius:a}}=t.legend.options;return t._getSortedDatasetMetas().map(l=>{const c=l.controller.getStyle(n?0:void 0),d=pn(c.borderWidth);return{text:e[l.index].label,fillStyle:c.backgroundColor,fontColor:o,hidden:!l.visible,lineCap:c.borderCapStyle,lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:(d.width+d.height)/4,strokeStyle:c.borderColor,pointStyle:r||c.pointStyle,rotation:c.rotation,textAlign:s||c.textAlign,borderRadius:i&&(a||c.borderRadius),datasetIndex:l.index}},this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class mm extends Ar{constructor(e){super(),this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,n){const r=this.options;if(this.left=0,this.top=0,!r.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=e,this.height=this.bottom=n;const s=_t(r.text)?r.text.length:1;this._padding=pn(r.padding);const o=s*Ut(r.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const e=this.options.position;return e==="top"||e==="bottom"}_drawArgs(e){const{top:n,left:r,bottom:s,right:o,options:i}=this,a=i.align;let l=0,c,d,u;return this.isHorizontal()?(d=Ht(a,r,o),u=n+e,c=o-r):(i.position==="left"?(d=r+e,u=Ht(a,s,n),l=ct*-.5):(d=o-e,u=Ht(a,n,s),l=ct*.5),c=s-n),{titleX:d,titleY:u,maxWidth:c,rotation:l}}draw(){const e=this.ctx,n=this.options;if(!n.display)return;const r=Ut(n.font),o=r.lineHeight/2+this._padding.top,{titleX:i,titleY:a,maxWidth:l,rotation:c}=this._drawArgs(o);po(e,n.text,0,0,r,{color:n.color,maxWidth:l,rotation:c,textAlign:Yc(n.align),textBaseline:"middle",translation:[i,a]})}}function pT(t,e){const n=new mm({ctx:t.ctx,options:e,chart:t});mn.configure(t,n,e),mn.addBox(t,n),t.titleBlock=n}var mT={id:"title",_element:mm,start(t,e,n){pT(t,n)},stop(t){const e=t.titleBlock;mn.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,n){const r=t.titleBlock;mn.configure(t,r,n),r.options=n},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Eo={average(t){if(!t.length)return!1;let e,n,r=new Set,s=0,o=0;for(e=0,n=t.length;ea+l)/r.size,y:s/o}},nearest(t,e){if(!t.length)return!1;let n=e.x,r=e.y,s=Number.POSITIVE_INFINITY,o,i,a;for(o=0,i=t.length;o-1?t.split(` `):t}function bT(t,e){const{element:n,datasetIndex:r,index:s}=e,o=t.getDatasetMeta(r).controller,{label:i,value:a}=o.getLabelAndValue(s);return{chart:t,label:i,parsed:o.getParsed(s),raw:t.data.datasets[r].data[s],formattedValue:a,dataset:o.getDataset(),dataIndex:s,datasetIndex:r,element:n}}function bm(t,e){const n=t.chart.ctx,{body:r,footer:s,title:o}=t,{boxWidth:i,boxHeight:a}=e,l=Ut(e.bodyFont),c=Ut(e.titleFont),d=Ut(e.footerFont),u=o.length,f=s.length,g=r.length,m=pn(e.padding);let p=m.height,y=0,b=r.reduce((E,k)=>E+k.before.length+k.lines.length+k.after.length,0);if(b+=t.beforeBody.length+t.afterBody.length,u&&(p+=u*c.lineHeight+(u-1)*e.titleSpacing+e.titleMarginBottom),b){const E=e.displayColors?Math.max(a,l.lineHeight):l.lineHeight;p+=g*E+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(p+=e.footerMarginTop+f*d.lineHeight+(f-1)*e.footerSpacing);let S=0;const x=function(E){y=Math.max(y,n.measureText(E).width+S)};return n.save(),n.font=c.string,at(t.title,x),n.font=l.string,at(t.beforeBody.concat(t.afterBody),x),S=e.displayColors?i+2+e.boxPadding:0,at(r,E=>{at(E.before,x),at(E.lines,x),at(E.after,x)}),S=0,n.font=d.string,at(t.footer,x),n.restore(),y+=m.width,{width:y,height:p}}function yT(t,e){const{y:n,height:r}=e;return nt.height-r/2?"bottom":"center"}function vT(t,e,n,r){const{x:s,width:o}=r,i=n.caretSize+n.caretPadding;if(t==="left"&&s+o+i>e.width||t==="right"&&s-o-i<0)return!0}function xT(t,e,n,r){const{x:s,width:o}=n,{width:i,chartArea:{left:a,right:l}}=t;let c="center";return r==="center"?c=s<=(a+l)/2?"left":"right":s<=o/2?c="left":s>=i-o/2&&(c="right"),vT(c,t,e,n)&&(c="center"),c}function ym(t,e,n){const r=n.yAlign||e.yAlign||yT(t,n);return{xAlign:n.xAlign||e.xAlign||xT(t,e,n,r),yAlign:r}}function wT(t,e){let{x:n,width:r}=t;return e==="right"?n-=r:e==="center"&&(n-=r/2),n}function kT(t,e,n){let{y:r,height:s}=t;return e==="top"?r+=n:e==="bottom"?r-=s+n:r-=s/2,r}function vm(t,e,n,r){const{caretSize:s,caretPadding:o,cornerRadius:i}=t,{xAlign:a,yAlign:l}=n,c=s+o,{topLeft:d,topRight:u,bottomLeft:f,bottomRight:g}=cs(i);let m=wT(e,a);const p=kT(e,l,c);return l==="center"?a==="left"?m+=c:a==="right"&&(m-=c):a==="left"?m-=Math.max(d,f)+s:a==="right"&&(m+=Math.max(u,g)+s),{x:Jt(m,0,r.width-e.width),y:Jt(p,0,r.height-e.height)}}function ka(t,e,n){const r=pn(n.padding);return e==="center"?t.x+t.width/2:e==="right"?t.x+t.width-r.right:t.x+r.left}function xm(t){return Vn([],Kn(t))}function CT(t,e,n){return ds(t,{tooltip:e,tooltipItems:n,type:"tooltip"})}function wm(t,e){const n=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return n?t.override(n):t}const km={beforeTitle:Gn,title(t){if(t.length>0){const e=t[0],n=e.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode==="dataset")return e.dataset.label||"";if(e.label)return e.label;if(r>0&&e.dataIndex"u"?km[e].call(n,r):s}class bd extends Ar{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const e=this._cachedAnimations;if(e)return e;const n=this.chart,r=this.options.setContext(this.getContext()),s=r.enabled&&n.options.animation&&r.animations,o=new E0(this.chart,s);return s._cacheable&&(this._cachedAnimations=Object.freeze(o)),o}getContext(){return this.$context||(this.$context=CT(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,n){const{callbacks:r}=n,s=Rt(r,"beforeTitle",this,e),o=Rt(r,"title",this,e),i=Rt(r,"afterTitle",this,e);let a=[];return a=Vn(a,Kn(s)),a=Vn(a,Kn(o)),a=Vn(a,Kn(i)),a}getBeforeBody(e,n){return xm(Rt(n.callbacks,"beforeBody",this,e))}getBody(e,n){const{callbacks:r}=n,s=[];return at(e,o=>{const i={before:[],lines:[],after:[]},a=wm(r,o);Vn(i.before,Kn(Rt(a,"beforeLabel",this,o))),Vn(i.lines,Rt(a,"label",this,o)),Vn(i.after,Kn(Rt(a,"afterLabel",this,o))),s.push(i)}),s}getAfterBody(e,n){return xm(Rt(n.callbacks,"afterBody",this,e))}getFooter(e,n){const{callbacks:r}=n,s=Rt(r,"beforeFooter",this,e),o=Rt(r,"footer",this,e),i=Rt(r,"afterFooter",this,e);let a=[];return a=Vn(a,Kn(s)),a=Vn(a,Kn(o)),a=Vn(a,Kn(i)),a}_createItems(e){const n=this._active,r=this.chart.data,s=[],o=[],i=[];let a=[],l,c;for(l=0,c=n.length;le.filter(d,u,f,r))),e.itemSort&&(a=a.sort((d,u)=>e.itemSort(d,u,r))),at(a,d=>{const u=wm(e.callbacks,d);s.push(Rt(u,"labelColor",this,d)),o.push(Rt(u,"labelPointStyle",this,d)),i.push(Rt(u,"labelTextColor",this,d))}),this.labelColors=s,this.labelPointStyles=o,this.labelTextColors=i,this.dataPoints=a,a}update(e,n){const r=this.options.setContext(this.getContext()),s=this._active;let o,i=[];if(!s.length)this.opacity!==0&&(o={opacity:0});else{const a=Eo[r.position].call(this,s,this._eventPosition);i=this._createItems(r),this.title=this.getTitle(i,r),this.beforeBody=this.getBeforeBody(i,r),this.body=this.getBody(i,r),this.afterBody=this.getAfterBody(i,r),this.footer=this.getFooter(i,r);const l=this._size=bm(this,r),c=Object.assign({},a,l),d=ym(this.chart,r,c),u=vm(r,c,d,this.chart);this.xAlign=d.xAlign,this.yAlign=d.yAlign,o={opacity:1,x:u.x,y:u.y,width:l.width,height:l.height,caretX:a.x,caretY:a.y}}this._tooltipItems=i,this.$context=void 0,o&&this._resolveAnimations().update(this,o),e&&r.external&&r.external.call(this,{chart:this.chart,tooltip:this,replay:n})}drawCaret(e,n,r,s){const o=this.getCaretPosition(e,r,s);n.lineTo(o.x1,o.y1),n.lineTo(o.x2,o.y2),n.lineTo(o.x3,o.y3)}getCaretPosition(e,n,r){const{xAlign:s,yAlign:o}=this,{caretSize:i,cornerRadius:a}=r,{topLeft:l,topRight:c,bottomLeft:d,bottomRight:u}=cs(a),{x:f,y:g}=e,{width:m,height:p}=n;let y,b,S,x,E,k;return o==="center"?(E=g+p/2,s==="left"?(y=f,b=y-i,x=E+i,k=E-i):(y=f+m,b=y+i,x=E-i,k=E+i),S=y):(s==="left"?b=f+Math.max(l,d)+i:s==="right"?b=f+m-Math.max(c,u)-i:b=this.caretX,o==="top"?(x=g,E=x-i,y=b-i,S=b+i):(x=g+p,E=x+i,y=b+i,S=b-i),k=x),{x1:y,x2:b,x3:S,y1:x,y2:E,y3:k}}drawTitle(e,n,r){const s=this.title,o=s.length;let i,a,l;if(o){const c=fs(r.rtl,this.x,this.width);for(e.x=ka(this,r.titleAlign,r),n.textAlign=c.textAlign(r.titleAlign),n.textBaseline="middle",i=Ut(r.titleFont),a=r.titleSpacing,n.fillStyle=r.titleColor,n.font=i.string,l=0;lS!==0)?(e.beginPath(),e.fillStyle=o.multiKeyBackground,ia(e,{x:p,y:m,w:c,h:l,radius:b}),e.fill(),e.stroke(),e.fillStyle=i.backgroundColor,e.beginPath(),ia(e,{x:y,y:m+1,w:c-2,h:l-2,radius:b}),e.fill()):(e.fillStyle=o.multiKeyBackground,e.fillRect(p,m,c,l),e.strokeRect(p,m,c,l),e.fillStyle=i.backgroundColor,e.fillRect(y,m+1,c-2,l-2))}e.fillStyle=this.labelTextColors[r]}drawBody(e,n,r){const{body:s}=this,{bodySpacing:o,bodyAlign:i,displayColors:a,boxHeight:l,boxWidth:c,boxPadding:d}=r,u=Ut(r.bodyFont);let f=u.lineHeight,g=0;const m=fs(r.rtl,this.x,this.width),p=function(_){n.fillText(_,m.x(e.x+g),e.y+f/2),e.y+=f+o},y=m.textAlign(i);let b,S,x,E,k,C,N;for(n.textAlign=i,n.textBaseline="middle",n.font=u.string,e.x=ka(this,y,r),n.fillStyle=r.bodyColor,at(this.beforeBody,p),g=a&&y!=="right"?i==="center"?c/2+d:c+2+d:0,E=0,C=s.length;E0&&n.stroke()}_updateAnimationTarget(e){const n=this.chart,r=this.$animations,s=r&&r.x,o=r&&r.y;if(s||o){const i=Eo[e.position].call(this,this._active,this._eventPosition);if(!i)return;const a=this._size=bm(this,e),l=Object.assign({},i,this._size),c=ym(n,e,l),d=vm(e,l,c,n);(s._to!==d.x||o._to!==d.y)&&(this.xAlign=c.xAlign,this.yAlign=c.yAlign,this.width=a.width,this.height=a.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,d))}}_willRender(){return!!this.opacity}draw(e){const n=this.options.setContext(this.getContext());let r=this.opacity;if(!r)return;this._updateAnimationTarget(n);const s={width:this.width,height:this.height},o={x:this.x,y:this.y};r=Math.abs(r)<.001?0:r;const i=pn(n.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;n.enabled&&a&&(e.save(),e.globalAlpha=r,this.drawBackground(o,e,s,n),C0(e,n.textDirection),o.y+=i.top,this.drawTitle(o,e,n),this.drawBody(o,e,n),this.drawFooter(o,e,n),S0(e,n.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,n){const r=this._active,s=e.map(({datasetIndex:a,index:l})=>{const c=this.chart.getDatasetMeta(a);if(!c)throw new Error("Cannot find a dataset at index "+a);return{datasetIndex:a,element:c.data[l],index:l}}),o=!Ri(r,s),i=this._positionChanged(s,n);(o||i)&&(this._active=s,this._eventPosition=n,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,n,r=!0){if(n&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,o=this._active||[],i=this._getActiveElements(e,o,n,r),a=this._positionChanged(i,e),l=n||!Ri(i,o)||a;return l&&(this._active=i,(s.enabled||s.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,n))),l}_getActiveElements(e,n,r,s){const o=this.options;if(e.type==="mouseout")return[];if(!s)return n.filter(a=>this.chart.data.datasets[a.datasetIndex]&&this.chart.getDatasetMeta(a.datasetIndex).controller.getParsed(a.index)!==void 0);const i=this.chart.getElementsAtEventForMode(e,o.mode,o,r);return o.reverse&&i.reverse(),i}_positionChanged(e,n){const{caretX:r,caretY:s,options:o}=this,i=Eo[o.position].call(this,e,n);return i!==!1&&(r!==i.x||s!==i.y)}}Fe(bd,"positioners",Eo);var Cm={id:"tooltip",_element:bd,positioners:Eo,afterInit(t,e,n){n&&(t.tooltip=new bd({chart:t,options:n}))},beforeUpdate(t,e,n){t.tooltip&&t.tooltip.initialize(n)},reset(t,e,n){t.tooltip&&t.tooltip.initialize(n)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const n={tooltip:e};if(t.notifyPlugins("beforeTooltipDraw",{...n,cancelable:!0})===!1)return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",n)}},afterEvent(t,e){if(t.tooltip){const n=e.replay;t.tooltip.handleEvent(e.event,n,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:km},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>t!=="filter"&&t!=="itemSort"&&t!=="external",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};const ST=(t,e,n,r)=>(typeof e=="string"?(n=t.push(e)-1,r.unshift({index:n,label:e})):isNaN(e)&&(n=null),n);function _T(t,e,n,r){const s=t.indexOf(e);if(s===-1)return ST(t,e,n,r);const o=t.lastIndexOf(e);return s!==o?n:s}const ET=(t,e)=>t===null?null:Jt(Math.round(t),0,e);function Sm(t){const e=this.getLabels();return t>=0&&tn.length-1?null:this.getPixelForValue(n[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}}Fe(yd,"id","category"),Fe(yd,"defaults",{ticks:{callback:Sm}});function MT(t,e){const n=[],{bounds:s,step:o,min:i,max:a,precision:l,count:c,maxTicks:d,maxDigits:u,includeBounds:f}=t,g=o||1,m=d-1,{min:p,max:y}=e,b=!lt(i),S=!lt(a),x=!lt(c),E=(y-p)/(u+1);let k=qp((y-p)/m/g)*g,C,N,_,w;if(k<1e-14&&!b&&!S)return[{value:p},{value:y}];w=Math.ceil(y/k)-Math.floor(p/k),w>m&&(k=qp(w*k/m/g)*g),lt(l)||(C=Math.pow(10,l),k=Math.ceil(k*C)/C),s==="ticks"?(N=Math.floor(p/k)*k,_=Math.ceil(y/k)*k):(N=p,_=y),b&&S&&o&&_M((a-i)/o,k/1e3)?(w=Math.round(Math.min((a-i)/k,d)),k=(a-i)/w,N=i,_=a):x?(N=b?i:N,_=S?a:_,w=c-1,k=(_-N)/w):(w=(_-N)/k,na(w,Math.round(w),k/1e3)?w=Math.round(w):w=Math.ceil(w));const M=Math.max(Zp(k),Zp(N));C=Math.pow(10,lt(l)?M:l),N=Math.round(N*C)/C,_=Math.round(_*C)/C;let O=0;for(b&&(f&&N!==i?(n.push({value:i}),Na)break;n.push({value:L})}return S&&f&&_!==a?n.length&&na(n[n.length-1].value,a,_m(a,E,t))?n[n.length-1].value=a:n.push({value:a}):(!S||_===a)&&n.push({value:_}),n}function _m(t,e,{horizontal:n,minRotation:r}){const s=Yn(r),o=(n?Math.sin(s):Math.cos(s))||.001,i=.75*e*(""+t).length;return Math.min(e/o,i)}class NT extends gs{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,n){return lt(e)||(typeof e=="number"||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){const{beginAtZero:e}=this.options,{minDefined:n,maxDefined:r}=this.getUserBounds();let{min:s,max:o}=this;const i=l=>s=n?s:l,a=l=>o=r?o:l;if(e){const l=lr(s),c=lr(o);l<0&&c<0?a(0):l>0&&c>0&&i(0)}if(s===o){let l=o===0?1:Math.abs(o*.05);a(o+l),e||i(s-l)}this.min=s,this.max=o}getTickLimit(){const e=this.options.ticks;let{maxTicksLimit:n,stepSize:r}=e,s;return r?(s=Math.ceil(this.max/r)-Math.floor(this.min/r)+1,s>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${r} would result generating up to ${s} ticks. Limiting to 1000.`),s=1e3)):(s=this.computeTickLimit(),n=n||11),n&&(s=Math.min(n,s)),s}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const e=this.options,n=e.ticks;let r=this.getTickLimit();r=Math.max(2,r);const s={maxTicks:r,bounds:e.bounds,min:e.min,max:e.max,precision:n.precision,step:n.stepSize,count:n.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:n.minRotation||0,includeBounds:n.includeBounds!==!1},o=this._range||this,i=MT(s,o);return e.bounds==="ticks"&&EM(i,this,"value"),e.reverse?(i.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),i}configure(){const e=this.ticks;let n=this.min,r=this.max;if(super.configure(),this.options.offset&&e.length){const s=(r-n)/Math.max(e.length-1,1)/2;n-=s,r+=s}this._startValue=n,this._endValue=r,this._valueRange=r-n}getLabelForValue(e){return Kc(e,this.chart.options.locale,this.options.ticks.format)}}class vd extends NT{determineDataLimits(){const{min:e,max:n}=this.getMinMax(!0);this.min=gn(e)?e:0,this.max=gn(n)?n:1,this.handleTickRangeOptions()}computeTickLimit(){const e=this.isHorizontal(),n=e?this.width:this.height,r=Yn(this.options.ticks.minRotation),s=(e?Math.sin(r):Math.cos(r))||.001,o=this._resolveTickFontOptions(0);return Math.ceil(n/Math.min(40,o.lineHeight/s))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}}Fe(vd,"id","linear"),Fe(vd,"defaults",{ticks:{callback:l0.formatters.numeric}});const Ca={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},en=Object.keys(Ca);function Em(t,e){return t-e}function Mm(t,e){if(lt(e))return null;const n=t._adapter,{parser:r,round:s,isoWeekday:o}=t._parseOpts;let i=e;return typeof r=="function"&&(i=r(i)),gn(i)||(i=typeof r=="string"?n.parse(i,r):n.parse(i)),i===null?null:(s&&(i=s==="week"&&(ra(o)||o===!0)?n.startOf(i,"isoWeek",o):n.startOf(i,s)),+i)}function Nm(t,e,n,r){const s=en.length;for(let o=en.indexOf(t);o=en.indexOf(n);o--){const i=en[o];if(Ca[i].common&&t._adapter.diff(s,r,i)>=e-1)return i}return en[n?en.indexOf(n):0]}function TT(t){for(let e=en.indexOf(t)+1,n=en.length;e=e?n[r]:n[s];t[o]=!0}}function AT(t,e,n,r){const s=t._adapter,o=+s.startOf(e[0].value,r),i=e[e.length-1].value;let a,l;for(a=o;a<=i;a=+s.add(a,1,r))l=n[a],l>=0&&(e[l].major=!0);return e}function Tm(t,e,n){const r=[],s={},o=e.length;let i,a;for(i=0;i+e.value))}initOffsets(e=[]){let n=0,r=0,s,o;this.options.offset&&e.length&&(s=this.getDecimalForValue(e[0]),e.length===1?n=1-s:n=(this.getDecimalForValue(e[1])-s)/2,o=this.getDecimalForValue(e[e.length-1]),e.length===1?r=o:r=(o-this.getDecimalForValue(e[e.length-2]))/2);const i=e.length<3?.5:.25;n=Jt(n,0,i),r=Jt(r,0,i),this._offsets={start:n,end:r,factor:1/(n+1+r)}}_generate(){const e=this._adapter,n=this.min,r=this.max,s=this.options,o=s.time,i=o.unit||Nm(o.minUnit,n,r,this._getLabelCapacity(n)),a=Je(s.ticks.stepSize,1),l=i==="week"?o.isoWeekday:!1,c=ra(l)||l===!0,d={};let u=n,f,g;if(c&&(u=+e.startOf(u,"isoWeek",l)),u=+e.startOf(u,c?"day":i),e.diff(r,n,i)>1e5*a)throw new Error(n+" and "+r+" are too far apart with stepSize of "+a+" "+i);const m=s.ticks.source==="data"&&this.getDataTimestamps();for(f=u,g=0;f+p)}getLabelForValue(e){const n=this._adapter,r=this.options.time;return r.tooltipFormat?n.format(e,r.tooltipFormat):n.format(e,r.displayFormats.datetime)}format(e,n){const s=this.options.time.displayFormats,o=this._unit,i=n||s[o];return this._adapter.format(e,i)}_tickFormatFunction(e,n,r,s){const o=this.options,i=o.ticks.callback;if(i)return ht(i,[e,n,r],this);const a=o.time.displayFormats,l=this._unit,c=this._majorUnit,d=l&&a[l],u=c&&a[c],f=r[n],g=c&&u&&f&&f.major;return this._adapter.format(e,s||(g?u:d))}generateTickLabels(e){let n,r,s;for(n=0,r=e.length;n0?a:1}getDataTimestamps(){let e=this._cache.data||[],n,r;if(e.length)return e;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(n=0,r=s.length;n=t[r].pos&&e<=t[s].pos&&({lo:r,hi:s}=Gc(t,"pos",e)),{pos:o,time:a}=t[r],{pos:i,time:l}=t[s]):(e>=t[r].time&&e<=t[s].time&&({lo:r,hi:s}=Gc(t,"time",e)),{time:o,pos:a}=t[r],{time:i,pos:l}=t[s]);const c=i-o;return c?a+(l-a)*(e-o)/c:a}class Am extends Sa{constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const e=this._getTimestampsForTable(),n=this._table=this.buildLookupTable(e);this._minPos=_a(n,this.min),this._tableRange=_a(n,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){const{min:n,max:r}=this,s=[],o=[];let i,a,l,c,d;for(i=0,a=e.length;i=n&&c<=r&&s.push(c);if(s.length<2)return[{time:n,pos:0},{time:r,pos:1}];for(i=0,a=s.length;is-o)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;const n=this.getDataTimestamps(),r=this.getLabelTimestamps();return n.length&&r.length?e=this.normalize(n.concat(r)):e=n.length?n:r,e=this._cache.all=e,e}getDecimalForValue(e){return(_a(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){const n=this._offsets,r=this.getDecimalForPixel(e)/n.factor-n.end;return _a(this._table,r*this._tableRange+this._minPos,!0)}}Fe(Am,"id","timeseries"),Fe(Am,"defaults",Sa.defaults);const Dm="label";function Im(t,e){typeof t=="function"?t(e):t&&(t.current=e)}function DT(t,e){const n=t.options;n&&e&&Object.assign(n,e)}function Lm(t,e){t.labels=e}function Pm(t,e,n=Dm){const r=[];t.datasets=e.map(s=>{const o=t.datasets.find(i=>i[n]===s[n]);return!o||!s.data||r.includes(o)?{...s}:(r.push(o),Object.assign(o,s),o)})}function IT(t,e=Dm){const n={labels:[],datasets:[]};return Lm(n,t.labels),Pm(n,t.datasets,e),n}function LT(t,e){const{height:n=150,width:r=300,redraw:s=!1,datasetIdKey:o,type:i,data:a,options:l,plugins:c=[],fallbackContent:d,updateMode:u,...f}=t,g=v.useRef(null),m=v.useRef(null),p=()=>{g.current&&(m.current=new So(g.current,{type:i,data:IT(a,o),options:l&&{...l},plugins:c}),Im(e,m.current))},y=()=>{Im(e,null),m.current&&(m.current.destroy(),m.current=null)};return v.useEffect(()=>{!s&&m.current&&l&&DT(m.current,l)},[s,l]),v.useEffect(()=>{!s&&m.current&&Lm(m.current.config.data,a.labels)},[s,a.labels]),v.useEffect(()=>{!s&&m.current&&a.datasets&&Pm(m.current.config.data,a.datasets,o)},[s,a.datasets]),v.useEffect(()=>{m.current&&(s?(y(),setTimeout(p)):m.current.update(u))},[s,l,a.labels,a.datasets,u]),v.useEffect(()=>{m.current&&(y(),setTimeout(p))},[i]),v.useEffect(()=>(p(),()=>y()),[]),h.jsx("canvas",{ref:g,role:"img",height:n,width:r,...f,children:d})}const PT=v.forwardRef(LT);function $m(t,e){return So.register(e),v.forwardRef((n,r)=>h.jsx(PT,{...n,ref:r,type:t}))}const $T=$m("bar",ua),FT=$m("doughnut",bo);So.register(yd,vd,wa,mT,Cm,pm);const zT=({labels:t,datasets:e,height:n=200,showLegend:r=!1,showGrid:s=!1,showYGrid:o=!1,showYAxis:i=!1,className:a="",id:l,editorAttrs:c})=>{const d=v.useMemo(()=>({labels:t??[],datasets:(e??[]).map((m,p)=>({label:m.label,data:m.data,backgroundColor:m.backgroundColor||(p===0?"#7C3AED":"#DDD6FE"),borderRadius:m.borderRadius??4,barThickness:16,maxBarThickness:20,yAxisID:m.yAxisID??"y"}))}),[t,e]),u=v.useMemo(()=>(e??[]).some(m=>m.yAxisID==="y1"),[e]),f=v.useMemo(()=>({responsive:!0,maintainAspectRatio:!1,animation:{duration:750,easing:"easeOutQuart"},animations:{x:{duration:0},y:{from:m=>{if(m.type==="data")return m.chart.scales.y.getPixelForValue(0)}}},transitions:{active:{animation:{duration:200}},resize:{animation:{duration:0}}},plugins:{legend:{display:r,position:"top"},tooltip:{backgroundColor:"#1e293b",titleColor:"#fff",bodyColor:"#fff",padding:12,cornerRadius:8}},scales:{x:{grid:{display:s},border:{display:!1},ticks:{color:"#94a3b8",font:{size:11}}},y:{type:"linear",position:"left",grid:{display:o,color:"#e2e8f0"},border:{display:!1},ticks:{display:i,color:"#94a3b8",font:{size:11},padding:8},beginAtZero:!0,grace:"5%"},...u?{y1:{type:"linear",position:"right",grid:{display:!1},border:{display:!1},ticks:{display:i,color:"#94a3b8",font:{size:11},padding:8},beginAtZero:!0,grace:"5%"}}:{}}}),[r,s,o,i,u]),g=typeof n=="number"?n:/^\d+$/.test(String(n))?`${n}px`:n;return h.jsx("div",{className:`bar-chart-container ${a}`,style:{height:g},id:l,...c,children:h.jsx($T,{data:d,options:f})})},VT=v.memo(zT,(t,e)=>JSON.stringify(t.labels)===JSON.stringify(e.labels)&&JSON.stringify(t.datasets)===JSON.stringify(e.datasets)&&t.height===e.height&&t.showLegend===e.showLegend&&t.showGrid===e.showGrid&&t.showYGrid===e.showYGrid&&t.showYAxis===e.showYAxis&&t.className===e.className&&t.id===e.id&&t.editorAttrs===e.editorAttrs);So.register(_o,Cm,pm);const HT=({data:t,centerLabel:e,centerValue:n,size:r=200,cutout:s="70%",showLegend:o=!1,className:i="",id:a,editorAttrs:l})=>{const c=v.useMemo(()=>({labels:(t??[]).map(f=>f.name),datasets:[{data:(t??[]).map(f=>f.value),backgroundColor:(t??[]).map(f=>f.color),borderWidth:0,hoverOffset:4}]}),[t]),d=v.useMemo(()=>({responsive:!0,maintainAspectRatio:!1,cutout:s,plugins:{legend:{display:o},tooltip:{backgroundColor:"#1e293b",titleColor:"#fff",bodyColor:"#fff",padding:12,cornerRadius:8,callbacks:{label:f=>` $${f.parsed.toLocaleString()}`}}}}),[s,o]),u=typeof r=="number"?r:/^\d+$/.test(String(r))?`${r}px`:r;return h.jsx("div",{className:`donut-chart-wrapper ${i}`,id:a,...l,children:h.jsxs("div",{className:"relative",style:{width:u,height:u},children:[h.jsx(FT,{data:c,options:d}),(e||n)&&h.jsxs("div",{className:"absolute inset-0 flex flex-col items-center justify-center pointer-events-none",children:[e&&h.jsx("span",{className:"text-xs text-slate-500 dark:text-slate-400",children:e}),n&&h.jsx("span",{className:"text-lg font-bold text-slate-900 dark:text-white",children:n})]})]})})},UT=({id:t,className:e,style:n,children:r,editorAttrs:s})=>h.jsx("div",{id:t,className:e,style:n,...s,children:r}),BT=({children:t,cols:e=1,responsive:n,gap:r,rowGap:s,colGap:o,autoRows:i,autoCols:a,flow:l="row",className:c="",style:d,onClick:u,id:f,editorAttrs:g})=>{const m=["grid"];if(e&&m.push(`grid-cols-${e}`),n&&(n.sm&&m.push(`sm:grid-cols-${n.sm}`),n.md&&m.push(`md:grid-cols-${n.md}`),n.lg&&m.push(`lg:grid-cols-${n.lg}`),n.xl&&m.push(`xl:grid-cols-${n.xl}`),n["2xl"]&&m.push(`2xl:grid-cols-${n["2xl"]}`)),r!==void 0&&r>0&&m.push(`gap-${r}`),s!==void 0&&s>0&&m.push(`gap-y-${s}`),o!==void 0&&o>0&&m.push(`gap-x-${o}`),i){const y={auto:"auto-rows-auto",min:"auto-rows-min",max:"auto-rows-max",fr:"auto-rows-fr"};m.push(y[i])}if(a){const y={auto:"auto-cols-auto",min:"auto-cols-min",max:"auto-cols-max",fr:"auto-cols-fr"};m.push(y[a])}const p={row:"grid-flow-row",col:"grid-flow-col",dense:"grid-flow-dense","row-dense":"grid-flow-row-dense","col-dense":"grid-flow-col-dense"};return m.push(p[l]),c&&m.push(c),h.jsx(I,{className:m.join(" "),style:d,onClick:u,id:f,...g,children:t})},jT=({children:t,direction:e="row",justify:n="start",align:r="stretch",wrap:s="nowrap",gap:o=0,grow:i=!1,shrink:a=!0,className:l="",style:c,onClick:d,id:u,editorAttrs:f})=>{const g=["flex"],m={row:"flex-row","row-reverse":"flex-row-reverse",col:"flex-col","col-reverse":"flex-col-reverse"};g.push(m[e]);const p={wrap:"flex-wrap",nowrap:"flex-nowrap","wrap-reverse":"flex-wrap-reverse"};g.push(p[s]);const y={start:"justify-start",end:"justify-end",center:"justify-center",between:"justify-between",around:"justify-around",evenly:"justify-evenly"};g.push(y[n]);const b={start:"items-start",end:"items-end",center:"items-center",baseline:"items-baseline",stretch:"items-stretch"};return g.push(b[r]),o>0&&g.push(`gap-${o}`),i===!0?g.push("flex-grow"):typeof i=="number"&&i>0&&g.push(`flex-grow-${i}`),a===!1?g.push("shrink-0"):a===!0?g.push("flex-shrink"):typeof a=="number"&&a>0&&g.push(`flex-shrink-${a}`),l&&g.push(l),h.jsx(I,{className:g.join(" "),style:c,onClick:d,id:u,...f,children:t})},WT=({title:t,subtitle:e,children:n,padding:r="none",background:s="none",maxWidth:o,centered:i=!1,border:a=!1,shadow:l="none",rounded:c=!1,className:d="",style:u,onClick:f,id:g,editorAttrs:m})=>{const p=[],y={none:"",sm:"p-2",md:"p-4",lg:"p-6",xl:"p-8"};y[r]&&p.push(y[r]);const b={none:"",white:"bg-white dark:bg-gray-700",gray:"bg-gray-50 dark:bg-gray-800",primary:"bg-blue-50 dark:bg-blue-900/20",secondary:"bg-gray-50 dark:bg-gray-800"};if(s!=="none"&&p.push(b[s]),o){const x={sm:"max-w-sm",md:"max-w-md",lg:"max-w-lg",xl:"max-w-xl","2xl":"max-w-2xl","4xl":"max-w-4xl","6xl":"max-w-6xl","7xl":"max-w-7xl",full:"max-w-full"};p.push(x[o])}i&&p.push("mx-auto"),a&&p.push("border","border-gray-200","dark:border-gray-700");const S={none:"",sm:"shadow-sm",md:"shadow-md",lg:"shadow-lg",xl:"shadow-xl"};return l!=="none"&&p.push(S[l]),c&&p.push("rounded-lg"),d&&p.push(d),h.jsxs(Od,{className:p.join(" "),style:u,onClick:f,id:g,...m,children:[(t||e)&&h.jsxs("div",{className:"mb-4",children:[t&&h.jsx("h2",{className:"text-2xl font-bold text-gray-900 dark:text-white",children:t}),e&&h.jsx("p",{className:"mt-1 text-sm text-gray-600 dark:text-gray-400",children:e})]}),n]})},GT=({leftWidth:t="250px",rightWidth:e="300px",leftSlot:n,centerSlot:r,rightSlot:s,className:o="",style:i,id:a,editorAttrs:l})=>{const c=`flex flex-row w-full h-full ${o}`.trim(),d={width:t,flexShrink:0},u={flex:1,minWidth:0},f={width:e,flexShrink:0};return h.jsxs(I,{className:c,style:i,id:a,...l,children:[h.jsx(I,{className:"flex flex-col",style:d,children:n}),h.jsx(I,{className:"flex flex-col",style:u,children:r}),h.jsx(I,{className:"flex flex-col",style:f,children:s})]})},YT={language:"json",theme:"vs-dark",options:{automaticLayout:!0,minimap:{enabled:!0},scrollBeyondLastLine:!1,fontSize:14,tabSize:2,formatOnPaste:!0,formatOnType:!0,wordWrap:"on",folding:!0,lineNumbers:"on",renderWhitespace:"selection"}},Fm=[{uri:`${window.location.origin}/schemas/layout.json`,fileMatch:["*layout*.json"],schema:{type:"object",properties:{version:{type:"string"},endpoint:{type:"string"},components:{type:"array"}},required:["version","endpoint","components"]}}],XT={"vs/nls":{availableLanguages:{"*":"ko"}}};function KT(t){t.languages.json.jsonDefaults.setDiagnosticsOptions({validate:!0,schemas:Fm,enableSchemaRequest:!0,schemaValidation:"error",schemaRequest:"error"})}const qT={identifier:"sirsoft-admin_basic",vendor:"sirsoft",name:{ko:"Admin Basic",en:"Admin Basic"},version:"1.0.4",license:"MIT",description:{ko:"그누보드7 기본 관리자 템플릿",en:"Gnuboard7 default admin template"},type:"admin",locales:["ko","en"],author:{name:"sirsoft",email:"contact@sirsoft.com",url:"https://sirsoft.com"},release_date:"2026-05-15",g7_version:">=7.0.0",dependencies:{modules:{},plugins:{}},assets:{css:["assets/css/main.css","assets/css/ui-system.css"],js:["dist/components.js"],fonts:["assets/fonts/"],images:["assets/images/"]},components:{basic:["a","button","checkbox","div","form","h1","h2","h3","hr","icon","img","input","label","li","nav","p","section","select","span","svg","table","tbody","td","textarea","th","thead","tr","ul"],composite:["action-menu","admin-footer","admin-header","admin-sidebar","alert-dialog","breadcrumb","card","confirm-dialog","data-grid","dialog","dropdown","empty-state","filter-group","icon-button","loading-spinner","modal","page-header","pagination","product-card","search-bar","stat-card","status-badge","tab-navigation","template-card","toast"],layout:["container","flex","grid","section"]},preview:{thumbnail:"preview/thumbnail.png",screenshots:["preview/screenshot1.png","preview/screenshot2.png"]},error_config:{layouts:{401:"errors/401",403:"errors/403",404:"errors/404",500:"errors/500",503:"errors/503",maintenance:"errors/maintenance"}},github_url:"https://github.com/gnuboard/g7-template-sirsoft-admin_basic",github_changelog_url:"https://github.com/gnuboard/g7-template-sirsoft-admin_basic/blob/main/CHANGELOG.md",externals:[{id:"fontawesome",type:"style",url:"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",preconnect:"https://cdnjs.cloudflare.com"},{id:"pretendard",type:"webfont",url:"https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css",preconnect:"https://cdn.jsdelivr.net",crossorigin:"anonymous"},{id:"flag-icons",type:"style",url:"https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"}]},qn=window.G7Core?.createLogger?.("Handler:SetTheme")??{log:(...t)=>console.log("[Handler:SetTheme]",...t),warn:(...t)=>console.warn("[Handler:SetTheme]",...t),error:(...t)=>console.error("[Handler:SetTheme]",...t)},xd=["auto","light","dark"],zm="g7_color_scheme",ZT=t=>t==="auto"?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":t,Ea=t=>{const e=ZT(t);document.documentElement.setAttribute("data-theme",e),e==="dark"?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")};function QT(){try{const t=localStorage.getItem(zm),e=t&&xd.includes(t)?t:"auto";Ea(e);try{window.G7Core?.state?.setGlobal?.({theme:e})}catch(n){qn.warn("Failed to set global theme state:",n)}qn.log("Initial theme applied:",e)}catch(t){qn.warn("Failed to load initial theme:",t),Ea("auto")}}async function JT(t,e){const n=t?.target;if(n&&typeof n=="string"&&xd.includes(n)){Ea(n);try{window.G7Core?.state?.setGlobal?.({theme:n})}catch(r){qn.warn("Failed to set global theme state:",r)}return}QT()}async function RT(t,e){let n;if(t?.target&&typeof t.target=="string"&&!t.target.includes("{{")&&(n=t.target),!n||typeof n!="string"){qn.warn("Invalid theme:",n);return}if(!xd.includes(n)){qn.warn("Unsupported theme:",n);return}const r=n;try{localStorage.setItem(zm,r)}catch(s){qn.error("Failed to save theme to localStorage:",s);return}Ea(r);try{window.G7Core?.state?.setGlobal?.({theme:r})}catch(s){qn.warn("Failed to set global theme state:",s)}qn.log("Theme changed to:",r)}const Mo=window.G7Core?.createLogger?.("Handler:InitMenu")??{log:(...t)=>console.log("[Handler:InitMenu]",...t),warn:(...t)=>console.warn("[Handler:InitMenu]",...t),error:(...t)=>console.error("[Handler:InitMenu]",...t)};function Vm(t,e){for(const n of t){if(n.slug===e)return n;if(n.children&&n.children.length>0){const r=Vm(n.children,e);if(r)return r}}return null}function Hm(t){return new URLSearchParams(window.location.search).get(t)}function e5(t){return window.G7Core?.state?.getDataSource?.(t)}async function t5(t,e=30,n=100){for(let r=0;r0)return o;await new Promise(i=>setTimeout(i,n))}return null}async function n5(t,e){const n=window.G7Core,r=Hm("menu"),s=Hm("mode");if(!r){Mo.log("[initMenuFromUrl] No menu parameter in URL");return}if(!n?.state?.set){Mo.warn("[initMenuFromUrl] G7Core.state.set not available");return}const o=await t5("menus");if(!o){Mo.warn("[initMenuFromUrl] menus data not available after waiting");return}const i=Vm(o,r);if(!i){Mo.warn("[initMenuFromUrl] Menu not found with slug:",r);return}const a=s==="edit"?"edit":"view";n.state.set({selectedMenuId:i.id,selectedMenu:i,panelMode:a}),a==="edit"&&n.state.set({formData:{name:i.name,slug:i.slug,url:i.url,icon:i.icon,parent_id:i.parent_id,module_id:i.module_id,is_active:i.is_active}}),Mo.log("[initMenuFromUrl] Menu initialized from URL:",{slug:r,mode:a,menuId:i.id})}const Xt=window.G7Core?.createLogger?.("Handler:FilterVisibility")??{log:(...t)=>console.log("[Handler:FilterVisibility]",...t),warn:(...t)=>console.warn("[Handler:FilterVisibility]",...t),error:(...t)=>console.error("[Handler:FilterVisibility]",...t)},Ma="g7_filter_visibility_";async function r5(t,e){const{storageKey:n,defaultFilters:r=[],stateKey:s="visibleFilters"}=t?.params||{};if(!n){Xt.warn("[initFilterVisibility] storageKey is required");return}const o=`${Ma}${n}`;try{const i=localStorage.getItem(o);let a;if(i){const l=JSON.parse(i);Array.isArray(l)?(a=l,Xt.log(`[initFilterVisibility] Loaded from localStorage: ${a.join(", ")}`)):(a=r,Xt.warn("[initFilterVisibility] Invalid saved data format, using defaults"))}else a=r,Xt.log(`[initFilterVisibility] No saved data, using defaults: ${r.join(", ")}`);e?.setState&&e.setState({[s]:a})}catch(i){Xt.error("[initFilterVisibility] Failed to load filter visibility:",i),e?.setState&&e.setState({[s]:r})}}async function s5(t,e){const{storageKey:n,filters:r}=t?.params||{};if(!n){Xt.warn("[saveFilterVisibility] storageKey is required");return}if(!Array.isArray(r)){Xt.warn("[saveFilterVisibility] filters must be an array");return}const s=`${Ma}${n}`;try{localStorage.setItem(s,JSON.stringify(r)),Xt.log(`[saveFilterVisibility] Saved to localStorage: ${r.join(", ")}`)}catch(o){Xt.error("[saveFilterVisibility] Failed to save filter visibility:",o)}}async function o5(t,e){const{storageKey:n,filterId:r,stateKey:s="visibleFilters"}=t?.params||{};if(!n||!r){Xt.warn("[toggleFilterVisibility] storageKey and filterId are required");return}const o=e?.state?.[s]||[];let i;o.includes(r)?i=o.filter(l=>l!==r):i=[...o,r],e?.setState&&e.setState({[s]:i});const a=`${Ma}${n}`;try{localStorage.setItem(a,JSON.stringify(i)),Xt.log(`[toggleFilterVisibility] Toggled ${r}: ${i.join(", ")}`)}catch(l){Xt.error("[toggleFilterVisibility] Failed to save:",l)}}async function i5(t,e){const{storageKey:n,defaultFilters:r=[],stateKey:s="visibleFilters"}=t?.params||{};if(!n){Xt.warn("[resetFilterVisibility] storageKey is required");return}const o=`${Ma}${n}`;try{localStorage.setItem(o,JSON.stringify(r)),e?.setState&&e.setState({[s]:r}),Xt.log(`[resetFilterVisibility] Reset to defaults: ${r.join(", ")}`)}catch(i){Xt.error("[resetFilterVisibility] Failed to reset:",i)}}const ms=window.G7Core?.createLogger?.("Handler:MultilingualTag")??{log:(...t)=>console.log("[Handler:MultilingualTag]",...t),warn:(...t)=>console.warn("[Handler:MultilingualTag]",...t),error:(...t)=>console.error("[Handler:MultilingualTag]",...t)};async function a5(t,e){const n=window.G7Core;if(!n){ms.error("G7Core not available");return}const r=n.state?.getGlobal?.()?.multilingualTagEdit;if(!r){ms.warn("No multilingualTagEdit state found"),n.modal?.close?.();return}const{fieldName:s,editingIndex:o,values:i,statePath:a,currentTags:l}=r;if(!Object.values(i||{}).some(g=>typeof g=="string"&&g.trim()!=="")){ms.warn("No values to save"),n.modal?.close?.(),wd(n);return}const d=Array.isArray(l)?l:[];let u;o==null?u=[...d,i]:u=d.map((g,m)=>m===o?i:g);const f=a||(s?.startsWith("form.")?s:`form.${s}`);n.state?.setParentLocal?.({[f]:u}),n.modal?.close?.(),wd(n)}async function l5(t,e){const n=window.G7Core;if(!n){ms.error("G7Core not available");return}n.modal?.close?.(),wd(n)}async function c5(t,e){const n=window.G7Core;if(!n){ms.error("G7Core not available");return}const r=t?.params?.locale;if(!r){ms.warn("No locale specified");return}const s=e?.event;let o="";s?.target?.value!==void 0?o=s.target.value:s?.value!==void 0&&(o=s.value);const a=(n.state?.getGlobal?.()||{}).multilingualTagEdit||{},l={...a.values||{},[r]:o};n.dispatch?.({handler:"setState",params:{target:"global",multilingualTagEdit:{...a,values:l}}})}function wd(t){t.dispatch?.({handler:"setState",params:{target:"global",multilingualTagEdit:null}})}function Um(t){const e=t.getFullYear(),n=String(t.getMonth()+1).padStart(2,"0"),r=String(t.getDate()).padStart(2,"0"),s=String(t.getHours()).padStart(2,"0"),o=String(t.getMinutes()).padStart(2,"0"),i=String(t.getSeconds()).padStart(2,"0");return`${e}-${n}-${r}T${s}:${o}:${i}`}function d5(t){const e=new Date,n=new Date(e.getFullYear(),e.getMonth(),e.getDate(),23,59,59),r=Um(n),s=new Date(e.getFullYear(),e.getMonth(),e.getDate(),0,0,0);switch(t){case"today":break;case"week":s.setDate(s.getDate()-6);break;case"month":s.setMonth(s.getMonth()-1);break;case"3months":s.setMonth(s.getMonth()-3);break;case"6months":s.setMonth(s.getMonth()-6);break;case"1year":s.setFullYear(s.getFullYear()-1);break}return{startDate:Um(s),endDate:r}}function u5(t){const{preset:e}=t.params||{preset:"today"},{startDate:n,endDate:r}=d5(e);return{startDate:n,endDate:r,preset:e}}const No=window.G7Core?.createLogger?.("Handler:Sidebar")??{log:(...t)=>console.log("[Handler:Sidebar]",...t),warn:(...t)=>console.warn("[Handler:Sidebar]",...t),error:(...t)=>console.error("[Handler:Sidebar]",...t)},Bm="g7_admin_sidebar_collapsed";function jm(){try{return localStorage.getItem(Bm)==="1"}catch{return!1}}function f5(t){try{localStorage.setItem(Bm,t?"1":"0")}catch(e){No.error("Failed to save sidebar state to localStorage:",e)}try{window.G7Core?.state?.setGlobal?.({sidebarCollapsed:t})}catch(e){No.warn("Failed to set global sidebar state:",e)}}function Wm(){const t=jm();try{window.G7Core?.state?.setGlobal?.({sidebarCollapsed:t})}catch(e){No.warn("Failed to restore sidebar state:",e)}No.log("Initial sidebar collapsed state restored:",t)}async function h5(t,e){Wm()}async function g5(t,e){const r=!(()=>{try{return!!window.G7Core?.state?.getGlobal?.()?.sidebarCollapsed}catch{return jm()}})();f5(r),No.log("Sidebar collapsed toggled to:",r)}const kd=window.G7Core?.createLogger?.("Handler:DownloadAttachment")??{log:(...t)=>console.log("[Handler:DownloadAttachment]",...t),warn:(...t)=>console.warn("[Handler:DownloadAttachment]",...t),error:(...t)=>console.error("[Handler:DownloadAttachment]",...t)};async function p5(t,e){const n=window.G7Core,{url:r,filename:s}=t?.params||{};if(!r){kd.warn("다운로드 URL 이 없어 요청을 건너뜁니다.");return}if(!n?.api?.get){kd.error("G7Core.api.get 이 초기화되지 않아 다운로드를 실행할 수 없습니다.");return}try{const o=await n.api.get(r,{responseType:"blob"});if(o){const i=URL.createObjectURL(o),a=document.createElement("a");a.href=i,a.download=s||"",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(i)}}catch(o){kd.error("첨부파일 다운로드 실패",o),n?.toast?.error?.(n?.t?.("common.download_failed")??"Download failed")}}const Na=window.G7Core?.createLogger?.("Handler:DetectAssetUrlMode")??{log:(...t)=>console.log("[Handler:DetectAssetUrlMode]",...t),warn:(...t)=>console.warn("[Handler:DetectAssetUrlMode]",...t),error:(...t)=>console.error("[Handler:DetectAssetUrlMode]",...t)},m5="G7_ASSET_PROBE_OK";async function Gm(t){try{const e=await fetch(t,{cache:"no-store",credentials:"omit"});if(!e.ok)return!1;const n=e.headers.get("content-type")??"";return/javascript|ecmascript/i.test(n)?(await e.text()).includes(m5):!1}catch{return!1}}async function Ym(){const[t,e]=await Promise.all([Gm("/api/system/asset-probe.js"),Gm("/api/system/asset-probe")]);return t&&e?"extension":!t&&e?"extensionless":"unavailable"}function b5(){return window.G7Config?.settings?.general?.asset_url_mode??"extension"}async function y5(t){try{const e=await Ym();if(e==="unavailable")return;const n=b5();if(e===n)return;window.G7Core?.state?.setGlobal?.({assetUrlModeDrift:{detected:e,stored:n}})}catch(e){Na.warn("자산 URL 방식 대조 실패 (대시보드 동작에는 영향 없음):",e)}}async function v5(t,e){const n=window.G7Core,r=e?.setState??n?.state?.setLocal,s=o=>{typeof r=="function"?r({asset_url_mode_detect_status:o}):Na.warn("setState 를 찾지 못해 감지 상태를 폼에 반영하지 못했습니다")};try{s("detecting");const o=await Ym();if(o==="unavailable"){s("unavailable");return}typeof r=="function"?r({"general.asset_url_mode":o,asset_url_mode_detect_status:o}):Na.warn("setState 를 찾지 못해 감지 결과를 폼에 반영하지 못했습니다")}catch(o){Na.error("자산 URL 방식 감지 실패:",o),s("unavailable")}}const Oa={detectAssetUrlMode:v5,checkAssetUrlModeDrift:y5,setTheme:RT,initTheme:JT,scrollToSection:eu,initMenuFromUrl:n5,initFilterVisibility:r5,saveFilterVisibility:s5,toggleFilterVisibility:o5,resetFilterVisibility:i5,saveMultilingualTag:a5,cancelMultilingualTag:l5,updateMultilingualTagValue:c5,setDateRange:u5,toggleSidebar:g5,initSidebar:h5,downloadAttachment:p5},Oo=window.G7Core?.createLogger?.("Template:sirsoft-admin_basic:IdentityLauncher")??{log:(...t)=>console.log("[Template:sirsoft-admin_basic:IdentityLauncher]",...t),warn:(...t)=>console.warn("[Template:sirsoft-admin_basic:IdentityLauncher]",...t),error:(...t)=>console.error("[Template:sirsoft-admin_basic:IdentityLauncher]",...t)};function x5(){const e=window.G7Core?.api?.getToken?.()??null;return e?{Authorization:`Bearer ${e}`}:{}}function w5(){try{const t=typeof window<"u"?window.localStorage?.getItem("g7_locale"):null;return t?{"Accept-Language":t}:{}}catch{return{}}}async function k5(t,e=null){const n={purpose:t.purpose};t.provider_id&&(n.provider_id=t.provider_id),e&&(n.target=e);const r=t.challenge_start_url||"/api/identity/challenges",s=await fetch(r,{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...w5(),...x5()},body:JSON.stringify(n)}),o=await s.json().catch(()=>null);if(!s.ok||!o?.success){const a=o?.message??`Challenge 시작 실패 (HTTP ${s.status})`;throw new Error(a)}const i=o.data??o;return{id:i.id,expires_at:i.expires_at,render_hint:i.render_hint??t.render_hint??"text_code",public_payload:i.public_payload,redirect_url:i.redirect_url,max_attempts:typeof i.max_attempts=="number"?i.max_attempts:void 0}}async function C5(t){const e=window.G7Core,n=e?.identity;if(!e?.dispatch||!e?.state||!n)return Oo.error("G7Core 또는 G7Core.identity 가 초기화되지 않아 launcher 를 실행할 수 없습니다."),{status:"failed",failureCode:"G7_NOT_READY"};if(t.render_hint==="external_redirect"||t.redirect_url)return n.redirectExternally(t);const r=t.target&&(t.target.email||t.target.phone)?t.target:null;let s;try{s=await k5(t,r)}catch(m){Oo.error("Challenge 시작 실패:",m);try{await e.dispatch({handler:"toast",params:{type:"error",message:m instanceof Error?m.message:"본인인증 시작에 실패했습니다."}})}catch{}return{status:"failed",failureCode:"CHALLENGE_START_FAILED",reason:m instanceof Error?m.message:String(m)}}if(s.redirect_url)return n.redirectExternally({...t,render_hint:"external_redirect",redirect_url:s.redirect_url});const o=s.expires_at?new Date(s.expires_at).getTime():0,i=o>0?Math.max(0,Math.floor((o-Date.now())/1e3)):0,a=typeof s.max_attempts=="number"?s.max_attempts:0;e.state.set({identityChallenge:{policy_key:t.policy_key,purpose:t.purpose,provider_id:t.provider_id??null,render_hint:s.render_hint,challenge_id:s.id,expires_at:s.expires_at,public_payload:s.public_payload??{},target:r,code:"",error:null,attempts:0,maxAttempts:a,remainingSeconds:i,resendCooldown:0}});let l=o,c=0;const d=window.setInterval(()=>{const m=l>0?Math.max(0,Math.floor((l-Date.now())/1e3)):0;c=Math.max(0,c-1),e.state.set({identityChallenge:{remainingSeconds:m,resendCooldown:c}})},1e3);let u=null;try{u=e.state.subscribe?.(m=>{const p=m?.identityChallenge?.expires_at;if(typeof p=="string"){const b=new Date(p).getTime();Number.isFinite(b)&&b!==l&&(l=b)}const y=m?.identityChallenge?.resendCooldown;typeof y=="number"&&y>c&&(c=y)})??null}catch{}const f=n.createDeferred();try{await e.dispatch({handler:"openModal",target:"identity-challenge-modal"})}catch(m){return Oo.error("openModal 실패 — 풀페이지로 폴백:",m),window.clearInterval(d),u?.(),n.redirectExternally({...t,render_hint:"external_redirect",redirect_url:`/admin/identity/challenge?challenge_id=${encodeURIComponent(s.id)}&return=${encodeURIComponent(window.location.href)}`})}const g=await f;return window.clearInterval(d),u?.(),g}function S5(){const t=window.G7Core?.identity;if(!t?.setLauncher){Oo.warn("G7Core.identity 가 아직 초기화되지 않아 launcher 등록을 건너뜁니다. 코어 부트스트랩 순서를 확인하세요.");return}t.setLauncher(C5),Oo.log("IDV launcher registered (sirsoft-admin_basic)")}function _5(t){const e=t.icons;return Array.isArray(e)?e.filter(n=>!!n&&typeof n=="object"&&typeof n.value=="string"):[]}function E5(t,e){const n=t.label;return typeof n=="string"?n.startsWith("$t:")?e(n.slice(3)):n:t.value}function M5(t,e,n){if(n==="")return!0;const r=n.toLowerCase();return t.value.toLowerCase().includes(r)||e.toLowerCase().includes(r)?!0:(t.keywords??[]).some(s=>s.toLowerCase().includes(r))}function N5({entry:t}){const e=t.preview;return e?.html?h.jsx("span",{className:"g7le-icon-preview",dangerouslySetInnerHTML:{__html:e.html},style:Sd}):e?.className?h.jsx("span",{className:`g7le-icon-preview ${e.className}`,style:Sd}):h.jsx("i",{className:`fas fa-${t.value}`,style:Sd,"aria-hidden":"true"})}function O5({control:t,value:e,onChange:n,t:r}){const s=_5(t),o=e==null?"":String(e),i=typeof t.iconColumns=="number"?t.iconColumns:6,a=t.iconSearchPlaceholder,l=typeof a=="string"&&a.startsWith("$t:")?r(a.slice(3)):r("layout_editor.icon_picker.search_placeholder"),[c,d]=v.useState("");if(s.length===0)return h.jsx("div",{className:"g7le-widget g7le-widget--icon-picker","data-testid":"g7le-widget-icon-picker",style:Xm,children:h.jsx("input",{type:"text",className:"g7le-widget g7le-widget--icon-free","data-testid":"g7le-widget-icon-picker-free",value:o,placeholder:r("layout_editor.icon_picker.free_input_hint"),onChange:g=>n(g.target.value===""?void 0:g.target.value),style:Km})});const u=g=>E5(g,r),f=s.filter(g=>M5(g,u(g),c));return h.jsxs("div",{className:"g7le-widget g7le-widget--icon-picker","data-testid":"g7le-widget-icon-picker",style:Xm,children:[h.jsx("input",{type:"text",className:"g7le-widget g7le-widget--icon-search","data-testid":"g7le-widget-icon-picker-search",value:c,placeholder:l,onChange:g=>d(g.target.value),style:Km}),h.jsxs("div",{className:"g7le-icon-grid","data-testid":"g7le-widget-icon-picker-grid",style:{...T5,gridTemplateColumns:`repeat(${i}, minmax(0, 1fr))`},children:[h.jsx("button",{type:"button","data-testid":"g7le-icon-cell-none",title:r("layout_editor.icon_picker.none"),onClick:()=>n(void 0),"aria-pressed":o==="",style:o===""?qm:Cd,children:h.jsx("span",{style:A5,children:"—"})}),f.map(g=>{const m=g.value===o;return h.jsx("button",{type:"button","data-testid":`g7le-icon-cell-${g.value}`,title:u(g),"aria-pressed":m,onClick:()=>n(g.value),style:m?qm:Cd,children:h.jsx(N5,{entry:g})},g.value)})]}),f.length===0&&h.jsx("div",{"data-testid":"g7le-widget-icon-picker-empty",style:D5,children:r("layout_editor.icon_picker.no_results")})]})}const Xm={display:"flex",flexDirection:"column",gap:6,width:"100%"},Km={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%"},T5={display:"grid",gap:4,maxHeight:220,overflowY:"auto",padding:2},Cd={display:"flex",alignItems:"center",justifyContent:"center",minHeight:34,padding:4,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",cursor:"pointer"},qm={...Cd,border:"2px solid #2563eb",background:"#eff6ff"},Sd={display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:16,lineHeight:1},A5={fontSize:10,color:"#475569",fontFamily:"monospace",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:"100%"},D5={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"4px 2px"},Zm=130,Mn=20;function I5(t){const e=t?.arrayProp;return typeof e=="string"&&e?e:"tabs"}function L5(t){let e=0;for(const n of t){const r=typeof n.id=="number"?n.id:Number(n.id);Number.isFinite(r)&&r>e&&(e=r)}return e+1}function P5({node:t,params:e,cellBoxes:n,t:r,onPatchNode:s}){const o=I5(e),i=v.useMemo(()=>{const g=(t.props??{})[o];return typeof g=="string"||!Array.isArray(g)?null:g},[t,o]),a=v.useMemo(()=>{const g=new Map,m=`props.${o}.`;for(const p of n??[]){if(!p.path.startsWith(m))continue;const y=Number(p.path.slice(m.length));Number.isInteger(y)&&g.set(y,{top:p.top,left:p.left,width:p.width,height:p.height})}return g},[n,o]),l=v.useCallback(g=>{const m=t.props??{};s({...t,props:{...m,[o]:g}})},[t,o,s]),c=v.useCallback(g=>{if(!i)return;const p={id:L5(i),label:r("editor.tabnav_inplace.new_tab")},y=i.slice();y.splice(g,0,p),l(y)},[i,l,r]),d=v.useCallback(g=>{if(!i)return;const m=i.slice();m.splice(g,1),l(m)},[i,l]),u=v.useCallback((g,m)=>{if(!i)return;const p=g+m;if(p<0||p>=i.length)return;const y=i.slice(),[b]=y.splice(g,1);y.splice(p,0,b),l(y)},[i,l]);if(!i||a.size===0)return null;const f={position:"absolute",width:Mn,height:Mn,display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,lineHeight:1,border:"1px solid rgba(0,0,0,0.15)",borderRadius:4,background:"#ffffff",color:"#1f2937",cursor:"pointer",boxShadow:"0 1px 2px rgba(0,0,0,0.18)",pointerEvents:"auto",padding:0,zIndex:Zm};return h.jsx("div",{className:"g7le-tabnav-inplace","data-testid":"g7le-tabnav-inplace",style:{position:"absolute",inset:0,pointerEvents:"none",zIndex:Zm},children:i.map((g,m)=>{const p=a.get(m);if(!p)return null;const y=p.left+p.width;return h.jsxs("div",{"data-testid":`g7le-tabnav-aff-${m}`,children:[h.jsx("button",{type:"button","data-testid":`g7le-tabnav-remove-${m}`,title:r("editor.tabnav_inplace.remove"),"aria-label":r("editor.tabnav_inplace.remove"),style:{...f,left:y-Mn/2,top:p.top-Mn/2,color:"#b91c1c"},onClick:b=>{b.preventDefault(),b.stopPropagation(),d(m)},children:"✕"}),m>0&&h.jsx("button",{type:"button","data-testid":`g7le-tabnav-move-left-${m}`,title:r("editor.tabnav_inplace.move_left"),"aria-label":r("editor.tabnav_inplace.move_left"),style:{...f,left:p.left,top:p.top+p.height-Mn/2},onClick:b=>{b.preventDefault(),b.stopPropagation(),u(m,-1)},children:"◀"}),m{b.preventDefault(),b.stopPropagation(),u(m,1)},children:"▶"}),m===0&&h.jsx("button",{type:"button","data-testid":"g7le-tabnav-add-0",title:r("editor.tabnav_inplace.add"),"aria-label":r("editor.tabnav_inplace.add"),style:{...f,left:p.left-Mn,top:p.top+p.height/2-Mn/2,color:"#15803d"},onClick:b=>{b.preventDefault(),b.stopPropagation(),c(0)},children:"+"}),h.jsx("button",{type:"button","data-testid":`g7le-tabnav-add-${m+1}`,title:r("editor.tabnav_inplace.add"),"aria-label":r("editor.tabnav_inplace.add"),style:{...f,left:y-Mn/2,top:p.top+p.height/2-Mn/2,color:"#15803d"},onClick:b=>{b.preventDefault(),b.stopPropagation(),c(m+1)},children:"+"})]},`tab-aff-${g.id}-${m}`)})})}const _d=window.G7Core?.createLogger?.("Template:sirsoft-admin_basic:EditorWidgets")??{log:(...t)=>console.log("[Template:sirsoft-admin_basic:EditorWidgets]",...t),warn:(...t)=>console.warn("[Template:sirsoft-admin_basic:EditorWidgets]",...t)};function $5(){if(typeof window>"u")return;const t=window.G7Core?.layoutEditor;if(!t?.registerWidget){_d.warn("G7Core.layoutEditor 미노출 — icon-picker 위젯 등록 보류");return}t.registerWidget("icon-picker",O5),_d.log("편집기 위젯 'icon-picker' 등록됨(Font Awesome 검색 그리드)"),t.registerCanvasOverlay&&(t.registerCanvasOverlay("tabnav",P5),_d.log("편집기 캔버스 오버레이 'tabnav' 등록됨(TabNavigation 탭 인플레이스)"))}const Ed=window.G7Core?.createLogger?.("Template:sirsoft-admin_basic")??{log:(...t)=>console.log("[Template:sirsoft-admin_basic]",...t),warn:(...t)=>console.warn("[Template:sirsoft-admin_basic]",...t),error:(...t)=>console.error("[Template:sirsoft-admin_basic]",...t)};typeof window<"u"&&(window.G7TemplateHandlers=Oa);function Qm(){if(typeof window<"u"){let t=0;const e=50,n=()=>{const r=window.G7Core?.getActionDispatcher?.();r?(Object.entries(Oa).forEach(([s,o])=>{r.registerHandler(s,o)}),Ed.log(`${Object.keys(Oa).length} custom handler(s) registered:`,Object.keys(Oa)),S5(),Wm()):(t++,t<=e?(Ed.warn(`ActionDispatcher not found, retrying... (${t}/${e})`),setTimeout(n,100)):Ed.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};document.readyState==="complete"?n():window.addEventListener("load",n)}}return $5(),Qm(),R.A=Lr,R.Accordion=sc,R.ActionMenu=bs,R.AdminFooter=_y,R.AdminHeader=wy,R.AdminSidebar=xy,R.Alert=cu,R.AlertDialog=uy,R.Badge=Pd,R.BarChart=VT,R.Breadcrumb=ou,R.Button=oe,R.Card=pb,R.CardGrid=Kb,R.CategoryTree=jS,R.Checkbox=vn,R.ChipCheckbox=OE,R.Code=ub,R.CodeEditor=Uv,R.ColumnSelector=Kv,R.ConfirmDialog=ru,R.Container=UT,R.DataGrid=jb,R.Dialog=nu,R.Div=I,R.DonutChart=HT,R.Dropdown=Zb,R.DropdownButton=TE,R.DropdownMenuItem=AE,R.DynamicFieldList=UE,R.EmptyState=by,R.ExtensionBadge=nh,R.FileInput=Md,R.FileUploader=Ep,R.FilterGroup=ey,R.FilterVisibilitySelector=CE,R.Flex=jT,R.Form=Pa,R.FormField=YE,R.Fragment=hb,R.Grid=BT,R.H1=Da,R.H2=Qn,R.H3=Ir,R.H4=Nd,R.Hr=gb,R.HtmlContent=Za,R.HtmlEditor=vE,R.I=pt,R.Icon=re,R.IconButton=ly,R.IconName=de,R.IconSelect=FS,R.ImageGallery=Bi,R.Img=yn,R.Input=wt,R.Label=rn,R.LanguageSelector=Sy,R.LayoutEditorHeader=Ty,R.LayoutFileList=Ay,R.LayoutHistoryPanel=jv,R.LayoutWarnings=kE,R.Li=La,R.LoadingSpinner=vy,R.LoginForm=Wv,R.Modal=Uo,R.MultilingualInput=Ha,R.MultilingualLocaleContext=$c,R.MultilingualTabPanel=GE,R.MultilingualTagInput=Gk,R.Nav=Lo,R.NotificationCenter=ky,R.Ol=sb,R.Optgroup=lb,R.Option=ab,R.P=bt,R.PageHeader=Ey,R.PageLoading=Jv,R.PageTransitionIndicator=Zv,R.Pagination=Po,R.PermissionTree=US,R.Pre=fb,R.ProductCard=Qb,R.RadioGroup=mE,R.RichSelect=ME,R.SearchBar=Rb,R.SearchableDropdown=Yk,R.Section=Od,R.SectionHeader=Ny,R.SectionLayout=WT,R.Select=Pr,R.SlotContainer=EE,R.SortableMenuItem=Gh,R.SortableMenuList=PS,R.Span=Q,R.StatCard=Jb,R.StatusBadge=$o,R.Svg=Hn,R.TabNavigation=ty,R.TabNavigationScroll=iy,R.Table=$a,R.TagInput=Bk,R.TagSelect=Xk,R.Tbody=za,R.Td=Kt,R.TemplateCard=Oy,R.Textarea=Zn,R.Tfoot=Va,R.Th=$r,R.Thead=Fa,R.ThemeToggle=Yv,R.ThreeColumnLayout=GT,R.Toast=py,R.Toggle=Ua,R.Tr=Jn,R.Ul=Ia,R.UnifiedIcon=db,R.UserProfile=Cy,R.VersionList=_u,R.configureJSONLanguageService=KT,R.defaultJSONSchemas=Fm,R.executeImageDownload=Ac,R.initTemplate=Qm,R.jsonEditorDefaults=YT,R.monacoInitOptions=XT,R.templateMetadata=qT,R.useCurrentMultilingualLocale=Ip,R.useImageGallery=fp,Object.defineProperty(R,Symbol.toStringTag,{value:"Module"}),R})({},ReactJSXRuntime,React,ReactDOM); -//# sourceMappingURL=components.iife.js.map diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index 71eaa782..83722118 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -253,7 +253,8 @@ "last_login_at": "Last Login", "ip_address": "IP Address", "country_select": "Select Country", - "country_search": "Search country..." + "country_search": "Search country...", + "lock_status": "Account lock status" }, "verification": { "method": "Verification Method", @@ -335,7 +336,13 @@ "profile_memo": "Profile/Memo", "activity_info": "Activity", "consent_info": "Consent" - } + }, + "unlock": "Unlock", + "unlock_success": "The account lock has been released.", + "lock_status": "Account lock status", + "locked_permanently_value": "Locked (indefinitely — administrator unlock required)", + "locked_until_value": "Locked (until {{until}})", + "not_locked_value": "Not locked" } }, "language_packs": { @@ -1475,7 +1482,13 @@ "app_key_desc": "Manage the application key used for encryption.", "current_app_key": "Current Key", "regenerate_key": "Regenerate Key", - "app_key_warning": "Changing the key may make previously encrypted data inaccessible." + "app_key_warning": "Changing the key may make previously encrypted data inaccessible.", + "password_min_length": "Minimum password length", + "password_min_length_hint": "Applies to sign-up, password change and reset. Not applied to login (so existing users are never locked out of their own accounts).", + "require_password_special_char": "Require special character", + "require_password_special_char_label": "Require at least one special character", + "require_password_special_char_hint": "When enabled, newly set passwords must contain at least one special character.", + "ip_throttle_floor_hint": "The per-IP request limit is six times this value, with a guaranteed floor of 30 requests per minute." }, "identity": { "sub_tabs": { diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 22c81b58..64c1aa3d 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -257,7 +257,8 @@ "last_login_at": "마지막 로그인", "ip_address": "IP", "country_select": "국가 선택", - "country_search": "국가 검색..." + "country_search": "국가 검색...", + "lock_status": "계정 잠금 상태" }, "verification": { "method": "본인확인방법", @@ -339,7 +340,13 @@ "profile_memo": "프로필/메모", "activity_info": "활동정보", "consent_info": "동의이력" - } + }, + "unlock": "잠금 해제", + "unlock_success": "계정 잠금이 해제되었습니다.", + "lock_status": "계정 잠금 상태", + "locked_permanently_value": "잠김 (무기한 — 관리자 해제 필요)", + "locked_until_value": "잠김 ({{until}} 까지)", + "not_locked_value": "정상" } }, "language_packs": { @@ -1479,7 +1486,13 @@ "app_key_desc": "암호화에 사용되는 어플리케이션 키를 관리합니다.", "current_app_key": "현재 키", "regenerate_key": "키 재생성", - "app_key_warning": "키를 변경하면 기존 암호화된 데이터에 접근할 수 없게 될 수 있습니다." + "app_key_warning": "키를 변경하면 기존 암호화된 데이터에 접근할 수 없게 될 수 있습니다.", + "password_min_length": "비밀번호 최소 길이", + "password_min_length_hint": "회원가입·비밀번호 변경·재설정에 적용됩니다. 로그인에는 적용되지 않습니다(기존 비밀번호로 로그인할 수 없게 되는 것을 막기 위함).", + "require_password_special_char": "특수문자 필수", + "require_password_special_char_label": "비밀번호에 특수문자를 반드시 포함", + "require_password_special_char_hint": "켜면 새로 설정하는 비밀번호에 특수문자가 최소 1개 필요합니다.", + "ip_throttle_floor_hint": "IP 단위 요청 제한은 이 값의 6배로 적용되며, 분당 최소 30회는 항상 보장됩니다." }, "identity": { "sub_tabs": { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_detail.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_detail.json index 69f91e9b..79b7dce9 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_detail.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_detail.json @@ -110,7 +110,7 @@ "name": "Icon", "props": { "name": "arrow-left", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -126,7 +126,7 @@ "name": "Button", "props": { "disabled": "{{user?.data?.abilities?.can_update !== true}}", - "className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + "className": "flex-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" }, "actions": [ { @@ -145,7 +145,7 @@ "name": "Icon", "props": { "name": "pencil", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -154,6 +154,66 @@ "text": "$t:admin.users.detail.edit" } ] + }, + { + "id": "header_unlock_button", + "type": "basic", + "name": "Button", + "if": "{{user?.data?.is_locked === true}}", + "props": { + "disabled": "{{user?.data?.abilities?.can_update !== true}}", + "className": "flex-center gap-2 btn btn-warning" + }, + "actions": [ + { + "type": "click", + "handler": "apiCall", + "auth_required": true, + "target": "/api/admin/users/{{route?.id}}/unlock", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "user" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:admin.users.detail.unlock_success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "unlock", + "className": "text-base" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:admin.users.detail.unlock" + } + ] } ] } @@ -230,7 +290,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0" } }, { @@ -280,7 +340,7 @@ "name": "Icon", "props": { "name": "user", - "className": "text-secondary w-5 h-5" + "className": "text-secondary text-xl" } }, { @@ -483,7 +543,7 @@ "condition": "{{user?.data?.email_verified_at}}", "props": { "name": "check-circle", - "className": "w-4 h-4 text-green-500" + "className": "text-base text-green-500" } }, { @@ -527,7 +587,7 @@ "name": "Icon", "props": { "name": "envelope", - "className": "text-secondary w-5 h-5" + "className": "text-secondary text-xl" } }, { @@ -688,7 +748,7 @@ "name": "Icon", "props": { "name": "map-pin", - "className": "text-secondary w-5 h-5" + "className": "text-secondary text-xl" } }, { @@ -857,7 +917,7 @@ "name": "Icon", "props": { "name": "user-circle", - "className": "text-secondary w-5 h-5" + "className": "text-secondary text-xl" } }, { @@ -992,7 +1052,7 @@ "name": "Icon", "props": { "name": "clock", - "className": "text-secondary w-5 h-5" + "className": "text-secondary text-xl" } }, { @@ -1068,6 +1128,48 @@ "text": "{{user?.data?.last_login_at ?? '-'}}" } ] + }, + { + "id": "field_lock_status", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-label-subtle block font-medium mb-1" + }, + "text": "$t:admin.users.form.fields.lock_status" + }, + { + "type": "basic", + "name": "Div", + "if": "{{user?.data?.locked_permanently === true}}", + "props": { + "className": "text-error-soft text-sm" + }, + "text": "$t:admin.users.detail.locked_permanently_value" + }, + { + "type": "basic", + "name": "Div", + "if": "{{user?.data?.locked_permanently !== true && user?.data?.is_locked === true}}", + "props": { + "className": "text-error-soft text-sm" + }, + "text": "$t:admin.users.detail.locked_until_value|until={{user?.data?.locked_until ?? ''}}" + }, + { + "type": "basic", + "name": "Div", + "if": "{{user?.data?.is_locked !== true}}", + "props": { + "className": "text-primary text-sm" + }, + "text": "$t:admin.users.detail.not_locked_value" + } + ] } ] }, @@ -1176,7 +1278,7 @@ "name": "Icon", "props": { "name": "file-contract", - "className": "text-secondary w-5 h-5" + "className": "text-secondary text-xl" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json index fe9962dd..b6dcf4d9 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json @@ -79,7 +79,8 @@ "filters[0][field]": "{{_local.searchField || 'all'}}", "filters[0][value]": "{{_local.searchQuery || undefined}}", "filters[0][operator]": "like" - } + }, + "transition_overlay_target": "users_data_grid__body" } } }, diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json index 2ba6e7c8..794bf643 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json @@ -260,8 +260,8 @@ "type": "number", "name": "advanced.layout_cache_ttl", "placeholder": "3600", - "min": "0", - "max": "14400", + "min": "{{_local?.form?._meta?.limits?.advanced_cache_ttl_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.advanced_cache_ttl_max ?? 14400}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-xs {{_local.errors?.['advanced.layout_cache_ttl'] ? 'input-error' : ''}}" } @@ -405,8 +405,8 @@ "type": "number", "name": "advanced.stats_cache_ttl", "placeholder": "1800", - "min": "0", - "max": "14400", + "min": "{{_local?.form?._meta?.limits?.advanced_cache_ttl_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.advanced_cache_ttl_max ?? 14400}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-xs {{_local.errors?.['advanced.stats_cache_ttl'] ? 'input-error' : ''}}" } @@ -550,8 +550,8 @@ "type": "number", "name": "advanced.seo_cache_ttl", "placeholder": "7200", - "min": "0", - "max": "14400", + "min": "{{_local?.form?._meta?.limits?.advanced_cache_ttl_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.advanced_cache_ttl_max ?? 14400}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-xs {{_local.errors?.['advanced.seo_cache_ttl'] ? 'input-error' : ''}}" } @@ -668,8 +668,8 @@ "type": "number", "name": "advanced.seo_sitemap_cache_ttl", "placeholder": "86400", - "min": "3600", - "max": "604800", + "min": "{{_local?.form?._meta?.limits?.advanced_seo_sitemap_cache_ttl_min ?? 3600}}", + "max": "{{_local?.form?._meta?.limits?.advanced_seo_sitemap_cache_ttl_max ?? 604800}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-xs {{_local.errors?.['advanced.seo_sitemap_cache_ttl'] ? 'input-error' : ''}}" } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json index 31c27be5..73ed5cba 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json @@ -341,7 +341,7 @@ "if": "{{_local.testingDriver !== 's3'}}", "props": { "name": "plug", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -459,7 +459,7 @@ "name": "Icon", "props": { "name": "{{_local.driverTestResults.s3.success ? 'check-circle' : 'x-circle'}}", - "className": "w-5 h-5" + "className": "text-xl" } }, { @@ -633,8 +633,8 @@ "type": "number", "name": "drivers.redis_port", "placeholder": "6379", - "min": 1, - "max": 65535, + "min": "{{_local?.form?._meta?.limits?.drivers_redis_port_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.drivers_redis_port_max ?? 65535}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.redis_port'] ? 'input-error' : ''}}" } @@ -718,8 +718,8 @@ "type": "number", "name": "drivers.redis_database", "placeholder": "0", - "min": 0, - "max": 15, + "min": "{{_local?.form?._meta?.limits?.drivers_redis_database_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.drivers_redis_database_max ?? 15}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.redis_database'] ? 'input-error' : ''}}" } @@ -760,7 +760,7 @@ "if": "{{_local.testingDriver !== 'redis'}}", "props": { "name": "plug", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -878,7 +878,7 @@ "name": "Icon", "props": { "name": "{{_local.driverTestResults.redis.success ? 'check-circle' : 'x-circle'}}", - "className": "w-5 h-5" + "className": "text-xl" } }, { @@ -970,8 +970,8 @@ "type": "number", "name": "drivers.memcached_port", "placeholder": "11211", - "min": 1, - "max": 65535, + "min": "{{_local?.form?._meta?.limits?.drivers_memcached_port_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.drivers_memcached_port_max ?? 65535}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.memcached_port'] ? 'input-error' : ''}}" } @@ -1012,7 +1012,7 @@ "if": "{{_local.testingDriver !== 'memcached'}}", "props": { "name": "plug", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -1128,7 +1128,7 @@ "name": "Icon", "props": { "name": "{{_local.driverTestResults.memcached.success ? 'check-circle' : 'x-circle'}}", - "className": "w-5 h-5" + "className": "text-xl" } }, { @@ -1252,7 +1252,7 @@ "name": "Icon", "props": { "name": "circle-info", - "className": "w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5" + "className": "text-base text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5" } }, { @@ -1358,7 +1358,7 @@ "name": "Icon", "props": { "name": "info-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1400,8 +1400,8 @@ "type": "number", "name": "drivers.session_lifetime", "placeholder": "120", - "min": 1, - "max": 43200, + "min": "{{_local?.form?._meta?.limits?.drivers_session_lifetime_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.drivers_session_lifetime_max ?? 43200}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.session_lifetime'] ? 'input-error w-32' : 'w-32'}}" } @@ -1524,7 +1524,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" + "className": "text-base text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" } }, { @@ -1566,7 +1566,7 @@ "name": "Icon", "props": { "name": "info-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1786,8 +1786,8 @@ "type": "number", "name": "drivers.log_days", "placeholder": "14", - "min": 1, - "max": 365, + "min": "{{_local?.form?._meta?.limits?.drivers_log_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.drivers_log_days_max ?? 365}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.log_days'] ? 'input-error w-32' : 'w-32'}}" } @@ -1835,7 +1835,7 @@ "name": "Icon", "props": { "name": "info-circle", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -2169,8 +2169,8 @@ "type": "number", "name": "drivers.websocket_port", "placeholder": "8080", - "min": 1, - "max": 65535, + "min": "{{_local?.form?._meta?.limits?.drivers_websocket_port_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.drivers_websocket_port_max ?? 65535}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.websocket_port'] ? 'input-error' : ''}}" } @@ -2360,8 +2360,8 @@ "type": "number", "name": "drivers.websocket_server_port", "placeholder": "8080", - "min": 1, - "max": 65535, + "min": "{{_local?.form?._meta?.limits?.drivers_websocket_server_port_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.drivers_websocket_server_port_max ?? 65535}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['drivers.websocket_server_port'] ? 'input-error' : ''}}" } @@ -2446,7 +2446,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" + "className": "text-base text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" } }, { @@ -2498,7 +2498,7 @@ "if": "{{_local.testingDriver !== 'websocket'}}", "props": { "name": "plug", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -2615,7 +2615,7 @@ "name": "Icon", "props": { "name": "{{_local.driverTestResults.websocket.success ? 'check-circle' : 'x-circle'}}", - "className": "w-5 h-5" + "className": "text-xl" } }, { @@ -2696,7 +2696,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0" } }, { @@ -2712,6 +2712,5 @@ ] } ], - "props": { - } + "props": {} } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_basic.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_basic.json index 104e0734..64e2ab9b 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_basic.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_basic.json @@ -123,8 +123,8 @@ "type": "number", "name": "identity.challenge_ttl_minutes", "placeholder": "15", - "min": 1, - "max": 1440, + "min": "{{_local?.form?._meta?.limits?.identity_challenge_ttl_minutes_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.identity_challenge_ttl_minutes_max ?? 1440}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['identity.challenge_ttl_minutes'] ? 'input-error md:w-48' : 'md:w-48'}}" } @@ -185,8 +185,8 @@ "type": "number", "name": "identity.max_attempts", "placeholder": "5", - "min": 1, - "max": 20, + "min": "{{_local?.form?._meta?.limits?.identity_max_attempts_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.identity_max_attempts_max ?? 20}}", "disabled": "{{_computed.isReadOnly}}", "className": "{{_local.errors?.['identity.max_attempts'] ? 'input-error md:w-48' : 'md:w-48'}}" } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_security.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_security.json index 80b4abda..17a3b33a 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_security.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_security.json @@ -255,8 +255,8 @@ "type": "number", "name": "security.auth_token_lifetime", "placeholder": "30", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.security_auth_token_lifetime_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.security_auth_token_lifetime_max ?? 3600}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-md {{_local.errors?.['security.auth_token_lifetime'] ? 'input-error' : ''}}" } @@ -303,8 +303,8 @@ "type": "number", "name": "security.max_login_attempts", "placeholder": "5", - "min": 0, - "max": 100, + "min": "{{_local?.form?._meta?.limits?.security_max_login_attempts_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.security_max_login_attempts_max ?? 100}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-md {{_local.errors?.['security.max_login_attempts'] ? 'input-error' : ''}}" } @@ -325,6 +325,14 @@ "className": "text-hint mt-1" }, "text": "$t:admin.settings.security.zero_means_unlimited" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-hint mt-1" + }, + "text": "$t:admin.settings.security.ip_throttle_floor_hint" } ] }, @@ -351,8 +359,8 @@ "type": "number", "name": "security.login_lockout_time", "placeholder": "5", - "min": 0, - "max": 1440, + "min": "{{_local?.form?._meta?.limits?.security_login_lockout_time_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.security_login_lockout_time_max ?? 1440}}", "disabled": "{{_computed.isReadOnly}}", "className": "input input-w-md {{_local.errors?.['security.login_lockout_time'] ? 'input-error' : ''}}" } @@ -375,6 +383,89 @@ "text": "$t:admin.settings.security.zero_means_unlimited" } ] + }, + { + "id": "field_password_min_length", + "type": "basic", + "name": "Div", + "props": { + "className": "" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:admin.settings.security.password_min_length" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "security.password_min_length", + "disabled": "{{_computed.isReadOnly}}", + "className": "input input-w-md {{_local.errors?.['security.password_min_length'] ? 'input-error' : ''}}", + "placeholder": "8", + "min": "{{_local?.form?._meta?.limits?.security_password_min_length_min ?? 6}}", + "max": "{{_local?.form?._meta?.limits?.security_password_min_length_max ?? 64}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['security.password_min_length']}}", + "props": { + "className": "form-error" + }, + "text": "{{_local.errors?.['security.password_min_length']?.[0] ?? ''}}" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-hint mt-1" + }, + "text": "$t:admin.settings.security.password_min_length_hint" + } + ] + }, + { + "id": "field_require_password_special_char", + "type": "basic", + "name": "Div", + "props": { + "className": "" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:admin.settings.security.require_password_special_char" + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "security.require_password_special_char", + "disabled": "{{_computed.isReadOnly}}", + "label": "$t:admin.settings.security.require_password_special_char_label" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-hint mt-1" + }, + "text": "$t:admin.settings.security.require_password_special_char_hint" + } + ] } ] } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index db911063..8484d60f 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -206,8 +206,7 @@ "text": "$t:admin.settings.seo.generator_enabled_desc" } ], - "props": { - } + "props": {} }, { "type": "composite", @@ -350,8 +349,8 @@ "props": { "type": "number", "name": "seo.og_image_default_width", - "min": "0", - "max": "8000", + "min": "{{_local?.form?._meta?.limits?.seo_og_image_default_width_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.seo_og_image_default_width_max ?? 8000}}", "placeholder": "1200", "disabled": "{{_computed.isReadOnly}}", "className": "" @@ -377,8 +376,8 @@ "props": { "type": "number", "name": "seo.og_image_default_height", - "min": "0", - "max": "8000", + "min": "{{_local?.form?._meta?.limits?.seo_og_image_default_height_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.seo_og_image_default_height_max ?? 8000}}", "placeholder": "630", "disabled": "{{_computed.isReadOnly}}", "className": "" @@ -766,8 +765,8 @@ "props": { "type": "number", "name": "seo.cache_ttl", - "min": 60, - "max": 86400, + "min": "{{_local?.form?._meta?.limits?.seo_cache_ttl_min ?? 60}}", + "max": "{{_local?.form?._meta?.limits?.seo_cache_ttl_max ?? 86400}}", "placeholder": "7200", "className": "" } @@ -947,8 +946,8 @@ "props": { "type": "number", "name": "seo.sitemap_cache_ttl", - "min": 3600, - "max": 604800, + "min": "{{_local?.form?._meta?.limits?.seo_sitemap_cache_ttl_min ?? 3600}}", + "max": "{{_local?.form?._meta?.limits?.seo_sitemap_cache_ttl_max ?? 604800}}", "placeholder": "86400", "className": "" } @@ -982,8 +981,8 @@ "props": { "type": "number", "name": "seo.sitemap_urls_per_file", - "min": 1000, - "max": 50000, + "min": "{{_local?.form?._meta?.limits?.seo_sitemap_urls_per_file_min ?? 1000}}", + "max": "{{_local?.form?._meta?.limits?.seo_sitemap_urls_per_file_max ?? 50000}}", "placeholder": "50000", "className": "" } @@ -1246,6 +1245,5 @@ ] } ], - "props": { - } + "props": {} } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_upload.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_upload.json index f0e3242b..e34f32e6 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_upload.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_upload.json @@ -83,8 +83,8 @@ "props": { "type": "number", "name": "upload.max_file_size", - "min": "1", - "max": "1024", + "min": "{{_local?.form?._meta?.limits?.upload_max_file_size_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.upload_max_file_size_max ?? 1024}}", "disabled": "{{_computed.isReadOnly}}", "className": "input flex-1 {{_local.errors?.['upload.max_file_size'] ? 'input-error' : ''}}" } @@ -230,8 +230,8 @@ "props": { "type": "number", "name": "upload.image_max_width", - "min": "100", - "max": "10000", + "min": "{{_local?.form?._meta?.limits?.upload_image_max_width_min ?? 100}}", + "max": "{{_local?.form?._meta?.limits?.upload_image_max_width_max ?? 10000}}", "disabled": "{{_computed.isReadOnly}}", "className": "input flex-1" } @@ -274,8 +274,8 @@ "props": { "type": "number", "name": "upload.image_max_height", - "min": "100", - "max": "10000", + "min": "{{_local?.form?._meta?.limits?.upload_image_max_height_min ?? 100}}", + "max": "{{_local?.form?._meta?.limits?.upload_image_max_height_max ?? 10000}}", "disabled": "{{_computed.isReadOnly}}", "className": "input flex-1" } @@ -318,8 +318,8 @@ "props": { "type": "number", "name": "upload.image_quality", - "min": "1", - "max": "100", + "min": "{{_local?.form?._meta?.limits?.upload_image_quality_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.upload_image_quality_max ?? 100}}", "disabled": "{{_computed.isReadOnly}}", "className": "input flex-1" } @@ -349,6 +349,5 @@ ] } ], - "props": { - } + "props": {} } diff --git a/templates/_bundled/sirsoft-admin_basic/src/styles/main.css b/templates/_bundled/sirsoft-admin_basic/src/styles/main.css index 86cf8e90..d03373fa 100644 --- a/templates/_bundled/sirsoft-admin_basic/src/styles/main.css +++ b/templates/_bundled/sirsoft-admin_basic/src/styles/main.css @@ -383,6 +383,8 @@ + + diff --git a/templates/_bundled/sirsoft-basic/dist/js/components.iife.js b/templates/_bundled/sirsoft-basic/dist/js/components.iife.js index f452ea12..29ee959b 100644 --- a/templates/_bundled/sirsoft-basic/dist/js/components.iife.js +++ b/templates/_bundled/sirsoft-basic/dist/js/components.iife.js @@ -84,4 +84,3 @@ self.addEventListener('message', async (e) => { } } `})]}):null},n0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionIndicator:Sa,default:Sa},Symbol.toStringTag,{value:"Module"})),Yi=window.G7Core?.createLogger?.("Comp:PageTransitionBlur")??{log:(...e)=>console.log("[Comp:PageTransitionBlur]",...e),warn:(...e)=>console.warn("[Comp:PageTransitionBlur]",...e)},_a=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Yi.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{Yi.log("isPending changed:",s),n(s)})},[]),r?c.jsx(W,{className:`fixed inset-0 z-40 bg-white/30 dark:bg-gray-900/30 backdrop-blur-sm transition-opacity duration-150 pointer-events-none ${e}`,style:t,role:"status","aria-label":"페이지 전환 중","data-testid":"page-transition-blur"}):null},o0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionBlur:_a,default:_a},Symbol.toStringTag,{value:"Module"})),Xi={sm:640,md:768,lg:1024,xl:1280,"2xl":1536},qi=new Set(["hidden","block","inline-block","inline","flex","inline-flex","grid","inline-grid","table","table-row","table-cell","contents","list-item","flow-root"]),a0=new Set(["Div","Flex","Grid","Section","Article","Nav","Aside","Main","Header","Footer","Fragment","Form","Container"]),s0=new Set(["H1","H2","H3","H4","H5","H6","P","Span","Label","A","Strong","Em","Small","Badge"]),l0=new Set(["Input","Textarea","Select","Checkbox","Radio","Toggle","DatePicker","TagInput","SearchBar"]),i0=new Set(["Img","Image","Avatar","AvatarUploader","ImageGallery","ProductImageViewer","FileUploader"]),Zi={DataGrid:(e,t)=>c.jsxs("div",{className:"w-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",children:[c.jsx("div",{className:`h-10 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 ${e}`,children:c.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[c.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"})]})}),Array.from({length:t},(r,n)=>c.jsx("div",{className:`h-12 border-b border-gray-100 dark:border-gray-800 ${e}`,style:{animationDelay:`${n*75}ms`},children:c.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[c.jsx("div",{className:"h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded"}),c.jsx("div",{className:"h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded"}),c.jsx("div",{className:"h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded"}),c.jsx("div",{className:"h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded"})]})},n))]}),Pagination:e=>c.jsx("div",{className:"flex justify-center gap-1.5 py-3",children:Array.from({length:5},(t,r)=>c.jsx("div",{className:`w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),Tabs:e=>c.jsxs("div",{children:[c.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2 mb-3",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),c.jsx("div",{className:`h-32 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]}),TabNavigation:e=>c.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),ProductCard:e=>c.jsxs("div",{className:"rounded-lg overflow-hidden",children:[c.jsx("div",{className:`h-48 w-full bg-gray-200 dark:bg-gray-700 ${e}`}),c.jsxs("div",{className:"p-3 space-y-2",children:[c.jsx("div",{className:`h-3.5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:`h-4 w-1/3 bg-gray-300 dark:bg-gray-600 rounded ${e}`})]})]}),UserInfo:e=>c.jsx("div",{className:`h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),QuantitySelector:e=>c.jsx("div",{className:`h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 ${e}`}),Header:e=>c.jsx("header",{className:"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:c.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8",children:c.jsxs("div",{className:"flex items-center justify-between h-16",children:[c.jsxs("div",{className:"flex items-center gap-8",children:[c.jsx("div",{className:`h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:"hidden lg:flex items-center gap-6",children:Array.from({length:4},(t,r)=>c.jsx("div",{className:`h-3.5 w-16 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))})]}),c.jsx("div",{className:"flex items-center gap-4",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))})]})})}),Footer:e=>c.jsx("footer",{className:"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-auto",children:c.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8",children:c.jsxs("div",{className:"flex flex-col items-center gap-4",children:[c.jsx("div",{className:`h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:`h-3 w-48 bg-gray-100 dark:bg-gray-800 rounded ${e}`}),c.jsx("div",{className:"flex gap-3",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))}),c.jsx("div",{className:`h-3 w-40 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]})})}),Modal:()=>c.jsx(c.Fragment,{}),Toast:()=>c.jsx(c.Fragment,{}),ConfirmDialog:()=>c.jsx(c.Fragment,{})};function c0(e){if(!e||e.length===0)return[];const t=e.filter(n=>n.if),r=e.filter(n=>!n.if);if(r.length>0&&t.length>0){const n=t.filter(o=>!Qi(o.if));return n.length>1&&d0(n)?[...r,Ji(n)]:[...r,...n]}if(t.length>1&&r.length===0){const n=t.filter(o=>!Qi(o.if));return n.length===0?[]:n.length===1?n:[Ji(n)]}return e}function Qi(e){const t=e.replace(/^\{\{|\}\}$/g,"").trim();return t.startsWith("!")||/\b(hasError|isError|isLoading|error|Error)\b/.test(t)||/===\s*0\s*(\)|\}|$)/.test(t)}function d0(e){if(e.length<2)return!1;const t=[];for(const n of e){const o=(n.if||"").replace(/^\{\{|\}\}$/g,"").trim(),a=o.match(/^(.+?)\s*===\s*['"]/);if(a)t.push(a[1].trim());else{const s=o.match(/(.+?)\s*===\s*['"]/);if(!s)return!1;t.push(s[1].trim())}}const r=t[0];return t.every(n=>n===r)}function Ji(e){return e.reduce((t,r)=>Ea(r)>Ea(t)?r:t)}function Ea(e){return e.children?e.children.length+e.children.reduce((t,r)=>t+Ea(r),0):0}function u0(e){return e.includes("{{")?e.replace(/\{\{([^}]+)\}\}/g,(t,r)=>{const n=r.match(/\?[\s]*['"]([^'"]*)['"]\s*:\s*['"]/);return n?n[1]:""}):e}function Ta(e,t){if(!e)return"";let r=e;return r=r.replace(/\b(bg-\S+|text-\S+|border\b|border-\S+|shadow\S*|ring-\S+|outline-\S+|divide-\S+|opacity-\S+|font-\S+|tracking-\S+|leading-\S+|whitespace-\S+|truncate|line-clamp-\S+|underline|no-underline|decoration-\S+|cursor-\S+|hover:\S+|focus:\S+|active:\S+|group-hover:\S+|transition\S*|duration-\S+|ease-\S+|animate-\S+|dark:\S+)\b/g,"").replace(/\s{2,}/g," ").trim(),(t==="Grid"||/\bgrid-cols-\d/.test(r))&&(r=r.replace(/\bgrid-cols-(\d+)/g,(n,o)=>{const a=parseInt(o);return a>4?`grid-cols-${Math.ceil(a/2)}`:`grid-cols-${a}`})),r}function f0(e){const{name:t,props:r}=e;if(!r||!t)return"";const n=[];if(t==="Flex"){n.push("flex");const o=r.direction||"row",a={row:"flex-row","row-reverse":"flex-row-reverse",col:"flex-col","col-reverse":"flex-col-reverse"};a[o]&&n.push(a[o]);const s=r.justify||"start",l={start:"justify-start",end:"justify-end",center:"justify-center",between:"justify-between",around:"justify-around",evenly:"justify-evenly"};l[s]&&n.push(l[s]);const f=r.align||"stretch",h={start:"items-start",end:"items-end",center:"items-center",baseline:"items-baseline",stretch:"items-stretch"};h[f]&&n.push(h[f]);const d=r.wrap||"nowrap",i={wrap:"flex-wrap",nowrap:"flex-nowrap","wrap-reverse":"flex-wrap-reverse"};i[d]&&n.push(i[d]),r.gap&&r.gap>0&&n.push(`gap-${r.gap}`)}if(t==="Grid"){n.push("grid");const o=r.cols||1;if(n.push(`grid-cols-${o}`),r.responsive){const a=r.responsive;a.sm&&n.push(`sm:grid-cols-${a.sm}`),a.md&&n.push(`md:grid-cols-${a.md}`),a.lg&&n.push(`lg:grid-cols-${a.lg}`),a.xl&&n.push(`xl:grid-cols-${a.xl}`),a["2xl"]&&n.push(`2xl:grid-cols-${a["2xl"]}`)}r.gap!==void 0&&r.gap>0&&n.push(`gap-${r.gap}`),r.rowGap!==void 0&&r.rowGap>0&&n.push(`gap-y-${r.rowGap}`),r.colGap!==void 0&&r.colGap>0&&n.push(`gap-x-${r.colGap}`)}return n.join(" ")}function g0(e){switch(e){case"pulse":return"animate-pulse";case"wave":return"animate-pulse";case"none":return"";default:return"animate-pulse"}}function qn(e,t,r,n){return!e||e.length===0?[]:c0(e).map((a,s)=>Ri(a,t,r,s,n))}function h0(e,t){const r=e.props?.className||e.className||"";return e.responsive?t>=1024&&e.responsive.desktop?.props?.className?e.responsive.desktop.props.className:t>=768&&e.responsive.tablet?.props?.className?e.responsive.tablet.props.className:r:r}function p0(e,t){const r=e.split(/\s+/).filter(Boolean),n=[],o=[];for(const f of r){const h=f.match(/^(sm|md|lg|xl|2xl):(.+)$/);if(h){const[,d,i]=h;if(qi.has(i)){n.push({breakpoint:Xi[d]||0,display:i});continue}(Xi[d]||0)<=t&&o.push(i);continue}if(qi.has(f)){n.push({breakpoint:0,display:f});continue}o.push(f)}if(n.length===0)return{visible:!0,className:e};n.sort((f,h)=>f.breakpoint-h.breakpoint);let a="";for(const f of n)f.breakpoint<=t&&(a=f.display);a||(a=n[0].display);const s=a!=="hidden",l=s?[a,...o].join(" "):"";return{visible:s,className:l}}function m0(e){const t=e.match(/\bgrid-cols-\[([^\]]+)\]/);if(!t)return{className:e};const r=t[1].replace(/_/g," ");return{className:e.replace(/\bgrid-cols-\[([^\]]+)\]/,"").replace(/\s{2,}/g," ").trim(),style:{gridTemplateColumns:r}}}function Ri(e,t,r,n,o){const{name:a,children:s,iteration:l}=e,f=h0(e,o),h=f0(e),d=h?`${h} ${f}`.trim():f,i=u0(d),u=p0(i,o);if(!a||!u.visible)return null;const g=u.className;if(l)return c.jsx(k.Fragment,{children:Array.from({length:r},(m,w)=>Ri({...e,iteration:void 0},t,r,w,o))},n);if(Zi[a])return c.jsx("div",{className:g,children:Zi[a](t,r)},n);if(a0.has(a)){const m=Ta(g,a);if(!s||s.length===0){const E=/\b(h-\S|w-\S)/.test(m)?`${m} bg-gray-200 dark:bg-gray-700 rounded ${t}`:`${m} h-3.5 w-full bg-gray-200 dark:bg-gray-700 rounded ${t}`.trim();return c.jsx("div",{className:E},n)}const w=qn(s,t,r,o);if(!(w.length>0&&w.some(y=>y!==null)))return null;const O=m0(m);return c.jsx("div",{className:O.className,style:O.style,children:w},n)}const p=Ta(g,a);if(s0.has(a)){const m={H1:"h-7",H2:"h-6",H3:"h-5",H4:"h-5",H5:"h-4",H6:"h-4",P:"h-3.5",Span:"h-3",Label:"h-3",A:"h-3.5",Badge:"h-5 w-16"},w={H1:"w-3/5",H2:"w-2/5",H3:"w-1/3",H4:"w-1/3",H5:"w-1/4",H6:"w-1/4",P:"w-full",Span:"w-1/4",Label:"w-1/4",A:"w-1/3",Strong:"w-1/5",Em:"w-1/4",Small:"w-1/5",Badge:"w-16"},x=m[a]||"h-3.5",O=w[a]||"w-full",y=a.startsWith("H")?"mb-2 mt-1":"mb-1.5";return c.jsx("div",{className:`${x} ${O} bg-gray-200 dark:bg-gray-700 rounded ${t} ${y} ${p}`.trim()},n)}if(l0.has(a))return a==="Textarea"?c.jsx("div",{className:`h-20 w-full bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 mb-2 ${t}`},n):a==="Checkbox"||a==="Radio"||a==="Toggle"?c.jsx("div",{className:`h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded mb-1.5 ${t}`},n):c.jsx("div",{className:`h-9 w-full bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 mb-2 ${t}`},n);if(i0.has(a))return a==="Avatar"||a==="AvatarUploader"?c.jsx("div",{className:`h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-full ${t}`},n):a==="ProductImageViewer"?c.jsx("div",{className:`h-80 w-full bg-gray-200 dark:bg-gray-700 rounded ${t}`},n):c.jsx("div",{className:`h-40 w-full bg-gray-200 dark:bg-gray-700 rounded mb-2 ${t}`},n);if(a==="Button"){if(s&&s.length>0){const m=Ta(g,a),w=qn(s,t,r,o);if(!(w.length>0&&w.some(E=>E!==null)))return null;const y=/\b(border|rounded|px-\d|py-\d)/.test(f)?"border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-lg":"";return c.jsx("div",{className:`${m} ${y}`.trim(),children:w},n)}return c.jsx("div",{className:`h-9 w-24 bg-gray-200 dark:bg-gray-700 rounded ${t}`},n)}return a==="Icon"?c.jsx("div",{className:`h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded ${t}`},n):s&&s.length>0?c.jsx("div",{className:p,children:qn(s,t,r,o)},n):c.jsx("div",{className:`h-3.5 w-full bg-gray-200 dark:bg-gray-700 rounded mb-1.5 ${t}`},n)}const Na=({components:e,options:t})=>{const r=g0(t.animation),o=window.G7Core?.useResponsive,a=o?.(),s=a?a.width:typeof window<"u"?window.innerWidth:1024;return c.jsx("div",{className:"w-full",role:"status","aria-busy":"true","aria-label":"Loading...",children:qn(e,r,t.iteration_count,s)})},b0=Object.freeze(Object.defineProperty({__proto__:null,PageSkeleton:Na,default:Na},Symbol.toStringTag,{value:"Module"})),y0=(e,t)=>window.G7Core?.t?.(e,t)??e,ec=({options:e})=>{const t=document.documentElement.classList.contains("dark"),r=t?"rgb(17,24,39)":"rgb(249,250,251)",n=t?"#6b7280":"#9ca3af",o=t?"#9ca3af":"#6b7280";return c.jsxs("div",{style:{position:"absolute",inset:0,zIndex:2147483647,overflow:"hidden",background:r,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"flex-start",paddingTop:"15%",gap:"12px"},children:[c.jsx("div",{style:{width:"32px",height:"32px",border:`3px solid ${n}`,borderTopColor:"transparent",borderRadius:"50%",animation:"g7-spin 0.8s linear infinite"}}),c.jsx("span",{style:{color:o,fontSize:"14px"},children:e?.text||y0("nav.loading")})]})},v0=Object.freeze(Object.defineProperty({__proto__:null,default:ec},Symbol.toStringTag,{value:"Module"})),Dr=(e,t)=>window.G7Core?.t?.(e,t)??e,tc=({currentPage:e,totalPages:t,onPageChange:r,maxVisiblePages:n=5,showFirstLast:o=!0,className:a="",style:s,prevText:l,nextText:f,id:h,editorAttrs:d})=>{const i=k.useMemo(()=>{const m=[];if(t<=n+2)for(let w=1;w<=t;w++)m.push(w);else{const w=Math.floor(n/2);let x=Math.max(2,e-w),O=Math.min(t-1,e+w);e<=w+1?O=n:e>=t-w&&(x=t-n+1),m.push(1),x>2&&m.push("...");for(let y=x;y<=O;y++)m.push(y);O1&&m.push(t)}return m},[e,t,n]),u=m=>{m<1||m>t||m===e||r(m)},g=()=>{e>1&&r(e-1)},p=()=>{eu(1),disabled:e===1,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.first_page"),children:"«"}),c.jsx(Q,{onClick:g,disabled:e===1,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.prev_page"),children:l||c.jsx(c.Fragment,{children:"‹"})}),c.jsx(W,{className:"flex items-center gap-1",children:i.map((m,w)=>{if(m==="...")return c.jsx(ee,{className:"px-3 py-1 text-sm text-gray-500 dark:text-gray-400",children:"..."},`ellipsis-${w}`);const x=m,O=x===e;return c.jsx(Q,{onClick:()=>u(x),className:`px-3 py-1 text-sm border rounded ${O?"bg-blue-500 dark:bg-blue-600 text-white border-blue-500 dark:border-blue-600":"border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800"}`,"aria-label":Dr("common.page_n",{n:x}),"aria-current":O?"page":void 0,children:x},x)})}),c.jsx(Q,{onClick:p,disabled:e===t,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.next_page"),children:f||c.jsx(c.Fragment,{children:"›"})}),o&&c.jsx(Q,{onClick:()=>u(t),disabled:e===t,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.last_page"),children:"»"})]})},w0=Object.freeze(Object.defineProperty({__proto__:null,Pagination:tc},Symbol.toStringTag,{value:"Module"})),rc=(e,t)=>window.G7Core?.t?.(e,t)??e,nc=({name:e="search",placeholder:t,value:r,onChange:n,onSubmit:o,showButton:a=!1,suggestions:s=[],onSuggestionClick:l,showSuggestions:f=!1,className:h="",style:d,id:i,editorAttrs:u})=>{const g=t??rc("common.search_placeholder"),[p,m]=k.useState(""),[w,x]=k.useState(!1),O=k.useRef(null),y=k.useRef(""),E=r!==void 0?r:p,M=f&&w&&s.length>0&&E.length>0;k.useEffect(()=>{r!==void 0&&(y.current=r)},[r]);const _=v=>{const N=v.target.value;m(N),n?.(v),y.current!==""&&N===""&&!v.nativeEvent.inputType&&setTimeout(()=>{const A=O.current?.querySelector("form");A&&A.requestSubmit()},0),y.current=N},I=v=>{m(v.text);const N={target:{value:v.text,name:e}};n?.(N),l?.(v),x(!1)},C=v=>{v.preventDefault(),o?.(v)};return c.jsxs(W,{ref:O,className:`relative ${h}`,style:d,id:i,...u,children:[c.jsx(so,{onSubmit:C,className:"relative",children:c.jsxs(W,{className:`relative flex items-center ${a?"gap-2":""}`,children:[c.jsxs(W,{className:"relative flex-1",children:[c.jsx(W,{className:"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none",children:c.jsx(de,{name:Je.Search,className:"w-5 h-5 text-gray-400 dark:text-gray-500"})}),c.jsx(xt,{type:"search",name:e,value:E,onChange:_,onFocus:()=>x(!0),onBlur:()=>setTimeout(()=>x(!1),200),placeholder:g,className:"w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"})]}),a&&c.jsx(Q,{type:"submit",className:"px-4 py-2 bg-gray-800 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-500 transition-colors text-sm font-medium cursor-pointer h-[42px]",children:rc("common.search")})]})}),M&&c.jsx(W,{className:"absolute z-10 w-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto",children:s.map(v=>c.jsx(W,{className:"px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 transition-colors",onClick:()=>I(v),children:c.jsxs(W,{className:"flex items-center gap-2",children:[c.jsx(de,{name:Je.Search,className:"w-4 h-4 text-gray-400 dark:text-gray-500"}),c.jsx(ee,{className:"text-sm text-gray-700 dark:text-gray-300",children:v.text})]})},v.id))})]})},x0=Object.freeze(Object.defineProperty({__proto__:null,SearchBar:nc},Symbol.toStringTag,{value:"Module"})),vt=window.G7Core,k0={sm:"md",md:"lg",lg:"xl",xl:"2xl","2xl":"3xl"};function Aa({src:e,fallbackText:t="U",size:r="lg",uploadEndpoint:n="/api/me/avatar",deleteEndpoint:o="/api/me/avatar",onUploadSuccess:a,onUploadError:s,onDeleteSuccess:l,onDeleteError:f,showDeleteButton:h=!0,accept:d="image/*",maxSize:i=5,className:u="",uploadButtonText:g,deleteButtonText:p,readOnly:m=!1,confirmDelete:w=!0,deleteConfirmMessage:x,confirmUpload:O=!0,uploadConfirmMessage:y,uploadSuccessActions:E,uploadErrorActions:M,deleteSuccessActions:_,deleteErrorActions:I,id:C,editorAttrs:v}){const N=k.useRef(null),[A,P]=k.useState(!1),[D,L]=k.useState(!1),[U,S]=k.useState(void 0),[b,$]=k.useState(null),[F,G]=k.useState(!1),[Y,B]=k.useState(!1),[V,j]=k.useState(null),[H,q]=k.useState(null),ue=k.useRef(`avatar-upload-${Math.random().toString(36).slice(2,11)}`).current;k.useEffect(()=>{if(V){const oe=URL.createObjectURL(V);return q(oe),()=>URL.revokeObjectURL(oe)}else q(null)},[V]);const te=k.useCallback(oe=>vt?.t?.(oe)??oe,[]),fe=k.useCallback(()=>{const oe=document.cookie.match(/XSRF-TOKEN=([^;]+)/);return oe?decodeURIComponent(oe[1]):null},[]),R=k.useCallback(()=>vt?.api?.getToken?.()??null,[]),ke=k.useCallback(async oe=>{B(!1),j(null),P(!0),$(null);try{const ae=new FormData;ae.append("avatar",oe);const be=fe(),xe=R(),He=await fetch(n,{method:"POST",headers:{Accept:"application/json",...be&&{"X-XSRF-TOKEN":be},...xe&&{Authorization:`Bearer ${xe}`}},credentials:"include",body:ae});if(!He.ok){const Oe=await He.json().catch(()=>({}));throw new Error(Oe.message||te("attachment.upload_failed"))}const Pe=await He.json(),Ke=Pe.data||Pe;Ke.avatar&&S(Ke.avatar),a?.(Ke),vt?.toast?.success?.(te("mypage.profile.avatar_uploaded")),E?.length?E.forEach(Oe=>vt?.dispatch?.(Oe)):vt?.refetchDataSource?.("user")}catch(ae){const be=ae instanceof Error?ae:new Error(String(ae));$(be.message),s?.(be),vt?.toast?.error?.(be.message),M?.length&&M.forEach(xe=>vt?.dispatch?.(xe))}finally{P(!1),N.current&&(N.current.value="")}},[n,a,s,fe,R,te]),me=k.useCallback(async oe=>{const ae=oe.target.files?.[0];if(!ae)return;$(null);const be=i*1024*1024;if(ae.size>be){const xe=new Error(te("attachment.file_too_large").replace(":max",`${i}MB`));$(xe.message),s?.(xe),vt?.toast?.error?.(xe.message);return}if(d&&!ae.type.match(d.replace("*",".*"))){const xe=new Error(te("attachment.invalid_file_type"));$(xe.message),s?.(xe),vt?.toast?.error?.(xe.message);return}O?(j(ae),B(!0)):ke(ae)},[i,d,s,O,ke,te]),Ge=k.useCallback(async()=>{G(!1),L(!0),$(null);try{const oe=fe(),ae=R(),be=await fetch(o,{method:"DELETE",headers:{Accept:"application/json","Content-Type":"application/json",...oe&&{"X-XSRF-TOKEN":oe},...ae&&{Authorization:`Bearer ${ae}`}},credentials:"include"});if(!be.ok){const xe=await be.json().catch(()=>({}));throw new Error(xe.message||te("attachment.delete_failed"))}S(null),l?.(),vt?.toast?.success?.(te("mypage.profile.avatar_deleted")),_?.length?_.forEach(xe=>vt?.dispatch?.(xe)):vt?.refetchDataSource?.("user")}catch(oe){const ae=oe instanceof Error?oe:new Error(String(oe));$(ae.message),f?.(ae),vt?.toast?.error?.(ae.message),I?.length&&I.forEach(be=>vt?.dispatch?.(be))}finally{L(!1)}},[o,l,f,fe,R,te]),De=k.useCallback(()=>{w?G(!0):Ge()},[w,Ge]),ye=k.useCallback(()=>{G(!1)},[]),Me=k.useCallback(()=>{B(!1),j(null),N.current&&(N.current.value="")},[]),Ce=k.useCallback(()=>{V&&ke(V)},[V,ke]),we=U===void 0?e:U,$e=!!we,Le=k0[r]||"lg",K=F||Y,re=()=>c.jsxs(W,{className:"flex items-center justify-center gap-2",children:[c.jsx(xt,{ref:N,id:ue,type:"file",accept:d,className:"hidden",onChange:me,disabled:A}),!$e&&c.jsxs(yr,{htmlFor:ue,className:`inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${A?"opacity-50 cursor-not-allowed":""}`,children:[A?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"image",size:"sm"}),c.jsx(ee,{children:g||te("mypage.profile.register_avatar")})]}),$e&&c.jsxs(c.Fragment,{children:[c.jsxs(yr,{htmlFor:ue,className:`inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${A?"opacity-50 cursor-not-allowed":""}`,children:[A?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"upload",size:"sm"}),c.jsx(ee,{children:g||te("common.change")})]}),h&&c.jsxs(Q,{type:"button",className:"inline-flex items-center gap-2 px-4 py-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg text-sm font-medium hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",onClick:De,disabled:D,children:[D?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"trash-2",size:"sm"}),c.jsx(ee,{children:p||te("common.delete")})]})]})]});return c.jsxs(W,{className:`flex flex-col items-center gap-3 ${u}`,id:C,...v,children:[Y&&H?c.jsxs(W,{className:"relative",children:[c.jsx(Lt,{src:H,alt:"Preview",className:`rounded-full object-cover ${r==="sm"?"w-10 h-10":r==="md"?"w-12 h-12":r==="lg"?"w-16 h-16":r==="xl"?"w-24 h-24":"w-32 h-32"}`}),c.jsx(W,{className:"absolute inset-0 rounded-full bg-blue-500/20 border-2 border-blue-500 border-dashed"})]}):c.jsx(Ur,{avatar:we||void 0,name:t,size:Le}),!m&&c.jsxs(W,{className:"w-[220px] flex flex-col items-center gap-2",children:[Y&&V&&c.jsxs(W,{className:"w-full p-3 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg",children:[c.jsxs(W,{className:"flex items-center gap-2 mb-2",children:[c.jsx(de,{name:"upload",size:"sm",className:"text-blue-600 dark:text-blue-400 shrink-0"}),c.jsx(ee,{className:"text-sm font-medium text-blue-800 dark:text-blue-300",children:y||te("mypage.profile.upload_avatar_confirm")})]}),c.jsxs(W,{className:"text-xs text-blue-600 dark:text-blue-400 truncate mb-3",title:V.name,children:[V.name," (",(V.size/1024/1024).toFixed(2),"MB)"]}),c.jsxs(W,{className:"grid grid-cols-2 gap-2",children:[c.jsxs(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium transition-colors disabled:opacity-50",onClick:Ce,disabled:A,children:[A?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"check",size:"sm"}),c.jsx(ee,{children:te("common.confirm")})]}),c.jsx(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",onClick:Me,children:c.jsx(ee,{children:te("common.cancel")})})]})]}),F&&c.jsxs(W,{className:"w-full p-3 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-lg",children:[c.jsxs(W,{className:"flex items-center gap-2 mb-2",children:[c.jsx(de,{name:"alert-triangle",size:"sm",className:"text-red-600 dark:text-red-400 shrink-0"}),c.jsx(ee,{className:"text-sm font-medium text-red-800 dark:text-red-300",children:x||te("mypage.profile.delete_avatar_confirm")})]}),c.jsxs(W,{className:"grid grid-cols-2 gap-2",children:[c.jsxs(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm font-medium transition-colors disabled:opacity-50",onClick:Ge,disabled:D,children:[D?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"trash-2",size:"sm"}),c.jsx(ee,{children:te("common.delete")})]}),c.jsx(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",onClick:ye,children:c.jsx(ee,{children:te("common.cancel")})})]})]}),!K&&re()]}),b&&!K&&c.jsxs(W,{className:"flex items-center gap-2 text-sm text-red-600 dark:text-red-400",children:[c.jsx(de,{name:"alert-circle",size:"sm"}),c.jsx(ee,{children:b})]})]})}const C0=Object.freeze(Object.defineProperty({__proto__:null,AvatarUploader:Aa,default:Aa},Symbol.toStringTag,{value:"Module"})),oc=()=>window.G7Core,Zn=(e,t)=>window.G7Core?.t?.(e,t)??e,ac={horizontal:"flex items-center gap-1.5",vertical:"flex flex-col items-start"},S0=()=>[{key:"view_profile",label:Zn("userinfo.view_profile"),icon:"user",path:"/users/{{userId}}"},{key:"view_posts",label:Zn("userinfo.view_posts"),icon:"file-lines",path:"/users/{{userId}}/posts"}],Ma=({text:e,title:t})=>e?c.jsx(ee,{className:"text-sm text-gray-500 dark:text-gray-400",title:t,children:e}):null,sc=({author:e,name:t,userId:r,subText:n,subTextTitle:o,isGuest:a=!1,isWithdrawn:s=!1,showDropdown:l=!0,clickable:f=!0,profilePath:h="/users/{userId}",className:d="",layout:i="vertical",text:u,stopPropagation:g=!1,menuItems:p,hideMenuItems:m=[],appendMenuItems:w=[],id:x,editorAttrs:O})=>{const y=r??e?.uuid,E=a||e?.is_guest||!1,M=s||e?.status==="withdrawn",_=M?Zn("userinfo.withdrawn_user"):u??t??e?.name??"",[I,C]=k.useState(!1),[v,N]=k.useState({top:0,left:0}),A=k.useRef(null),P=k.useRef(null),D=k.useRef(null),L=h.replace("{userId}",String(y??"")),U=`${ac[i]} ${d}`;k.useEffect(()=>{if(!I)return;const j=q=>{const ue=q.target,te=A.current?.contains(ue),fe=P.current?.contains(ue);!te&&!fe&&C(!1)},H=()=>C(!1);return document.addEventListener("mousedown",j),window.addEventListener("scroll",H,!0),window.addEventListener("resize",H),()=>{document.removeEventListener("mousedown",j),window.removeEventListener("scroll",H,!0),window.removeEventListener("resize",H)}},[I]);const S=k.useCallback(()=>{if(p&&p.length>0)return p.filter(q=>q.show!==!1);let H=S0().filter(q=>!m.includes(q.key)&&q.show!==!1);return w&&w.length>0&&(H=[...H,...w.filter(q=>q.show!==!1)]),H},[p,m,w]),b=k.useCallback(()=>{if(D.current){const j=D.current.getBoundingClientRect();N({top:j.bottom+4,left:j.left})}},[]),$=k.useCallback((j,H)=>{if(H.stopPropagation(),H.preventDefault(),C(!1),j.onClick)j.onClick();else if(j.path){const q=j.path.replace(/\{\{userId\}\}/g,String(y??"")),ue=oc();ue?.navigate?ue.navigate(q):window.location.href=q}},[y]),F=k.useCallback(j=>{j.preventDefault(),g&&j.stopPropagation(),!(E||!y)&&(l?(I||b(),C(H=>!H)):f&&oc()?.navigate?.(L))},[g,E,y,l,I,b,f,L]);if(E||!y)return c.jsxs(W,{className:U,id:x,...O,children:[c.jsxs(W,{className:"flex items-center gap-1.5 text-gray-400 dark:text-gray-500",children:[c.jsx(ee,{children:_}),c.jsx(ee,{className:"text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded",children:Zn("userinfo.guest_badge")})]}),c.jsx(Ma,{text:n,title:o})]});if(M)return c.jsxs(W,{className:U,id:x,...O,children:[c.jsx(ee,{className:"text-gray-400 dark:text-gray-500 line-through",children:_}),c.jsx(Ma,{text:n,title:o})]});const G=S(),B=/\btext-\S+/.test(d)?"":"text-gray-900 dark:text-white",V=l||f?`${B} hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer font-medium`.trim():`${B} font-medium`.trim();return c.jsxs(W,{ref:A,className:`relative inline-block ${d}`,id:x,...O,children:[c.jsxs(W,{className:ac[i],children:[c.jsx(Q,{ref:D,onClick:F,className:V,children:_}),c.jsx(Ma,{text:n,title:o})]}),l&&I&&G.length>0&&Jt.createPortal(c.jsx(W,{ref:P,className:"fixed w-40 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 z-[9999]",style:{top:v.top,left:v.left},"data-testid":"author-dropdown-menu",children:G.map(j=>c.jsxs(Q,{onClick:H=>$(j,H),className:"flex items-center justify-start gap-2 w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer","data-testid":`menu-item-${j.key}`,children:[j.icon&&c.jsx(de,{name:j.icon,className:"w-4 h-4"}),c.jsx(ee,{children:j.label})]},j.key))}),document.body)]})},_0=Object.freeze(Object.defineProperty({__proto__:null,UserInfo:sc},Symbol.toStringTag,{value:"Module"})),lc=({tabs:e,activeTabId:t,onTabChange:r,variant:n="underline",className:o="",style:a,hiddenTabIds:s=[],mobileBreakpoint:l=768,id:f,editorAttrs:h})=>{const i=window.G7Core?.useResponsive,u=i?.(),g=u?u.width{!y.disabled&&y.id!==t&&r?.(y.id)},m=y=>{const E=y.target.value,M=Number(E),_=typeof E=="string"&&E!==""&&!Number.isNaN(M)?M:E,I=e.find(C=>String(C.id)===String(_));I&&p(I)},w=s.length>0?e.filter(y=>!s.includes(y.id)):e;if(g)return c.jsx(W,{className:`relative ${o}`,style:a,id:f,...h,children:c.jsx(Xa,{value:t!==void 0?String(t):"",onChange:m,options:w.map(y=>({value:String(y.id),label:y.badge!==void 0?`${y.label} (${y.badge})`:y.label,disabled:y.disabled})),className:"w-full"})});const x=y=>{const E=y.id===t,M="flex items-center gap-2 px-3 py-2 font-medium text-sm transition-all shrink-0 whitespace-nowrap";if(y.disabled)return`${M} opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600`;switch(n){case"pills":return E?`${M} bg-blue-600 dark:bg-blue-500 text-white rounded-lg`:`${M} text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg`;case"underline":return E?`${M} text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white`:`${M} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border-b-2 border-transparent hover:border-gray-300 dark:hover:border-gray-600`;default:return E?`${M} text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 border-b-2 border-blue-600 dark:border-blue-400`:`${M} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 border-b-2 border-transparent`}},O=n==="underline"?"flex gap-0 border-b border-gray-200 dark:border-gray-700":"flex gap-2";return c.jsx(W,{className:`relative ${o}`,style:a,id:f,...h,children:c.jsx($r,{className:O,children:w.map(y=>c.jsxs(Q,{type:"button",onClick:()=>p(y),disabled:y.disabled,className:x(y),children:[y.iconName&&c.jsx(de,{name:y.iconName,size:"sm"}),c.jsx(ee,{children:y.label}),y.badge!==void 0&&c.jsx(W,{className:"flex items-center justify-center min-w-5 h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full",children:c.jsx(ee,{children:y.badge})})]},y.id))})})},E0=Object.freeze(Object.defineProperty({__proto__:null,TabNavigation:lc},Symbol.toStringTag,{value:"Module"})),T0={Header:()=>Promise.resolve().then(()=>od),Footer:()=>Promise.resolve().then(()=>id),MobileNav:()=>Promise.resolve().then(()=>cd),SlotContainer:()=>Promise.resolve().then(()=>rd),NotificationCenter:()=>Promise.resolve().then(()=>nd),ProductCard:()=>Promise.resolve().then(()=>Ld),BrandMark:()=>Promise.resolve().then(()=>Fd),ImageGallery:()=>Promise.resolve().then(()=>Al),ProductImageViewer:()=>Promise.resolve().then(()=>jf),QuantitySelector:()=>Promise.resolve().then(()=>Bf),PostReactions:()=>Promise.resolve().then(()=>Vf),RichTextEditor:()=>Promise.resolve().then(()=>Yf),HtmlContent:()=>Promise.resolve().then(()=>Xf),HtmlEditor:()=>Promise.resolve().then(()=>qf),ExpandableContent:()=>Promise.resolve().then(()=>Zf),FileUploader:()=>Promise.resolve().then(()=>Xh),ConfirmDialog:()=>Promise.resolve().then(()=>Rf),SocialLoginButtons:()=>Promise.resolve().then(()=>Zh),Toast:()=>Promise.resolve().then(()=>t0),PageTransitionIndicator:()=>Promise.resolve().then(()=>n0),PageTransitionBlur:()=>Promise.resolve().then(()=>o0),PageSkeleton:()=>Promise.resolve().then(()=>b0),PageLoading:()=>Promise.resolve().then(()=>v0),ThemeToggle:()=>Promise.resolve().then(()=>Qc),Pagination:()=>Promise.resolve().then(()=>w0),SearchBar:()=>Promise.resolve().then(()=>x0),Avatar:()=>Promise.resolve().then(()=>ed),AvatarUploader:()=>Promise.resolve().then(()=>C0),UserInfo:()=>Promise.resolve().then(()=>_0),Modal:()=>Promise.resolve().then(()=>Jf),TabNavigation:()=>Promise.resolve().then(()=>E0)},N0=({id:e,className:t,style:r,children:n,editorAttrs:o})=>c.jsx(W,{id:e,className:t,style:r,...o,children:n}),A0=({id:e,children:t,cols:r=1,responsive:n,gap:o,rowGap:a,colGap:s,autoRows:l,autoCols:f,flow:h="row",className:d="",style:i,onClick:u,editorAttrs:g})=>{const p=["grid"];if(r&&p.push(`grid-cols-${r}`),n&&(n.sm&&p.push(`sm:grid-cols-${n.sm}`),n.md&&p.push(`md:grid-cols-${n.md}`),n.lg&&p.push(`lg:grid-cols-${n.lg}`),n.xl&&p.push(`xl:grid-cols-${n.xl}`),n["2xl"]&&p.push(`2xl:grid-cols-${n["2xl"]}`)),o!==void 0&&o>0&&p.push(`gap-${o}`),a!==void 0&&a>0&&p.push(`gap-y-${a}`),s!==void 0&&s>0&&p.push(`gap-x-${s}`),l){const w={auto:"auto-rows-auto",min:"auto-rows-min",max:"auto-rows-max",fr:"auto-rows-fr"};p.push(w[l])}if(f){const w={auto:"auto-cols-auto",min:"auto-cols-min",max:"auto-cols-max",fr:"auto-cols-fr"};p.push(w[f])}const m={row:"grid-flow-row",col:"grid-flow-col",dense:"grid-flow-dense","row-dense":"grid-flow-row-dense","col-dense":"grid-flow-col-dense"};return p.push(m[h]),d&&p.push(d),c.jsx(W,{id:e,className:p.join(" "),style:i,onClick:u,...g,children:t})},M0=({id:e,children:t,direction:r="row",justify:n="start",align:o="stretch",wrap:a="nowrap",gap:s=0,grow:l=!1,shrink:f=!0,className:h="",style:d,onClick:i,editorAttrs:u})=>{const g=["flex"],p={row:"flex-row","row-reverse":"flex-row-reverse",col:"flex-col","col-reverse":"flex-col-reverse"};g.push(p[r]);const m={wrap:"flex-wrap",nowrap:"flex-nowrap","wrap-reverse":"flex-wrap-reverse"};g.push(m[a]);const w={start:"justify-start",end:"justify-end",center:"justify-center",between:"justify-between",around:"justify-around",evenly:"justify-evenly"};g.push(w[n]);const x={start:"items-start",end:"items-end",center:"items-center",baseline:"items-baseline",stretch:"items-stretch"};return g.push(x[o]),s>0&&g.push(`gap-${s}`),l===!0?g.push("flex-grow"):typeof l=="number"&&l>0&&g.push(`flex-grow-${l}`),f===!1?g.push("shrink-0"):f===!0?g.push("flex-shrink"):typeof f=="number"&&f>0&&g.push(`flex-shrink-${f}`),h&&g.push(h),c.jsx(W,{id:e,className:g.join(" "),style:d,onClick:i,...u,children:t})},O0=({id:e,title:t,subtitle:r,children:n,padding:o="md",background:a="none",maxWidth:s,centered:l=!1,border:f=!1,shadow:h="none",rounded:d=!1,className:i="",style:u,onClick:g,editorAttrs:p})=>{const m=[],w={none:"p-0",sm:"p-2",md:"p-4",lg:"p-6",xl:"p-8"};m.push(w[o]);const x={none:"",white:"bg-white dark:bg-gray-700",gray:"bg-gray-50 dark:bg-gray-800",primary:"bg-blue-50 dark:bg-blue-900/20",secondary:"bg-gray-50 dark:bg-gray-800"};if(a!=="none"&&m.push(x[a]),s){const y={sm:"max-w-sm",md:"max-w-md",lg:"max-w-lg",xl:"max-w-xl","2xl":"max-w-2xl","4xl":"max-w-4xl","6xl":"max-w-6xl","7xl":"max-w-7xl",full:"max-w-full"};m.push(y[s])}l&&m.push("mx-auto"),f&&m.push("border","border-gray-200","dark:border-gray-700");const O={none:"",sm:"shadow-sm",md:"shadow-md",lg:"shadow-lg",xl:"shadow-xl"};return h!=="none"&&m.push(O[h]),d&&m.push("rounded-lg"),i&&m.push(i),c.jsxs(Za,{id:e,className:m.join(" "),style:u,onClick:g,...p,children:[(t||r)&&c.jsxs(W,{className:"mb-4",children:[t&&c.jsx(Va,{className:"text-2xl font-bold text-gray-900 dark:text-white",children:t}),r&&c.jsx(Dt,{className:"mt-1 text-sm text-gray-600 dark:text-gray-400",children:r})]}),n]})},I0=({id:e,leftWidth:t="250px",rightWidth:r="300px",leftSlot:n,centerSlot:o,rightSlot:a,className:s="",style:l,editorAttrs:f})=>{const h=`flex flex-row w-full h-full ${s}`.trim(),d={width:t,flexShrink:0},i={flex:1,minWidth:0},u={width:r,flexShrink:0};return c.jsxs(W,{id:e,className:h,style:l,...f,children:[c.jsx(W,{className:"flex flex-col",style:d,children:n}),c.jsx(W,{className:"flex flex-col",style:i,children:o}),c.jsx(W,{className:"flex flex-col",style:u,children:a})]})},D0={identifier:"sirsoft-basic",vendor:"sirsoft",name:{ko:"Basic",en:"Basic"},version:"1.0.4",license:"MIT",description:{ko:"그누보드7 기본 사용자 템플릿",en:"Gnuboard7 default user template"},type:"user",locales:["ko","en"],author:{name:"sirsoft",email:"contact@sirsoft.com",url:"https://sirsoft.com"},release_date:"2026-01-07",g7_version:">=7.0.0",dependencies:{modules:{"sirsoft-board":">=1.0.0","sirsoft-ecommerce":">=1.0.5","sirsoft-page":">=1.0.0"},plugins:{"sirsoft-daum_postcode":">=1.0.0"}},assets:{css:["dist/css/components.css"],js:["dist/js/components.iife.js"],fonts:[],images:[]},components:{basic:["a","button","checkbox","div","form","h1","h2","h3","h4","hr","icon","img","input","label","li","nav","p","section","select","span","svg","table","tbody","td","textarea","th","thead","tr","ul"],composite:["header","footer","mobile-nav","product-card","rich-text-editor","image-gallery","quantity-selector","post-reactions","social-login-buttons","file-uploader","card","modal","toast","pagination","stat-card","status-badge","tab-navigation","empty-state","loading-spinner","breadcrumb","search-bar","dropdown"],layout:["container","flex","grid","section"]},preview:{thumbnail:"preview/thumbnail.png",screenshots:["preview/screenshot1.png","preview/screenshot2.png"]},error_config:{layouts:{401:"errors/401",403:"errors/403",404:"errors/404",500:"errors/500",503:"errors/503",maintenance:"errors/maintenance"}},features:{dark_mode:!0,responsive:!0,multi_language:!0,multi_currency:!0},github_url:"https://github.com/gnuboard/g7-template-sirsoft-basic",github_changelog_url:"https://github.com/gnuboard/g7-template-sirsoft-basic/blob/main/CHANGELOG.md",externals:[{id:"fontawesome",type:"style",url:"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",preconnect:"https://cdnjs.cloudflare.com"},{id:"pretendard",type:"webfont",url:"https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css",preconnect:"https://cdn.jsdelivr.net",crossorigin:"anonymous"}]},ic={KRW:{locale:"ko-KR",decimals:0},USD:{locale:"en-US",decimals:2},JPY:{locale:"ja-JP",decimals:0},CNY:{locale:"zh-CN",decimals:2},EUR:{locale:"de-DE",decimals:2}};function Lr(e,t="KRW"){if(!Number.isFinite(e))return"0";const r=ic[t]||ic.KRW;try{return new Intl.NumberFormat(r.locale,{style:"currency",currency:t,minimumFractionDigits:r.decimals,maximumFractionDigits:r.decimals}).format(e)}catch{return e.toLocaleString()+(t==="KRW"?"원":"")}}function Qn(e,t,r=0){if(!e)return;const n=e.KRW?.value??e.KRW?.price??0,o={};for(const[a,s]of Object.entries(e)){const l=s?.value??s?.price??0;let f=0;r>0&&(a==="KRW"?f=r:n>0&&(f=r*l/n));const h=(l+f)*t;o[a]={value:h,formatted:Lr(h,a)}}return o}function Jn(e){const t={};for(const n of e){const o=n?.multiCurrencyTotalPrice;if(o)for(const[a,s]of Object.entries(o)){const l=s?.value??s?.price??0;t[a]=(t[a]??0)+l}}const r={};for(const[n,o]of Object.entries(t))r[n]={value:o,formatted:Lr(o,n)};return r}function cc(e,t){if(!e||!t?.length)return 0;let r=0;for(const n of t){const o=e[n.id];if(o==null)continue;const a=n.values?.find(s=>s.id===Number(o));a&&(r+=a.price_adjustment??0)}return r}function Rn(e){return e.name_localized?e.name_localized:typeof e.name=="string"?e.name:e.name?.ko??""}function L0(e,t,r){if(t&&r in t)return t[r];if(Array.isArray(e)){const n=e.find(o=>typeof o.key=="string"?o.key===r:o.key?.ko===r||Object.values(o.key).includes(r));return n?typeof n.value=="string"?n.value:n.value?.ko??Object.values(n.value)[0]:void 0}return e[r]}function P0(e,t){if(t)return t;if(!Array.isArray(e))return e;const r={};for(const n of e){const o=typeof n.key=="string"?n.key:n.key?.ko??Object.values(n.key)[0]??"",a=typeof n.value=="string"?n.value:n.value?.ko??Object.values(n.value)[0]??"";o&&(r[o]=a)}return r}function F0(e,t,r){return r.find(n=>!n?.option_values||!n?.is_active?!1:e.every(o=>{const a=Rn(o),s=t?.[a];return L0(n.option_values,n.option_values_localized,a)===s}))}function $0(e,t){const r=e.params;if(!r)return;const{optionGroups:n,options:o,selectedOptionItems:a,preferredCurrency:s}=r;let l;if(r.newGroupName&&r.newValue?l={...r.currentSelection??{},[r.newGroupName]:r.newValue}:l=r.currentSelection??{},!n?.every(M=>{const _=Rn(M);return l?.[_]}))return;const h=F0(n,l,o??[]);if(!h){t.setState?.({currentSelection:{},__mergeMode:"shallow"});return}const d=n.map(M=>l[Rn(M)]).join("_"),i=a??[];if(i.find(M=>M.id===d)){const M=window.G7Core;M?.toast?.warning?.(M?.t?.("sirsoft-ecommerce.shop.already_added_option")??"이미 추가된 옵션입니다."),t.setState?.({currentSelection:{},__mergeMode:"shallow"});return}const g=h.selling_price??0,p={};n.forEach(M=>{const _=Rn(M);p[_]=l[_]});const m=r.additionalOptionGroups??[],w={};for(const M of m){if(!M.is_required)continue;const _=M.values?.find(I=>I.is_default);_&&(w[M.id]=_.id)}const x=cc(w,m),O=g+x,y={id:d,optionId:h.id,options:p,optionValues:P0(h.option_values,h.option_values_localized),quantity:1,stock:h.stock_quantity??999,unitPrice:g,unitPriceFormatted:Lr(g,s),totalPrice:O,totalPriceFormatted:Lr(O,s),multiCurrencyUnitPrice:h.multi_currency_selling_price,multiCurrencyTotalPrice:Qn(h.multi_currency_selling_price,1,x),additionalOptionSelections:w,additionalOptionsTotal:x},E=[...i,y];t.setState?.({selectedOptionItems:E,selectedTotalMultiCurrency:Jn(E),currentSelection:{},__mergeMode:"shallow"})}function U0(e,t){const r=e.params;if(!r)return;const{selectedOptionItems:n,preferredCurrency:o}=r,a=Number(r.itemIndex)||0,s=Number(r.newQuantity)||1,l=(n??[]).map((f,h)=>{if(h!==a)return f;const d=f?.unitPrice??0,i=f?.additionalOptionsTotal??0,u=(d+i)*s;return{...f,quantity:s,totalPrice:u,totalPriceFormatted:Lr(u,o),multiCurrencyTotalPrice:Qn(f?.multiCurrencyUnitPrice,s,i)}});t.setState?.({selectedOptionItems:l,selectedTotalMultiCurrency:Jn(l)})}function z0(e,t){const r=e.params;if(!r)return;const{selectedOptionItems:n,additionalOptionGroups:o,preferredCurrency:a}=r,s=Number(r.itemIndex)||0,l=Number(r.additionalOptionId),f=Object.prototype.hasOwnProperty.call(r,"customText"),h=r.valueId===""||r.valueId==null?null:Number(r.valueId),d=(n??[]).map((i,u)=>{if(u!==s)return i;const g={...i.additionalOptionSelections??{}},p={...i.additionalOptionCustomTexts??{}};if(f){const y=String(r.customText??"");y.trim()===""?delete p[l]:p[l]=y}else h==null?(delete g[l],delete p[l]):(g[l]=h,(o??[]).find(M=>M.id===l)?.values?.find(M=>M.id===h)?.allow_custom_text||delete p[l]);const m=cc(g,o),w=i?.unitPrice??0,x=i?.quantity??1,O=(w+m)*x;return{...i,additionalOptionSelections:g,additionalOptionCustomTexts:p,additionalOptionsTotal:m,totalPrice:O,totalPriceFormatted:Lr(O,a),multiCurrencyTotalPrice:Qn(i?.multiCurrencyUnitPrice,x,m)}});t.setState?.({selectedOptionItems:d,selectedTotalMultiCurrency:Jn(d)})}function H0(e,t){const r=e.params;if(!r)return;const n=Number(r.itemIndex)||0,o=(r.selectedOptionItems??[]).filter((a,s)=>s!==n);t.setState?.({selectedOptionItems:o,selectedTotalMultiCurrency:Jn(o)})}function G0(e,t){const r=e.params;if(!r)return;const n=Math.max(1,Number(r.newQuantity)||1);t.setState?.({noOptionQuantity:n,noOptionTotalMultiCurrency:Qn(r.multiCurrencyUnitPrice,n)})}const cn=window.G7Core?.createLogger?.("Handler:Cart")??{log:(...e)=>console.log("[Handler:Cart]",...e),warn:(...e)=>console.warn("[Handler:Cart]",...e),error:(...e)=>console.error("[Handler:Cart]",...e)};function hr(e){const t=window.G7Core;t?.state?.setLocal?(t.state.setLocal(e),cn.log("Local state updated:",e)):cn.warn("G7Core.state.setLocal not available")}function dc(){return window.G7Core?.state?.getLocal?.()||{}}function j0(){return window.G7Core?.state?.get?.()||{}}function B0(e,t="info"){const r=window.G7Core;r?.toast?.show?r.toast.show(e,t):cn.warn("G7Core.toast.show not available, message:",e)}function W0(e){const t=window.G7Core;return t?.t?t.t(e):e}function uc(){const e=window.G7Core;if(e?.dispatch){const r=dc().selectedItems||[];e.dispatch({handler:"refetchDataSource",params:{dataSourceId:"cartItems"}}),cn.log("Refetching cartItems with selected_ids:",r)}else cn.warn("G7Core.dispatch not available")}function K0(e,t){const{itemId:r,selectedItems:n}=e?.params||{},o=n||[];let a;o.includes(r)?a=o.filter(h=>h!==r):a=[...o,r];const l=j0().cartItems?.data?.items||[],f=l.length>0&&a.length===l.length;hr({selectedItems:a,allSelected:f,selectedCount:a.length}),uc()}function V0(e,t){const{allItemIds:r,isSelected:n}=e?.params||{};hr({selectedItems:n?r:[],allSelected:n,selectedCount:n?r.length:0}),uc()}function Y0(e,t){const{optionName:r,optionValue:n}=e?.params||{},a=dc().selectedOptions||{};hr({selectedOptions:{...a,[r]:n}})}function X0(e,t){const{targetIds:r}=e?.params||{};if(!r||r.length===0){B0(W0("shop.cart.select_items_to_delete"),"warning");return}hr({deleteModal:{visible:!0,targetIds:r}})}function q0(e,t){const{targetItem:r}=e?.params||{};hr({optionModal:{visible:!0,targetItem:r},selectedOptions:r?.options||{},selectedQuantity:r?.quantity})}function Z0(e,t){const{selectedItems:r}=e?.params||{};if(!r||r.length===0){hr({isCalculating:!1});return}hr({isCalculating:!0})}const pr=window.G7Core?.createLogger?.("Handler:CartOptionChange")??{log:(...e)=>console.log("[Handler:CartOptionChange]",...e),warn:(...e)=>console.warn("[Handler:CartOptionChange]",...e),error:(...e)=>console.error("[Handler:CartOptionChange]",...e)};function Oa(e){return e.name_localized?e.name_localized:typeof e.name=="string"?e.name:e.name?.ko??""}function Q0(e,t,r){if(t&&r in t)return t[r];if(Array.isArray(e)){const n=e.find(o=>typeof o.key=="string"?o.key===r:o.key?.ko===r||Object.values(o.key).includes(r));return n?typeof n.value=="string"?n.value:n.value?.ko??Object.values(n.value)[0]:void 0}return e[r]}function fc(e,t,r){return!e||!t?.length?null:t.every(a=>{const s=Oa(a);return e[s]})?r?.find(a=>t.every(s=>{const l=Oa(s);return Q0(a.option_values,a.option_values_localized,l)===e[l]}))||null:(pr.log("Not all options selected yet"),null)}function J0(e,t){const{selectedOptions:r,optionGroups:n,options:o}=e?.params||{};pr.log("findMatchingOption called:",{selectedOptions:r,optionGroups:n,optionsCount:o?.length});const a=fc(r,n,o);return pr.log("Matched option:",a),{matchedOption:a}}function R0(e,t){const{currentOptionValues:r,optionGroups:n,options:o}=e?.params||{};if(pr.log("initCartOptionSelection called:",{currentOptionValues:r,optionGroups:n,optionsCount:o?.length}),!n?.length||!r)return pr.log("No option groups or current values, returning empty"),{selectedOptions:{},matchedOption:null};const a={};for(const l of n){const f=Oa(l),h=r[f],d=l.values_localized??(Array.isArray(l.values)?l.values.map(i=>typeof i=="string"?i:i?.ko??""):[]);h&&d.includes(h)&&(a[f]=h)}pr.log("Initial selectedOptions:",a);const s=fc(a,n,o||[]);return pr.log("Matched option:",s),{selectedOptions:a,matchedOption:s}}const dn="g7_cart_key",ep="/api/modules/sirsoft-ecommerce/cart/key",Ia="g7_guest_order_token",gc="g7_guest_order_number",Da="g7_guest_order_expires_at",at=window.G7Core?.createLogger?.("Handler:Storage")??{log:(...e)=>console.log("[Handler:Storage]",...e),warn:(...e)=>console.warn("[Handler:Storage]",...e),error:(...e)=>console.error("[Handler:Storage]",...e)};async function hc(){try{const e=await fetch(ep,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"}});return e.ok?(await e.json()).data?.cart_key||null:(at.error("Failed to issue cartKey from API:",e.status),null)}catch(e){return at.error("Error issuing cartKey from API:",e),null}}function rr(e){const t=window.G7Core;t?.state?.set?(t.state.set(e),at.log("Global state updated:",e)):at.warn("G7Core.state.set not available")}function tp(){try{const e=window.G7Config;return e?.user?e.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}async function rp(e,t){let r=null;try{r=localStorage.getItem(dn)}catch{}if(r)at.log("Existing cartKey loaded:",r);else if(r=await hc(),r)try{localStorage.setItem(dn,r),at.log("New cartKey issued from API:",r)}catch{}else at.error("Failed to issue cartKey from API");rr({cartKey:r})}function np(e,t){try{return localStorage.getItem(dn)}catch{return null}}function pc(e,t){try{localStorage.removeItem(dn),at.log("cartKey removed from localStorage")}catch{}rr({cartKey:null})}async function op(e,t){if(tp()){pc();return}const n=await hc();if(n){try{localStorage.setItem(dn,n),at.log("New cartKey regenerated from API:",n)}catch{}rr({cartKey:n})}else at.error("Failed to regenerate cartKey from API")}function ap(e,t){const{key:r,value:n}=e?.params||{};if(!r){at.warn("saveToStorage: key is required");return}try{localStorage.setItem(r,n),at.log("Saved to storage:",r,n)}catch{}}function sp(e,t){const{key:r,defaultValue:n}=e?.params||{};if(!r)return at.warn("loadFromStorage: key is required"),n||null;try{return localStorage.getItem(r)||n||null}catch{return n||null}}function La(){try{sessionStorage.removeItem(Ia),sessionStorage.removeItem(gc),sessionStorage.removeItem(Da)}catch{}}function lp(e,t){let r=null,n=null;try{r=sessionStorage.getItem(Ia),n=sessionStorage.getItem(Da)}catch{}let o=!1;if(r){const a=n?new Date(n).getTime():NaN;(isNaN(a)||a<=Date.now())&&(La(),r=null,o=!0)}if(r){rr({guestOrderToken:r}),at.log("Guest order token loaded from sessionStorage");return}if(!o){const s=window.G7Core?.state?.get?.("_global")?.guestOrderToken;if(typeof s=="string"&&s!==""){at.log("Guest order token preserved from in-memory _global (sessionStorage empty)");return}}rr({guestOrderToken:null}),at.log("No guest order token (visitor not authenticated)")}function ip(e,t){const{token:r,orderNumber:n,expiresAt:o}=e?.params||{};if(!r||!n||!o){at.warn("saveGuestOrderToken: token, orderNumber, expiresAt are required");return}try{sessionStorage.setItem(Ia,r),sessionStorage.setItem(gc,n),sessionStorage.setItem(Da,o)}catch{}rr({guestOrderToken:r})}function cp(e,t){La(),rr({guestOrderToken:null})}function dp(e,t){La(),rr({guestOrderToken:null})}function up(e,t){const{product:r,priceField:n,currencyCode:o}=e,a=o||t.getState("_global.preferredCurrency")||"KRW",s=`multi_currency_${n}`,l=r[s];if(l&&l[a])return l[a].formatted;const f=`${n}_formatted`;return r[f]||String(r[n])}const Pa={KRW:{code:"KRW",symbol:"₩",locale:"ko-KR",decimals:0},USD:{code:"USD",symbol:"$",locale:"en-US",decimals:2},JPY:{code:"JPY",symbol:"¥",locale:"ja-JP",decimals:0},CNY:{code:"CNY",symbol:"¥",locale:"zh-CN",decimals:2},EUR:{code:"EUR",symbol:"€",locale:"de-DE",decimals:2}};function fp(e,t){const{value:r,currencyCode:n,locale:o}=e,a=n||t.getState("_global.preferredCurrency")||"KRW",s=Pa[a]||Pa.KRW,l=o||s.locale;try{return new Intl.NumberFormat(l,{style:"currency",currency:s.code,minimumFractionDigits:s.decimals,maximumFractionDigits:s.decimals}).format(r)}catch{const f=r.toLocaleString(void 0,{minimumFractionDigits:s.decimals,maximumFractionDigits:s.decimals});return`${s.symbol}${f}`}}function gp(e){return Pa[e]?.symbol||e}const Fa=window.G7Core?.createLogger?.("Handler:Currency")??{log:(...e)=>console.log("[Handler:Currency]",...e),warn:(...e)=>console.warn("[Handler:Currency]",...e),error:(...e)=>console.error("[Handler:Currency]",...e)},mc="g7_preferred_currency",hp="KRW";function pp(e,t){const{defaultCurrency:r=hp}=e;let n=r;try{if(typeof window<"u"&&window.localStorage){const o=localStorage.getItem(mc);o&&bc(o)&&(n=o)}}catch{Fa.warn("localStorage 접근 불가, 기본 통화 사용:",r)}return t.setState("global",{preferredCurrency:n}),n}function mp(e,t){const{currencyCode:r}=e;if(!bc(r)){Fa.warn("유효하지 않은 통화 코드:",r);return}try{typeof window<"u"&&window.localStorage&&localStorage.setItem(mc,r)}catch{Fa.warn("localStorage 저장 실패")}t.setState("global",{preferredCurrency:r})}function bc(e){return typeof e=="string"&&/^[A-Z]{3}$/.test(e)}const Pr=window.G7Core?.createLogger?.("Handler:SetTheme")??{log:(...e)=>console.log("[Handler:SetTheme]",...e),warn:(...e)=>console.warn("[Handler:SetTheme]",...e),error:(...e)=>console.error("[Handler:SetTheme]",...e)},$a=["auto","light","dark"],yc="g7_color_scheme",bp=e=>e==="auto"?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":e,eo=e=>{const t=bp(e);document.documentElement.setAttribute("data-theme",t),t==="dark"?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")};function yp(){try{const e=localStorage.getItem(yc),t=e&&$a.includes(e)?e:"auto";eo(t),Pr.log("Initial theme applied:",t)}catch(e){Pr.warn("Failed to load initial theme:",e),eo("auto")}}async function vp(e,t){const r=e?.target;if(r&&typeof r=="string"&&$a.includes(r)){eo(r);return}yp()}async function wp(e,t){let r;if(e?.target&&typeof e.target=="string"&&!e.target.includes("{{")&&(r=e.target),!r||typeof r!="string"){Pr.warn("Invalid theme:",r);return}if(!$a.includes(r)){Pr.warn("Unsupported theme:",r);return}const n=r;try{localStorage.setItem(yc,n)}catch(o){Pr.error("Failed to save theme to localStorage:",o);return}eo(n),Pr.log("Theme changed to:",n)}const xp=window.G7Core?.createLogger?.("Handler:RedirectToLoginWithReturn")??{log:(...e)=>console.log("[Handler:RedirectToLoginWithReturn]",...e),warn:(...e)=>console.warn("[Handler:RedirectToLoginWithReturn]",...e),error:(...e)=>console.error("[Handler:RedirectToLoginWithReturn]",...e)};async function kp(e,t){const r=window.G7Core;if(!r?.dispatch){xp.error("G7Core.dispatch 가 초기화되지 않아 로그인 리다이렉트를 실행할 수 없습니다.");return}const n=window.location.pathname+window.location.search;await r.dispatch({handler:"navigate",params:{path:"/login",query:{redirect:n}}})}const Ua=window.G7Core?.createLogger?.("Handler:DownloadAttachment")??{log:(...e)=>console.log("[Handler:DownloadAttachment]",...e),warn:(...e)=>console.warn("[Handler:DownloadAttachment]",...e),error:(...e)=>console.error("[Handler:DownloadAttachment]",...e)};async function Cp(e,t){const r=window.G7Core,{url:n,filename:o}=e?.params||{};if(!n){Ua.warn("다운로드 URL 이 없어 요청을 건너뜁니다.");return}if(!r?.api?.get){Ua.error("G7Core.api.get 이 초기화되지 않아 다운로드를 실행할 수 없습니다.");return}try{const a=await r.api.get(n,{responseType:"blob"});if(a){const s=URL.createObjectURL(a),l=document.createElement("a");l.href=s,l.download=o||"",document.body.appendChild(l),l.click(),document.body.removeChild(l),URL.revokeObjectURL(s)}}catch(a){Ua.error("첨부파일 다운로드 실패",a),r?.toast?.error?.(r?.t?.("common.download_failed")??"Download failed")}}const to={"sirsoft-basic.addSelectedItemIfComplete":$0,"sirsoft-basic.updateSelectedItemQuantity":U0,"sirsoft-basic.removeSelectedItem":H0,"sirsoft-basic.updateNoOptionQuantity":G0,"sirsoft-basic.setBlockAdditionalOption":z0,"sirsoft-basic.getDisplayPrice":up,"sirsoft-basic.formatCurrency":fp,"sirsoft-basic.getCurrencySymbol":gp,"sirsoft-basic.loadPreferredCurrency":pp,"sirsoft-basic.savePreferredCurrency":mp,setTheme:wp,initTheme:vp,redirectToLoginWithReturn:kp,downloadAttachment:Cp,toggleCartItemSelection:K0,selectAllCartItems:V0,setCartOption:Y0,openCartDeleteModal:X0,openCartOptionModal:q0,recalculateCart:Z0,findMatchingOption:J0,initCartOptionSelection:R0,initCartKey:rp,getCartKey:np,clearCartKey:pc,regenerateCartKey:op,saveToStorage:ap,loadFromStorage:sp,initGuestOrderToken:lp,saveGuestOrderToken:ip,clearGuestOrderToken:cp,clearGuestTokenOnEntry:dp},un=window.G7Core?.createLogger?.("Template:sirsoft-basic:IdentityLauncher")??{log:(...e)=>console.log("[Template:sirsoft-basic:IdentityLauncher]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic:IdentityLauncher]",...e),error:(...e)=>console.error("[Template:sirsoft-basic:IdentityLauncher]",...e)};function Sp(e){const t=window.G7Core,r=t?.state?.getLocal?.()??{},n=t?.state?.getGlobal?.()??t?.state?.get?.()??{},o=(...f)=>f.find(h=>typeof h=="string"&&h.length>0),a=o(e.target?.email,n?.currentUser?.email,n?.user?.email,n?.auth?.user?.email,e.purpose==="signup"?r?.registerForm?.email:void 0,e.purpose==="password_reset"?r?.passwordResetForm?.email:void 0,e.purpose==="password_reset"?r?.email:void 0),s=o(e.target?.phone,n?.currentUser?.phone,n?.user?.phone,n?.auth?.user?.phone);if(!a&&!s)return null;const l={};return a&&(l.email=a),s&&(l.phone=s),l}function _p(){const t=window.G7Core?.api?.getToken?.()??null;return t?{Authorization:`Bearer ${t}`}:{}}function Ep(){try{const e=typeof window<"u"?window.localStorage?.getItem("g7_locale"):null;return e?{"Accept-Language":e}:{}}catch{return{}}}async function Tp(e,t){const r={purpose:e.purpose};e.provider_id&&(r.provider_id=e.provider_id),t&&(r.target=t);const n=e.challenge_start_url||"/api/identity/challenges",o=await fetch(n,{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...Ep(),..._p()},body:JSON.stringify(r)}),a=await o.json().catch(()=>null);if(!o.ok||!a?.success){const l=a?.message??`Challenge 시작 실패 (HTTP ${o.status})`;throw new Error(l)}const s=a.data??a;return{id:s.id,expires_at:s.expires_at,render_hint:s.render_hint??e.render_hint??"text_code",public_payload:s.public_payload,redirect_url:s.redirect_url,max_attempts:typeof s.max_attempts=="number"?s.max_attempts:void 0}}async function Np(e){const t=window.G7Core,r=t?.identity;if(!t?.dispatch||!t?.state||!r)return un.error("G7Core 또는 G7Core.identity 가 초기화되지 않아 launcher 를 실행할 수 없습니다."),{status:"failed",failureCode:"G7_NOT_READY"};if(e.render_hint==="external_redirect"||e.redirect_url)return r.redirectExternally(e);const n=Sp(e);let o;try{o=await Tp(e,n)}catch(p){un.error("Challenge 시작 실패:",p);try{await t.dispatch({handler:"toast",params:{type:"error",message:p instanceof Error?p.message:"본인인증 시작에 실패했습니다."}})}catch{}return{status:"failed",failureCode:"CHALLENGE_START_FAILED",reason:p instanceof Error?p.message:String(p)}}if(o.redirect_url)return r.redirectExternally({...e,render_hint:"external_redirect",redirect_url:o.redirect_url});const a=o.expires_at?new Date(o.expires_at).getTime():0,s=a>0?Math.max(0,Math.floor((a-Date.now())/1e3)):0,l=typeof o.max_attempts=="number"?o.max_attempts:0;t.state.set({identityChallenge:{policy_key:e.policy_key,purpose:e.purpose,provider_id:e.provider_id??null,render_hint:o.render_hint,challenge_id:o.id,expires_at:o.expires_at,public_payload:o.public_payload??{},target:n??null,code:"",error:null,attempts:0,maxAttempts:l,remainingSeconds:s,resendCooldown:0}});let f=a,h=0;const d=window.setInterval(()=>{const p=f>0?Math.max(0,Math.floor((f-Date.now())/1e3)):0;h=Math.max(0,h-1),t.state.set({identityChallenge:{remainingSeconds:p,resendCooldown:h}})},1e3);let i=null;try{i=t.state.subscribe?.(p=>{const m=p?.identityChallenge?.expires_at;if(typeof m=="string"){const x=new Date(m).getTime();Number.isFinite(x)&&x!==f&&(f=x)}const w=p?.identityChallenge?.resendCooldown;typeof w=="number"&&w>h&&(h=w)})??null}catch{}const u=r.createDeferred();try{await t.dispatch({handler:"openModal",target:"identity-challenge-modal"})}catch(p){return un.error("openModal 실패 — 풀페이지로 폴백:",p),window.clearInterval(d),i?.(),r.redirectExternally({...e,render_hint:"external_redirect",redirect_url:`/identity/challenge?challenge_id=${encodeURIComponent(o.id)}&return=${encodeURIComponent(window.location.href)}`})}const g=await u;return window.clearInterval(d),i?.(),g}function Ap(){const e=window.G7Core?.identity;if(!e?.setLauncher){un.warn("G7Core.identity 가 아직 초기화되지 않아 launcher 등록을 건너뜁니다. 코어 부트스트랩 순서를 확인하세요.");return}e.setLauncher(Np),un.log("IDV launcher registered (sirsoft-basic)")}const vc=window.G7Core?.createLogger?.("Template:sirsoft-basic:device")??{log:(...e)=>console.log("[Template:sirsoft-basic:device]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic:device]",...e)};function Mp(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),r=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),n=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(r)?!0:/macintosh|mac os x/.test(t)&&n>1}function Op(){const e=window.G7Core;if(!e?.state?.get||!e?.state?.set){vc.warn("G7Core.state not available; skip iOS device correction");return}if(!Mp())return;const r=(e.state.get()||{}).appConfig||{};r.isIos!==!0&&(e.state.set({appConfig:{...r,isIos:!0}}),vc.log("appConfig.isIos corrected to true by client signals (iPadOS desktop-UA)"))}function Ip(e){const t=e.icons;return Array.isArray(t)?t.filter(r=>!!r&&typeof r=="object"&&typeof r.value=="string"):[]}function Dp(e,t){const r=e.label;return typeof r=="string"?r.startsWith("$t:")?t(r.slice(3)):r:e.value}function Lp(e,t,r){if(r==="")return!0;const n=r.toLowerCase();return e.value.toLowerCase().includes(n)||t.toLowerCase().includes(n)?!0:(e.keywords??[]).some(o=>o.toLowerCase().includes(n))}function Pp({entry:e}){const t=e.preview;return t?.html?c.jsx("span",{className:"g7le-icon-preview",dangerouslySetInnerHTML:{__html:t.html},style:Ha}):t?.className?c.jsx("span",{className:`g7le-icon-preview ${t.className}`,style:Ha}):c.jsx("i",{className:`fas fa-${e.value}`,style:Ha,"aria-hidden":"true"})}function Fp({control:e,value:t,onChange:r,t:n}){const o=Ip(e),a=t==null?"":String(t),s=typeof e.iconColumns=="number"?e.iconColumns:6,l=e.iconSearchPlaceholder,f=typeof l=="string"&&l.startsWith("$t:")?n(l.slice(3)):n("layout_editor.icon_picker.search_placeholder"),[h,d]=k.useState("");if(o.length===0)return c.jsx("div",{className:"g7le-widget g7le-widget--icon-picker","data-testid":"g7le-widget-icon-picker",style:wc,children:c.jsx("input",{type:"text",className:"g7le-widget g7le-widget--icon-free","data-testid":"g7le-widget-icon-picker-free",value:a,placeholder:n("layout_editor.icon_picker.free_input_hint"),onChange:g=>r(g.target.value===""?void 0:g.target.value),style:xc})});const i=g=>Dp(g,n),u=o.filter(g=>Lp(g,i(g),h));return c.jsxs("div",{className:"g7le-widget g7le-widget--icon-picker","data-testid":"g7le-widget-icon-picker",style:wc,children:[c.jsx("input",{type:"text",className:"g7le-widget g7le-widget--icon-search","data-testid":"g7le-widget-icon-picker-search",value:h,placeholder:f,onChange:g=>d(g.target.value),style:xc}),c.jsxs("div",{className:"g7le-icon-grid","data-testid":"g7le-widget-icon-picker-grid",style:{...$p,gridTemplateColumns:`repeat(${s}, minmax(0, 1fr))`},children:[c.jsx("button",{type:"button","data-testid":"g7le-icon-cell-none",title:n("layout_editor.icon_picker.none"),onClick:()=>r(void 0),"aria-pressed":a==="",style:a===""?kc:za,children:c.jsx("span",{style:Up,children:"—"})}),u.map(g=>{const p=g.value===a;return c.jsx("button",{type:"button","data-testid":`g7le-icon-cell-${g.value}`,title:i(g),"aria-pressed":p,onClick:()=>r(g.value),style:p?kc:za,children:c.jsx(Pp,{entry:g})},g.value)})]}),u.length===0&&c.jsx("div",{"data-testid":"g7le-widget-icon-picker-empty",style:zp,children:n("layout_editor.icon_picker.no_results")})]})}const wc={display:"flex",flexDirection:"column",gap:6,width:"100%"},xc={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%"},$p={display:"grid",gap:4,maxHeight:220,overflowY:"auto",padding:2},za={display:"flex",alignItems:"center",justifyContent:"center",minHeight:34,padding:4,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",cursor:"pointer"},kc={...za,border:"2px solid #2563eb",background:"#eff6ff"},Ha={display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:16,lineHeight:1},Up={fontSize:10,color:"#475569",fontFamily:"monospace",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:"100%"},zp={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"4px 2px"},Cc=window.G7Core?.createLogger?.("Template:sirsoft-basic:EditorWidgets")??{log:(...e)=>console.log("[Template:sirsoft-basic:EditorWidgets]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic:EditorWidgets]",...e)};function Hp(){if(typeof window>"u")return;const e=window.G7Core?.layoutEditor;if(!e?.registerWidget){Cc.warn("G7Core.layoutEditor 미노출 — icon-picker 위젯 등록 보류");return}e.registerWidget("icon-picker",Fp),Cc.log("편집기 위젯 'icon-picker' 등록됨(Font Awesome 검색 그리드)")}const Ga=window.G7Core?.createLogger?.("Template:sirsoft-basic")??{log:(...e)=>console.log("[Template:sirsoft-basic]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic]",...e),error:(...e)=>console.error("[Template:sirsoft-basic]",...e)};typeof window<"u"&&(window.G7TemplateHandlers=to);function Sc(){if(typeof window<"u"){let e=0;const t=50,r=()=>{const n=window.G7Core?.getActionDispatcher?.();n?(Object.entries(to).forEach(([o,a])=>{n.registerHandler(o,a)}),Ga.log(`${Object.keys(to).length} custom handler(s) registered:`,Object.keys(to)),Ap(),Op()):(e++,e<=t?(Ga.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(r,100)):Ga.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};document.readyState==="complete"?r():window.addEventListener("load",r)}}return Hp(),Sc(),J.A=ao,J.Avatar=Ur,J.AvatarUploader=Aa,J.BrandMark=xo,J.Button=Q,J.Checkbox=qa,J.Code=Xc,J.ConfirmDialog=ta,J.Container=N0,J.Div=W,J.ExpandableContent=Jo,J.FileInput=Ba,J.FileUploader=Xn,J.Flex=M0,J.Footer=rs,J.Form=so,J.Grid=A0,J.H1=Fc,J.H2=Va,J.H3=gn,J.H4=Ya,J.Header=es,J.Hr=lo,J.HtmlContent=qo,J.HtmlEditor=Ll,J.Icon=de,J.IconName=Je,J.ImageGallery=Tr,J.Img=Lt,J.Input=xt,J.Label=yr,J.Li=Xt,J.MobileNav=ns,J.Modal=Ro,J.Nav=$r,J.NotificationCenter=mn,J.Ol=$c,J.Optgroup=Gc,J.Option=Hc,J.P=Dt,J.PageLoading=ec,J.PageSkeleton=Na,J.PageTransitionBlur=_a,J.PageTransitionIndicator=Sa,J.Pagination=tc,J.PasswordInput=Ka,J.PostReactions=Il,J.ProductCard=ms,J.ProductImageViewer=An,J.QuantitySelector=Ml,J.RichTextEditor=Dl,J.SearchBar=nc,J.Section=Za,J.SectionLayout=O0,J.Select=Xa,J.SlotContainer=pn,J.SocialLoginButtons=Ki,J.Span=ee,J.Svg=fn,J.TabNavigation=lc,J.Table=jc,J.Tbody=Wc,J.Td=Yc,J.Textarea=no,J.Th=Vc,J.Thead=Bc,J.ThemeToggle=hn,J.ThreeColumnLayout=I0,J.Toast=Ca,J.Tr=Kc,J.Ul=oo,J.UserInfo=sc,J.availablePasswordRules=ro,J.compositeComponents=T0,J.defaultPasswordRules=Wa,J.initTemplate=Sc,J.templateMetadata=D0,Object.defineProperty(J,Symbol.toStringTag,{value:"Module"}),J})({},ReactJSXRuntime,React,ReactDOM); -//# sourceMappingURL=components.iife.js.map diff --git a/tests/Feature/Api/Admin/AdminTemplateAssetControllerTest.php b/tests/Feature/Api/Admin/AdminTemplateAssetControllerTest.php index 30522270..4f5a65fa 100644 --- a/tests/Feature/Api/Admin/AdminTemplateAssetControllerTest.php +++ b/tests/Feature/Api/Admin/AdminTemplateAssetControllerTest.php @@ -492,6 +492,11 @@ class AdminTemplateAssetControllerTest extends TestCase */ public function test_editor_assets_css_points_to_editor_endpoint(): void { + // 자산 URL 의 확장자 표기는 사이트 설정(general.asset_url_mode)이 정한다. + // 이 테스트가 단언하는 것은 "편집기 전용 엔드포인트인가" 이므로, 설정을 명시해 + // 개발 사이트가 어느 모드를 쓰든 결과가 달라지지 않게 고정한다. + config(['g7_settings.core.general.asset_url_mode' => 'extension']); + $response = $this->withHeaders([ 'Authorization' => "Bearer {$this->adminToken}", 'Accept' => 'application/json', diff --git a/tests/Feature/Api/Admin/SettingsLimitsMetaTest.php b/tests/Feature/Api/Admin/SettingsLimitsMetaTest.php new file mode 100644 index 00000000..93388c7d --- /dev/null +++ b/tests/Feature/Api/Admin/SettingsLimitsMetaTest.php @@ -0,0 +1,118 @@ +admin = $this->createSettingsAdmin(); + $this->token = $this->admin->createToken('test-token')->plainTextToken; + } + + /** + * 설정 조회 권한을 가진 관리자를 생성합니다. + * + * @return User 생성된 관리자 + */ + private function createSettingsAdmin(): User + { + $user = User::factory()->create(['password' => Hash::make('password123')]); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.settings.read'], + [ + 'name' => json_encode(['ko' => 'core.settings.read', 'en' => 'core.settings.read']), + 'description' => json_encode(['ko' => 'core.settings.read', 'en' => 'core.settings.read']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => 'admin', + ] + ); + + $role = Role::create([ + 'identifier' => 'settings_limits_admin_'.uniqid(), + 'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']), + 'description' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'is_active' => true, + ]); + $role->permissions()->sync([$permission->id]); + + $adminBaseRole = Role::firstOrCreate( + ['identifier' => 'admin'], + [ + 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), + 'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'is_active' => true, + ] + ); + + $user->roles()->attach($adminBaseRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($role->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * 설정 조회 응답이 화면이 바인딩할 한계값을 함께 내려주는지 검증합니다. + */ + #[Test] + public function index_response_includes_settings_limits_meta(): void + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ])->getJson('/api/admin/settings'); + + $response->assertStatus(200); + + $limits = $response->json('data._meta.limits'); + + $this->assertIsArray($limits, '설정 응답에 _meta.limits 가 없습니다 — 화면이 경계값을 받을 수 없습니다.'); + $this->assertSame(config('core.settings_limits'), $limits); + } + + /** + * 저장 규칙이 리터럴이 아니라 설정 한계값을 읽는지 검증합니다. + */ + #[Test] + public function save_rules_read_password_length_bounds_from_config(): void + { + config([ + 'core.settings_limits.security_password_min_length_min' => 9, + 'core.settings_limits.security_password_min_length_max' => 33, + ]); + + $rules = (new SaveSettingsRequest)->rules()['security.password_min_length']; + + $this->assertContains('min:9', $rules); + $this->assertContains('max:33', $rules); + } +} diff --git a/tests/Feature/Api/Auth/RegisterPolicyDrivenSignupTest.php b/tests/Feature/Api/Auth/RegisterPolicyDrivenSignupTest.php index 9c1ab446..cba95903 100644 --- a/tests/Feature/Api/Auth/RegisterPolicyDrivenSignupTest.php +++ b/tests/Feature/Api/Auth/RegisterPolicyDrivenSignupTest.php @@ -5,6 +5,8 @@ namespace Tests\Feature\Api\Auth; use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface; use App\Enums\IdentityVerificationStatus; use App\Enums\UserStatus; +use App\Extension\HookManager; +use App\Listeners\Identity\EnforceIdentityPolicyListener; use App\Models\IdentityPolicy; use App\Models\IdentityVerificationLog; use App\Models\Role; @@ -15,6 +17,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Str; use Tests\TestCase; /** @@ -96,7 +99,7 @@ class RegisterPolicyDrivenSignupTest extends TestCase $token = 'test-idv-token-'.bin2hex(random_bytes(8)); IdentityVerificationLog::create([ - 'id' => (string) \Illuminate\Support\Str::uuid(), + 'id' => (string) Str::uuid(), 'provider_id' => 'g7:core.mail', 'purpose' => 'signup', 'channel' => 'email', @@ -140,7 +143,7 @@ class RegisterPolicyDrivenSignupTest extends TestCase $token = 'test-idv-token-'.bin2hex(random_bytes(8)); IdentityVerificationLog::create([ - 'id' => (string) \Illuminate\Support\Str::uuid(), + 'id' => (string) Str::uuid(), 'provider_id' => 'g7:core.mail', 'purpose' => 'signup', 'channel' => 'email', @@ -185,7 +188,7 @@ class RegisterPolicyDrivenSignupTest extends TestCase $token = 'test-idv-token-'.bin2hex(random_bytes(8)); IdentityVerificationLog::create([ - 'id' => (string) \Illuminate\Support\Str::uuid(), + 'id' => (string) Str::uuid(), 'provider_id' => 'g7:core.mail', 'purpose' => 'signup', 'channel' => 'email', @@ -280,6 +283,34 @@ class RegisterPolicyDrivenSignupTest extends TestCase $this->assertSame(UserStatus::Active->value, $user->status); } + /** + * signup_after_create 정책이 켜져 있어도 가입 훅이 이중으로 강제되지 않는지 확인합니다. + * + * `core.auth.after_register` 는 challenge 를 발행하는 전담 리스너가 소유한다. 일반 정책 + * 강제 리스너까지 이 훅을 구독하면, challenge 를 발행한 직후 428 이 던져져 가입 자체가 + * 막힌다 — '가입 후 본인확인' 정책이 '가입 전' 정책과 구분되지 않고, 켜는 순간 아무도 + * 가입할 수 없게 된다. 동작(위 두 테스트)과 별개로 구독 상태를 직접 고정한다. + */ + public function test_after_register_hook_is_not_subscribed_by_the_generic_enforcer(): void + { + $this->enablePolicy('core.auth.signup_after_create'); + + EnforceIdentityPolicyListener::syncDynamicHookSubscriptions(); + + $hooks = new \ReflectionProperty(HookManager::class, 'hooks'); + $hooks->setAccessible(true); + + $registered = $hooks->getValue()['core.auth.after_register'] ?? []; + $priorities = array_keys($registered); + + $this->assertContains(5, $priorities, 'challenge 발행 리스너가 구독되어 있어야 합니다.'); + $this->assertNotContains( + 15, + $priorities, + '정책 강제 리스너가 after_register 를 구독하면 가입이 428 로 막힙니다.' + ); + } + /** * 시드된 정책의 enabled 를 true 로 토글합니다. */ diff --git a/tests/Feature/Attachment/AttachmentUploadPolicyTest.php b/tests/Feature/Attachment/AttachmentUploadPolicyTest.php new file mode 100644 index 00000000..36e203a5 --- /dev/null +++ b/tests/Feature/Attachment/AttachmentUploadPolicyTest.php @@ -0,0 +1,184 @@ +user = $this->createUploaderAdmin(); + } + + /** + * @test + */ + public function 설정된_최대_용량을_초과하면_422_로_차단된다(): void + { + // 기본값(10240KB)과 다른 값을 주입해야 폴백과의 우연한 일치를 피할 수 있다 + config(['attachment.max_file_size' => 100]); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments', [ + 'file' => UploadedFile::fake()->create('big.pdf', 200, 'application/pdf'), + ]); + + $response->assertStatus(422); + } + + /** + * @test + */ + public function 설정된_최대_용량_이내는_통과한다(): void + { + config(['attachment.max_file_size' => 100]); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments', [ + 'file' => UploadedFile::fake()->create('small.pdf', 50, 'application/pdf'), + ]); + + $response->assertStatus(201); + } + + /** + * @test + */ + public function 허용되지_않는_확장자는_422_로_차단된다(): void + { + config(['attachment.allowed_extensions' => ['png', 'pdf']]); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments', [ + 'file' => UploadedFile::fake()->create('shell.php', 10, 'text/x-php'), + ]); + + $response->assertStatus(422); + } + + /** + * @test + */ + public function 허용된_확장자는_통과한다(): void + { + config(['attachment.allowed_extensions' => ['png', 'pdf']]); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments', [ + 'file' => UploadedFile::fake()->image('photo.png'), + ]); + + $response->assertStatus(201); + } + + /** + * @test + */ + public function 허용_확장자_목록이_비면_제한하지_않는다(): void + { + // 운영 중 예상치 못한 확장자로 업로드가 막히는 상황의 탈출구 + config(['attachment.allowed_extensions' => []]); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments', [ + 'file' => UploadedFile::fake()->create('anything.php', 10, 'text/x-php'), + ]); + + $response->assertStatus(201); + } + + /** + * @test + */ + public function 확장_훅으로_허용_확장자를_조정할_수_있다(): void + { + config(['attachment.allowed_extensions' => ['png']]); + + HookManager::addFilter( + 'core.attachment.allowed_extensions', + fn (array $extensions) => array_merge($extensions, ['pdf']), + ); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments', [ + 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), + ]); + + $response->assertStatus(201); + } + + /** + * @test + */ + public function 일괄_업로드도_동일한_정책을_적용한다(): void + { + config(['attachment.allowed_extensions' => ['png']]); + + $response = $this->actingAs($this->user, 'sanctum')->postJson('/api/admin/attachments/batch', [ + 'files' => [ + UploadedFile::fake()->image('ok.png'), + UploadedFile::fake()->create('shell.php', 10, 'text/x-php'), + ], + ]); + + $response->assertStatus(422); + } + + /** + * 첨부파일 업로드 권한을 가진 관리자를 생성합니다. + * + * @return User 관리자 사용자 + */ + private function createUploaderAdmin(): User + { + $user = User::factory()->create(['is_super' => true, 'status' => 'active']); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.attachments.create'], + [ + 'name' => json_encode(['ko' => '첨부파일 업로드', 'en' => 'Upload Attachments']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => PermissionType::Admin, + ] + ); + + $role = Role::firstOrCreate( + ['identifier' => 'admin'], + [ + 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'is_active' => true, + ] + ); + + if (! $role->permissions()->where('permissions.id', $permission->id)->exists()) { + $role->permissions()->attach($permission->id); + } + + $user->roles()->attach($role->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } +} diff --git a/tests/Feature/Auth/AccountLockoutTest.php b/tests/Feature/Auth/AccountLockoutTest.php new file mode 100644 index 00000000..c4db21e3 --- /dev/null +++ b/tests/Feature/Auth/AccountLockoutTest.php @@ -0,0 +1,305 @@ +setSecuritySettings([ + 'login_attempt_enabled' => true, + 'max_login_attempts' => 3, + 'login_lockout_time' => 0, + ]); + + Cache::flush(); + } + + /** + * @test + */ + public function 잠금시간_0_설정에서_임계값_도달_시_영구_잠금된다(): void + { + $user = User::factory()->create([ + 'email' => 'permlock@example.com', + 'password' => Hash::make('correct-password'), + 'status' => 'active', + ]); + + for ($i = 1; $i <= 3; $i++) { + $this->postJson('/api/auth/login', [ + 'email' => 'permlock@example.com', + 'password' => 'wrong-password', + ])->assertStatus(401); + } + + $user->refresh(); + $this->assertTrue((bool) $user->locked_permanently); + $this->assertNull($user->locked_until); + + // 하루가 지나도 여전히 잠금 (수정 전: 1분 뒤 자동 해제) + $this->travel(1)->day(); + + $this->postJson('/api/auth/login', [ + 'email' => 'permlock@example.com', + 'password' => 'correct-password', + ])->assertStatus(423); + } + + /** + * @test + */ + public function 영구_잠금_응답은_locked_until_이_null_이어도_직렬화된다(): void + { + $user = User::factory()->create([ + 'email' => 'permlock2@example.com', + 'password' => Hash::make('correct-password'), + 'status' => 'active', + ]); + + app(UserRepositoryInterface::class)->lockAccount($user, 0); + + // 수정 전: $e->lockedUntil->toIso8601String() 가 null 에서 fatal + $response = $this->postJson('/api/auth/login', [ + 'email' => 'permlock2@example.com', + 'password' => 'correct-password', + ]); + + $response->assertStatus(423); + $this->assertNull($response->json('errors.locked_until')); + } + + /** + * @test + */ + public function 관리자_해제_ap_i_로_영구_잠금을_풀_수_있다(): void + { + $locked = User::factory()->create([ + 'email' => 'unlockme@example.com', + 'password' => Hash::make('correct-password'), + 'status' => 'active', + ]); + app(UserRepositoryInterface::class)->lockAccount($locked, 0); + + $admin = $this->createAdminUser(); + + $this->actingAs($admin, 'sanctum') + ->postJson("/api/admin/users/{$locked->uuid}/unlock") + ->assertStatus(200); + + $locked->refresh(); + $this->assertFalse((bool) $locked->locked_permanently); + $this->assertNull($locked->locked_until); + + // actingAs(sanctum) 이 기본 guard 까지 sanctum 으로 고정하므로 되돌린 뒤 + // 실제 로그인(Auth::attempt) 가능 여부를 확인한다. + // (actingAs 는 config('auth.defaults.guard') 자체를 sanctum 으로 덮어쓰므로 config 를 다시 읽으면 안 된다) + $this->app['auth']->shouldUse('web'); + $this->app['auth']->forgetGuards(); + + $this->postJson('/api/auth/login', [ + 'email' => 'unlockme@example.com', + 'password' => 'correct-password', + ])->assertStatus(200); + } + + /** + * @test + */ + public function 권한_없는_사용자는_해제_ap_i_를_호출할_수_없다(): void + { + $locked = User::factory()->create(['status' => 'active']); + app(UserRepositoryInterface::class)->lockAccount($locked, 0); + + $member = User::factory()->create(['status' => 'active']); + + $this->actingAs($member, 'sanctum') + ->postJson("/api/admin/users/{$locked->uuid}/unlock") + ->assertStatus(403); + + $this->assertTrue((bool) $locked->fresh()->locked_permanently); + } + + /** + * self 스코프 역할은 남의 계정을 해제할 수 없다. + * + * 해제 엔드포인트는 신규 권한을 만들지 않고 `core.users.update` 를 재사용한다. + * 기본 manager 역할이 그 권한을 self 스코프로 갖고 있으므로, 스코프가 이 경로에 + * 적용되지 않으면 매니저가 임의 계정의 잠금을 푸는 우회로가 된다. + * + * @test + */ + public function self_스코프_역할은_남의_계정_잠금을_해제할_수_없다(): void + { + $locked = User::factory()->create(['status' => 'active']); + app(UserRepositoryInterface::class)->lockAccount($locked, 0); + + $manager = $this->createSelfScopedManager(); + + // 전제 확인 — 이 사용자의 실효 스코프가 정말 self 인가 + $this->assertSame('self', $manager->getEffectiveScopeForPermission('core.users.update')); + $this->assertSame('user', PermissionHelper::getResourceRouteKey('core.users.update')); + + $this->actingAs($manager, 'sanctum') + ->postJson("/api/admin/users/{$locked->uuid}/unlock") + ->assertStatus(403); + + $this->assertTrue( + (bool) $locked->fresh()->locked_permanently, + 'self 스코프 역할이 남의 계정 잠금을 해제했습니다.' + ); + } + + /** + * @test + */ + public function 유한_잠금_설정은_기존과_동일하게_만료된다(): void + { + $this->setSecuritySettings(['login_lockout_time' => 5]); + + $user = User::factory()->create([ + 'email' => 'tmplock@example.com', + 'password' => Hash::make('correct-password'), + 'status' => 'active', + ]); + + for ($i = 1; $i <= 3; $i++) { + $this->postJson('/api/auth/login', [ + 'email' => 'tmplock@example.com', + 'password' => 'wrong-password', + ])->assertStatus(401); + } + + $this->assertFalse((bool) $user->fresh()->locked_permanently); + + $this->travel(6)->minutes(); + Cache::flush(); + + $this->postJson('/api/auth/login', [ + 'email' => 'tmplock@example.com', + 'password' => 'correct-password', + ])->assertStatus(200); + } + + /** + * 보안 환경설정 값을 테스트용으로 덮어씁니다. + * + * @param array $values 덮어쓸 보안 설정 값 + */ + private function setSecuritySettings(array $values): void + { + $existing = (array) config('g7_settings.core.security', []); + config(['g7_settings.core.security' => array_merge($existing, $values)]); + } + + /** + * 사용자 수정 권한을 가진 관리자를 생성합니다. + * + * @return User 관리자 사용자 + */ + private function createAdminUser(): User + { + $user = User::factory()->create(['is_super' => true, 'status' => 'active']); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.users.update'], + [ + 'name' => json_encode(['ko' => '사용자 수정', 'en' => 'Update Users']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => PermissionType::Admin, + ] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + [ + 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'is_active' => true, + ] + ); + + if (! $adminRole->permissions()->where('permissions.id', $permission->id)->exists()) { + $adminRole->permissions()->attach($permission->id); + } + + $user->roles()->attach($adminRole->id, [ + 'assigned_at' => now(), + 'assigned_by' => null, + ]); + + return $user->fresh(); + } + + /** + * `core.users.update` 를 self 스코프로만 가진 사용자를 만듭니다 (기본 manager 역할 구성). + * + * @return User 생성된 사용자 + */ + private function createSelfScopedManager(): User + { + $user = User::factory()->create(['is_super' => false, 'status' => 'active']); + + // resource_route_key/owner_key 는 config/core.php 의 선언과 같아야 한다 — + // 비어 있으면 미들웨어가 스코프 검사를 건너뛰어 이 테스트가 무의미해진다. + $permission = Permission::firstOrCreate( + ['identifier' => 'core.users.update'], + [ + 'name' => json_encode(['ko' => '사용자 수정', 'en' => 'Update Users']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => PermissionType::Admin, + ] + ); + $permission->forceFill(['resource_route_key' => 'user', 'owner_key' => 'id'])->save(); + + $role = Role::firstOrCreate( + ['identifier' => 'manager'], + [ + 'name' => json_encode(['ko' => '매니저', 'en' => 'Manager']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'is_active' => true, + ] + ); + + $role->permissions()->syncWithoutDetaching([ + $permission->id => ['scope_type' => 'self'], + ]); + + $user->roles()->attach($role->id, [ + 'assigned_at' => now(), + 'assigned_by' => null, + ]); + + return $user->fresh(); + } +} diff --git a/tests/Feature/Auth/PasswordPolicyTest.php b/tests/Feature/Auth/PasswordPolicyTest.php new file mode 100644 index 00000000..910ac599 --- /dev/null +++ b/tests/Feature/Auth/PasswordPolicyTest.php @@ -0,0 +1,218 @@ + array_merge($existing, $values)]); + } + + /** + * @test + */ + public function 설정한_최소_길이가_회원가입에_적용된다(): void + { + $this->setSecuritySettings(['password_min_length' => 12]); + + // 11자 — 수정 전에는 min:8 리터럴이라 통과했다 + $validator = $this->validatePassword('abcdefghijk'); + + $this->assertTrue($validator->errors()->has('password')); + } + + /** + * @test + */ + public function 설정한_최소_길이를_만족하면_통과한다(): void + { + $this->setSecuritySettings(['password_min_length' => 12]); + + $validator = $this->validatePassword('abcdefghijkl'); + + $this->assertFalse($validator->errors()->has('password')); + } + + /** + * @test + */ + public function 특수문자_필수_설정이_적용된다(): void + { + $this->setSecuritySettings([ + 'password_min_length' => 8, + 'require_password_special_char' => true, + ]); + + $validator = $this->validatePassword('abcdefgh'); + + $this->assertTrue($validator->errors()->has('password')); + } + + /** + * @test + */ + public function 로그인_규칙에는_길이_제한이_없다(): void + { + // 로그인은 기존 비밀번호를 대조하는 지점이며, 길이 규칙은 정책 미달 사실을 + // 미인증 공격자에게 누설하는 oracle 이 된다. 수정 전에는 min:6 이 있었다. + $rules = (new LoginRequest)->rules(); + + $this->assertIsString($rules['password']); + $this->assertStringNotContainsString('min:', $rules['password']); + } + + /** + * @test + */ + public function 정책_규칙이_설정값을_읽는다(): void + { + $this->setSecuritySettings(['password_min_length' => 20]); + + $rule = PasswordPolicy::rule(); + + // Password 규칙 내부 min 값 확인 (리플렉션 — 공개 접근자가 없음) + $reflection = new \ReflectionProperty($rule, 'min'); + $this->assertSame(20, $reflection->getValue($rule)); + } + + /** + * @test + */ + public function 설정이_비어도_기본값으로_동작한다(): void + { + $this->setSecuritySettings(['password_min_length' => 0]); + + $rule = PasswordPolicy::rule(); + + $reflection = new \ReflectionProperty($rule, 'min'); + $this->assertSame(PasswordPolicy::DEFAULT_MIN_LENGTH, $reflection->getValue($rule)); + } + + /** + * @test + */ + public function 비로그인_비밀번호_요청은_모두_정책을_사용한다(): void + { + $this->setSecuritySettings(['password_min_length' => 17]); + + // 비밀번호를 **설정**하는 모든 지점. 한 곳이라도 리터럴로 남으면 그 화면이 정책 + // 우회로가 된다 (관리자 사용자 수정 화면이 실제로 min:8 리터럴로 남아 있었다). + $requestClasses = [ + RegisterRequest::class, + ResetPasswordRequest::class, + ChangePasswordRequest::class, + CreateUserRequest::class, + UpdateProfileRequest::class, + UpdateUserRequest::class, + ]; + + foreach ($requestClasses as $requestClass) { + $rules = (new $requestClass)->rules(); + $passwordRule = collect($rules['password'])->first(fn ($rule) => $rule instanceof Password); + + $this->assertNotNull( + $passwordRule, + "{$requestClass} 의 password 규칙이 PasswordPolicy 를 사용하지 않습니다." + ); + + $reflection = new \ReflectionProperty($passwordRule, 'min'); + $this->assertSame( + 17, + $reflection->getValue($passwordRule), + "{$requestClass} 가 설정값 대신 리터럴 최소 길이를 사용합니다." + ); + } + } + + /** + * @test + */ + public function 두_보안_설정_키가_저장_후_재조회에_반영된다(): void + { + // 저장 화이트리스트에 두 키가 없으면 저장 요청이 조용히 탈락해 UI 가 값을 잃는다. + app(ConfigRepositoryInterface::class)->saveCategory('security', [ + 'password_min_length' => 14, + 'require_password_special_char' => true, + ]); + + $saved = app(ConfigRepositoryInterface::class)->getCategory('security'); + + $this->assertSame(14, (int) $saved['password_min_length']); + $this->assertTrue((bool) $saved['require_password_special_char']); + } + + /** + * 회원가입 요청의 비밀번호 규칙만 실행합니다. + * + * HTTP 엔드포인트를 거치면 확장 리스너 의존이 끼어들어 정책 검증과 무관한 실패가 난다. + * + * @param string $password 검사할 비밀번호 + * @return Validator 실행된 검증기 + */ + private function validatePassword(string $password) + { + $rules = (new RegisterRequest)->rules(); + + $validator = $this->app['validator']->make( + ['password' => $password, 'password_confirmation' => $password], + ['password' => $rules['password']] + ); + $validator->passes(); + + return $validator; + } +} diff --git a/tests/Feature/Console/Vendor/BuildVendorBundleCommandTest.php b/tests/Feature/Console/Vendor/BuildVendorBundleCommandTest.php index 3326a846..d189834a 100644 --- a/tests/Feature/Console/Vendor/BuildVendorBundleCommandTest.php +++ b/tests/Feature/Console/Vendor/BuildVendorBundleCommandTest.php @@ -40,7 +40,7 @@ class BuildVendorBundleCommandTest extends TestCase * 의 vendor-bundle.zip 까지 가짜 498-byte zip 으로 덮어쓰는 사고를 방지하기 위해 * 실제 파일을 snapshot 하고 tearDown 에서 복원한다. * - * @var array path => content (binary-safe) + * @var array path => content (binary-safe) */ private array $realBundleSnapshots = []; @@ -92,6 +92,11 @@ class BuildVendorBundleCommandTest extends TestCase $this->restoreRealBundles(); + // 스냅샷 해제: 실제 vendor-bundle.zip 은 10MB 단위라 PHPUnit 이 테스트 인스턴스를 스위트 + // 종료까지 붙들면 테스트 메서드 수만큼 메모리에 남는다. 전체 스위트를 한 프로세스로 + // 실행할 때 memory_limit 을 소진시키는 원인이므로 복구 직후 즉시 비운다. + $this->realBundleSnapshots = []; + parent::tearDown(); } diff --git a/tests/Feature/Extension/HookListenerAllowlistIsolationTest.php b/tests/Feature/Extension/HookListenerAllowlistIsolationTest.php new file mode 100644 index 00000000..09933ed6 --- /dev/null +++ b/tests/Feature/Extension/HookListenerAllowlistIsolationTest.php @@ -0,0 +1,125 @@ +assertTrue(ExtensionTestAllowlist::isActive()); + $this->assertTrue(ExtensionTestAllowlist::isAllowed('module', 'sirsoft-board')); + $this->assertFalse( + ExtensionTestAllowlist::isAllowed('module', 'sirsoft-ecommerce'), + 'allowlist 밖 모듈이 허용되면 그 모듈의 리스너가 프로바이더 없이 등록된다.' + ); + $this->assertFalse(ExtensionTestAllowlist::isAllowed('plugin', 'sirsoft-gdpr')); + } + + /** + * 해석할 수 없는 리스너는 호출자의 작업을 깨뜨리지 않고 건너뛰는지 확인합니다. + * + * 수정 전에는 BindingResolutionException 이 그대로 전파돼, 큐 드라이버가 sync 인 환경에서 + * 게시글 삭제 요청 자체가 500 이 됐다. + */ + #[Test] + public function unresolvable_listener_is_skipped_instead_of_breaking_the_caller(): void + { + Log::spy(); + + $job = new DispatchHookListenerJob( + UnwiredExtensionListenerStub::class, + 'handle', + [], + [], + ); + + $job->handle(); + + Log::shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '훅 리스너를 해석할 수 없어')) + ->once(); + } + + /** + * 리스너 자신의 도메인 예외는 그대로 전파되는지 확인합니다. + * + * 해석 실패만 건너뛰어야 하며, 리스너가 실제로 던진 예외까지 삼키면 실패가 조용해진다. + */ + #[Test] + public function listener_domain_exception_still_propagates(): void + { + $job = new DispatchHookListenerJob( + ThrowingListenerStub::class, + 'handle', + [], + [], + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('도메인 실패'); + + $job->handle(); + } +} + +/** + * 배선되지 않은 확장의 리스너 대역 — 해석 불가능한 의존을 요구합니다. + */ +class UnwiredExtensionListenerStub +{ + /** + * @param UnboundExtensionContract $dependency 바인딩되지 않은 의존 + */ + public function __construct(private UnboundExtensionContract $dependency) {} + + /** + * 훅 처리 (도달하지 않아야 함) + */ + public function handle(): void {} +} + +/** + * 어떤 구현체도 바인딩되지 않은 계약 (확장 ServiceProvider 미등록 상황 재현용) + */ +interface UnboundExtensionContract {} + +/** + * 도메인 예외를 던지는 리스너 대역 + */ +class ThrowingListenerStub +{ + /** + * 훅 처리 — 도메인 예외 발생 + */ + public function handle(): void + { + throw new \RuntimeException('도메인 실패'); + } +} diff --git a/tests/Feature/Extension/ModuleCacheTest.php b/tests/Feature/Extension/ModuleCacheTest.php index e366bd00..984aa4d8 100644 --- a/tests/Feature/Extension/ModuleCacheTest.php +++ b/tests/Feature/Extension/ModuleCacheTest.php @@ -17,8 +17,12 @@ use Tests\TestCase; * 새 CacheInterface 기반 캐시 키(`g7:core:ext.modules.active_identifiers` 등)와 * Trait 의 캐시 무효화 동작을 검증합니다. * - * 참고: 이전의 "빈 배열 캐시 금지" 정책은 새 시스템에서 폐기되었습니다. - * 대신 모듈 설치/활성화 훅에서 invalidateModuleStatusCache() 로 확실히 무효화합니다. + * "빈 배열 캐시 금지" 정책은 한때 폐기되고 명시적 무효화(invalidateModuleStatusCache)에만 + * 의존했으나, 그 구조로는 회복 불가능한 실패가 실제로 발생하여 되살렸습니다: + * DB 에는 활성 모듈 3건이 있는데 캐시에는 빈 배열이 남아, 모든 모듈 관리자 화면이 404 가 + * 되고 TTL(기본 하루) 이 지나기 전까지 스스로 회복되지 않았습니다. 확장 작업 도중 상태가 + * 잠시 active 가 아닌 창이 존재하는 한, 그 순간의 읽기를 막을 방법은 없습니다. + * 따라서 명시적 무효화는 그대로 두되, **빈 결과는 애초에 캐시하지 않습니다**. */ class ModuleCacheTest extends TestCase { @@ -26,18 +30,27 @@ class ModuleCacheTest extends TestCase private const PLUGIN_ACTIVE_KEY = 'g7:core:ext.plugins.active_identifiers'; + private const TEMP_MODULE = 'vendor-status_window_module'; + + private const TEMP_PLUGIN = 'vendor-status_window_plugin'; + protected function setUp(): void { parent::setUp(); - Cache::flush(); + // 확장 상태 캐시는 개발 환경과 **같은 저장소**를 쓴다. 테스트 DB 에는 확장 행이 없으므로, + // 여기서 계산한 "활성 확장 없음" 이 공유 캐시에 실리면 개발 사이트가 그 값을 읽어 + // 모든 모듈 화면이 404 가 된다(실제로 발생). 이 클래스만 메모리 저장소로 격리한다. + config(['cache.default' => 'array']); + + Cache::store('array')->flush(); ModuleManager::invalidateModuleStatusCache(); PluginManager::invalidatePluginStatusCache(); } protected function tearDown(): void { - Cache::flush(); + Cache::store('array')->flush(); parent::tearDown(); } @@ -101,4 +114,83 @@ class ModuleCacheTest extends TestCase $this->assertNotNull($cached, '활성 플러그인 목록이 캐시되어야 합니다'); $this->assertEquals($result, $cached); } + + /** + * 빈 결과는 캐시에 남지 않아야 합니다 (모듈). + * + * 확장 작업 중 상태가 잠시 active 가 아닌 창에서 이 목록을 읽으면 빈 배열이 하루 동안 + * 굳는다. 그 뒤 상태가 정상으로 돌아와도 모든 모듈이 꺼진 것처럼 동작한다. + */ + #[Test] + public function it_does_not_cache_empty_active_modules(): void + { + // 이 클래스는 RefreshDatabase 를 쓰지 않는다(실제 설치 상태를 읽는 테스트가 섞여 있음). + // 따라서 원래 상태를 기억했다가 반드시 되돌린다. + $originalStatuses = Module::query()->pluck('status', 'identifier')->all(); + Module::where('identifier', self::TEMP_MODULE)->delete(); + + try { + $module = Module::factory()->create([ + 'identifier' => self::TEMP_MODULE, + 'status' => ExtensionStatus::Inactive->value, + ]); + + // 확장 작업 중 상태 창 재현 — 활성 모듈이 하나도 없는 순간 + Module::query()->update(['status' => ExtensionStatus::Inactive->value]); + ModuleManager::invalidateModuleStatusCache(); + + $this->assertSame([], ModuleManager::getActiveModuleIdentifiers()); + $this->assertNull( + Cache::get(self::MODULE_ACTIVE_KEY), + '빈 결과가 캐시되면 상태가 정상으로 돌아와도 하루 동안 모든 모듈이 꺼진 것으로 보입니다.' + ); + + // 상태 복원 후 무효화 없이도 즉시 반영되어야 한다 + $module->update(['status' => ExtensionStatus::Active->value]); + + $this->assertContains( + self::TEMP_MODULE, + ModuleManager::getActiveModuleIdentifiers(), + '상태가 정상으로 돌아왔는데도 빈 목록이 반환됩니다 — 스스로 회복되지 않는 상태입니다.' + ); + } finally { + Module::where('identifier', self::TEMP_MODULE)->delete(); + foreach ($originalStatuses as $identifier => $status) { + Module::where('identifier', $identifier)->update(['status' => $status]); + } + ModuleManager::invalidateModuleStatusCache(); + } + } + + /** + * 빈 결과는 캐시에 남지 않아야 합니다 (플러그인). + */ + #[Test] + public function it_does_not_cache_empty_active_plugins(): void + { + $originalStatuses = Plugin::query()->pluck('status', 'identifier')->all(); + Plugin::where('identifier', self::TEMP_PLUGIN)->delete(); + + try { + $plugin = Plugin::factory()->create([ + 'identifier' => self::TEMP_PLUGIN, + 'status' => ExtensionStatus::Inactive->value, + ]); + Plugin::query()->update(['status' => ExtensionStatus::Inactive->value]); + PluginManager::invalidatePluginStatusCache(); + + $this->assertSame([], PluginManager::getActivePluginIdentifiers()); + $this->assertNull(Cache::get(self::PLUGIN_ACTIVE_KEY)); + + $plugin->update(['status' => ExtensionStatus::Active->value]); + + $this->assertContains(self::TEMP_PLUGIN, PluginManager::getActivePluginIdentifiers()); + } finally { + Plugin::where('identifier', self::TEMP_PLUGIN)->delete(); + foreach ($originalStatuses as $identifier => $status) { + Plugin::where('identifier', $identifier)->update(['status' => $status]); + } + PluginManager::invalidatePluginStatusCache(); + } + } } diff --git a/tests/Feature/Services/LayoutExtensionIntegrationTest.php b/tests/Feature/Services/LayoutExtensionIntegrationTest.php index 53931d31..0b2bdaf7 100644 --- a/tests/Feature/Services/LayoutExtensionIntegrationTest.php +++ b/tests/Feature/Services/LayoutExtensionIntegrationTest.php @@ -1030,8 +1030,11 @@ class LayoutExtensionIntegrationTest extends TestCase $result = $this->service->applyExtensions($layout, $this->template->id); - $handlers = array_column($result['init_actions'] ?? [], 'handler'); + // 결과는 표준 키(initActions) 로 통합된다. deprecated 별칭(init_actions)이 함께 남으면 + // 엔진의 `initActions || init_actions` 단락이 한쪽을 통째로 무시하므로 제거되어야 한다. + $handlers = array_column($result['initActions'] ?? [], 'handler'); $this->assertSame(['hostInit', 'sirsoft-ecommerce.initPreferredCurrency'], $handlers, '호스트 init_actions 뒤에 확장 init_actions 가 병합되어야 한다'); + $this->assertArrayNotHasKey('init_actions', $result, 'deprecated 별칭 키가 남으면 통합 결과가 무시된다'); } /** diff --git a/tests/Feature/Services/TemplateServiceLocalizationTest.php b/tests/Feature/Services/TemplateServiceLocalizationTest.php index 9cb6a7ea..f0d3ec90 100644 --- a/tests/Feature/Services/TemplateServiceLocalizationTest.php +++ b/tests/Feature/Services/TemplateServiceLocalizationTest.php @@ -14,9 +14,10 @@ class TemplateServiceLocalizationTest extends TestCase { App::setLocale('ko'); + // 메시지는 원인(:error)을 함께 노출한다 — 치환까지 확인해야 자리표시자 누락을 잡는다. $this->assertEquals( - '파일 복사에 실패했습니다.', - __('templates.errors.file_copy_failed') + '파일 복사에 실패했습니다: 디스크 없음', + __('templates.errors.file_copy_failed', ['error' => '디스크 없음']) ); $this->assertEquals( @@ -33,8 +34,8 @@ class TemplateServiceLocalizationTest extends TestCase App::setLocale('en'); $this->assertEquals( - 'Failed to copy files.', - __('templates.errors.file_copy_failed') + 'Failed to copy files: disk missing', + __('templates.errors.file_copy_failed', ['error' => 'disk missing']) ); $this->assertEquals( @@ -50,13 +51,13 @@ class TemplateServiceLocalizationTest extends TestCase { // 한국어로 설정 App::setLocale('ko'); - $koreanMessage = __('templates.errors.file_copy_failed'); - $this->assertEquals('파일 복사에 실패했습니다.', $koreanMessage); + $koreanMessage = __('templates.errors.file_copy_failed', ['error' => 'x']); + $this->assertEquals('파일 복사에 실패했습니다: x', $koreanMessage); // 영어로 전환 App::setLocale('en'); - $englishMessage = __('templates.errors.file_copy_failed'); - $this->assertEquals('Failed to copy files.', $englishMessage); + $englishMessage = __('templates.errors.file_copy_failed', ['error' => 'x']); + $this->assertEquals('Failed to copy files: x', $englishMessage); // 메시지가 다른지 확인 $this->assertNotEquals($koreanMessage, $englishMessage); diff --git a/tests/Feature/Services/TemplateServiceRoutesMergeTest.php b/tests/Feature/Services/TemplateServiceRoutesMergeTest.php index 80079a39..8a78c789 100644 --- a/tests/Feature/Services/TemplateServiceRoutesMergeTest.php +++ b/tests/Feature/Services/TemplateServiceRoutesMergeTest.php @@ -191,6 +191,76 @@ class TemplateServiceRoutesMergeTest extends TestCase $this->assertNotEmpty($modulePaths, 'Module routes should be merged'); } + /** + * 활성 모듈의 디렉토리가 없으면 열화 상태로 표시되는지 확인합니다. + * + * 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다. + * 이 순간의 응답이 캐시 버전 키에 남으면, 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 + * 해당 모듈의 모든 화면이 404 가 된다 — 버전은 이미 올라간 뒤라 스스로 회복되지 않는다. + * 캐시 여부를 판단할 수 있도록 병합 결과가 열화였음을 알려야 한다. + */ + #[Test] + public function route_merge_is_flagged_degraded_when_active_module_directory_is_missing(): void + { + Template::factory()->create([ + 'identifier' => 'sirsoft-admin_basic', + 'type' => 'admin', + 'status' => ExtensionStatus::Active->value, + ]); + + $this->templateManager->method('getTemplateInfo')->willReturn([ + 'identifier' => 'sirsoft-admin_basic', + 'type' => 'admin', + ]); + + // 활성 목록에는 있으나 디렉토리가 없는 모듈 (= 업데이트 중 스왑 창) + $mockModule = $this->createMock(ModuleInterface::class); + $mockModule->method('getIdentifier')->willReturn('vendor-absent_module'); + $this->moduleManager->method('getActiveModules')->willReturn([ + 'vendor-absent_module' => $mockModule, + ]); + + $result = $this->templateService->getRoutesDataWithModules('sirsoft-admin_basic'); + + $this->assertTrue($result['success']); + $this->assertTrue( + $result['degraded'], + '활성 모듈 디렉토리가 없는데 열화로 표시되지 않았습니다 — 이 응답이 캐시되면 업데이트 후에도 404 가 남습니다.' + ); + $this->assertTrue($this->templateService->lastRouteMergeWasDegraded()); + } + + /** + * 정상 상태에서는 열화로 표시되지 않는지 확인합니다 (과잉 차단 방지). + * + * 열화 판정이 헐거우면 모든 응답이 캐시되지 않아 매 요청이 디스크 병합으로 떨어진다. + */ + #[Test] + public function route_merge_is_not_flagged_degraded_in_normal_state(): void + { + Template::factory()->create([ + 'identifier' => 'sirsoft-admin_basic', + 'type' => 'admin', + 'status' => ExtensionStatus::Active->value, + ]); + + $this->templateManager->method('getTemplateInfo')->willReturn([ + 'identifier' => 'sirsoft-admin_basic', + 'type' => 'admin', + ]); + + $mockModule = $this->createMock(ModuleInterface::class); + $mockModule->method('getIdentifier')->willReturn('sirsoft-board'); + $this->moduleManager->method('getActiveModules')->willReturn([ + 'sirsoft-board' => $mockModule, + ]); + + $result = $this->templateService->getRoutesDataWithModules('sirsoft-admin_basic'); + + $this->assertTrue($result['success']); + $this->assertFalse($result['degraded']); + } + #[Test] public function returns_error_for_nonexistent_template(): void { diff --git a/tests/Feature/Settings/SettingsBoundaryContractTest.php b/tests/Feature/Settings/SettingsBoundaryContractTest.php new file mode 100644 index 00000000..a3cf56e8 --- /dev/null +++ b/tests/Feature/Settings/SettingsBoundaryContractTest.php @@ -0,0 +1,198 @@ + + */ + private function boundKeys(): array + { + return [ + 'upload.max_file_size' => 'upload_max_file_size', + 'upload.image_max_width' => 'upload_image_max_width', + 'upload.image_max_height' => 'upload_image_max_height', + 'upload.image_quality' => 'upload_image_quality', + 'seo.og_image_default_width' => 'seo_og_image_default_width', + 'seo.og_image_default_height' => 'seo_og_image_default_height', + 'seo.cache_ttl' => 'seo_cache_ttl', + 'seo.sitemap_cache_ttl' => 'seo_sitemap_cache_ttl', + 'seo.sitemap_urls_per_file' => 'seo_sitemap_urls_per_file', + 'security.password_min_length' => 'security_password_min_length', + 'security.auth_token_lifetime' => 'security_auth_token_lifetime', + 'security.max_login_attempts' => 'security_max_login_attempts', + 'security.login_lockout_time' => 'security_login_lockout_time', + 'advanced.layout_cache_ttl' => 'advanced_cache_ttl', + 'advanced.stats_cache_ttl' => 'advanced_cache_ttl', + 'advanced.seo_cache_ttl' => 'advanced_cache_ttl', + 'advanced.seo_sitemap_cache_ttl' => 'advanced_seo_sitemap_cache_ttl', + 'drivers.redis_port' => 'drivers_redis_port', + 'drivers.redis_database' => 'drivers_redis_database', + 'drivers.memcached_port' => 'drivers_memcached_port', + 'drivers.session_lifetime' => 'drivers_session_lifetime', + 'drivers.websocket_port' => 'drivers_websocket_port', + 'drivers.websocket_server_port' => 'drivers_websocket_server_port', + 'drivers.log_days' => 'drivers_log_days', + 'identity.challenge_ttl_minutes' => 'identity_challenge_ttl_minutes', + 'identity.max_attempts' => 'identity_max_attempts', + ]; + } + + /** + * 규칙 배열에서 min/max 숫자를 뽑습니다. + * + * @param array|string $rules 검증 규칙 + * @return array{min: string|null, max: string|null} 경계값 + */ + private function boundsOf(array|string $rules): array + { + $flat = is_string($rules) ? explode('|', $rules) : $rules; + $found = ['min' => null, 'max' => null]; + + foreach ($flat as $rule) { + if (! is_string($rule)) { + continue; + } + + foreach (['min', 'max'] as $bound) { + if (str_starts_with($rule, $bound.':')) { + $found[$bound] = substr($rule, strlen($bound) + 1); + } + } + + // 'integer|min:1|max:1024' 처럼 한 문자열에 묶인 형태 + if (str_contains($rule, '|')) { + foreach (explode('|', $rule) as $part) { + foreach (['min', 'max'] as $bound) { + if (str_starts_with($part, $bound.':')) { + $found[$bound] = substr($part, strlen($bound) + 1); + } + } + } + } + } + + return $found; + } + + /** + * 저장 규칙의 경계값이 한계값 설정과 일치하는지 확인합니다. + */ + #[Test] + public function save_rules_use_the_shared_limit_values(): void + { + $limits = config('core.settings_limits'); + $rules = (new SaveSettingsRequest)->rules(); + + foreach ($this->boundKeys() as $settingKey => $limitPrefix) { + $this->assertArrayHasKey($settingKey, $rules, "{$settingKey} 규칙이 없습니다."); + + $bounds = $this->boundsOf($rules[$settingKey]); + + foreach (['min', 'max'] as $bound) { + $limitKey = "{$limitPrefix}_{$bound}"; + + if (! array_key_exists($limitKey, $limits)) { + continue; + } + + $this->assertSame( + (string) $limits[$limitKey], + $bounds[$bound], + "{$settingKey} 의 {$bound} 이 한계값 설정({$limitKey})과 다릅니다 — " + .'규칙이 리터럴로 되돌아갔는지 확인하세요.' + ); + } + } + } + + /** + * 한계값 설정이 바뀌면 규칙도 따라 바뀌는지 확인합니다. + * + * 배선이 풀려 리터럴로 되돌아가면 이 테스트가 먼저 깨진다. + */ + #[Test] + public function changing_a_limit_changes_the_rule(): void + { + config(['core.settings_limits.drivers_log_days_max' => 999]); + + $bounds = $this->boundsOf((new SaveSettingsRequest)->rules()['drivers.log_days']); + + $this->assertSame('999', $bounds['max']); + } + + /** + * 모든 설정 필드가 오류 안내에 쓸 항목명을 갖는지 확인합니다. + * + * 항목명이 없으면 Laravel 이 키를 그대로 풀어 쓴다 — 화면에는 + * "security.password min length 필드는 64를 초과하면 안 됩니다" 처럼 내부 식별자가 노출된다. + * 규칙에 필드를 추가하면서 항목명을 빠뜨리는 것이 이 결함의 유일한 경로이므로, + * 규칙 키 전수와 항목명 전수를 직접 대조한다. + */ + #[Test] + public function every_setting_field_has_a_translated_attribute_label(): void + { + $request = new SaveSettingsRequest; + $attributes = $request->attributes(); + + // 배열 항목 규칙(`foo.*`)은 부모 키의 항목명을 물려받으므로 대조 대상에서 제외한다. + $fieldKeys = array_values(array_filter( + array_keys($request->rules()), + fn (string $key) => str_contains($key, '.') && ! str_contains($key, '*') + )); + + $missing = array_values(array_filter( + $fieldKeys, + fn (string $key) => ! array_key_exists($key, $attributes) + )); + + $this->assertSame( + [], + $missing, + '항목명이 없는 설정 필드가 있습니다 — 저장 실패 안내에 내부 식별자가 그대로 노출됩니다: ' + .implode(', ', $missing) + ); + } + + /** + * 항목명이 실제 번역 문장으로 해석되는지 확인합니다. + * + * `attributes()` 에 키를 채워도 lang 파일에 항목이 없으면 `validation.attributes.xxx` + * 라는 키 문자열이 그대로 화면에 나온다 — 내부 식별자 노출이라는 결과는 같다. + */ + #[Test] + public function attribute_labels_resolve_to_real_translations(): void + { + $unresolved = []; + + foreach ((new SaveSettingsRequest)->attributes() as $field => $label) { + if (str_starts_with((string) $label, 'validation.attributes.')) { + $unresolved[] = $field; + } + } + + $this->assertSame( + [], + $unresolved, + '번역이 없어 키 문자열이 그대로 노출되는 항목명이 있습니다: '.implode(', ', $unresolved) + ); + } +} diff --git a/tests/Feature/Settings/UploadAllowedExtensionsContractTest.php b/tests/Feature/Settings/UploadAllowedExtensionsContractTest.php new file mode 100644 index 00000000..3d335c74 --- /dev/null +++ b/tests/Feature/Settings/UploadAllowedExtensionsContractTest.php @@ -0,0 +1,130 @@ + 'upload', + 'upload' => [ + 'max_file_size' => 20, + 'allowed_extensions' => $allowedExtensions, + ], + ]); + + $request->setContainer($this->app)->validateResolved(); + + return $request->validated(); + } + + /** + * applyUploadConfig 를 리플렉션으로 호출합니다. + * + * @param array $uploadSettings upload 카테고리 설정 데이터 + */ + private function applyUploadConfig(array $uploadSettings): void + { + $configRepository = $this->createMock(JsonConfigRepository::class); + $configRepository->method('getCategory') + ->with('upload') + ->willReturn($uploadSettings); + + $provider = new SettingsServiceProvider($this->app); + $method = new ReflectionMethod($provider, 'applyUploadConfig'); + $method->invoke($provider, $configRepository); + } + + /** + * 화면이 보내는 콤마 문자열이 저장 단계에서 배열로 정규화되는지 확인합니다. + */ + #[Test] + public function comma_string_is_normalized_to_an_array_before_saving(): void + { + $validated = $this->validatedUploadPayload('JPG, .png ,pdf,,'); + + $this->assertSame( + ['jpg', 'png', 'pdf'], + $validated['upload']['allowed_extensions'], + '허용 확장자 문자열이 배열로 정규화되지 않았습니다 — 저장돼도 업로드 제한에 반영되지 않습니다.' + ); + } + + /** + * 배열로 보내는 경로(API 직접 호출 등)도 같은 형태로 정규화되는지 확인합니다. + */ + #[Test] + public function array_input_is_normalized_the_same_way(): void + { + $validated = $this->validatedUploadPayload(['JPG', '.PNG', ' pdf ']); + + $this->assertSame(['jpg', 'png', 'pdf'], $validated['upload']['allowed_extensions']); + } + + /** + * 저장한 확장자 목록이 실제 업로드 제한으로 이어지는지 확인합니다 (저장 → 적용 전 구간). + */ + #[Test] + public function saved_extensions_reach_the_upload_restriction(): void + { + config(['attachment.allowed_extensions' => ['gif']]); + + $validated = $this->validatedUploadPayload('jpg,png'); + app(SettingsService::class)->saveSettings($validated); + + $stored = app(ConfigRepositoryInterface::class)->getCategory('upload'); + + $this->assertSame(['jpg', 'png'], $stored['allowed_extensions']); + + $this->applyUploadConfig($stored); + + $this->assertSame(['jpg', 'png'], config('attachment.allowed_extensions')); + } + + /** + * 이미 문자열로 저장된 기존 설치도 업로드 제한에 반영되는지 확인합니다. + */ + #[Test] + public function legacy_string_value_already_on_disk_is_still_applied(): void + { + config(['attachment.allowed_extensions' => ['gif']]); + + $this->applyUploadConfig(['allowed_extensions' => 'JPG, .png , pdf']); + + $this->assertSame( + ['jpg', 'png', 'pdf'], + config('attachment.allowed_extensions'), + '문자열로 저장된 기존 설정이 업로드 제한에 반영되지 않았습니다.' + ); + } +} diff --git a/tests/Feature/View/AdminBladeTemplateLoadingTest.php b/tests/Feature/View/AdminBladeTemplateLoadingTest.php index 53c9e7c9..05a29054 100644 --- a/tests/Feature/View/AdminBladeTemplateLoadingTest.php +++ b/tests/Feature/View/AdminBladeTemplateLoadingTest.php @@ -72,7 +72,8 @@ class AdminBladeTemplateLoadingTest extends TestCase $response->assertSee('data-template-id="sirsoft-admin_basic"', false); // 템플릿 엔진 초기화 스크립트 확인 - $response->assertSee("templateId: 'sirsoft-admin_basic'", false); + // 초기화 설정은 @json 으로 직렬화된다 — 표기는 JSON 형태여야 한다. + $response->assertSee('"templateId":"sirsoft-admin_basic"', false); } /** @@ -106,7 +107,8 @@ class AdminBladeTemplateLoadingTest extends TestCase $response->assertSee('data-template-id="sirsoft-admin_basic"', false); // 템플릿 엔진 초기화 스크립트에 기본값 확인 - $response->assertSee("templateId: 'sirsoft-admin_basic'", false); + // 초기화 설정은 @json 으로 직렬화된다 — 표기는 JSON 형태여야 한다. + $response->assertSee('"templateId":"sirsoft-admin_basic"', false); } /** @@ -184,9 +186,10 @@ class AdminBladeTemplateLoadingTest extends TestCase // Assert: 템플릿 엔진 초기화 설정 확인 $response->assertStatus(200); - $response->assertSee("templateId: 'sirsoft-admin_basic'", false); - $response->assertSee('locale:', false); // locale 값은 테스트 환경에 따라 'en' 또는 'ko'일 수 있음 - $response->assertSee('debug:', false); + // 초기화 설정은 @json 으로 직렬화된다 — 표기는 JSON 형태여야 한다. + $response->assertSee('"templateId":"sirsoft-admin_basic"', false); + $response->assertSee('"locale":', false); // locale 값은 테스트 환경에 따라 'en' 또는 'ko'일 수 있음 + $response->assertSee('"debug":', false); } /** diff --git a/tests/Feature/View/TemplateExternalsRenderingTest.php b/tests/Feature/View/TemplateExternalsRenderingTest.php index 211c5a39..dc5edab8 100644 --- a/tests/Feature/View/TemplateExternalsRenderingTest.php +++ b/tests/Feature/View/TemplateExternalsRenderingTest.php @@ -125,7 +125,7 @@ class TemplateExternalsRenderingTest extends TestCase $this->assertStringContainsString('href="https://cdn.example.com/user.css"', $html); $this->assertStringContainsString('src="https://cdn.example.com/user.js"', $html); - $this->assertStringContainsString("templateType: 'user'", $html); + $this->assertStringContainsString('"templateType":"user"', $html); $this->assertBefore($html, 'href="https://cdn.example.com"', 'href="https://cdn.example.com/user.css"'); } @@ -199,18 +199,33 @@ class TemplateExternalsRenderingTest extends TestCase 'js' => ['dist/js/components.iife.js'], ], 'components' => ['basic' => [], 'composite' => [], 'layout' => []], + // 에러 레이아웃 식별자는 디렉토리 접두사를 포함한다 (실제 템플릿과 동일 규약). + // 접두사를 빼면 활성화 검증이 layouts/error_404.json 을 찾다가 실패한다. 'error_config' => [ 'layouts' => [ - '404' => 'error_404', - '403' => 'error_403', - '500' => 'error_500', + '404' => 'errors/404', + '403' => 'errors/403', + '500' => 'errors/500', ], ], 'externals' => $externals, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); File::put($this->fixtureTemplatePath.'/components.json', json_encode(['components' => []], JSON_PRETTY_PRINT)); - File::put($this->fixtureTemplatePath.'/routes.json', json_encode(['routes' => []], JSON_PRETTY_PRINT)); + + // 사용자 템플릿은 홈 라우트가 있어야 `/` 요청이 렌더된다 — 없으면 catch-all 이 404 를 + // 돌려주어 externals 가 실린 화면 자체를 검사할 수 없다. + File::put($this->fixtureTemplatePath.'/routes.json', json_encode([ + 'routes' => [ + ['path' => '/', 'layout' => 'home', 'auth_required' => false], + ], + ], JSON_PRETTY_PRINT)); + File::put($this->fixtureTemplatePath.'/layouts/home.json', json_encode([ + 'version' => '1.0.0', + 'layout_name' => 'home', + 'meta' => ['title' => 'Home'], + 'components' => [], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); File::put($this->fixtureTemplatePath.'/dist/js/components.iife.js', '// test bundle'); File::put($this->fixtureTemplatePath.'/dist/css/components.css', '/* test css */'); $errorLayout = json_encode([ @@ -219,9 +234,9 @@ class TemplateExternalsRenderingTest extends TestCase 'meta' => ['title' => 'Error'], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($this->fixtureTemplatePath.'/layouts/errors/error_404.json', $errorLayout); - File::put($this->fixtureTemplatePath.'/layouts/errors/error_403.json', $errorLayout); - File::put($this->fixtureTemplatePath.'/layouts/errors/error_500.json', $errorLayout); + File::put($this->fixtureTemplatePath.'/layouts/errors/404.json', $errorLayout); + File::put($this->fixtureTemplatePath.'/layouts/errors/403.json', $errorLayout); + File::put($this->fixtureTemplatePath.'/layouts/errors/500.json', $errorLayout); } private function deleteFixtureTemplate(): void diff --git a/tests/Playwright/specs/admin/settings-security-outbound-toggle.spec.ts b/tests/Playwright/specs/admin/settings-security-outbound-toggle.spec.ts index cd7e43cf..94a4c0f7 100644 --- a/tests/Playwright/specs/admin/settings-security-outbound-toggle.spec.ts +++ b/tests/Playwright/specs/admin/settings-security-outbound-toggle.spec.ts @@ -66,6 +66,40 @@ test('#466 - 토글을 클릭하면 상태가 바뀌고 저장 버튼이 활성 await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); }); +// @scenario tab=security, permitted=yes +// @effects password_limit_binding_resolved +test('#493 - 비밀번호 최소 길이의 경계값이 서버 한계값에서 실제로 해석된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoSecurityTab(page); + + const input = page.locator('input[name="security.password_min_length"]').first(); + await expect(input).toBeAttached({ timeout: 20_000 }); + + // 서버가 실제로 내려주는 한계값 목록을 그대로 읽는다 + const limits = await page.evaluate(async (bearer) => { + const response = await fetch('/api/admin/settings', { + headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }, + }); + const body = await response.json(); + + return (body?.data?._meta?.limits ?? {}) as Record; + }, token); + + // 화면이 참조하는 키가 응답에 없으면 바인딩은 영구히 폴백으로 떨어진다. + // 값(6/64)이 폴백과 같아 화면만 봐서는 구분되지 않으므로 키 존재로 판정한다. + expect( + Object.keys(limits), + '보안 한계값이 설정 응답에 실려 있지 않습니다.', + ).toContain('security_password_min_length_min'); + expect(Object.keys(limits)).toContain('security_password_min_length_max'); + + // 렌더된 경계값이 서버 한계값과 일치한다 + await expect(input).toHaveAttribute('min', String(limits.security_password_min_length_min)); + await expect(input).toHaveAttribute('max', String(limits.security_password_min_length_max)); +}); + /** * 보안 탭 토글의 on/off 상태를 읽는다 (sr-only checkbox 의 checked 기준). */ diff --git a/tests/TestCase.php b/tests/TestCase.php index 0f955077..a73985e6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,7 +5,9 @@ namespace Tests; use App\Contracts\Notifications\ChannelReadinessCheckerInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Extension\Testing\ExtensionTestAllowlist; +use App\Helpers\PermissionHelper; use App\Listeners\Identity\EnforceIdentityPolicyListener; +use App\Support\AssetUrl; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -52,6 +54,12 @@ abstract class TestCase extends BaseTestCase $this->afterApplicationCreated(function () { $this->fakeSettingsDisk(); + $this->pinEnvironmentDependentSettings(); + + // 권한 스코프 캐시는 정적이라 프로세스 전체에 남는다 — 케이스마다 DB 는 + // 초기화되는데 캐시만 남으면 앞 케이스의 권한 행 상태가 뒤로 새어, + // 스코프 검사가 조용히 건너뛰어진다 (개별 통과 / 스위트 실패). + PermissionHelper::clearPermissionScopeCache(); }); if (! self::$staleConnectionsCleaned) { @@ -85,6 +93,24 @@ abstract class TestCase extends BaseTestCase Storage::fake('settings'); } + /** + * 개발 사이트 설정이 테스트 결과를 좌우하는 항목을 기본값으로 고정합니다. + * + * 설정 *읽기* 는 부팅 시 `g7_settings.*` Config 로 적재되므로 디스크 페이크보다 앞섭니다 + * (fakeSettingsDisk 주석 참조). 즉 개발자가 자기 사이트에 어떤 값을 저장해 두었는지가 + * 그대로 테스트에 흘러듭니다. + * + * `general.asset_url_mode` 가 그 예다. 개발 사이트를 확장자 없는 모드로 운영하면 + * 기본 모드를 전제한 테스트(자산 URL 생성·blade 렌더)가 그 환경에서만 무더기로 깨진다. + * 실제로 이 환경에서 8건이 그렇게 실패했다. + * + * 다른 모드를 검증해야 하는 테스트는 `AssetUrl::forceMode()` 로 자기 전제를 명시한다. + */ + private function pinEnvironmentDependentSettings(): void + { + config(['g7_settings.core.general.asset_url_mode' => AssetUrl::MODE_EXTENSION]); + } + /** * mail 채널을 발송 준비 완료 상태로 만듭니다. * diff --git a/tests/Unit/Extension/ExtensionUpdateStatusCacheSymmetryTest.php b/tests/Unit/Extension/ExtensionUpdateStatusCacheSymmetryTest.php new file mode 100644 index 00000000..93e6e49d --- /dev/null +++ b/tests/Unit/Extension/ExtensionUpdateStatusCacheSymmetryTest.php @@ -0,0 +1,89 @@ +updateMethodSource(base_path($relativePath), $method); + + $restores = preg_match_all("/'status'\s*=>\s*\\\$previousStatus/", $body); + $invalidations = substr_count($body, $invalidateCall); + + $this->assertGreaterThan(0, $restores, "{$relativePath} 에서 상태 복원 지점을 찾지 못했습니다 — 검사가 무력화됐습니다."); + + $this->assertGreaterThanOrEqual( + $restores, + $invalidations, + "{$relativePath}: 상태 복원 {$restores}회에 비해 캐시 무효화가 {$invalidations}회뿐입니다 — " + .'복원했는데 비우지 않은 경로가 있으면 그 확장 화면이 캐시 만료까지 404 로 남습니다.' + ); + } + + /** + * 업데이트 메서드 본문을 주석 제거 후 반환합니다. + * + * 주석을 남겨 두면 설명 문장에 들어 있는 호출 표기가 검사를 통과시킨다. + * + * @param string $path 대상 파일 절대 경로 + * @param string $method 대상 메서드명 (updateModule / updatePlugin) + * @return string 주석이 제거된 메서드 본문 + */ + private function updateMethodSource(string $path, string $method): string + { + $this->assertFileExists($path); + + $stripped = ''; + foreach (token_get_all(file_get_contents($path)) as $token) { + if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + $stripped .= is_array($token) ? $token[1] : $token; + } + + $start = strpos($stripped, "public function {$method}("); + $this->assertNotFalse($start, "{$path} 에서 {$method}() 메서드를 찾지 못했습니다."); + + // 중괄호 균형으로 메서드 본문 끝을 찾는다 + $open = strpos($stripped, '{', $start); + $depth = 0; + $length = strlen($stripped); + + for ($i = $open; $i < $length; $i++) { + if ($stripped[$i] === '{') { + $depth++; + } elseif ($stripped[$i] === '}') { + $depth--; + if ($depth === 0) { + return substr($stripped, $open, $i - $open + 1); + } + } + } + + $this->fail("{$path} 의 update() 본문 끝을 찾지 못했습니다."); + } +} diff --git a/tests/Unit/Jobs/DispatchHookListenerJobTest.php b/tests/Unit/Jobs/DispatchHookListenerJobTest.php index cd38b966..31aaf5bd 100644 --- a/tests/Unit/Jobs/DispatchHookListenerJobTest.php +++ b/tests/Unit/Jobs/DispatchHookListenerJobTest.php @@ -6,6 +6,7 @@ use App\Extension\HookArgumentSerializer; use App\Jobs\DispatchHookListenerJob; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Request; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; use Tests\TestCase; @@ -100,6 +101,51 @@ class DispatchHookListenerJobTest extends TestCase $this->assertEquals('198.51.100.7', ContextAwareStubListener::$capturedIp); $this->assertEquals('api/admin/settings', ContextAwareStubListener::$capturedPath); } + + /** + * Job 실행 후 원래 요청 바인딩이 되돌려지는지 검증합니다. + * + * 큐 드라이버가 sync 면 이 Job 은 원 HTTP 요청과 같은 컨테이너에서 실행됩니다. + * 컨텍스트 복원이 재구성 Request 를 바인딩한 채 끝나면, 그 뒤로 이어지는 같은 요청의 + * 코드가 헤더·입력이 지워진 가짜 GET 요청을 보게 됩니다 — 뒤 순위 훅 리스너가 전송 + * 헤더를 읽지 못해 조용히 건너뛰는 사고가 여기서 납니다. + */ + public function test_job_restores_original_request_binding_after_handle(): void + { + ContextAwareStubListener::reset(); + + $original = Request::create( + '/api/auth/login', + 'POST', + [], + [], + [], + ['HTTP_X_CART_KEY' => 'ck_'.str_repeat('a', 32)], + ); + app()->instance('request', $original); + + $job = new DispatchHookListenerJob( + ContextAwareStubListener::class, + 'capture', + HookArgumentSerializer::serialize([]), + [ + 'ip_address' => '198.51.100.7', + 'user_agent' => 'WorkerAgent/1.0', + 'locale' => 'ko', + 'path' => 'api/auth/login', + ] + ); + + $job->handle(); + + $this->assertSame($original, app('request'), 'Job 이 원 요청 바인딩을 되돌리지 않았습니다.'); + $this->assertSame( + 'ck_'.str_repeat('a', 32), + request()->header('X-Cart-Key'), + '큐 리스너 실행 후 원 요청의 헤더가 사라졌습니다 — 뒤 순위 리스너가 전송 헤더를 읽지 못합니다.' + ); + $this->assertSame('POST', request()->getMethod()); + } } class StubJobListener diff --git a/tests/Unit/Listeners/CoreActivityLogListenerTest.php b/tests/Unit/Listeners/CoreActivityLogListenerTest.php index c46cb99b..15d75f47 100644 --- a/tests/Unit/Listeners/CoreActivityLogListenerTest.php +++ b/tests/Unit/Listeners/CoreActivityLogListenerTest.php @@ -2,10 +2,10 @@ namespace Tests\Unit\Listeners; -use App\ActivityLog\ChangeDetector; use App\Enums\ActivityLogType; use App\Listeners\CoreActivityLogListener; use App\Models\ActivityLog; +use App\Models\Attachment; use App\Models\Schedule; use App\Models\User; use Illuminate\Database\Eloquent\Model; @@ -62,10 +62,6 @@ class CoreActivityLogListenerTest extends TestCase /** * User mock 생성 * - * @param int $id - * @param string $uuid - * @param string $email - * @param string $name * @return MockInterface|User */ private function createUserMock(int $id = 1, string $uuid = 'uuid-123', string $email = 'test@test.com', string $name = 'Test User'): MockInterface @@ -79,8 +75,6 @@ class CoreActivityLogListenerTest extends TestCase /** * Model mock 생성 * - * @param int $id - * @param array $extraAttributes * @return MockInterface|Model */ private function createModelMock(int $id = 1, array $extraAttributes = []): MockInterface @@ -93,11 +87,6 @@ class CoreActivityLogListenerTest extends TestCase /** * logChannel->info() 기대 설정 헬퍼 - * - * @param string $expectedAction - * @param ActivityLogType $expectedLogType - * @param string $expectedDescriptionKey - * @param bool $expectLoggable */ private function expectLogInfo( string $expectedAction, @@ -129,12 +118,13 @@ class CoreActivityLogListenerTest extends TestCase // getSubscribedHooks 테스트 // ═══════════════════════════════════════════ - public function test_getSubscribedHooks_returns_all_hooks(): void + public function test_get_subscribed_hooks_returns_all_hooks(): void { $hooks = CoreActivityLogListener::getSubscribedHooks(); - // 64개 로깅(after_*) 훅 (mail_template 2개 제거됨, IDV/auth 후속 추가 분 포함, develop 머지 후 +2) - $this->assertCount(64, $hooks); + // 65개 로깅(after_*) 훅 (mail_template 2개 제거됨, IDV/auth 후속 추가 분 포함, + // develop 머지 후 +2, 관리자 계정 잠금 해제(core.auth.account_unlocked) +1) + $this->assertCount(65, $hooks); } public function test_handle_does_nothing(): void @@ -148,7 +138,7 @@ class CoreActivityLogListenerTest extends TestCase // User 핸들러 테스트 (8개) // ═══════════════════════════════════════════ - public function test_handleUserAfterCreate_logs_activity(): void + public function test_handle_user_after_create_logs_activity(): void { $user = $this->createUserMock(1, 'uuid-abc', 'new@test.com', 'New User'); @@ -167,7 +157,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterCreate($user, ['email' => 'new@test.com']); } - public function test_handleUserAfterUpdate_logs_activity_with_changes(): void + public function test_handle_user_after_update_logs_activity_with_changes(): void { $user = $this->createUserMock(1, 'uuid-abc'); @@ -186,7 +176,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterUpdate($user, ['name' => 'Updated']); } - public function test_handleUserAfterUpdate_accepts_snapshot_argument(): void + public function test_handle_user_after_update_accepts_snapshot_argument(): void { $user = $this->createUserMock(1, 'uuid-abc'); $snapshot = ['id' => 1, 'name' => 'Old Name']; @@ -201,7 +191,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterUpdate($user, [], $snapshot); } - public function test_handleUserAfterDelete_logs_activity(): void + public function test_handle_user_after_delete_logs_activity(): void { // id 없는 경우 — ActivityLog::where() 호출 스킵, 로깅만 검증 $userData = ['uuid' => 'uuid-del', 'email' => 'del@test.com']; @@ -219,7 +209,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterDelete($userData); } - public function test_handleUserAfterDelete_handles_missing_uuid(): void + public function test_handle_user_after_delete_handles_missing_uuid(): void { // uuid 없는 경우 — 빈 문자열 fallback 검증 $userData = []; @@ -234,7 +224,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterDelete($userData); } - public function test_handleUserAfterWithdraw_logs_activity(): void + public function test_handle_user_after_withdraw_logs_activity(): void { $user = $this->createUserMock(1, 'uuid-wd'); @@ -243,7 +233,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterWithdraw($user); } - public function test_handleUserAfterShow_logs_activity(): void + public function test_handle_user_after_show_logs_activity(): void { $user = $this->createUserMock(1, 'uuid-show'); @@ -260,7 +250,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterShow($user); } - public function test_handleUserAfterList_logs_activity(): void + public function test_handle_user_after_list_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -274,7 +264,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterList(25); } - public function test_handleUserAfterSearch_logs_activity(): void + public function test_handle_user_after_search_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -289,7 +279,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleUserAfterSearch('john', 3); } - public function test_handleUserAfterBulkUpdate_logs_per_item(): void + public function test_handle_user_after_bulk_update_logs_per_item(): void { $users = collect([ User::factory()->create(), @@ -320,7 +310,7 @@ class CoreActivityLogListenerTest extends TestCase $this->assertCount(3, $loggedContexts); } - public function test_handleUserAfterBulkUpdate_skips_nonexistent_uuids(): void + public function test_handle_user_after_bulk_update_skips_nonexistent_uuids(): void { $user = User::factory()->create(); @@ -338,7 +328,7 @@ class CoreActivityLogListenerTest extends TestCase // Auth 핸들러 테스트 (6개) // ═══════════════════════════════════════════ - public function test_handleAuthAfterLogin_logs_activity(): void + public function test_handle_auth_after_login_logs_activity(): void { $user = $this->createUserMock(1); @@ -355,7 +345,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAuthAfterLogin($user, ['ip' => '127.0.0.1']); } - public function test_handleAuthLogout_logs_activity(): void + public function test_handle_auth_logout_logs_activity(): void { $user = $this->createUserMock(2); @@ -372,7 +362,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAuthLogout($user); } - public function test_handleAuthRegister_logs_activity_with_user_type(): void + public function test_handle_auth_register_logs_activity_with_user_type(): void { // auth 경로는 사용자 화면이므로 User 타입으로 자동 결정 $this->app->instance('request', Request::create('/api/auth/register')); @@ -391,7 +381,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAuthRegister($user, ['email' => 'new@test.com']); } - public function test_handleAuthForgotPassword_logs_activity(): void + public function test_handle_auth_forgot_password_logs_activity(): void { $this->app->instance('request', Request::create('/api/auth/forgot-password')); $user = $this->createUserMock(4); @@ -409,7 +399,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAuthForgotPassword($user); } - public function test_handleAuthResetPassword_logs_activity(): void + public function test_handle_auth_reset_password_logs_activity(): void { $this->app->instance('request', Request::create('/api/auth/reset-password')); $user = $this->createUserMock(5); @@ -427,7 +417,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAuthResetPassword($user); } - public function test_handleAuthRecordConsents_logs_activity(): void + public function test_handle_auth_record_consents_logs_activity(): void { $this->app->instance('request', Request::create('/api/auth/record-consents')); $user = $this->createUserMock(6); @@ -450,7 +440,7 @@ class CoreActivityLogListenerTest extends TestCase // Role 핸들러 테스트 (5개) // ═══════════════════════════════════════════ - public function test_handleRoleAfterCreate_logs_activity(): void + public function test_handle_role_after_create_logs_activity(): void { $role = $this->createModelMock(10, ['name' => 'Editor']); @@ -468,7 +458,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleRoleAfterCreate($role); } - public function test_handleRoleAfterUpdate_logs_activity_with_changes(): void + public function test_handle_role_after_update_logs_activity_with_changes(): void { $role = $this->createModelMock(10, ['name' => 'SuperEditor']); @@ -486,7 +476,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleRoleAfterUpdate($role); } - public function test_handleRoleAfterUpdate_accepts_snapshot_argument(): void + public function test_handle_role_after_update_accepts_snapshot_argument(): void { $role = $this->createModelMock(10, ['name' => 'Editor']); $snapshot = ['id' => 10, 'name' => 'Old Role']; @@ -501,7 +491,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleRoleAfterUpdate($role, $snapshot); } - public function test_handleRoleAfterDelete_logs_activity(): void + public function test_handle_role_after_delete_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -516,7 +506,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleRoleAfterDelete(99); } - public function test_handleRoleAfterSyncPermissions_logs_activity(): void + public function test_handle_role_after_sync_permissions_logs_activity(): void { $role = $this->createModelMock(10); $previous = ['users.view', 'users.create']; @@ -537,7 +527,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleRoleAfterSyncPermissions($role, $previous, $current); } - public function test_handleRoleAfterToggleStatus_logs_activity(): void + public function test_handle_role_after_toggle_status_logs_activity(): void { $role = $this->createModelMock(10); @@ -550,7 +540,7 @@ class CoreActivityLogListenerTest extends TestCase // Menu 핸들러 테스트 (6개) // ═══════════════════════════════════════════ - public function test_handleMenuAfterCreate_logs_activity(): void + public function test_handle_menu_after_create_logs_activity(): void { $menu = $this->createModelMock(5, ['title' => 'Settings']); @@ -568,7 +558,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleMenuAfterCreate($menu); } - public function test_handleMenuAfterUpdate_logs_activity_with_changes(): void + public function test_handle_menu_after_update_logs_activity_with_changes(): void { $menu = $this->createModelMock(5, ['title' => 'Updated Settings']); @@ -586,7 +576,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleMenuAfterUpdate($menu); } - public function test_handleMenuAfterUpdate_accepts_snapshot_argument(): void + public function test_handle_menu_after_update_accepts_snapshot_argument(): void { $menu = $this->createModelMock(5, ['title' => 'Settings']); $snapshot = ['id' => 5, 'title' => 'Old Title']; @@ -601,7 +591,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleMenuAfterUpdate($menu, $snapshot); } - public function test_handleMenuAfterDelete_logs_activity(): void + public function test_handle_menu_after_delete_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -616,7 +606,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleMenuAfterDelete(77); } - public function test_handleMenuAfterUpdateOrder_logs_activity(): void + public function test_handle_menu_after_update_order_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -629,7 +619,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleMenuAfterUpdateOrder([['id' => 1, 'order' => 0], ['id' => 2, 'order' => 1]]); } - public function test_handleMenuAfterToggleStatus_logs_activity(): void + public function test_handle_menu_after_toggle_status_logs_activity(): void { $menu = $this->createModelMock(5); @@ -638,7 +628,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleMenuAfterToggleStatus($menu); } - public function test_handleMenuAfterSyncRoles_logs_activity(): void + public function test_handle_menu_after_sync_roles_logs_activity(): void { $menu = $this->createModelMock(5); $previous = [1, 2, 3]; @@ -663,7 +653,7 @@ class CoreActivityLogListenerTest extends TestCase // Settings 핸들러 테스트 (2개) // ═══════════════════════════════════════════ - public function test_handleSettingsAfterSave_logs_activity(): void + public function test_handle_settings_after_save_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -678,7 +668,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleSettingsAfterSave('general', ['site_name' => 'G7', 'site_url' => 'https://g7.dev'], true); } - public function test_handleSettingsAfterSet_logs_activity(): void + public function test_handle_settings_after_set_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -696,7 +686,7 @@ class CoreActivityLogListenerTest extends TestCase // Schedule 핸들러 테스트 (6개) // ═══════════════════════════════════════════ - public function test_handleScheduleAfterCreate_logs_activity(): void + public function test_handle_schedule_after_create_logs_activity(): void { $schedule = $this->createModelMock(7); @@ -713,7 +703,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleScheduleAfterCreate($schedule); } - public function test_handleScheduleAfterUpdate_logs_activity_with_changes(): void + public function test_handle_schedule_after_update_logs_activity_with_changes(): void { $schedule = $this->createModelMock(7); @@ -731,7 +721,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleScheduleAfterUpdate($schedule); } - public function test_handleScheduleAfterUpdate_accepts_snapshot_argument(): void + public function test_handle_schedule_after_update_accepts_snapshot_argument(): void { $schedule = $this->createModelMock(7); $snapshot = ['id' => 7, 'command' => 'old:command']; @@ -746,7 +736,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleScheduleAfterUpdate($schedule, $snapshot); } - public function test_handleScheduleAfterDelete_logs_activity(): void + public function test_handle_schedule_after_delete_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -761,7 +751,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleScheduleAfterDelete(15); } - public function test_handleScheduleAfterRun_logs_activity(): void + public function test_handle_schedule_after_run_logs_activity(): void { $schedule = $this->createModelMock(7); $history = $this->createModelMock(100); @@ -779,7 +769,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleScheduleAfterRun($schedule, $history); } - public function test_handleScheduleAfterBulkUpdate_logs_per_item(): void + public function test_handle_schedule_after_bulk_update_logs_per_item(): void { $schedules = collect([ Schedule::create(['name' => 'Schedule A', 'command' => 'test:a', 'expression' => '* * * * *']), @@ -810,7 +800,7 @@ class CoreActivityLogListenerTest extends TestCase $this->assertCount(3, $loggedContexts); } - public function test_handleScheduleAfterBulkUpdate_skips_nonexistent_ids(): void + public function test_handle_schedule_after_bulk_update_skips_nonexistent_ids(): void { $schedule = Schedule::create(['name' => 'Schedule X', 'command' => 'test:x', 'expression' => '* * * * *']); @@ -824,7 +814,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleScheduleAfterBulkUpdate([$schedule->id, 99999], true, 2); } - public function test_handleScheduleAfterBulkDelete_logs_per_item(): void + public function test_handle_schedule_after_bulk_delete_logs_per_item(): void { $ids = [4, 5]; @@ -854,7 +844,7 @@ class CoreActivityLogListenerTest extends TestCase // Attachment 핸들러 테스트 (3개) // ═══════════════════════════════════════════ - public function test_handleAttachmentAfterUpload_logs_activity(): void + public function test_handle_attachment_after_upload_logs_activity(): void { $attachment = $this->createModelMock(20); @@ -863,7 +853,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAttachmentAfterUpload($attachment); } - public function test_handleAttachmentAfterDelete_logs_activity(): void + public function test_handle_attachment_after_delete_logs_activity(): void { $attachment = $this->createModelMock(20); @@ -878,7 +868,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleAttachmentAfterDelete($attachment); } - public function test_handleAttachmentAfterBulkDelete_creates_per_item_logs(): void + public function test_handle_attachment_after_bulk_delete_creates_per_item_logs(): void { $ids = [10, 20, 30]; $snapshots = [ @@ -897,7 +887,7 @@ class CoreActivityLogListenerTest extends TestCase $loggedIds[] = $context['loggable_id']; return $context['log_type'] === ActivityLogType::Admin - && $context['loggable_type'] === \App\Models\Attachment::class + && $context['loggable_type'] === Attachment::class && $context['description_key'] === 'activity_log.description.attachment_bulk_delete' && $context['description_params']['count'] === 1 && $context['properties']['identifier'] === 'post-images' @@ -957,7 +947,7 @@ class CoreActivityLogListenerTest extends TestCase ]; } - public function test_handleModuleAfterInstall_includes_version_in_properties(): void + public function test_handle_module_after_install_includes_version_in_properties(): void { $this->logChannel->shouldReceive('info') ->once() @@ -970,7 +960,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleModuleAfterInstall('sirsoft-ecommerce', ['version' => '2.0.0']); } - public function test_handleModuleAfterUninstall_logs_activity(): void + public function test_handle_module_after_uninstall_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -986,7 +976,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleModuleAfterUninstall('sirsoft-ecommerce', ['version' => '1.0.0'], true); } - public function test_handleModuleAfterUpdate_logs_activity(): void + public function test_handle_module_after_update_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1002,7 +992,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleModuleAfterUpdate('sirsoft-ecommerce', ['success' => true], ['version' => '1.1.0']); } - public function test_handleModuleAfterRefreshLayouts_logs_activity(): void + public function test_handle_module_after_refresh_layouts_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1061,7 +1051,7 @@ class CoreActivityLogListenerTest extends TestCase ]; } - public function test_handlePluginAfterInstall_includes_version_in_properties(): void + public function test_handle_plugin_after_install_includes_version_in_properties(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1074,7 +1064,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handlePluginAfterInstall('sirsoft-payment', ['version' => '3.0.0']); } - public function test_handlePluginAfterUninstall_logs_activity(): void + public function test_handle_plugin_after_uninstall_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1090,7 +1080,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handlePluginAfterUninstall('sirsoft-payment', false, true); } - public function test_handlePluginAfterUpdate_logs_activity(): void + public function test_handle_plugin_after_update_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1110,7 +1100,7 @@ class CoreActivityLogListenerTest extends TestCase // Template 핸들러 테스트 (6개) // ═══════════════════════════════════════════ - public function test_handleTemplateAfterInstall_logs_activity(): void + public function test_handle_template_after_install_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1126,7 +1116,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleTemplateAfterInstall('sirsoft-admin_basic', ['version' => '1.0.0']); } - public function test_handleTemplateAfterActivate_logs_activity(): void + public function test_handle_template_after_activate_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1140,7 +1130,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleTemplateAfterActivate(['identifier' => 'sirsoft-admin_basic']); } - public function test_handleTemplateAfterActivate_handles_missing_identifier(): void + public function test_handle_template_after_activate_handles_missing_identifier(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1152,7 +1142,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleTemplateAfterActivate([]); } - public function test_handleTemplateAfterDeactivate_logs_activity(): void + public function test_handle_template_after_deactivate_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1166,7 +1156,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleTemplateAfterDeactivate('sirsoft-basic'); } - public function test_handleTemplateAfterUninstall_logs_activity(): void + public function test_handle_template_after_uninstall_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1182,7 +1172,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleTemplateAfterUninstall('sirsoft-admin_basic', ['version' => '1.0.0'], true); } - public function test_handleTemplateAfterVersionUpdate_logs_activity(): void + public function test_handle_template_after_version_update_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1198,7 +1188,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleTemplateAfterVersionUpdate('sirsoft-admin_basic', ['success' => true], ['version' => '2.0.0']); } - public function test_handleTemplateAfterRefreshLayouts_logs_activity(): void + public function test_handle_template_after_refresh_layouts_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1216,7 +1206,7 @@ class CoreActivityLogListenerTest extends TestCase // Layout 핸들러 테스트 (2개) // ═══════════════════════════════════════════ - public function test_handleLayoutAfterUpdate_logs_activity(): void + public function test_handle_layout_after_update_logs_activity(): void { $layout = $this->createModelMock(50); @@ -1233,7 +1223,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleLayoutAfterUpdate($layout, 1, 'admin/users/index', ['content' => '{}']); } - public function test_handleLayoutAfterVersionRestore_logs_activity(): void + public function test_handle_layout_after_version_restore_logs_activity(): void { $newVersion = $this->createModelMock(51); @@ -1254,7 +1244,7 @@ class CoreActivityLogListenerTest extends TestCase // Module Settings 핸들러 테스트 (2개) // ═══════════════════════════════════════════ - public function test_handleModuleSettingsAfterSave_logs_activity(): void + public function test_handle_module_settings_after_save_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1270,7 +1260,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handleModuleSettingsAfterSave('sirsoft-ecommerce', ['currency' => 'KRW', 'tax_rate' => 10], true); } - public function test_handleModuleSettingsAfterReset_logs_activity(): void + public function test_handle_module_settings_after_reset_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1288,7 +1278,7 @@ class CoreActivityLogListenerTest extends TestCase // Plugin Settings 핸들러 테스트 (2개) // ═══════════════════════════════════════════ - public function test_handlePluginSettingsAfterSave_logs_activity(): void + public function test_handle_plugin_settings_after_save_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1304,7 +1294,7 @@ class CoreActivityLogListenerTest extends TestCase $this->listener->handlePluginSettingsAfterSave('sirsoft-payment', ['api_key' => 'xxx', 'sandbox' => true], true); } - public function test_handlePluginSettingsAfterReset_logs_activity(): void + public function test_handle_plugin_settings_after_reset_logs_activity(): void { $this->logChannel->shouldReceive('info') ->once() @@ -1322,7 +1312,7 @@ class CoreActivityLogListenerTest extends TestCase // 에러 처리 테스트 // ═══════════════════════════════════════════ - public function test_logActivity_catches_exception_and_logs_error(): void + public function test_log_activity_catches_exception_and_logs_error(): void { $user = $this->createUserMock(1, 'uuid-err'); diff --git a/tests/Unit/Providers/SettingsUploadConfigTest.php b/tests/Unit/Providers/SettingsUploadConfigTest.php new file mode 100644 index 00000000..c52d4a2b --- /dev/null +++ b/tests/Unit/Providers/SettingsUploadConfigTest.php @@ -0,0 +1,91 @@ +createMock(JsonConfigRepository::class); + $configRepository->method('getCategory') + ->with('upload') + ->willReturn($uploadSettings); + + $provider = new SettingsServiceProvider($this->app); + $method = new ReflectionMethod($provider, 'applyUploadConfig'); + $method->invoke($provider, $configRepository); + } + + /** + * 관리자 설정 MB 가 KB 로 변환되어 attachment config 에 반영되는지 테스트합니다. + */ + public function test_max_file_size_mb_is_converted_to_kb(): void + { + $this->callApplyUploadConfig(['max_file_size' => 20]); + + $this->assertSame(20480, config('attachment.max_file_size')); + } + + /** + * 소비자가 없는 dead key 를 더 이상 쓰지 않는지 테스트합니다. + */ + public function test_dead_g7_upload_key_is_not_written(): void + { + $this->callApplyUploadConfig(['max_file_size' => 20]); + + $this->assertNull(config('g7.upload.max_size')); + } + + /** + * 허용 확장자가 소문자로 정규화되어 반영되는지 테스트합니다. + */ + public function test_allowed_extensions_are_normalized(): void + { + $this->callApplyUploadConfig(['allowed_extensions' => ['JPG', '.PNG', 'pdf']]); + + $this->assertSame(['jpg', 'png', 'pdf'], config('attachment.allowed_extensions')); + } + + /** + * 빈 배열(제한 없음)도 그대로 반영되는지 테스트합니다. + */ + public function test_empty_allowed_extensions_is_applied_as_no_restriction(): void + { + config(['attachment.allowed_extensions' => ['png']]); + + $this->callApplyUploadConfig(['allowed_extensions' => []]); + + $this->assertSame([], config('attachment.allowed_extensions')); + } + + /** + * 설정이 비어 있으면 기본값을 건드리지 않는지 테스트합니다. + */ + public function test_empty_settings_keep_defaults(): void + { + config(['attachment.max_file_size' => 10240]); + + $this->callApplyUploadConfig([]); + + $this->assertSame(10240, config('attachment.max_file_size')); + } +} diff --git a/tests/Unit/Repositories/UserRepositoryAccountLockTest.php b/tests/Unit/Repositories/UserRepositoryAccountLockTest.php new file mode 100644 index 00000000..48234ab0 --- /dev/null +++ b/tests/Unit/Repositories/UserRepositoryAccountLockTest.php @@ -0,0 +1,102 @@ +create(); + + $lockedUntil = $this->repository()->lockAccount($user, 0); + + $user->refresh(); + + // 영구 잠금은 해제 시각이 없다 (NULL = 잠금 없음 규약과 컬럼으로 분리) + $this->assertNull($lockedUntil); + $this->assertNull($user->locked_until); + $this->assertTrue((bool) $user->locked_permanently); + $this->assertTrue($this->repository()->isLocked($user)); + } + + /** + * @test + */ + public function 영구_잠금은_시간이_지나도_해제되지_않는다(): void + { + $user = User::factory()->create(); + $this->repository()->lockAccount($user, 0); + + $this->travel(10)->years(); + + $this->assertTrue($this->repository()->isLocked($user->fresh())); + } + + /** + * @test + */ + public function 로그인_시도_초기화는_영구_잠금도_해제한다(): void + { + $user = User::factory()->create(); + $this->repository()->lockAccount($user, 0); + + $this->repository()->resetLoginAttempts($user->fresh()); + + $user->refresh(); + $this->assertFalse((bool) $user->locked_permanently); + $this->assertNull($user->locked_until); + $this->assertFalse($this->repository()->isLocked($user)); + } + + /** + * @test + */ + public function 유한_잠금은_기존과_동일하게_동작한다(): void + { + $user = User::factory()->create(); + + $lockedUntil = $this->repository()->lockAccount($user, 5); + + $this->assertNotNull($lockedUntil); + $this->assertFalse((bool) $user->fresh()->locked_permanently); + $this->assertTrue($this->repository()->isLocked($user->fresh())); + + $this->travel(6)->minutes(); + + $this->assertFalse($this->repository()->isLocked($user->fresh())); + } + + /** + * @test + */ + public function 음수_잠금시간도_영구_잠금으로_처리된다(): void + { + $user = User::factory()->create(); + + $this->repository()->lockAccount($user, -10); + + $this->assertTrue((bool) $user->fresh()->locked_permanently); + $this->assertTrue($this->repository()->isLocked($user->fresh())); + } +} diff --git a/tests/Unit/Services/ExtensionBundleServiceTest.php b/tests/Unit/Services/ExtensionBundleServiceTest.php index 62d2880d..f8be231b 100644 --- a/tests/Unit/Services/ExtensionBundleServiceTest.php +++ b/tests/Unit/Services/ExtensionBundleServiceTest.php @@ -177,6 +177,11 @@ class ExtensionBundleServiceTest extends TestCase public function test_dev_rewrites_source_mapping_url_to_asset_serving_path(): void { + // 자산 URL 표기(경로형 vs `?file=` 쿼리형)는 사이트 설정이 정한다. 이 테스트가 보려는 + // 것은 "개발 환경에서 소스맵 URL 이 에셋 서빙 주소로 재작성되는가" 이므로, 개발 사이트가 + // 어느 모드를 쓰든 결과가 달라지지 않도록 경로형으로 고정한다. + config(['g7_settings.core.general.asset_url_mode' => 'extension']); + // 기본 testing 환경 = 비프로덕션 → dev rewrite 경로 $a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map"); diff --git a/tests/Unit/Services/MailIdentityProviderCodeLengthTest.php b/tests/Unit/Services/MailIdentityProviderCodeLengthTest.php new file mode 100644 index 00000000..48af0de5 --- /dev/null +++ b/tests/Unit/Services/MailIdentityProviderCodeLengthTest.php @@ -0,0 +1,118 @@ +provider()->getSettingsSchema(); + + // 수정 전: min/max 키 자체가 없었다 + $this->assertArrayHasKey('min', $schema['code_length']); + $this->assertArrayHasKey('max', $schema['code_length']); + $this->assertSame(MailIdentityProvider::MIN_CODE_LENGTH, $schema['code_length']['min']); + $this->assertSame(MailIdentityProvider::MAX_CODE_LENGTH, $schema['code_length']['max']); + $this->assertSame(MailIdentityProvider::DEFAULT_CODE_LENGTH, $schema['code_length']['default']); + } + + /** + * 경계 내 길이는 그대로 적용되는지 테스트합니다. + * + * @scenario provider=mail, code_length=within_bounds + * + * @effects configured_length_applied_verbatim + */ + public function test_code_length_within_bounds_is_applied(): void + { + $method = new ReflectionMethod(MailIdentityProvider::class, 'generateNumericCode'); + + $this->assertSame(8, strlen($method->invoke($this->provider(), 8))); + $this->assertSame(4, strlen($method->invoke($this->provider(), 4))); + $this->assertSame(10, strlen($method->invoke($this->provider(), 10))); + } + + /** + * 경계를 벗어나면 조용한 클램프 대신 기본값 폴백 + 경고 로그를 남기는지 테스트합니다. + * + * @scenario provider=mail, code_length=above_max + * + * @effects out_of_range_falls_back_to_default_with_warning + */ + public function test_out_of_range_falls_back_to_default_with_warning(): void + { + Log::spy(); + + $method = new ReflectionMethod(MailIdentityProvider::class, 'generateNumericCode'); + + // 수정 전: 12 → 10 으로 조용히 클램프 + $code = $method->invoke($this->provider(), 12); + + $this->assertSame(MailIdentityProvider::DEFAULT_CODE_LENGTH, strlen($code)); + Log::shouldHaveReceived('warning')->once(); + } + + /** + * 하한 미만도 동일하게 폴백하는지 테스트합니다. + * + * @scenario provider=mail, code_length=below_min + * + * @effects out_of_range_falls_back_to_default_with_warning + */ + public function test_below_minimum_falls_back_to_default(): void + { + $method = new ReflectionMethod(MailIdentityProvider::class, 'generateNumericCode'); + + $this->assertSame( + MailIdentityProvider::DEFAULT_CODE_LENGTH, + strlen($method->invoke($this->provider(), 2)) + ); + } + + /** + * 생성된 코드가 숫자로만 구성되는지 테스트합니다. + * + * @scenario provider=mail, code_length=within_bounds + * + * @effects generated_code_is_numeric + */ + public function test_generated_code_is_numeric(): void + { + $method = new ReflectionMethod(MailIdentityProvider::class, 'generateNumericCode'); + + $this->assertMatchesRegularExpression('/^\d{6}$/', $method->invoke($this->provider(), 6)); + } +} diff --git a/tests/Unit/Services/ModuleServiceAssetTest.php b/tests/Unit/Services/ModuleServiceAssetTest.php index 71f30151..cf4231c7 100644 --- a/tests/Unit/Services/ModuleServiceAssetTest.php +++ b/tests/Unit/Services/ModuleServiceAssetTest.php @@ -150,6 +150,32 @@ class ModuleServiceAssetTest extends TestCase 'status' => ExtensionStatus::Active->value, ]); + $filePath = $this->testModulePath.'/dist/js/manifest.json'; + file_put_contents($filePath, '{"version":3,"sources":[]}'); + + // Act + $result = $this->moduleService->getAssetFilePath('test-module', 'dist/js/manifest.json'); + + // Assert + $this->assertTrue($result['success']); + $this->assertEquals('application/json', $result['mimeType']); + } + + /** + * 소스맵(.map)은 JSON 으로 타입 지정하지 않는다. + * + * 소스맵에는 원본 코드 전문(sourcesContent)이 담겨 있어, 서빙 허용 자체를 로컬 환경으로 + * 한정하고 MIME 표에서도 제외했다(#471 / 공개 #72). 표에 되돌아오면 그 조치가 조용히 + * 풀린 것이므로 명시적으로 고정한다. + */ + public function test_source_map_is_not_typed_as_json(): void + { + // Arrange + Module::factory()->create([ + 'identifier' => 'test-module', + 'status' => ExtensionStatus::Active->value, + ]); + $filePath = $this->testModulePath.'/dist/js/module.iife.js.map'; file_put_contents($filePath, '{"version":3,"sources":[]}'); @@ -157,8 +183,7 @@ class ModuleServiceAssetTest extends TestCase $result = $this->moduleService->getAssetFilePath('test-module', 'dist/js/module.iife.js.map'); // Assert - $this->assertTrue($result['success']); - $this->assertEquals('application/json', $result['mimeType']); + $this->assertNotEquals('application/json', $result['mimeType']); } /** diff --git a/tests/Unit/Services/NotificationDefinitionServiceTest.php b/tests/Unit/Services/NotificationDefinitionServiceTest.php index af95d492..806aabb3 100644 --- a/tests/Unit/Services/NotificationDefinitionServiceTest.php +++ b/tests/Unit/Services/NotificationDefinitionServiceTest.php @@ -30,8 +30,11 @@ class NotificationDefinitionServiceTest extends TestCase */ public function test_resolve_returns_active_definition(): void { + // 코어 알림 정의(welcome 등)는 config/core.php 를 SSoT 로 부팅 시 DB 에 동기화되므로 + // 같은 type 을 새로 만들면 중복이 된다. 이 테스트가 보려는 것은 resolve() 의 동작이라 + // 코어가 선점하지 않는 전용 type 을 쓴다. NotificationDefinition::create([ - 'type' => 'welcome', + 'type' => 'active_resolve_target', 'hook_prefix' => 'core.auth', 'extension_type' => 'core', 'extension_identifier' => 'core', @@ -43,12 +46,12 @@ class NotificationDefinitionServiceTest extends TestCase 'is_default' => true, ]); - $this->service->invalidateCache('welcome'); + $this->service->invalidateCache('active_resolve_target'); - $result = $this->service->resolve('welcome'); + $result = $this->service->resolve('active_resolve_target'); $this->assertNotNull($result); - $this->assertEquals('welcome', $result->type); + $this->assertEquals('active_resolve_target', $result->type); } /** @@ -111,8 +114,12 @@ class NotificationDefinitionServiceTest extends TestCase $result = $this->service->getAllActive(); - $this->assertCount(1, $result); - $this->assertEquals('active_one', $result->first()->type); + // 코어 알림 정의가 부팅 시 함께 동기화되므로 절대 개수로는 판정할 수 없다. + // 이 테스트의 주제는 "활성만 돌려주는가" 이므로 이 테스트가 만든 두 건으로 판정한다. + $types = $result->pluck('type'); + + $this->assertContains('active_one', $types); + $this->assertNotContains('inactive_one', $types); } /** diff --git a/tests/Unit/Services/NotificationTemplateServiceTest.php b/tests/Unit/Services/NotificationTemplateServiceTest.php index 8b332fe1..b4aa7d71 100644 --- a/tests/Unit/Services/NotificationTemplateServiceTest.php +++ b/tests/Unit/Services/NotificationTemplateServiceTest.php @@ -200,31 +200,37 @@ class NotificationTemplateServiceTest extends TestCase */ public function test_reset_to_default_restores_click_url_and_recipients(): void { - $definition = NotificationDefinition::create([ - 'type' => 'welcome', - 'hook_prefix' => 'core.auth', - 'extension_type' => 'core', - 'extension_identifier' => 'core', - 'name' => ['ko' => '회원가입 환영'], - 'variables' => [], - 'channels' => ['database'], - 'hooks' => [], - 'is_active' => true, - 'is_default' => true, - ]); + // welcome 은 코어 알림 정의라 config/core.php 를 SSoT 로 부팅 시 이미 동기화되어 있다. + // 이 테스트는 그 정의에 템플릿을 매달아 복원 동작을 보므로, 있으면 그대로 쓴다. + $definition = NotificationDefinition::firstOrCreate( + ['type' => 'welcome'], + [ + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '회원가입 환영'], + 'variables' => [], + 'channels' => ['database'], + 'hooks' => [], + 'is_active' => true, + 'is_default' => true, + ] + ); - // 관리자가 click_url/recipients 를 임의 값으로 수정한 상태 - $template = NotificationTemplate::create([ - 'definition_id' => $definition->id, - 'channel' => 'database', - 'subject' => ['ko' => '수정된 제목'], - 'body' => ['ko' => '수정된 본문'], - 'click_url' => '/custom-edited-url', - 'recipients' => [['type' => 'admin']], - 'is_active' => true, - 'is_default' => false, - 'user_overrides' => ['click_url' => true, 'recipients' => true], - ]); + // 관리자가 click_url/recipients 를 임의 값으로 수정한 상태. + // 코어 템플릿이 이미 동기화되어 있으면 그 행을 수정본으로 만든다 (실제 시나리오와 동일). + $template = NotificationTemplate::updateOrCreate( + ['definition_id' => $definition->id, 'channel' => 'database'], + [ + 'subject' => ['ko' => '수정된 제목'], + 'body' => ['ko' => '수정된 본문'], + 'click_url' => '/custom-edited-url', + 'recipients' => [['type' => 'admin']], + 'is_active' => true, + 'is_default' => false, + 'user_overrides' => ['click_url' => true, 'recipients' => true], + ] + ); $defaultData = $this->service->getDefaultTemplateData('welcome', 'database'); $restored = $this->service->resetToDefault($template, $defaultData); diff --git a/tests/scenarios/attachment-upload-policy.yaml b/tests/scenarios/attachment-upload-policy.yaml new file mode 100644 index 00000000..d2b09c7a --- /dev/null +++ b/tests/scenarios/attachment-upload-policy.yaml @@ -0,0 +1,60 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(업로드 검증·설정 변환) +# 이며 PHPUnit Feature/Unit 테스트가 축 조합을 커버하고 green 이다. + +feature: 첨부파일 업로드 정책(용량·확장자) 강제 (C2) + +description: | + 관리자 환경설정의 업로드 정책이 실제 업로드 경로에서 강제되도록 한다. + + 배경: + - 두 업로드 FormRequest 가 존재하지 않는 config 키(`attachment.max_size`)를 읽어 + 폴백 상수로 동작했다. 폴백(10 * 1024)이 실제 기본값(10240 KB)과 우연히 같아 + 기본 설정 상태에서는 증상이 드러나지 않았다. + - `SettingsServiceProvider::applyUploadConfig()` 도 같은 오타 키를 읽어 관리자 설정이 + 어디에도 반영되지 않았고, 소비자 0건인 `g7.upload.max_size` 로 써 두 번째 SSoT 를 만들었다. + - 확장자 제한은 설정에만 존재하고 강제 지점이 0건이었다. + + 단위 규약: + - `config/attachment.*` = KB (Laravel `max:` 규칙과 동일 단위, 변환 없음) + - settings `upload.*` = MB (관리자 UI 단위) + - 변환은 `SettingsServiceProvider::applyUploadConfig()` 단 한 곳 + +axes: + entry_point: [single_upload, batch_upload] + size_state: [within_limit, over_limit] + extension_state: [allowed, disallowed] + allowed_list: [configured, empty, hook_extended] + saved_shape: [comma_string, array, legacy_string_on_disk] + +exclusions: + - { allowed_list: empty, extension_state: disallowed, reason: "목록이 비면 확장자 제한 자체가 없음 (탈출구)" } + +effects: + - over_limit_upload_returns_422 + - within_limit_upload_succeeds + - disallowed_extension_returns_422 + - allowed_extension_succeeds + - empty_allowed_list_disables_extension_restriction + - hook_can_extend_allowed_extensions + - batch_upload_applies_same_policy + - settings_mb_converted_to_kb_in_attachment_config + - dead_g7_upload_key_no_longer_written + - allowed_extensions_normalized_to_lowercase + - empty_settings_keep_config_defaults + - stored_filename_extension_derived_from_mime_not_client_filename + - comma_string_input_normalized_to_array_before_saving + - saved_extensions_reach_the_upload_restriction + - legacy_string_value_on_disk_is_still_applied + +test_files: + - tests/Feature/Attachment/AttachmentUploadPolicyTest.php + - tests/Feature/Settings/UploadAllowedExtensionsContractTest.php + - tests/Unit/Providers/SettingsUploadConfigTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: 확장자 거부 메시지는 lang/{ko,en}/attachment.php#validation.file_extension_invalid + - rule: service-direct-data-access + coverage: AttachmentService 는 Repository 위임 유지 (본 변경은 저장 확장자 산출만 수정) diff --git a/tests/scenarios/auth-login-throttle.yaml b/tests/scenarios/auth-login-throttle.yaml index fe4d39e6..c62d6c54 100644 --- a/tests/scenarios/auth-login-throttle.yaml +++ b/tests/scenarios/auth-login-throttle.yaml @@ -17,16 +17,23 @@ description: | axes: login_attempt_enabled: [true, false] user_existence: [exists, not_exists] - lock_state: [none, active, expired] + lock_state: [none, active, expired, permanent] + lockout_minutes_setting: [positive, zero] credential: [valid, invalid] controller: [user, admin] ip_pattern: [same, different] + unlock_path: [none, successful_login, admin_manual] exclusions: - { lock_state: active, credential: valid, reason: "잠금 상태에서는 자격 증명과 무관하게 423" } + - { lock_state: permanent, credential: valid, reason: "동일 — 영구 잠금도 자격 증명과 무관하게 423" } - { user_existence: not_exists, lock_state: active, reason: "존재하지 않는 사용자는 잠금 불가" } - { user_existence: not_exists, lock_state: expired, reason: "동일 사유" } + - { user_existence: not_exists, lock_state: permanent, reason: "동일 사유" } - { login_attempt_enabled: false, lock_state: active, reason: "기능 OFF 시 사전 잠금 체크 자체 미수행" } + - { lockout_minutes_setting: positive, lock_state: permanent, reason: "양수 설정에서는 영구 잠금이 생성되지 않음" } + - { lockout_minutes_setting: zero, lock_state: expired, reason: "영구 잠금은 만료되지 않음" } + - { unlock_path: admin_manual, user_existence: not_exists, reason: "해제 대상이 없음" } effects: - failed_attempt_increments_counter @@ -46,9 +53,18 @@ effects: - activity_log_records_auth_account_locked_with_attempts_and_minutes_params - per_ip_throttle_blocks_burst_attempts_with_429 - per_ip_throttle_uses_security_max_login_attempts_setting_x6_with_min_30_floor + - lockout_time_zero_creates_permanent_lock_not_one_minute + - permanent_lock_survives_arbitrary_time_passage + - permanent_lock_response_serializes_with_null_locked_until + - successful_login_reset_clears_permanent_lock_flag + - admin_manual_unlock_endpoint_clears_lock + - admin_manual_unlock_requires_users_update_permission + - finite_lockout_setting_keeps_previous_expiry_behavior test_files: - tests/Feature/Auth/LoginThrottleTest.php + - tests/Feature/Auth/AccountLockoutTest.php + - tests/Unit/Repositories/UserRepositoryAccountLockTest.php rules_layer_coverage: - rule: service-direct-data-access diff --git a/tests/scenarios/idv-code-length.yaml b/tests/scenarios/idv-code-length.yaml new file mode 100644 index 00000000..22ff7841 --- /dev/null +++ b/tests/scenarios/idv-code-length.yaml @@ -0,0 +1,43 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 시나리오와 동일 한계). 본 변경은 백엔드 전용(스키마 경계 선언 + +# 폴백)이며 PHPUnit Unit 테스트가 경계 조합을 커버하고 green 이다. + +feature: 본인인증 인증코드 길이 경계를 스키마가 선언 (C3) + +description: | + 메일 본인인증 Provider 의 `code_length` 경계를 설정 스키마가 선언하고, + 서비스의 무명 클램프를 제거한다. + + 배경: + - 스키마가 경계를 선언하지 않아 서비스가 자체 상수로 `max(4, min(10, $length))` 방어했다. + 설정에 12 를 넣으면 조용히 10 으로 뭉개져, 관리자는 12 자리로 설정했다고 믿지만 + 실제 발송 코드는 10 자리인 상태가 된다. + + 방침 (최소 범위): + - `getSettingsSchema()['code_length']` 에 `min`/`max` 를 넣어 **경계를 스키마가 선언**한다. + - 서비스의 클램프를 제거하고, 경계 위반 시 조용한 클램프 대신 + **경고 로그 + 스키마 default(6) 폴백**. 런타임 인증 흐름 중단은 위험하므로 예외가 아니다. + - 관리자 API 가 스키마를 그대로 노출하므로 min/max 가 자동으로 UI 계약에 반영된다. + - 저장 검증 파이프라인 신설은 과잉 — `withConfig()` 쓰기 경로가 코어에 0건이다 + (`coverage.json` 의 `extension-settings-schema-boundary-rules` 에 todo 로 기록). + +axes: + provider: [mail] + code_length: [below_min, within_bounds, above_max, schema_declaration] + +exclusions: + - { provider: mail, code_length: schema_declaration, reason: "선언 자체를 확인하는 축 — 코드 생성 조합과 교차하지 않음" } + +effects: + - schema_declares_min_max_boundaries + - configured_length_applied_verbatim + - out_of_range_falls_back_to_default_with_warning + - generated_code_is_numeric + +test_files: + - tests/Unit/Services/MailIdentityProviderCodeLengthTest.php + +rules_layer_coverage: + - rule: service-config-value-clamp + coverage: 제거한 max(4, min(10, $length)) 형태가 재유입되면 이 룰이 검출한다 diff --git a/tests/scenarios/password-policy.yaml b/tests/scenarios/password-policy.yaml new file mode 100644 index 00000000..c8634ccf --- /dev/null +++ b/tests/scenarios/password-policy.yaml @@ -0,0 +1,61 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 시나리오와 동일 한계). 본 변경은 백엔드 검증 규칙 + 관리자 설정 화면이며 +# PHPUnit Feature 테스트와 Vitest 레이아웃 테스트가 축 조합을 커버하고 green 이다. + +feature: 비밀번호 정책 설정 강제 (C4) + +description: | + 관리자 보안 설정의 비밀번호 정책이 비밀번호를 받는 모든 경로에서 동일하게 적용되도록 한다. + + 배경: + - `security.password_min_length` · `security.password_require_special` 설정이 화면에는 + 있었지만 어떤 검증 규칙도 읽지 않는 고아 설정이었다. + - 비밀번호 길이 제한은 `LoginRequest` 에만 `min:6` 으로 하드코딩되어 있었다. 로그인은 + 기존 비밀번호를 대조하는 자리라 정책을 걸 자리가 아니고(정책 강화 시 기존 사용자가 + 로그인 불가), 정작 비밀번호를 **정하는** 경로에는 정책이 없었다. + + 적용 지점: + - 비밀번호를 새로 정하는 6개 경로가 `PasswordPolicy::rule()` 하나를 공유한다 + (회원가입 · 비밀번호 재설정 · 비밀번호 변경 · 관리자 회원 생성 · 관리자 회원 수정 · 프로필 수정). + - 로그인은 길이 규칙을 갖지 않는다. + +axes: + entry_point: + - register + - reset_password + - change_password + - admin_create_user + - admin_update_user + - update_profile + min_length_setting: [default, raised, lowered] + special_char_setting: [required, not_required] + input_state: [satisfies_policy, violates_length, violates_special] + +exclusions: + - { special_char_setting: not_required, input_state: violates_special, reason: "특수문자 미요구 설정에서는 위반 상태가 성립하지 않음" } + +effects: + - policy_rule_reads_min_length_from_settings + - policy_rule_reads_special_requirement_from_settings + - password_shorter_than_setting_is_rejected + - password_meeting_setting_is_accepted + - special_char_requirement_is_enforced_when_enabled + - login_request_has_no_length_rule + - all_password_setting_paths_share_one_rule + - empty_security_settings_fall_back_to_defaults + - saved_security_settings_are_visible_on_reload + - admin_screen_min_length_bounds_follow_settings_limits + +test_files: + - tests/Feature/Auth/PasswordPolicyTest.php + - tests/Feature/Settings/SettingsBoundaryContractTest.php + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-security-password-policy.test.ts + +rules_layer_coverage: + - rule: formrequest-numeric-boundary-drift + coverage: 최소 길이 경계는 config('core.settings_limits.security_password_min_length_*') 단일 출처 + - rule: layout-input-boundary-hardcoded + coverage: 보안 탭 입력 min/max 는 `_meta.limits` 바인딩 (리터럴 없음) + - rule: i18n-throw-hardcoded-korean + coverage: 정책 위반 메시지는 lang/{ko,en}/validation.php 의 password 키