diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7c5b72..d41a41c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ - 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.) - 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다. +### Fixed + +- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다. +- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다. +- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다. +- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다. +- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다. + ## [7.0.8] - 2026-08-22 ### Added diff --git a/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php index 2b39ecce..60614db2 100644 --- a/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php +++ b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php @@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle */ private function refreshRegistry(): void { - // Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget - $this->app->forgetInstance(LanguagePackRegistry::class); - $this->app->forgetInstance(LanguagePackSeedInjector::class); - + // boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다. + // forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가 + // 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지 + // 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해 + // 캡처된 참조에도 전파한다 (#597 에서 실측·교정). $registry = $this->app->make(LanguagePackRegistry::class); + $registry->invalidate(); config([ 'app.supported_locales' => $registry->getActiveCoreLocales(), 'app.locale_names' => $registry->getLocaleNames(), diff --git a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php index 2ec5bd7c..ef880a55 100644 --- a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php +++ b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php @@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; /** - * `core.language_packs.after_activate` / `after_deactivate` 액션 훅을 받아 DB JSON 다국어 + * `core.language_packs.activated` / `deactivated` 액션 훅을 받아 DB JSON 다국어 * 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘). * * 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일 * 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다. * - * 정책 (계획서 §6.6, §9.4): + * 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path + * 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다: * - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가 - * - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존) - * - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기 - * - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음 + * - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존) + * - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기 + * (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다) + * - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음 */ // audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님) class SyncDatabaseTranslations diff --git a/app/Models/IdentityMessageTemplate.php b/app/Models/IdentityMessageTemplate.php index 4190b7fe..e9cd546a 100644 --- a/app/Models/IdentityMessageTemplate.php +++ b/app/Models/IdentityMessageTemplate.php @@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model 'is_active', ]; + /** + * 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록. + * (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정) + * + * @var array + */ + protected array $translatableTrackableFields = [ + 'subject', + 'body', + ]; + /** * 활동 로그 추적 필드. * diff --git a/app/Models/NotificationTemplate.php b/app/Models/NotificationTemplate.php index b15f53dd..72e8de99 100644 --- a/app/Models/NotificationTemplate.php +++ b/app/Models/NotificationTemplate.php @@ -27,6 +27,20 @@ class NotificationTemplate extends Model 'is_active', ]; + /** + * 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록. + * + * 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을 + * 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의 + * 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정). + * + * @var array + */ + protected array $translatableTrackableFields = [ + 'subject', + 'body', + ]; + /** * 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제. */ @@ -110,13 +124,6 @@ class NotificationTemplate extends Model return $this->belongsTo(User::class, 'updated_by'); } - /** - * 특정 채널의 템플릿 조회. - * - * @param Builder $query - * @param string $channel - * @return Builder - */ /** * 수신자 규칙이 설정되어 있는지 확인합니다. * @@ -127,6 +134,13 @@ class NotificationTemplate extends Model return ! empty($this->recipients); } + /** + * 특정 채널의 템플릿 조회. + * + * @param Builder $query + * @param string $channel + * @return Builder + */ public function scopeByChannel(Builder $query, string $channel): Builder { return $query->where('channel', $channel); diff --git a/app/Repositories/LanguagePackTranslationRepository.php b/app/Repositories/LanguagePackTranslationRepository.php index 6ba7b7ec..30f731ed 100644 --- a/app/Repositories/LanguagePackTranslationRepository.php +++ b/app/Repositories/LanguagePackTranslationRepository.php @@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi */ public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array { - $audit = []; - $locale = $pack->locale; + // 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가 + // 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이 + // 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정). + app()->instance('user_overrides.seeding', true); + try { + $audit = []; + $locale = $pack->locale; - if (! empty($seedBundle['permissions'])) { - $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit); - } - if (! empty($seedBundle['roles'])) { - $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit); - } - if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) { - $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit); - } - if (! empty($seedBundle['notifications'])) { - $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit); - } - if (! empty($seedBundle['identity_messages'])) { - $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit); - } - if (! empty($seedBundle['manifest'])) { - $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit); - } + if (! empty($seedBundle['permissions'])) { + $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit); + } + if (! empty($seedBundle['roles'])) { + $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit); + } + if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) { + $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit); + } + if (! empty($seedBundle['notifications'])) { + $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit); + } + if (! empty($seedBundle['identity_messages'])) { + $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit); + } + if (! empty($seedBundle['manifest'])) { + $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit); + } - return $audit; + return $audit; + } finally { + app()->forgetInstance('user_overrides.seeding'); + } } /** @@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi * @return array> 감사 로그 항목 */ public function stripLocaleFromPack(LanguagePack $pack): array + { + // applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단) + app()->instance('user_overrides.seeding', true); + try { + return $this->stripLocaleFromPackBody($pack); + } finally { + app()->forgetInstance('user_overrides.seeding'); + } + } + + /** + * stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행). + * + * @param LanguagePack $pack 비활성화된 언어팩 + * @return array> 감사 로그 항목 + */ + private function stripLocaleFromPackBody(LanguagePack $pack): array { $audit = []; $locale = $pack->locale; @@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi } $hasLocaleKey = array_key_exists($locale, $current); - $isOverridden = isset($overrides[$column]); + $isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale); if ($hasLocaleKey && $isOverridden) { $audit[] = [ @@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi 'locale' => $locale, 'reason' => 'user_overrides', ]; + continue; } @@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi continue; } - if (isset($overrides[$column])) { + if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) { $audit[] = [ 'action' => 'preserved', 'table' => $row->getTable(), @@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi 'locale' => $locale, 'reason' => 'user_overrides', ]; + continue; } @@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi $row->saveQuietly(); } } + + /** + * 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다. + * + * 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` — + * HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로 + * 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤 + * 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가 + * 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정). + * + * 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은 + * 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩 + * 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음). + * + * @param array $overrides user_overrides 리스트 + * @param string $column 다국어 JSON 컬럼명 + * @param string $locale 대상 로케일 + * @return bool 보존 대상 여부 + */ + protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool + { + return in_array($column.'.'.$locale, $overrides, true); + } } diff --git a/app/Services/IdentityMessageTemplateService.php b/app/Services/IdentityMessageTemplateService.php index ed882824..f690138d 100644 --- a/app/Services/IdentityMessageTemplateService.php +++ b/app/Services/IdentityMessageTemplateService.php @@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface; use App\Extension\HookManager; use App\Models\IdentityMessageDefinition; use App\Models\IdentityMessageTemplate; +use App\Services\LanguagePack\LanguagePackSeedInjector; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Auth; @@ -37,6 +38,7 @@ class IdentityMessageTemplateService private readonly IdentityMessageTemplateRepositoryInterface $repository, private readonly IdentityMessageDefinitionService $definitionService, private readonly CacheInterface $cache, + private readonly LanguagePackSeedInjector $seedInjector, ) {} /** @@ -271,11 +273,19 @@ class IdentityMessageTemplateService { $coreDefinitions = $this->loadCoreMessageDefinitions(); - return HookManager::applyFilters( + $definitions = HookManager::applyFilters( 'core.identity.filter_default_message_definitions', $coreDefinitions, [] ); + + // 활성 언어팩 seed 로케일 병합 — 시딩(seed.identity_messages.translations)과 같은 SSoT. + // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의 + // ko/en 만으로 대체해 영구 소실시킨다 (notification 쪽 실사례의 동형 — #597 보완 실측). + // injectIdentityMessages 는 string 키('mail.purpose.signup' 등)로 seed 를 매칭하므로 + // loadCoreMessageDefinitions 가 config 키를 보존해 넘긴다. 확장 리스너가 정수 키로 + // append 한 항목은 seed 에 매칭되지 않아 그대로 통과한다. + return $this->seedInjector->injectIdentityMessages($definitions); } /** @@ -292,13 +302,14 @@ class IdentityMessageTemplateService $common = $this->commonVariables(); $result = []; - foreach ($messages as $data) { + foreach ($messages as $key => $data) { if (($data['variables'] ?? null) === '__common__') { $data['variables'] = $common; } $data['extension_type'] = 'core'; $data['extension_identifier'] = 'core'; - $result[] = $data; + // config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조) + $result[$key] = $data; } return $result; diff --git a/app/Services/LanguagePack/LanguagePackSeedInjector.php b/app/Services/LanguagePack/LanguagePackSeedInjector.php index 48121990..84b85cf4 100644 --- a/app/Services/LanguagePack/LanguagePackSeedInjector.php +++ b/app/Services/LanguagePack/LanguagePackSeedInjector.php @@ -112,7 +112,7 @@ class LanguagePackSeedInjector } foreach ($definitions as $defIdx => $def) { - $type = $def['type'] ?? null; + $type = $this->resolveDefinitionType($defIdx, $def); if (! $type || ! isset($seed[$type])) { continue; } @@ -166,7 +166,7 @@ class LanguagePackSeedInjector continue; } foreach ($definitions as $defIdx => $def) { - $type = $def['type'] ?? null; + $type = $this->resolveDefinitionType($defIdx, $def); if (! $type || ! isset($seed[$type])) { continue; } @@ -722,4 +722,30 @@ class LanguagePackSeedInjector return $config; } + + /** + * 정의 배열 항목에서 알림 type 을 해석합니다. + * + * 코어 시더(`loadConfigSeed('core.notification_definitions', ...)`)는 config 원형 + * (연관 배열 — 키가 곧 type, 항목에 type 필드 없음)을 그대로 필터에 넘기고, + * 확장 시더(ModuleManager/PluginManager)와 [기본값 복원] 경로는 각 항목에 type + * 필드를 포함한 리스트를 넘긴다. 양쪽 형태를 모두 수용한다 — + * 문자열 키가 있으면 그것이 type 이고, 아니면 항목의 type 필드를 본다. + * (형태 불일치 시 예외 없이 전 항목이 스킵되어 ja 가 조용히 주입되지 않는 + * 회귀가 있었다 — 2026-08-24 #597 보완 실측. NotificationSeedInjectionTest 가 고정.) + * + * @param int|string $defIdx 정의 배열의 키 + * @param mixed $def 정의 항목 + * @return string|null 해석된 type (해석 불가 시 null) + */ + private function resolveDefinitionType(int|string $defIdx, mixed $def): ?string + { + if (is_string($defIdx) && $defIdx !== '') { + return $defIdx; + } + + $type = is_array($def) ? ($def['type'] ?? null) : null; + + return is_string($type) && $type !== '' ? $type : null; + } } diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php index 36c613f4..c6484156 100644 --- a/app/Services/LanguagePackService.php +++ b/app/Services/LanguagePackService.php @@ -1814,10 +1814,21 @@ class LanguagePackService } } - // 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만) + // 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다. + // Updating: 설치 트랜잭션 이전에 실패한 경우. + // Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 — + // 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 + // 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측). + // 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다. try { $current = $this->repository->findByIdentifier($identifier); - if ($current && $current->status === LanguagePackStatus::Updating->value) { + $restorable = [ + LanguagePackStatus::Updating->value, + LanguagePackStatus::Installed->value, + ]; + if ($current + && $current->status !== $previousStatus + && in_array($current->status, $restorable, true)) { $this->repository->update($current, ['status' => $previousStatus]); } } catch (Throwable $statusError) { diff --git a/app/Services/NotificationTemplateService.php b/app/Services/NotificationTemplateService.php index 9d652331..d4855cf2 100644 --- a/app/Services/NotificationTemplateService.php +++ b/app/Services/NotificationTemplateService.php @@ -7,6 +7,7 @@ use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface; use App\Contracts\Repositories\NotificationTemplateRepositoryInterface; use App\Extension\HookManager; use App\Models\NotificationTemplate; +use App\Services\LanguagePack\LanguagePackSeedInjector; use Illuminate\Database\Eloquent\Collection; class NotificationTemplateService @@ -33,6 +34,7 @@ class NotificationTemplateService private readonly NotificationTemplateRepositoryInterface $templateRepository, private readonly NotificationDefinitionRepositoryInterface $definitionRepository, private readonly CacheInterface $cache, + private readonly LanguagePackSeedInjector $seedInjector, ) {} /** @@ -189,6 +191,11 @@ class NotificationTemplateService ['type' => $type, 'channel' => $channel] ); + // 활성 언어팩 seed 로케일 병합 — 시딩(seed.notifications.translations)과 같은 SSoT. + // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의 + // ko/en 만으로 대체해 영구 소실시킨다 (2026-08-23 실사례 — #597 보완 실측). + $definitions = $this->mergeLanguagePackLocalesIntoDefaults($definitions); + foreach ($definitions as $def) { if ($def['type'] === $type) { foreach ($def['templates'] as $tpl) { @@ -207,6 +214,36 @@ class NotificationTemplateService return []; } + /** + * 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다. + * + * 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는 + * 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다. + * definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의 + * 정의에 오병합될 여지는 없다. + * + * @param array> $definitions type 필드를 포함한 기본 정의 배열 + * @return array> 활성 팩 로케일이 병합된 정의 배열 + */ + private function mergeLanguagePackLocalesIntoDefaults(array $definitions): array + { + $definitions = $this->seedInjector->injectNotifications($definitions); + + $extensionIdentifiers = []; + foreach ($definitions as $def) { + $identifier = is_array($def) ? ($def['extension_identifier'] ?? null) : null; + if (is_string($identifier) && $identifier !== '' && $identifier !== 'core') { + $extensionIdentifiers[$identifier] = true; + } + } + + foreach (array_keys($extensionIdentifiers) as $identifier) { + $definitions = $this->seedInjector->injectExtensionNotifications($definitions, $identifier); + } + + return $definitions; + } + /** * 미리보기용 렌더링. * diff --git a/database/seeders/IdentityMessageDefinitionSeeder.php b/database/seeders/IdentityMessageDefinitionSeeder.php index 2468a621..449a75f4 100644 --- a/database/seeders/IdentityMessageDefinitionSeeder.php +++ b/database/seeders/IdentityMessageDefinitionSeeder.php @@ -79,13 +79,18 @@ class IdentityMessageDefinitionSeeder extends Seeder $messages = config('core.identity_messages', []); $result = []; - foreach ($messages as $data) { + foreach ($messages as $key => $data) { if (($data['variables'] ?? null) === '__common__') { $data['variables'] = $this->commonVariables(); } $data['extension_type'] = 'core'; $data['extension_identifier'] = 'core'; - $result[] = $data; + // config 복합 키('mail.purpose.signup' 등)를 보존 — 언어팩 주입 필터 + // (seed.identity_messages.translations)가 이 키로 seed 를 매칭한다. + // 리스트로 만들면 전 항목이 매칭 불가로 스킵되어 팩 로케일이 오류 없이 + // 주입되지 않는다 (#597 에서 실측·교정 — IdentityMessageTemplateService + // ::loadCoreMessageDefinitions 의 동형 보존과 짝을 이룬다). + $result[$key] = $data; } return $result; diff --git a/docs/backend/notification-system.md b/docs/backend/notification-system.md index 4c9b0b0f..523b7477 100644 --- a/docs/backend/notification-system.md +++ b/docs/backend/notification-system.md @@ -611,6 +611,49 @@ user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동 - 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다. - `exclude_trigger_user` 는 회원(user_id) 기준이라 게스트 수신자를 깨뜨리지 않습니다. +### 채널 메타의 화면 표시 키 (hidden_tab · tab_channels · tab_label_key · hidden_template_editor) + +확장이 `core.notification.filter_available_channels` 훅으로 등록하는 채널 메타는 임의 필드를 보존해 프론트(`availableChannels` 데이터소스)까지 그대로 도달한다. 관리자 알림 설정 화면(코어 `admin_settings` · 게시판 `admin_board_settings` · 이커머스 `admin_ecommerce_settings`)은 다음 범용 키를 해석한다. 특정 확장 이름을 알지 못하며, 키가 없으면 종전과 동일하게 동작한다. + +| 키 | 타입 | 효과 | +| --- | --- | --- | +| `hidden_tab` | bool | 채널 서브탭만 숨긴다. 채널 토글 카드·발송 축은 그대로 | +| `tab_channels` | string[] | 이 채널의 탭이 대표하는 채널 id 목록. 목록 중 하나라도 활성 저장이면 탭을 노출한다(여러 채널을 하나의 탭으로 묶을 때) | +| `tab_label_key` | string | 탭 라벨용 프론트 lang 키(`$t` 해석). 카드 라벨(`name_key`)과 분리 | +| `hidden_template_editor` | bool | 알림 템플릿 [편집] 모달에서 코어의 언어탭·제목·본문·클릭 URL·변수 안내·[미리보기]·코어 [저장]을 숨긴다. 그 채널의 본문 규격이 코어 템플릿과 달라 확장이 편집기와 저장을 대신할 때 선언한다. 수신자 규칙·[취소]는 남는다 | + +`hidden_template_editor` 를 선언한 확장은 편집 모달의 확장 지점 두 곳에 자기 UI 를 주입한다(3면 공통 이름 — extension_point 파일 1본으로 세 화면에 주입된다). + +| extension_point | 위치 | props | +| --- | --- | --- | +| `notification_template_form_sections` | 수신자 규칙 다음, 코어 언어탭 앞 | `definition` · `template` · `channel` · `modalId` · `stateKey`(면별 코어 모달 상태 키) · `saveEndpoint`(코어 채널 템플릿 PUT) · `refetchDataSourceId`(면별 알림 정의 목록) | +| `notification_template_form_footer_actions` | [취소] 와 코어 [저장] 사이 | 위와 동일 | + +확장은 이 props 만으로 코어 저장 계약을 이행한다 — 코어 엔드포인트·상태 키를 리터럴로 적지 않는다. 코어 채널 템플릿의 수신자 규칙은 `_global?.[extensionPointProps.stateKey]?.recipients` 로 읽어 바뀐 경우에만 `saveEndpoint` 로 PUT 한다(매번 PUT 하면 코어가 행을 "사용자 수정" 으로 표시한다). 선례: `plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_*.json`. 행 하단 확장 지점 `notification_definition_row_footer`(props `definition` · `activeChannel`)는 상태 요약 같은 읽기 전용 UI 용이다. + +### 정의 타입 라벨의 공급처 + +관리자 알림 설정 화면의 정의 목록 제목은 DB `name` 컬럼이 아니라 프론트 다국어 키 +`admin.settings.notification_definitions.types.{type}` 로 렌더된다. 이 키의 공급처는 정의 소유자를 따른다. + +| 정의 소유 | 라벨 위치 | +| --- | --- | +| 코어 (`config/core.php` `notification_definitions`) | admin 템플릿 `lang/partial/{ko,en}/admin.json` 의 `settings.notification_definitions.types` (ja 는 템플릿 언어팩) | +| 모듈 | 각 모듈의 알림 설정 화면과 lang 이 담당 | + +키가 없으면 예외도 경고도 없이 원시 키 문자열이 화면 제목으로 그대로 노출된다. 코어 정의를 +추가하는 변경은 반드시 admin 템플릿 라벨(ko·en)과 템플릿 언어팩(ja)을 함께 추가해야 하며, +코어 정의 전수 ↔ 템플릿 라벨 패리티는 정적 검사(테스트)가 자동 대조한다. + +## 채널 템플릿을 시드하는 확장의 [기본값 복원] 의무 + +확장이 시딩 필터(`seed.notifications.translations` 등)로 자기 채널의 템플릿 행을 알림 정의에 끼워 넣으면, 같은 증강을 +`core.notification.filter_default_definitions` 에도 걸어야 한다. 관리자 화면의 [기본값 복원]은 +`NotificationTemplateService::getDefaultTemplateData()` 가 이 필터를 통과시킨 정의 배열에서 해당 채널의 template 을 찾아 +복원하므로, 시드 출처와 복원 출처가 같은 함수가 아니면 그 채널 행의 복원은 "기본 템플릿 데이터를 찾을 수 없습니다" 로 끝난다 +(시드는 되는데 복원만 안 되는 상태라 오류 로그도 남지 않는다). 모듈이 자기 정의를 이 필터에 보태는 priority(20) 뒤에서 돌아야 +모듈 정의도 증강된다. 선례: `plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php`. + ### 채널별 비회원 발송 허용 (allow_guest) 채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다. diff --git a/docs/extension/language-packs.md b/docs/extension/language-packs.md index 49529cce..b2aebfb7 100644 --- a/docs/extension/language-packs.md +++ b/docs/extension/language-packs.md @@ -287,6 +287,48 @@ foreach ($config['categories'] as $cat) { ... } - `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions) - 기타 → `seed.{target}.{entity}.translations` 필터 (단순 entity 시드) +### 복원 경로의 언어팩 병합 + +[기본값 복원](알림 템플릿·본인인증 메시지)은 위 시딩 필터가 아니라 자체 기본값 필터 +(`core.notification.filter_default_definitions` / `core.identity.filter_default_message_definitions`)를 +탄다. 복원 기본값에는 **활성 언어팩 seed 로케일이 반드시 병합**되어야 한다 — 병합하지 않으면 +복원이 팩이 주입해 둔 로케일(ja 등)을 config 의 ko/en 만으로 대체해 영구 소실시키며, +오류도 로그도 남지 않는다. 코어 서비스(NotificationTemplateService·IdentityMessageTemplateService)가 +필터 적용 후 시딩과 같은 주입기(SSoT)로 병합하며, 정적 검사(테스트)가 이 계약을 고정한다. + +시딩 필터의 페이로드는 **발화 주체의 원형 키를 보존**해야 한다 — 코어 알림 시더는 config +원형(연관 배열, 키가 곧 type), 본인인증은 복합 키(`mail.purpose.signup` 등)가 매칭 키다. +키를 버리고 리스트로 만들면 주입기가 전 항목을 스킵해 팩 로케일이 오류 없이 주입되지 않는다. +알림 주입기는 두 형태(문자열 키 우선, 없으면 type 필드)를 수용한다. + +### 사용자 수정 보존 판정과 시딩 컨텍스트 + +언어팩 병합·제거의 사용자 수정 보존 판정은 운영자 수정의 실제 기록 형식인 **dot-path 항목** +(`user_overrides` 의 `"{컬럼}.{로케일}"`)을 기준으로 한다. 컬럼 전체 항목(`"subject"`)은 시더 +재실행의 컬럼 보호 선언이며 팩 병합을 막지 않는다 — 막으면 팩 업데이트가 자기 로케일 번역을 +갱신하지 못한다. + +이를 위해 두 가지가 함께 지켜져야 한다: + +- 다국어 JSON 컬럼을 추적하는 모델은 `translatableTrackableFields` 를 선언한다 — 미선언 시 + 운영자 수정이 컬럼 전체 항목으로 기록되어 로케일 단위 보존이 불가능해진다. +- 언어팩 주입·제거처럼 시스템이 수행하는 저장은 시딩 컨텍스트(`user_overrides.seeding` 바인딩) + 안에서 실행한다 — 바인딩 없이 저장하면 주입 자체가 "사용자 수정"으로 오인 기록되어 이후 + 시더 재실행의 컬럼 갱신이 영구히 얼어붙는다. + +### 실패한 업데이트의 상태 복원 + +`language-pack:update` 가 설치 트랜잭션 이후(활성화 단계)에 실패하면 팩 상태를 이전 상태로 +복원한다. 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 백엔드 번역이 +안내 없이 기본 로케일로 폴백된다. + +### 같은 프로세스 활성화와 필터 인스턴스 + +시더 번역 필터 클로저는 boot 시점의 injector→registry **인스턴스**를 캡처한다. 활성 팩 목록 +캐시를 갱신할 때는 싱글톤을 유지한 채 `LanguagePackRegistry::invalidate()` 로 내부 캐시만 +비운다 — `forgetInstance` 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시가 남아, 같은 +프로세스에서 활성화된 신규 팩이 시더 필터에 보이지 않는다(오류 없이 해당 로케일만 미주입). + ### IDV 도메인 lang pack 커버리지 `config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개: diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index 1dc7a8a9..814169f7 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -8,4 +8,4 @@ ### Added -- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다. +- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다. 알림 편집 창에 통합된 알림톡 템플릿·문자(SMS) 섹션의 제목·안내·승인 여부 배지(승인됨/미승인)와 검수자 전달 의견 입력란의 라벨·안내도 일본어로 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php index db1bf3d7..432bcd5f 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -107,6 +107,7 @@ return [ 'message_type' => 'メッセージタイプ', 'emphasize_type' => '強調タイプ', 'template_content' => '本文', + 'comment' => '審査担当者への伝達事項', 'preview_message' => 'プレビュー文言', 'category_code' => 'カテゴリ', 'security_flag' => 'セキュリティテンプレート', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json index 897c15aa..a32b249c 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json @@ -105,7 +105,7 @@ } }, "template": { - "tab_guide": "通知ごとにアラームトークテンプレートを作成して審査を申請してください。カカオ承認後にアラームトークが自動送信され、代替SMS・SMS単独送信もこの画面で設定します。", + "tab_guide": "各通知の[編集]でアラームトークのテンプレートとSMS本文をまとめて作成し、審査を申請してください。カカオの承認後にアラームトークが自動送信され、代替SMS・SMS単独送信も同じ画面で設定します。", "status": { "unwritten": "未作成", "draft": "作成中", @@ -137,7 +137,11 @@ "sms_only": "SMS単独", "sms_body_prefix": "本文:", "sms_body_missing": "SMS本文未設定", - "btn_edit_sms": "SMS本文を編集" + "btn_edit_sms": "SMS本文を編集", + "sms_label": "SMS", + "on": "使用", + "off": "未使用", + "alimtalk_disabled": "送信停止中" }, "form": { "modal_title": "アラームトークテンプレート — {name}", @@ -199,6 +203,9 @@ "btn_remove_quick_reply": "クイックリプライ削除", "btn_save": "保存", "btn_save_request": "保存して審査申請", + "request_comment": "審査担当者への伝達事項", + "request_comment_placeholder": "例: #{name}=山田太郎, #{order_number}=20260823-0001", + "request_comment_hint": "審査申請時にカカオの審査担当者へ一緒に送られます(任意・500文字以内)。カカオの審査ガイドは本文の変数ごとに例示値を求めるため、変数ごとの例を書いておくと差し戻しを減らせます。保存はされず、申請時にのみ送信されます。", "saved_toast": "通知テンプレートを保存しました。", "requested_toast": "審査を申請しました。承認されるとアラームトーク送信が開始されます。" }, @@ -217,6 +224,18 @@ "rejection": { "modal_title": "却下理由 — {name}", "btn_close": "閉じる" + }, + "approval": { + "approved": "承認済み", + "not_approved": "未承認" + }, + "section": { + "alimtalk_title": "アラームトークテンプレート", + "sms_title": "SMS", + "alimtalk_enabled": "アラームトーク送信を使用", + "locked_hint": "審査中または承認済みのテンプレートは内容を変更できません。変更するには先に[申請取消]または[編集(承認取消)]を押してください。", + "saved_toast": "通知テンプレートを保存しました。", + "loading": "テンプレートを読み込み中…" } }, "manage": { 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 dab376af..1efc9648 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 @@ -9,6 +9,7 @@ ### Added - 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다. +- 알림 설정 화면의 「사이트맵 재생성 완료」・「사이트맵 재생성 실패」 알림 이름 번역을 추가했습니다. ## [1.0.6] - 2026-08-22 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 d6435cd2..83ae197c 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 @@ -1862,7 +1862,9 @@ "types": { "welcome": "会員登録ウェルカム", "reset_password": "パスワード再設定", - "password_changed": "パスワード変更通知" + "password_changed": "パスワード変更通知", + "sitemap_regenerated": "サイトマップ再生成完了", + "sitemap_regenerate_failed": "サイトマップ再生成失敗" }, "channels_label": "チャネル:", "edit_modal": { diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index a3851b4e..296e46ce 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다. +- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다. ## [1.0.5] - 2026-08-22 diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts new file mode 100644 index 00000000..6f3604c5 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts @@ -0,0 +1,138 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio) +// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다. +/** + * 게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름) + * + * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록: + * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞) + * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이) + * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다 + * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일. + * + * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은 + * 기대값을 함께 고치면 통과한다. + * + * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections + */ + +import { describe, it, expect } from 'vitest'; +import modal from '../../../layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json'; + +type Node = Record; +const PREFIX = 'board_'; +const STATE_KEY = 'board_notification_template_form_modal'; +const MODAL_ID = 'modal_board_notification_template_edit'; +const REFETCH_ID = 'boardNotificationDefinitions'; + +const findById = (node: unknown, id: string): Node | null => { + if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; } + if (node && typeof node === 'object') { + if ((node as Node).id === id) return node as Node; + for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; } + } + return null; +}; +const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => { + if (Array.isArray(node)) { + const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id); + if (idx >= 0) return { arr: node, idx }; + for (const n of node) { const r = findParentArray(n, id); if (r) return r; } + return null; + } + if (node && typeof node === 'object') { + for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; } + } + return null; +}; +const evalIf = (expr: string, scope: Record): boolean => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n]))); +}; +const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({ + availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } }, + _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } }, +}); + +const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId']; + +describe('게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => { + it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => { + const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`); + expect(loc).toBeTruthy(); + const { arr, idx } = loc!; + expect(arr[idx].type).toBe('extension_point'); + expect(arr[idx].name).toBe('notification_template_form_sections'); + expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`); + expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`); + }); + + it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const right = footer.children[1].children as Node[]; + expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']); + expect(right[1].name).toBe('notification_template_form_footer_actions'); + expect(JSON.stringify(right[0])).toContain('$t:common.cancel'); + expect(JSON.stringify(right[2])).toContain('$t:common.save'); + }); + + it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => { + const ep = findById(modal, id)!; + expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort()); + expect(ep.props.modalId).toBe(MODAL_ID); + expect(ep.props.stateKey).toBe(STATE_KEY); + expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID); + expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`); + expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`); + expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`); + expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`); + }); +}); + +describe('게시판 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => { + const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info']; + + it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node.if, `${id} if`).toBeTruthy(); + expect(evalIf(node.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(node.if, scope('mail', undefined))).toBe(true); + expect(evalIf(node.if, scope('sms', false))).toBe(true); + }); + + it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_click_url_input`)!; + expect(evalIf(node.if, scope('database', undefined))).toBe(true); + expect(evalIf(node.if, scope('mail', undefined))).toBe(false); + expect(evalIf(node.if, scope('database', true))).toBe(false); + }); + + it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_variables_info`)!; + const noVars = { ...scope('mail', undefined) } as Node; + noVars._global[STATE_KEY].definition = { variables: [] }; + expect(evalIf(node.if, noVars)).toBe(false); + }); + + it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const preview = footer.children[0].children[0] as Node; + const right = footer.children[1].children as Node[]; + const cancel = right[0]; + const save = right[2]; + for (const b of [preview, save]) { + expect(evalIf(b.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(b.if, scope('mail', undefined))).toBe(true); + } + expect(cancel.if).toBeUndefined(); + }); + + it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => { + for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node, id).toBeTruthy(); + expect(String(node.if ?? '')).not.toContain('hidden_template_editor'); + } + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json index 830721d2..2cd46c2c 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json @@ -500,8 +500,7 @@ ] } ], - "props": { - } + "props": {} } ] } @@ -528,6 +527,21 @@ } ] }, + { + "id": "board_template_form_sections_ext", + "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.", + "type": "extension_point", + "name": "notification_template_form_sections", + "props": { + "definition": "{{_global.board_notification_template_form_modal?.definition}}", + "template": "{{_global.board_notification_template_form_modal?.template}}", + "channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_board_notification_template_edit", + "stateKey": "board_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "boardNotificationDefinitions" + } + }, { "id": "board_template_lang_tabs", "type": "basic", @@ -559,7 +573,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "board_template_subject_input", @@ -613,7 +628,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "board_template_body_input", @@ -668,13 +684,14 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "board_template_click_url_input", "type": "basic", "name": "Div", - "if": "{{_global.board_notification_template_form_modal?.template?.channel === 'database'}}", + "if": "{{(_global.board_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "children": [ { "type": "basic", @@ -724,7 +741,7 @@ "id": "board_template_variables_info", "type": "basic", "name": "Div", - "if": "{{(_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0}}", + "if": "{{((_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4" }, @@ -841,17 +858,18 @@ "target": "modal_board_notification_template_preview" } ], - "onError": [ - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message}}" - } - } - ] + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] }, @@ -879,6 +897,21 @@ } ] }, + { + "id": "board_template_form_footer_ext", + "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.", + "type": "extension_point", + "name": "notification_template_form_footer_actions", + "props": { + "definition": "{{_global.board_notification_template_form_modal?.definition}}", + "template": "{{_global.board_notification_template_form_modal?.template}}", + "channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_board_notification_template_edit", + "stateKey": "board_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "boardNotificationDefinitions" + } + }, { "type": "basic", "name": "Button", @@ -978,7 +1011,8 @@ ] } } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 8badf34f..6cbd3e88 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -13,6 +13,7 @@ ### Changed - 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다. +- 이커머스 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다. ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts new file mode 100644 index 00000000..5bde5a12 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts @@ -0,0 +1,138 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio) +// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다. +/** + * 이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름) + * + * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록: + * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞) + * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이) + * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다 + * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일. + * + * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은 + * 기대값을 함께 고치면 통과한다. + * + * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections + */ + +import { describe, it, expect } from 'vitest'; +import modal from '../../../layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json'; + +type Node = Record; +const PREFIX = 'ecommerce_'; +const STATE_KEY = 'ecommerce_notification_template_form_modal'; +const MODAL_ID = 'modal_ecommerce_notification_template_edit'; +const REFETCH_ID = 'ecommerceNotificationDefinitions'; + +const findById = (node: unknown, id: string): Node | null => { + if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; } + if (node && typeof node === 'object') { + if ((node as Node).id === id) return node as Node; + for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; } + } + return null; +}; +const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => { + if (Array.isArray(node)) { + const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id); + if (idx >= 0) return { arr: node, idx }; + for (const n of node) { const r = findParentArray(n, id); if (r) return r; } + return null; + } + if (node && typeof node === 'object') { + for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; } + } + return null; +}; +const evalIf = (expr: string, scope: Record): boolean => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n]))); +}; +const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({ + availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } }, + _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } }, +}); + +const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId']; + +describe('이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => { + it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => { + const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`); + expect(loc).toBeTruthy(); + const { arr, idx } = loc!; + expect(arr[idx].type).toBe('extension_point'); + expect(arr[idx].name).toBe('notification_template_form_sections'); + expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`); + expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`); + }); + + it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const right = footer.children[1].children as Node[]; + expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']); + expect(right[1].name).toBe('notification_template_form_footer_actions'); + expect(JSON.stringify(right[0])).toContain('$t:common.cancel'); + expect(JSON.stringify(right[2])).toContain('$t:common.save'); + }); + + it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => { + const ep = findById(modal, id)!; + expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort()); + expect(ep.props.modalId).toBe(MODAL_ID); + expect(ep.props.stateKey).toBe(STATE_KEY); + expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID); + expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`); + expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`); + expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`); + expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`); + }); +}); + +describe('이커머스 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => { + const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info']; + + it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node.if, `${id} if`).toBeTruthy(); + expect(evalIf(node.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(node.if, scope('mail', undefined))).toBe(true); + expect(evalIf(node.if, scope('sms', false))).toBe(true); + }); + + it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_click_url_input`)!; + expect(evalIf(node.if, scope('database', undefined))).toBe(true); + expect(evalIf(node.if, scope('mail', undefined))).toBe(false); + expect(evalIf(node.if, scope('database', true))).toBe(false); + }); + + it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_variables_info`)!; + const noVars = { ...scope('mail', undefined) } as Node; + noVars._global[STATE_KEY].definition = { variables: [] }; + expect(evalIf(node.if, noVars)).toBe(false); + }); + + it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const preview = footer.children[0].children[0] as Node; + const right = footer.children[1].children as Node[]; + const cancel = right[0]; + const save = right[2]; + for (const b of [preview, save]) { + expect(evalIf(b.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(b.if, scope('mail', undefined))).toBe(true); + } + expect(cancel.if).toBeUndefined(); + }); + + it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => { + for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node, id).toBeTruthy(); + expect(String(node.if ?? '')).not.toContain('hidden_template_editor'); + } + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json index bab7fc2f..5387c06d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json @@ -500,8 +500,7 @@ ] } ], - "props": { - } + "props": {} } ] } @@ -528,6 +527,21 @@ } ] }, + { + "id": "ecommerce_template_form_sections_ext", + "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.", + "type": "extension_point", + "name": "notification_template_form_sections", + "props": { + "definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}", + "template": "{{_global.ecommerce_notification_template_form_modal?.template}}", + "channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_ecommerce_notification_template_edit", + "stateKey": "ecommerce_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "ecommerceNotificationDefinitions" + } + }, { "id": "ecommerce_template_lang_tabs", "type": "basic", @@ -559,7 +573,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "ecommerce_template_subject_input", @@ -613,7 +628,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "ecommerce_template_body_input", @@ -668,13 +684,14 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "ecommerce_template_click_url_input", "type": "basic", "name": "Div", - "if": "{{_global.ecommerce_notification_template_form_modal?.template?.channel === 'database'}}", + "if": "{{(_global.ecommerce_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "children": [ { "type": "basic", @@ -724,7 +741,7 @@ "id": "ecommerce_template_variables_info", "type": "basic", "name": "Div", - "if": "{{(_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0}}", + "if": "{{((_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4" }, @@ -841,17 +858,18 @@ "target": "modal_ecommerce_notification_definition_preview" } ], - "onError": [ - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message}}" - } - } - ] + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] }, @@ -879,6 +897,21 @@ } ] }, + { + "id": "ecommerce_template_form_footer_ext", + "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.", + "type": "extension_point", + "name": "notification_template_form_footer_actions", + "props": { + "definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}", + "template": "{{_global.ecommerce_notification_template_form_modal?.template}}", + "channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_ecommerce_notification_template_edit", + "stateKey": "ecommerce_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "ecommerceNotificationDefinitions" + } + }, { "type": "basic", "name": "Button", @@ -978,7 +1011,8 @@ ] } } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index efed6a42..c26e0605 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -9,8 +9,8 @@ ### Added - 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. -- 알림 설정 화면의 '비즈뿌리오' 통합 탭에서 알림별 카카오 알림톡 템플릿을 직접 작성해 검수를 신청하고, 승인되면 그 내용으로 자동 발송됩니다. 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형과 버튼·바로연결을 지원하며, 검수 결과는 30분 주기로 자동 확인되고 [새로고침]으로 즉시 확인할 수 있습니다. 반려되면 사유를 화면에서 확인하고 수정 후 다시 신청합니다. 게시판·이커머스 알림 설정 화면에서도 동일하게 동작합니다. -- 알림별 대체 SMS(알림톡 실패 시)와 SMS 단독 발송 본문을 같은 화면에서 설정할 수 있습니다. 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다(해당 언어가 비어 있으면 기본 언어로 발송). [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. 승인 취소 시에는 알림톡 발송이 즉시 중단됨을 경고합니다. +- 알림 설정 화면의 '비즈뿌리오' 통합 탭에서 각 알림의 [편집]을 누르면 알림 템플릿 편집 창 안에서 카카오 알림톡 템플릿을 직접 작성해 검수를 신청하고, 승인되면 그 내용으로 자동 발송됩니다. 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형과 버튼·바로연결을 지원하며, 검수 결과는 30분 주기로 자동 확인되고 편집 창의 [새로고침]으로 즉시 확인할 수 있습니다. 검수 신청 시 변수 예시값 등 검수자에게 전달할 의견을 함께 적어 보낼 수 있으며, 반려되면 사유를 같은 창에서 확인하고 수정 후 다시 신청합니다. 알림 목록의 각 행에는 승인 여부(승인됨 / 미승인·세부 상태)와 문자 설정 요약이 표시됩니다. 게시판·이커머스 알림 설정 화면에서도 동일하게 동작합니다. +- 같은 편집 창에서 알림별 대체 SMS(알림톡 실패 시)·SMS 단독 발송 여부와 문자 본문을 함께 설정하고, 하단 [저장] 한 번으로 알림톡 템플릿·문자 설정·수신자 규칙을 모두 저장합니다. 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다(해당 언어가 비어 있으면 기본 언어로 발송). [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. 검수중·승인된 템플릿은 내용이 잠기며, 승인 취소는 즉시 알림톡 발송이 중단됨을 같은 창에서 확인한 뒤 진행합니다. - 플러그인 설정의 '알림 템플릿 관리' 화면에서 모든 알림의 템플릿 상태를 한눈에 보고 검색·필터·수정·삭제할 수 있습니다. - 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다. - 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/README.md b/plugins/_bundled/sirsoft-message_bizppurio/README.md index f1b231fc..17f8a1b9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md @@ -114,10 +114,10 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook 카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다. -작성 위치는 두 곳이고 화면 구성은 같습니다. +작성 위치는 두 곳입니다. -- **알림 설정 > 비즈뿌리오 탭** — 알림 항목마다 알림톡 템플릿과 대체 SMS 를 함께 설정합니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다. -- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다. +- **알림 설정 > 비즈뿌리오 탭** — 알림 항목의 **[편집]** 을 누르면 알림 템플릿 편집 창 안에 **알림톡 템플릿** 섹션과 **문자(SMS)** 섹션이 함께 열립니다. 알림톡 본문·유형·버튼, 대체 SMS/SMS 단독 여부와 문자 본문, 수신자 규칙을 한 창에서 고치고 하단 **[저장]** 한 번으로 모두 저장합니다(알림톡 검수는 **[저장 후 검수 신청]** — 작성 폼 하단의 **검수자 전달 의견**에 변수 예시값 등을 적으면 신청과 함께 카카오 검수자에게 전달됩니다). 이 채널에서는 코어의 제목/본문 입력이 숨겨지고 알림톡 본문이 그 자리를 대신합니다. 알림 목록의 각 행 하단에는 승인 여부(**승인됨** / **미승인 (세부 상태)**)와 문자 설정 요약이 표시됩니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다. +- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다(자체 작성/SMS 본문 모달). 기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다. @@ -136,13 +136,13 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook | 차단 | ❌ | 카카오에서 차단된 템플릿 | | 휴면 | ❌ | 장기 미사용으로 휴면 전환. 화면의 [휴면 해제]로 복구 | -검수 결과는 30분 주기로 자동 확인하며, 각 행의 [새로고침]으로 즉시 확인할 수도 있습니다. +검수 결과는 30분 주기로 자동 확인하며, 편집 창의 [새로고침]으로 즉시 확인할 수도 있습니다. -승인된 템플릿의 내용을 고치려면 [수정]을 눌러 승인을 먼저 취소해야 합니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 화면에서 확인 후 진행합니다. +검수중·승인된 템플릿은 편집 창에서 내용이 잠기고 요약만 표시됩니다. 검수중이면 [신청 취소] 후, 승인된 템플릿은 [수정 (승인 취소)] 를 눌러 승인을 먼저 취소해야 고칠 수 있습니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 같은 창의 확인 박스에서 확인 후 진행합니다. ### 문자(SMS) 본문 -알림마다 문자 본문을 따로 입력합니다. 쓰임은 두 가지이고 본문은 하나를 공유합니다. +알림마다 문자 본문을 따로 입력합니다(편집 창의 문자(SMS) 섹션). 쓰임은 두 가지이고 본문은 하나를 공유하며, 카카오 검수 대상이 아니므로 [저장] 즉시 반영됩니다. - **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다. - **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md index b2e4a93b..e957e91a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md @@ -672,6 +672,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | id | path | integer | 예 | 숫자만 | 템플릿 PK | +| comment | body | string | 아니오 | 500자 이내 (앞뒤 공백 제거, 비어 있으면 미전달) | 검수자 전달 의견 — kapi `template/request` 의 comment 로 그대로 실린다. 행에는 저장하지 않는다 | **요청 예시** @@ -679,7 +680,12 @@ Content-Type: application/json POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/request HTTP/1.1 Host: api.example.com Accept: application/json +Content-Type: application/json Authorization: Bearer {YOUR_TOKEN} + +{ + "comment": "변수 예시: #{name}=홍길동, #{order_number}=20260823-0001" +} ``` **응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) @@ -710,7 +716,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | | 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | -| 422 | Unprocessable Entity | content 미작성 / 신청 불가 상태(draft·rejected 외) / kapi 실패(`errors.bizppurio_message`·`errors.result_code`) / 발신프로필 키(sender_key) 미설정 / 템플릿 코드 채번 실패 | +| 422 | Unprocessable Entity | comment 500자 초과 / content 미작성 / 신청 불가 상태(draft·rejected 외) / kapi 실패(`errors.bizppurio_message`·`errors.result_code`) / 발신프로필 키(sender_key) 미설정 / 템플릿 코드 채번 실패 | **설명** @@ -720,6 +726,9 @@ Authorization: Bearer {YOUR_TOKEN} add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음 시도에서 다시 채번부터 진행된다. `draft`/`rejected` 상태에서만 신청할 수 있으며(requested 는 이미 검수중, approved 는 승인 취소 먼저), content 미작성이면 422 를 반환한다. 성공 시 `inspection_detail` 은 초기화된다. +요청 본문의 `comment`(선택, ≤500)는 ③ 의 kapi request 에 검수자 전달 의견으로 그대로 실리며 행에는 저장하지 않는다 — +카카오 심사 가이드가 본문 변수마다 요구하는 '예시 텍스트' 를 검수자에게 전할 유일한 통로다(kapi `template/add` 에는 +예시 필드가 없다). 500자 초과는 kapi 호출 전에 422 로 끝나므로 행 상태와 선점은 바뀌지 않는다. ### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/cancel-request diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 0557050d..abafde00 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -132,6 +132,7 @@ return [ 'message_type' => 'message type', 'emphasize_type' => 'emphasis type', 'template_content' => 'body', + 'comment' => 'reviewer comment', 'preview_message' => 'preview message', 'category_code' => 'category', 'security_flag' => 'secure template flag', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index 5652a7a3..03bfeb60 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -132,6 +132,7 @@ return [ 'message_type' => '메시지 유형', 'emphasize_type' => '강조 유형', 'template_content' => '본문', + 'comment' => '검수자 전달 의견', 'preview_message' => '미리보기 문구', 'category_code' => '카테고리', 'security_flag' => '보안 템플릿 여부', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json index cf17dc15..a6a1fb4e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -1,6 +1,7 @@ { "extension_point": "notification_definition_row_footer", - "comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단(코어 notification_definition_row_footer 슬롯)에 알림톡 라이프사이클 줄 + SMS 설정 줄을 채운다(#597 §4.1). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽고, 작성/수정은 modal_bizppurio_template(오버레이 등록), SMS 본문은 modal_bizppurio_sms, 반려 사유는 modal_bizppurio_rejection, 승인 취소 경고는 modal_bizppurio_cancel_approval 을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.", + "comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단 상태 요약(PO 결정 2026-08-23 — 버튼 없음, 조작은 코어 [편집] 모달로 일원화). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽는다. 1줄: 알림톡 승인 2단 배지 + 신청/동기화일 + 발송 사용 여부 / 2줄: 문자(SMS) 대체·단독 사용 여부 + 본문 미리보기.", + "priority": 320, "components": [ { "id": "bizppurio_row_lifecycle", @@ -14,787 +15,181 @@ { "type": "basic", "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-2" + }, "children": [ { "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-comment-dots", - "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label", - "props": { - "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "{{$t('sirsoft-message_bizppurio.template.status.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten'))}}", - "props": { - "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'stopped' || (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0" - }, - "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}" - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0" - }, - "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}" - } - ], + "name": "Icon", "props": { - "className": "flex-center gap-2 min-w-0 flex-wrap" + "name": "fas fa-comment-dots", + "className": "text-sm text-teal-600 dark:text-teal-400 flex-shrink-0" } }, { "type": "basic", - "name": "Div", + "name": "Span", + "props": { + "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label" + }, + { + "type": "basic", + "name": "Span", + "comment": "승인 여부 2단 배지(PO 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.", + "props": { + "className": "{{'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'stopped' || (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, "children": [ { "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Toggle", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}", - "props": { - "checked": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false}}", - "size": "sm" - }, - "actions": [ - { - "type": "change", - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false)}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_enabled", - "props": { - "className": "text-xs text-gray-500 dark:text-gray-400" - }, - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}" - } - ], - "props": { - "className": "flex-center gap-1.5" - } + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.approval.' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'approved' ? 'approved' : 'not_approved'))}}" }, { "type": "basic", - "name": "Button", + "name": "Span", + "if": "{{(((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') !== 'approved'}}", "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30" + "className": "font-normal opacity-80" }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_reject_view": { - "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", - "comments": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}" - } - } - }, - { - "handler": "openModal", - "target": "modal_bizppurio_rejection" - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_view_reason", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected' && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_upload": { "uploading": false, "error": null }, - "bz_tpl_modal": { - "id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id ?? null}}", - "notification_type": "{{extensionPointProps.definition?.type}}", - "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", - "variables": "{{extensionPointProps.definition?.variables ?? []}}", - "status": "draft", - "inspection_detail": [], - "isSaving": false, - "error": null, - "sender_profile": "", - "content": { - "templateName": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", - "templateMessageType": "BA", - "templateEmphasizeType": "NONE", - "templateContent": "{{(((extensionPointProps.definition?.templates ?? []).find(tpl => tpl.channel === 'alimtalk')?.body?.[$locale] ?? '').split('{').join('#{'))}}", - "templateExtra": "", - "templateHeader": "", - "templateTitle": "", - "templateSubtitle": "", - "templateImageName": "", - "templateImageUrl": "", - "categoryCode": "", - "buttons": [], - "quickReplies": [], - "templateItem": { - "list": [], - "summary": { - "title": "", - "description": "" - } - }, - "templateItemHighlight": { - "title": "", - "description": "", - "imageUrl": "" - } - } - } - } - }, - { - "handler": "openModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioCategories" - } - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioProfiles" - } - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_compose", - "if": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) || !(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", - "params": { - "method": "GET" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_upload": { "uploading": false, "error": null }, - "bz_tpl_modal": { - "id": "{{response.data?.template?.id}}", - "notification_type": "{{response.data?.template?.notification_type}}", - "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? response.data?.template?.notification_type}}", - "variables": "{{extensionPointProps.definition?.variables ?? []}}", - "status": "{{response.data?.template?.status}}", - "inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}", - "isSaving": false, - "error": null, - "sender_profile": "", - "content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}" - } - } - }, - { - "handler": "openModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioCategories" - } - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioProfiles" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft' || (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected')}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval": { - "id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", - "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", - "isSaving": false - } - } - }, - { - "handler": "openModal", - "target": "modal_bizppurio_cancel_approval" - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'approved'}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30", - "disabled": "{{_global.bz_row_busy ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_row_busy": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_row_busy": false - } - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.row.requested_toast", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_row_busy": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_row_busy ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_request", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft'}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/cancel-request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'requested'}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/release", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.row.released_toast", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_release", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'dormant'}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/sync", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh", - "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code}}" + "text": "{{'(' + $t('sirsoft-message_bizppurio.template.status.' + (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) + ')'}}" } - ], + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}", "props": { - "className": "flex-center gap-1.5 flex-shrink-0 flex-wrap" - } + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled === false)}}", + "props": { + "className": "text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_disabled" } - ], - "props": { - "className": "flex-between gap-2" - } + ] }, { "type": "basic", "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-2" + }, "children": [ { "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-comment-sms", - "size": "sm", - "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Label", - "comment": "알림톡 실패 시 SMS 대체발송 — 변경 즉시 저장(행이 없으면 draft 로 생성)", - "props": { - "className": "flex items-center gap-1.5 cursor-pointer select-none" - }, - "children": [ - { - "type": "basic", - "name": "Checkbox", - "props": { - "autoBinding": false, - "checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}", - "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" - }, - "actions": [ - { - "type": "change", - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}", - "params": { - "method": "PUT", - "body": { - "fallback_sms_enabled": "{{$event.target.checked}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-300" - } - } - ] - }, - { - "type": "basic", - "name": "Label", - "comment": "알림톡 없이 SMS 만 발송 — 변경 즉시 저장", - "props": { - "className": "flex items-center gap-1.5 cursor-pointer select-none" - }, - "children": [ - { - "type": "basic", - "name": "Checkbox", - "props": { - "autoBinding": false, - "checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}", - "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" - }, - "actions": [ - { - "type": "change", - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}", - "params": { - "method": "PUT", - "body": { - "sms_only": "{{$event.target.checked}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.sms_only", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-300" - } - } - ] - }, - { - "type": "basic", - "name": "Span", - "comment": "SMS 본문 미리보기. 로케일 선택·폴백·유무 판정은 서버(BizppurioTemplate::getLocalizedSmsBody/hasSmsBody)가 하고 화면은 결과만 그린다 — 여기서 맵을 직접 훑으면 발송 게이트와 규칙이 갈린다.", - "text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix')}} {{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').slice(0, 40) + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').length > 40 ? '…' : '')}}", - "props": { - "className": "text-xs text-gray-500 dark:text-gray-400 truncate min-w-0" - }, - "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}" - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - }, - "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}" - } - ], + "name": "Icon", "props": { - "className": "flex-center gap-3 min-w-0 flex-wrap" + "name": "fas fa-comment-sms", + "className": "text-sm text-gray-500 dark:text-gray-400 flex-shrink-0" } }, { "type": "basic", - "name": "Button", + "name": "Span", "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" + "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0" }, - "actions": [ + "text": "$t:sirsoft-message_bizppurio.template.row.sms_label" + }, + { + "id": "bizppurio_row_sms_fallback", + "type": "basic", + "name": "Div", + "comment": "fallback_sms 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(PO 지시 2026-08-23, 행의 활성·기본·승인 배지와 일관).", + "props": { + "className": "flex-center gap-1 flex-shrink-0" + }, + "children": [ { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal": { - "notification_type": "{{extensionPointProps.definition?.type}}", - "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", - "variables": "{{extensionPointProps.definition?.variables ?? []}}", - "body": "{{Object.assign({}, bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body)}}", - "editLang": "{{$locale}}", - "isSaving": false - } - } - }, - { - "handler": "openModal", - "target": "modal_bizppurio_sms" - } - ] - } + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms" + }, + { + "id": "bizppurio_row_sms_fallback_badge", + "type": "basic", + "name": "Span", + "props": { + "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "text": "{{$t('sirsoft-message_bizppurio.template.row.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled) ? 'on' : 'off'))}}" } - ], - "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_sms" + ] + }, + { + "id": "bizppurio_row_sms_only", + "type": "basic", + "name": "Div", + "comment": "sms_only 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(PO 지시 2026-08-23, 행의 활성·기본·승인 배지와 일관).", + "props": { + "className": "flex-center gap-1 flex-shrink-0" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_only" + }, + { + "id": "bizppurio_row_sms_only_badge", + "type": "basic", + "name": "Span", + "props": { + "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "text": "{{$t('sirsoft-message_bizppurio.template.row.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only) ? 'on' : 'off'))}}" + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 truncate max-w-md" + }, + "text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix') + ' ' + (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '')}}" + }, + { + "id": "bizppurio_row_sms_body_missing", + "type": "basic", + "name": "Span", + "comment": "본문 미설정 = 값 없음 — '발송 사용 안 함' 칩과 같은 외곽선 스타일.", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}", + "props": { + "className": "text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing" } - ], - "props": { - "className": "flex-between gap-2" - } + ] } ] } - ], - "priority": 320 + ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json index fa29ff1e..998a903d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json @@ -1,6 +1,6 @@ { "target_layout": "admin_board_settings", - "comment": "[게시판 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 게시판 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 알림 행 하단(notification_definition_row_footer 슬롯 — notification_row_footer.json)에 알림톡 라이프사이클·SMS 설정 줄 ③ 작성/신청·SMS 본문·승인 취소·반려 사유 모달 4종. 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회한다. 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염).", + "comment": "[게시판 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 게시판 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, PO 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.", "data_sources": [ { "id": "bizppurioTemplates", @@ -189,2389 +189,6 @@ ] } ], - "modals": [ - { - "id": "modal_bizppurio_template", - "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", - "size": "xl", - "closeOnOverlayClick": false - }, - "children": [ - { - "id": "bizppurio_board_tpl_modal_body", - "type": "basic", - "name": "Div", - "props": { - "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-circle-exclamation", - "size": "sm", - "className": "text-red-600 dark:text-red-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", - "props": { - "className": "text-xs font-medium text-red-800 dark:text-red-200" - } - } - ], - "props": { - "className": "flex-center gap-2" - } - }, - { - "type": "basic", - "name": "P", - "iteration": { - "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", - "item_var": "cm" - }, - "props": { - "className": "text-xs text-red-700 dark:text-red-300 mt-1" - }, - "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" - } - ], - "props": { - "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-triangle-exclamation", - "size": "sm", - "className": "text-red-600 dark:text-red-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_modal?.error}}", - "props": { - "className": "text-xs text-red-800 dark:text-red-200" - } - } - ], - "props": { - "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "if": "{{_global.bz_tpl_modal?.error}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" - }, - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-full text-sm", - "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", - "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.sender_profile": "{{$event.target.value}}" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.category *" - }, - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-full text-sm", - "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", - "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" - } - } - ] - } - ] - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.name *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", - "name": "bz_template_name", - "maxLength": 200 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateName": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" - }, - { - "type": "basic", - "name": "Div", - "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", - "props": { - "className": "flex flex-wrap gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "BA" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "EX" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "AD" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "MI" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" - }, - { - "type": "basic", - "name": "Div", - "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", - "props": { - "className": "flex flex-wrap gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "NONE" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "TEXT" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" - } - } - ] - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-3" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.image *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "type": "file", - "accept": ".jpg,.jpeg,.png", - "className": "block w-full text-xs text-gray-600 dark:text-gray-300" - }, - "actions": [ - { - "type": "change", - "handler": "sirsoft-message_bizppurio.uploadTemplateImage", - "params": { - "stateTarget": "global", - "statePathUrl": "bz_tpl_modal.content.templateImageUrl", - "statePathName": "bz_tpl_modal.content.templateImageName", - "statePathStatus": "bz_tpl_upload" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", - "props": { - "className": "text-xs text-amber-600 dark:text-amber-400" - }, - "if": "{{_global.bz_tpl_upload?.uploading}}" - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_upload?.error ?? ''}}", - "props": { - "className": "text-xs text-red-600 dark:text-red-400" - }, - "if": "{{_global.bz_tpl_upload?.error}}" - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", - "props": { - "className": "text-xs text-green-700 dark:text-green-400 break-all" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" - } - ], - "props": { - "className": "space-y-1" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" - } - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-1.5" - } - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", - "maxLength": 500 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_list" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.item_add" - } - ], - "props": { - "className": "flex-between mt-2" - } - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", - "item_var": "itm", - "index_var": "itmIdx" - }, - "props": { - "className": "flex items-center gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 6, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", - "value": "{{itm.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 23, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", - "value": "{{itm.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium mt-2" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 6, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", - "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 14, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", - "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" - } - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-1.5" - } - } - ], - "props": { - "className": "space-y-1.5" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.content *" - }, - { - "type": "basic", - "name": "Span", - "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "name": "bz_template_content", - "rows": 6, - "maxLength": 1000, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", - "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{_global.bz_tpl_modal?.variables ?? []}}", - "item_var": "v" - }, - "props": { - "type": "button", - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" - }, - "text": "{{'#{' + (v.key ?? '') + '}'}}", - "actions": [ - { - "type": "click", - "handler": "sirsoft-message_bizppurio.insertVariable", - "params": { - "variable": "{{v.key}}", - "target": "bz_template_content", - "stateTarget": "global", - "statePath": "bz_tpl_modal.content.templateContent" - } - } - ] - } - ], - "props": { - "className": "flex flex-wrap gap-1" - } - } - ], - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.extra *" - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "rows": 2, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" - } - } - ] - } - ], - "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.header" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", - "maxLength": 16 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.buttons" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.button_add" - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", - "item_var": "buttonsItem", - "index_var": "buttonsIdx" - }, - "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-40 text-xs", - "value": "{{buttonsItem.linkType ?? 'WL'}}", - "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", - "value": "{{buttonsItem.name ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ], - "props": { - "className": "flex items-center gap-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", - "value": "{{buttonsItem.linkMo ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", - "value": "{{buttonsItem.linkPc ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", - "value": "{{buttonsItem.linkAnd ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", - "value": "{{buttonsItem.linkIos ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", - "value": "{{buttonsItem.telNumber ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'TN'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", - "value": "{{buttonsItem.pluginId ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" - } - } - ] - } - ], - "props": { - "className": "space-y-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", - "item_var": "quickRepliesItem", - "index_var": "quickRepliesIdx" - }, - "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-40 text-xs", - "value": "{{quickRepliesItem.linkType ?? 'WL'}}", - "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", - "value": "{{quickRepliesItem.name ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ], - "props": { - "className": "flex items-center gap-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", - "value": "{{quickRepliesItem.linkMo ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", - "value": "{{quickRepliesItem.linkPc ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", - "value": "{{quickRepliesItem.linkAnd ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", - "value": "{{quickRepliesItem.linkIos ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", - "value": "{{quickRepliesItem.telNumber ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'TN'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", - "value": "{{quickRepliesItem.pluginId ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" - } - } - ] - } - ], - "props": { - "className": "space-y-1.5" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_template" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", - "params": { - "method": "POST", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}", - "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", - "if": "{{!(_global.bz_tpl_modal?.id)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", - "if": "{{_global.bz_tpl_modal?.id}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", - "params": { - "method": "POST", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}", - "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" - } - }, - "onSuccess": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", - "if": "{{!(_global.bz_tpl_modal?.id)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}" - } - }, - "onSuccess": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", - "if": "{{_global.bz_tpl_modal?.id}}" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_sms", - "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", - "size": "md", - "closeOnOverlayClick": false - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.sms.hint", - "props": { - "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" - } - }, - { - "id": "bz_sms_lang_tabs", - "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", - "type": "basic", - "name": "Div", - "props": { - "className": "flex border-b border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{$locales}}", - "item_var": "loc" - }, - "props": { - "type": "button", - "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" - }, - "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.editLang": "{{loc}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "name": "bz_sms_body", - "rows": 4, - "maxLength": 2000, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{_global.bz_sms_modal?.variables ?? []}}", - "item_var": "v" - }, - "props": { - "type": "button", - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" - }, - "text": "{{'#{' + (v.key ?? '') + '}'}}", - "actions": [ - { - "type": "click", - "handler": "sirsoft-message_bizppurio.insertVariable", - "params": { - "variable": "{{v.key}}", - "target": "bz_sms_body", - "stateTarget": "global", - "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" - } - } - ] - } - ], - "props": { - "className": "flex flex-wrap gap-1" - } - } - ], - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" - } - ], - "props": { - "className": "space-y-3" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_sms" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", - "params": { - "method": "PUT", - "body": { - "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_sms" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_cancel_approval", - "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", - "size": "sm", - "closeOnOverlayClick": false - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-triangle-exclamation", - "className": "text-amber-500 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", - "props": { - "className": "text-sm text-gray-700 dark:text-gray-200" - } - } - ], - "props": { - "className": "flex items-start gap-2" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_cancel_approval" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", - "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", - "type": "info" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_cancel_approval" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_rejection", - "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", - "size": "md" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_reject_view?.comments ?? []}}", - "item_var": "cm" - }, - "props": { - "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "children": [ - { - "type": "basic", - "name": "P", - "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", - "props": { - "className": "text-xs text-red-500 dark:text-red-400" - } - }, - { - "type": "basic", - "name": "P", - "text": "{{cm.content ?? ''}}", - "props": { - "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" - } - } - ] - } - ], - "props": { - "className": "space-y-2 max-h-80 overflow-y-auto" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_rejection" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - } - ], "init_actions": [ { "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index 6cc90787..061d6ad3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -1,6 +1,6 @@ { "target_layout": "admin_settings", - "comment": "알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 알림 행 하단(notification_definition_row_footer 슬롯 — notification_row_footer.json)에 알림톡 라이프사이클·SMS 설정 줄 ③ 작성/신청·SMS 본문·승인 취소·반려 사유 모달 4종. 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회한다. 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염).", + "comment": "알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, PO 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.", "data_sources": [ { "id": "bizppurioTemplates", @@ -189,2389 +189,6 @@ ] } ], - "modals": [ - { - "id": "modal_bizppurio_template", - "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", - "size": "xl", - "closeOnOverlayClick": false - }, - "children": [ - { - "id": "bizppurio_tpl_modal_body", - "type": "basic", - "name": "Div", - "props": { - "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-circle-exclamation", - "size": "sm", - "className": "text-red-600 dark:text-red-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", - "props": { - "className": "text-xs font-medium text-red-800 dark:text-red-200" - } - } - ], - "props": { - "className": "flex-center gap-2" - } - }, - { - "type": "basic", - "name": "P", - "iteration": { - "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", - "item_var": "cm" - }, - "props": { - "className": "text-xs text-red-700 dark:text-red-300 mt-1" - }, - "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" - } - ], - "props": { - "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-triangle-exclamation", - "size": "sm", - "className": "text-red-600 dark:text-red-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_modal?.error}}", - "props": { - "className": "text-xs text-red-800 dark:text-red-200" - } - } - ], - "props": { - "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "if": "{{_global.bz_tpl_modal?.error}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" - }, - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-full text-sm", - "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", - "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.sender_profile": "{{$event.target.value}}" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.category *" - }, - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-full text-sm", - "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", - "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" - } - } - ] - } - ] - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.name *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", - "name": "bz_template_name", - "maxLength": 200 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateName": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" - }, - { - "type": "basic", - "name": "Div", - "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", - "props": { - "className": "flex flex-wrap gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "BA" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "EX" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "AD" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "MI" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" - }, - { - "type": "basic", - "name": "Div", - "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", - "props": { - "className": "flex flex-wrap gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "NONE" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "TEXT" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" - } - } - ] - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-3" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.image *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "type": "file", - "accept": ".jpg,.jpeg,.png", - "className": "block w-full text-xs text-gray-600 dark:text-gray-300" - }, - "actions": [ - { - "type": "change", - "handler": "sirsoft-message_bizppurio.uploadTemplateImage", - "params": { - "stateTarget": "global", - "statePathUrl": "bz_tpl_modal.content.templateImageUrl", - "statePathName": "bz_tpl_modal.content.templateImageName", - "statePathStatus": "bz_tpl_upload" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", - "props": { - "className": "text-xs text-amber-600 dark:text-amber-400" - }, - "if": "{{_global.bz_tpl_upload?.uploading}}" - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_upload?.error ?? ''}}", - "props": { - "className": "text-xs text-red-600 dark:text-red-400" - }, - "if": "{{_global.bz_tpl_upload?.error}}" - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", - "props": { - "className": "text-xs text-green-700 dark:text-green-400 break-all" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" - } - ], - "props": { - "className": "space-y-1" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" - } - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-1.5" - } - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", - "maxLength": 500 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_list" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.item_add" - } - ], - "props": { - "className": "flex-between mt-2" - } - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", - "item_var": "itm", - "index_var": "itmIdx" - }, - "props": { - "className": "flex items-center gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 6, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", - "value": "{{itm.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 23, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", - "value": "{{itm.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium mt-2" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 6, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", - "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 14, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", - "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" - } - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-1.5" - } - } - ], - "props": { - "className": "space-y-1.5" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.content *" - }, - { - "type": "basic", - "name": "Span", - "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "name": "bz_template_content", - "rows": 6, - "maxLength": 1000, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", - "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{_global.bz_tpl_modal?.variables ?? []}}", - "item_var": "v" - }, - "props": { - "type": "button", - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" - }, - "text": "{{'#{' + (v.key ?? '') + '}'}}", - "actions": [ - { - "type": "click", - "handler": "sirsoft-message_bizppurio.insertVariable", - "params": { - "variable": "{{v.key}}", - "target": "bz_template_content", - "stateTarget": "global", - "statePath": "bz_tpl_modal.content.templateContent" - } - } - ] - } - ], - "props": { - "className": "flex flex-wrap gap-1" - } - } - ], - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.extra *" - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "rows": 2, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" - } - } - ] - } - ], - "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.header" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", - "maxLength": 16 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.buttons" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.button_add" - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", - "item_var": "buttonsItem", - "index_var": "buttonsIdx" - }, - "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-40 text-xs", - "value": "{{buttonsItem.linkType ?? 'WL'}}", - "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", - "value": "{{buttonsItem.name ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ], - "props": { - "className": "flex items-center gap-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", - "value": "{{buttonsItem.linkMo ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", - "value": "{{buttonsItem.linkPc ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", - "value": "{{buttonsItem.linkAnd ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", - "value": "{{buttonsItem.linkIos ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", - "value": "{{buttonsItem.telNumber ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'TN'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", - "value": "{{buttonsItem.pluginId ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" - } - } - ] - } - ], - "props": { - "className": "space-y-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", - "item_var": "quickRepliesItem", - "index_var": "quickRepliesIdx" - }, - "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-40 text-xs", - "value": "{{quickRepliesItem.linkType ?? 'WL'}}", - "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", - "value": "{{quickRepliesItem.name ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ], - "props": { - "className": "flex items-center gap-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", - "value": "{{quickRepliesItem.linkMo ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", - "value": "{{quickRepliesItem.linkPc ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", - "value": "{{quickRepliesItem.linkAnd ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", - "value": "{{quickRepliesItem.linkIos ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", - "value": "{{quickRepliesItem.telNumber ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'TN'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", - "value": "{{quickRepliesItem.pluginId ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" - } - } - ] - } - ], - "props": { - "className": "space-y-1.5" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_template" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", - "params": { - "method": "POST", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}", - "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", - "if": "{{!(_global.bz_tpl_modal?.id)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", - "if": "{{_global.bz_tpl_modal?.id}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", - "params": { - "method": "POST", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}", - "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" - } - }, - "onSuccess": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", - "if": "{{!(_global.bz_tpl_modal?.id)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}" - } - }, - "onSuccess": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", - "if": "{{_global.bz_tpl_modal?.id}}" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_sms", - "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", - "size": "md", - "closeOnOverlayClick": false - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.sms.hint", - "props": { - "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" - } - }, - { - "id": "bz_sms_lang_tabs", - "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", - "type": "basic", - "name": "Div", - "props": { - "className": "flex border-b border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{$locales}}", - "item_var": "loc" - }, - "props": { - "type": "button", - "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" - }, - "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.editLang": "{{loc}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "name": "bz_sms_body", - "rows": 4, - "maxLength": 2000, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{_global.bz_sms_modal?.variables ?? []}}", - "item_var": "v" - }, - "props": { - "type": "button", - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" - }, - "text": "{{'#{' + (v.key ?? '') + '}'}}", - "actions": [ - { - "type": "click", - "handler": "sirsoft-message_bizppurio.insertVariable", - "params": { - "variable": "{{v.key}}", - "target": "bz_sms_body", - "stateTarget": "global", - "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" - } - } - ] - } - ], - "props": { - "className": "flex flex-wrap gap-1" - } - } - ], - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" - } - ], - "props": { - "className": "space-y-3" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_sms" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", - "params": { - "method": "PUT", - "body": { - "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_sms" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_cancel_approval", - "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", - "size": "sm", - "closeOnOverlayClick": false - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-triangle-exclamation", - "className": "text-amber-500 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", - "props": { - "className": "text-sm text-gray-700 dark:text-gray-200" - } - } - ], - "props": { - "className": "flex items-start gap-2" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_cancel_approval" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", - "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", - "type": "info" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_cancel_approval" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_rejection", - "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", - "size": "md" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_reject_view?.comments ?? []}}", - "item_var": "cm" - }, - "props": { - "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "children": [ - { - "type": "basic", - "name": "P", - "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", - "props": { - "className": "text-xs text-red-500 dark:text-red-400" - } - }, - { - "type": "basic", - "name": "P", - "text": "{{cm.content ?? ''}}", - "props": { - "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" - } - } - ] - } - ], - "props": { - "className": "space-y-2 max-h-80 overflow-y-auto" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_rejection" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - } - ], "init_actions": [ { "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json index cd5a7ce2..9470ce51 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json @@ -1,6 +1,6 @@ { "target_layout": "admin_ecommerce_settings", - "comment": "[이커머스 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 이커머스 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 알림 행 하단(notification_definition_row_footer 슬롯 — notification_row_footer.json)에 알림톡 라이프사이클·SMS 설정 줄 ③ 작성/신청·SMS 본문·승인 취소·반려 사유 모달 4종. 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회한다. 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염).", + "comment": "[이커머스 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 이커머스 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, PO 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.", "data_sources": [ { "id": "bizppurioTemplates", @@ -189,2389 +189,6 @@ ] } ], - "modals": [ - { - "id": "modal_bizppurio_template", - "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", - "size": "xl", - "closeOnOverlayClick": false - }, - "children": [ - { - "id": "bizppurio_ecommerce_tpl_modal_body", - "type": "basic", - "name": "Div", - "props": { - "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-circle-exclamation", - "size": "sm", - "className": "text-red-600 dark:text-red-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", - "props": { - "className": "text-xs font-medium text-red-800 dark:text-red-200" - } - } - ], - "props": { - "className": "flex-center gap-2" - } - }, - { - "type": "basic", - "name": "P", - "iteration": { - "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", - "item_var": "cm" - }, - "props": { - "className": "text-xs text-red-700 dark:text-red-300 mt-1" - }, - "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" - } - ], - "props": { - "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-triangle-exclamation", - "size": "sm", - "className": "text-red-600 dark:text-red-400 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_modal?.error}}", - "props": { - "className": "text-xs text-red-800 dark:text-red-200" - } - } - ], - "props": { - "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "if": "{{_global.bz_tpl_modal?.error}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" - }, - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-full text-sm", - "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", - "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.sender_profile": "{{$event.target.value}}" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.category *" - }, - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-full text-sm", - "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", - "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" - } - } - ] - } - ] - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.name *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", - "name": "bz_template_name", - "maxLength": 200 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateName": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" - }, - { - "type": "basic", - "name": "Div", - "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", - "props": { - "className": "flex flex-wrap gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "BA" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "EX" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "AD" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateMessageType": "MI" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" - }, - { - "type": "basic", - "name": "Div", - "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", - "props": { - "className": "flex flex-wrap gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "NONE" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "TEXT" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" - } - } - ] - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-3" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.image *" - }, - { - "type": "basic", - "name": "Input", - "props": { - "type": "file", - "accept": ".jpg,.jpeg,.png", - "className": "block w-full text-xs text-gray-600 dark:text-gray-300" - }, - "actions": [ - { - "type": "change", - "handler": "sirsoft-message_bizppurio.uploadTemplateImage", - "params": { - "stateTarget": "global", - "statePathUrl": "bz_tpl_modal.content.templateImageUrl", - "statePathName": "bz_tpl_modal.content.templateImageName", - "statePathStatus": "bz_tpl_upload" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", - "props": { - "className": "text-xs text-amber-600 dark:text-amber-400" - }, - "if": "{{_global.bz_tpl_upload?.uploading}}" - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_upload?.error ?? ''}}", - "props": { - "className": "text-xs text-red-600 dark:text-red-400" - }, - "if": "{{_global.bz_tpl_upload?.error}}" - }, - { - "type": "basic", - "name": "Span", - "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", - "props": { - "className": "text-xs text-green-700 dark:text-green-400 break-all" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" - } - ], - "props": { - "className": "space-y-1" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" - } - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-1.5" - } - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", - "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", - "maxLength": 500 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_list" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.item_add" - } - ], - "props": { - "className": "flex-between mt-2" - } - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", - "item_var": "itm", - "index_var": "itmIdx" - }, - "props": { - "className": "flex items-center gap-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 6, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", - "value": "{{itm.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 23, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", - "value": "{{itm.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium mt-2" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 6, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", - "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "maxLength": 14, - "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", - "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" - } - } - ] - } - ], - "props": { - "className": "grid grid-cols-2 gap-1.5" - } - } - ], - "props": { - "className": "space-y-1.5" - }, - "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.content *" - }, - { - "type": "basic", - "name": "Span", - "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500" - } - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "name": "bz_template_content", - "rows": 6, - "maxLength": 1000, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", - "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{_global.bz_tpl_modal?.variables ?? []}}", - "item_var": "v" - }, - "props": { - "type": "button", - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" - }, - "text": "{{'#{' + (v.key ?? '') + '}'}}", - "actions": [ - { - "type": "click", - "handler": "sirsoft-message_bizppurio.insertVariable", - "params": { - "variable": "{{v.key}}", - "target": "bz_template_content", - "stateTarget": "global", - "statePath": "bz_tpl_modal.content.templateContent" - } - } - ] - } - ], - "props": { - "className": "flex flex-wrap gap-1" - } - } - ], - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.extra *" - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "rows": 2, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" - } - } - ] - } - ], - "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary block text-xs font-medium mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.header" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", - "maxLength": 16 - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" - } - } - ] - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", - "props": { - "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.buttons" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.button_add" - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", - "item_var": "buttonsItem", - "index_var": "buttonsIdx" - }, - "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-40 text-xs", - "value": "{{buttonsItem.linkType ?? 'WL'}}", - "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", - "value": "{{buttonsItem.name ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ], - "props": { - "className": "flex items-center gap-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", - "value": "{{buttonsItem.linkMo ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", - "value": "{{buttonsItem.linkPc ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", - "value": "{{buttonsItem.linkAnd ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", - "value": "{{buttonsItem.linkIos ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", - "value": "{{buttonsItem.telNumber ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'TN'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", - "value": "{{buttonsItem.pluginId ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" - } - } - ], - "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" - } - } - ] - } - ], - "props": { - "className": "space-y-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "text-secondary text-xs font-medium" - }, - "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", - "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" - } - ], - "props": { - "className": "flex-between" - } - }, - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", - "item_var": "quickRepliesItem", - "index_var": "quickRepliesIdx" - }, - "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "composite", - "name": "Select", - "props": { - "className": "w-40 text-xs", - "value": "{{quickRepliesItem.linkType ?? 'WL'}}", - "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", - "value": "{{quickRepliesItem.name ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", - "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" - }, - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" - } - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-trash-can", - "size": "sm" - } - } - ] - } - ], - "props": { - "className": "flex items-center gap-1.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", - "value": "{{quickRepliesItem.linkMo ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", - "value": "{{quickRepliesItem.linkPc ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'WL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", - "value": "{{quickRepliesItem.linkAnd ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", - "value": "{{quickRepliesItem.linkIos ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'AL'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", - "value": "{{quickRepliesItem.telNumber ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'TN'}}" - }, - { - "type": "basic", - "name": "Input", - "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", - "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", - "value": "{{quickRepliesItem.pluginId ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" - } - } - ], - "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" - } - ], - "props": { - "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" - } - } - ] - } - ], - "props": { - "className": "space-y-1.5" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_template" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", - "params": { - "method": "POST", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}", - "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", - "if": "{{!(_global.bz_tpl_modal?.id)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", - "if": "{{_global.bz_tpl_modal?.id}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", - "params": { - "method": "POST", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}", - "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" - } - }, - "onSuccess": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", - "if": "{{!(_global.bz_tpl_modal?.id)}}" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": true, - "bz_tpl_modal.error": null - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", - "params": { - "method": "PUT", - "body": { - "alimtalk_enabled": true, - "content": "{{_global.bz_tpl_modal?.content}}" - } - }, - "onSuccess": [ - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_template" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_tpl_modal.isSaving": false, - "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" - } - } - ] - } - ] - }, - "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", - "if": "{{_global.bz_tpl_modal?.id}}" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_sms", - "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", - "size": "md", - "closeOnOverlayClick": false - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.sms.hint", - "props": { - "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" - } - }, - { - "id": "bz_sms_lang_tabs", - "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", - "type": "basic", - "name": "Div", - "props": { - "className": "flex border-b border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{$locales}}", - "item_var": "loc" - }, - "props": { - "type": "button", - "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" - }, - "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", - "actions": [ - { - "type": "click", - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.editLang": "{{loc}}" - } - } - ] - } - ] - }, - { - "type": "basic", - "name": "Textarea", - "props": { - "name": "bz_sms_body", - "rows": 4, - "maxLength": 2000, - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", - "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" - } - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "iteration": { - "source": "{{_global.bz_sms_modal?.variables ?? []}}", - "item_var": "v" - }, - "props": { - "type": "button", - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" - }, - "text": "{{'#{' + (v.key ?? '') + '}'}}", - "actions": [ - { - "type": "click", - "handler": "sirsoft-message_bizppurio.insertVariable", - "params": { - "variable": "{{v.key}}", - "target": "bz_sms_body", - "stateTarget": "global", - "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" - } - } - ] - } - ], - "props": { - "className": "flex flex-wrap gap-1" - } - } - ], - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" - } - ], - "props": { - "className": "space-y-3" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_sms" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", - "params": { - "method": "PUT", - "body": { - "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", - "type": "success" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_sms" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_sms_modal.isSaving": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_cancel_approval", - "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", - "size": "sm", - "closeOnOverlayClick": false - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "fas fa-triangle-exclamation", - "className": "text-amber-500 flex-shrink-0" - } - }, - { - "type": "basic", - "name": "P", - "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", - "props": { - "className": "text-sm text-gray-700 dark:text-gray-200" - } - } - ], - "props": { - "className": "flex items-start gap-2" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_cancel_approval" - } - ], - "text": "$t:common.cancel" - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", - "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": true - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", - "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioTemplates" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", - "type": "info" - } - }, - { - "handler": "closeModal", - "target": "modal_bizppurio_cancel_approval" - }, - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": false - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "global", - "bz_cancel_approval.isSaving": false - } - }, - { - "handler": "toast", - "params": { - "message": "{{error.errors?.bizppurio_message ?? error.message}}", - "type": "error" - } - } - ] - } - ] - } - } - ], - "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - }, - { - "id": "modal_bizppurio_rejection", - "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", - "size": "md" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Div", - "iteration": { - "source": "{{_global.bz_reject_view?.comments ?? []}}", - "item_var": "cm" - }, - "props": { - "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" - }, - "children": [ - { - "type": "basic", - "name": "P", - "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", - "props": { - "className": "text-xs text-red-500 dark:text-red-400" - } - }, - { - "type": "basic", - "name": "P", - "text": "{{cm.content ?? ''}}", - "props": { - "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" - } - } - ] - } - ], - "props": { - "className": "space-y-2 max-h-80 overflow-y-auto" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" - }, - "actions": [ - { - "type": "click", - "handler": "closeModal", - "target": "modal_bizppurio_rejection" - } - ], - "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" - } - ], - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - } - } - ] - } - ], "init_actions": [ { "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json new file mode 100644 index 00000000..64e05477 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json @@ -0,0 +1,797 @@ +{ + "extension_point": "notification_template_form_footer_actions", + "comment": "코어 알림 템플릿 [편집] 모달 푸터에 주입되는 통합 저장 버튼(PO 결정 2026-08-23 — 저장은 하나로). 채널 메타 hidden_template_editor 로 코어 [저장]/[미리보기]가 숨겨진 자리에서, [저장] 한 번으로 ① 알림톡 템플릿(draft/rejected 만 내용 갱신) + 발송 설정·SMS 본문 ② 수신자 규칙이 바뀐 경우에만 코어 채널 템플릿을 순차 저장한다(앞 단계 실패 시 중단). [저장 후 검수 신청]은 알림톡 본문이 있고 수정 가능한 상태에서만 노출된다 — 카카오 검수는 알림톡 템플릿만 대상이며 SMS 는 검수 없이 즉시 반영된다. 더블클릭 재진입은 click if + disabled 로 막고, 중복 검수 신청의 SSoT 는 서버 원자 선점이다.", + "priority": 320, + "components": [ + { + "id": "bizppurio_tpl_footer_actions", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.channel === 'alimtalk'}}", + "props": { + "className": "flex gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "if": "{{(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '') && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false) || !((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null, + "bz_tpl_modal.errors": null, + "bz_sms_modal.isSaving": true + } + }, + { + "comment": "① 행 없음 + 본문 입력 → POST(생성). 검수 신청은 생성 응답의 id 로 이어간다.", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "if": "{{!(_global.bz_tpl_modal?.id) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}", + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", + "params": { + "method": "POST", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "② 행 있음 + draft/rejected + 본문 입력 → PUT(내용 갱신).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "if": "{{!!(_global.bz_tpl_modal?.id) && (['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "PUT", + "body": { + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", + "params": { + "method": "POST", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-2 text-sm px-4 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false) || !((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}", + "props": { + "name": "spinner", + "className": "animate-spin text-sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.isSaving ?? false) ? '$t:common.saving' : '$t:common.save'}}" + } + ], + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null, + "bz_tpl_modal.errors": null, + "bz_sms_modal.isSaving": true + } + }, + { + "comment": "① 행 없음 + 본문 입력 → POST(생성). 검수 신청은 생성 응답의 id 로 이어간다.", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "if": "{{!(_global.bz_tpl_modal?.id) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}", + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "② 행 있음 + draft/rejected + 본문 입력 → PUT(내용 갱신).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "if": "{{!!(_global.bz_tpl_modal?.id) && (['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "PUT", + "body": { + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "③ 본문 미입력(SMS 만 설정) 또는 내용 잠금 상태(검수중/승인 등) → delivery upsert(발송 설정·SMS 본문만, 행 없으면 draft 생성).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_tpl_modal?.notification_type}}", + "if": "{{!((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '') || (!!(_global.bz_tpl_modal?.id) && !(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')))}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json new file mode 100644 index 00000000..1b62a039 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json @@ -0,0 +1,2714 @@ +{ + "extension_point": "notification_template_form_sections", + "comment": "코어 알림 템플릿 [편집] 모달 본문에 주입되는 비즈뿌리오 섹션(PO 결정 2026-08-23 — 행 하단 별도 버튼/모달 폐지, 편집 모달 통합). 3면(코어/게시판/이커머스)이 같은 extension_point 를 노출하므로 파일 1본으로 공유한다. ① 알림톡 템플릿: 승인 2단 배지 + 상태 액션(새로고침/신청 취소/승인 취소 후 수정/휴면 해제) + 작성 폼(draft/rejected) 또는 잠금 요약 ② 문자(SMS): 대체/단독 토글 + 로케일별 본문(검수 대상 아님). 저장은 notification_template_form_footer_actions 의 통합 [저장]이 담당한다. 상태는 _global.bz_tpl_modal / bz_sms_modal / bz_tpl_ui / bz_tpl_upload 에 담고 섹션 마운트(lifecycle.onMount) 시 요약 맵(bizppurioTemplates)으로 시딩한다. 코어 제목/본문 입력은 채널 메타 hidden_template_editor 로 코어 모달이 스스로 숨긴다.", + "priority": 320, + "components": [ + { + "id": "bizppurio_tpl_sections", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.channel === 'alimtalk'}}", + "props": { + "className": "space-y-4" + }, + "lifecycle": { + "onMount": [ + { + "handler": "sequence", + "comment": "코어 [편집] 모달이 열릴 때(섹션 마운트) 요약 맵으로 상태를 시딩하고, 행이 있으면 상세(content)를 받아온다. 카테고리·발신프로필은 작성 셀렉트용(자격증명 미설정 422 는 suppress).", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_upload": { + "uploading": false, + "error": null + }, + "bz_tpl_ui": { + "confirmCancelApproval": false + }, + "bz_tpl_modal": { + "id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id ?? null}}", + "notification_type": "{{extensionPointProps.definition?.type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "status": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status ?? 'draft'}}", + "has_content": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)}}", + "loading": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "template_code": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code ?? null}}", + "alimtalk_enabled": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) ? ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled) !== false) : true}}", + "inspection_detail": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}", + "isSaving": false, + "error": null, + "errors": null, + "request_comment": "", + "sender_profile": "", + "content": { + "templateName": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "{{(((extensionPointProps.definition?.templates ?? []).find(tpl => tpl.channel === 'alimtalk')?.body?.[$locale] ?? '').split('{').join('#{'))}}", + "templateExtra": "", + "templateHeader": "", + "templateTitle": "", + "templateSubtitle": "", + "templateImageName": "", + "templateImageUrl": "", + "categoryCode": "", + "buttons": [], + "quickReplies": [], + "templateItem": { + "list": [], + "summary": { + "title": "", + "description": "" + } + }, + "templateItemHighlight": { + "title": "", + "description": "", + "imageUrl": "" + } + } + }, + "bz_sms_modal": { + "notification_type": "{{extensionPointProps.definition?.type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "body": "{{Object.assign({}, bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body)}}", + "editLang": "{{$locale}}", + "fallback_sms_enabled": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}", + "sms_only": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}", + "isSaving": false + } + } + }, + { + "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}", + "bz_tpl_modal.loading": false, + "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}", + "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}", + "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.loading": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioCategories" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioProfiles" + } + } + ] + } + } + ], + "onUnmount": [ + { + "comment": "모달이 닫히면(언마운트) 폼 상태를 비운다 — 다음 오픈은 항상 시딩 전(READY=false)에서 시작해 이전 알림의 값이 보이거나 저장되는 창을 없앤다.", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal": null, + "bz_sms_modal": null + } + } + ] + }, + "children": [ + { + "id": "bizppurio_tpl_alimtalk_section", + "type": "basic", + "name": "Div", + "props": { + "className": "rounded-lg border border-teal-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-dots", + "className": "text-base text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "H4", + "props": { + "className": "text-sm font-semibold text-gray-800 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.alimtalk_title" + }, + { + "type": "basic", + "name": "Span", + "comment": "승인 여부 2단 배지(PO 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.", + "props": { + "className": "{{'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'stopped' || (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.approval.' + ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'approved' ? 'approved' : 'not_approved'))}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') !== 'approved'}}", + "props": { + "className": "font-normal opacity-80" + }, + "text": "{{'(' + $t('sirsoft-message_bizppurio.template.status.' + (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten')) + ')'}}" + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "if": "{{!!(_global.bz_tpl_modal?.template_code)}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/sync", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}", + "bz_tpl_modal.loading": false, + "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}", + "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}", + "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.loading": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast", + "type": "success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'requested'}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "draft" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast", + "type": "success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'dormant'}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_release", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/release", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}", + "bz_tpl_modal.loading": false, + "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}", + "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}", + "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.loading": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.released_toast", + "type": "success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'approved' && !(_global.bz_tpl_ui?.confirmCancelApproval ?? false)}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed border-amber-400 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-gray-700" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_ui.confirmCancelApproval": true + } + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "comment": "알림톡 발송 사용 — 통합 [저장]으로 함께 저장된다(즉시 저장 아님).", + "props": { + "className": "inline-flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.alimtalk_enabled": "{{$event.target.checked}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.alimtalk_enabled" + } + ] + }, + { + "type": "basic", + "name": "Div", + "comment": "승인 취소 확인(인라인 — 모달 중첩 대신). 확정 시 kapi cancel_approval → draft 복귀 — 발송 게이트 즉시 차단(대체 SMS 는 SMS 설정대로 계속).", + "if": "{{(_global.bz_tpl_ui?.confirmCancelApproval ?? false) && _global.bz_tpl_modal?.status === 'approved'}}", + "props": { + "className": "p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-start gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "className": "text-base text-amber-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-amber-900 dark:text-amber-100" + }, + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_tpl_modal?.notification_name ?? ''}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_ui.confirmCancelApproval": false + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'approved'}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-approval", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "draft", + "bz_tpl_ui.confirmCancelApproval": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", + "type": "info" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", + "props": { + "className": "text-xs font-medium text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "P", + "iteration": { + "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "text-xs text-red-700 dark:text-red-300 mt-1" + }, + "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" + } + ], + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.error}}", + "props": { + "className": "text-xs text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.error}}" + }, + { + "type": "basic", + "name": "Div", + "if": "{{_global.bz_tpl_modal?.errors && typeof _global.bz_tpl_modal?.errors === 'object'}}", + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Ul", + "props": { + "className": "list-disc pl-5 space-y-0.5" + }, + "children": [ + { + "type": "basic", + "name": "Li", + "iteration": { + "source": "{{Object.values(_global.bz_tpl_modal?.errors ?? {}).flat()}}", + "item_var": "msg" + }, + "props": { + "className": "text-xs text-red-800 dark:text-red-200" + }, + "text": "{{msg}}" + } + ] + } + ] + }, + { + "id": "bizppurio_tpl_loading", + "type": "basic", + "name": "Div", + "comment": "상세(content) 를 받아오는 동안 편집 폼을 열지 않는다 — 시딩 내용으로 저장되거나 입력이 상세 도착에 덮이는 경쟁 차단(Playwright 실측).", + "if": "{{!((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "className": "flex-center gap-2 text-xs text-gray-500 dark:text-gray-400 py-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "spinner", + "className": "animate-spin text-sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.section.loading" + } + ] + }, + { + "id": "bizppurio_tpl_locked", + "type": "basic", + "name": "Div", + "comment": "검수중/승인/휴면/중지/차단 — 내용 잠금. 요약만 보여주고 수정은 [신청 취소]/[승인 취소 후 수정] 뒤에만 가능(서버 가드 content_locked 와 동일 규칙).", + "if": "{{!(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "className": "space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300 p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.locked_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.form.message_type') + ': ' + $t('sirsoft-message_bizppurio.template.form.templateMessageType_options.' + (_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA'))}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.form.emphasize_type') + ': ' + $t('sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.' + (_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE'))}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "text-sm font-mono whitespace-pre-wrap p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100" + }, + "text": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + } + ] + }, + { + "id": "bizppurio_tpl_editor", + "type": "basic", + "name": "Div", + "comment": "작성 폼(구 modal_bizppurio_template 본문을 그대로 이식). draft/rejected(또는 행 없음)에서만 노출.", + "if": "{{(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "className": "space-y-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.sender_profile": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.category *" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.name *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", + "name": "bz_template_name", + "maxLength": 200 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateName": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "BA" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "EX" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "AD" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "MI" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateEmphasizeType": "NONE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateEmphasizeType": "TEXT" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-3" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.image *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "file", + "accept": ".jpg,.jpeg,.png", + "className": "block w-full text-xs text-gray-600 dark:text-gray-300" + }, + "actions": [ + { + "type": "change", + "handler": "sirsoft-message_bizppurio.uploadTemplateImage", + "params": { + "stateTarget": "global", + "statePathUrl": "bz_tpl_modal.content.templateImageUrl", + "statePathName": "bz_tpl_modal.content.templateImageName", + "statePathStatus": "bz_tpl_upload" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400" + }, + "if": "{{_global.bz_tpl_upload?.uploading}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_upload?.error ?? ''}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400" + }, + "if": "{{_global.bz_tpl_upload?.error}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", + "props": { + "className": "text-xs text-green-700 dark:text-green-400 break-all" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" + } + ], + "props": { + "className": "space-y-1" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", + "maxLength": 500 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_list" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.item_add" + } + ], + "props": { + "className": "flex-between mt-2" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", + "item_var": "itm", + "index_var": "itmIdx" + }, + "props": { + "className": "flex items-center gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{itm.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 23, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", + "value": "{{itm.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium mt-2" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 14, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + } + ], + "props": { + "className": "space-y-2" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.content *" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_template_content", + "rows": 6, + "maxLength": 1000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", + "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_tpl_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_template_content", + "stateTarget": "global", + "statePath": "bz_tpl_modal.content.templateContent" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.extra *" + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 2, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" + } + } + ] + } + ], + "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.header" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", + "maxLength": 16 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.buttons" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.button_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", + "item_var": "buttonsItem", + "index_var": "buttonsIdx" + }, + "props": { + "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{buttonsItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{buttonsItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{buttonsItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{buttonsItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{buttonsItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{buttonsItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{buttonsItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{buttonsItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-2" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", + "item_var": "quickRepliesItem", + "index_var": "quickRepliesIdx" + }, + "props": { + "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{quickRepliesItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{quickRepliesItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{quickRepliesItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{quickRepliesItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{quickRepliesItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{quickRepliesItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{quickRepliesItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{quickRepliesItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-2" + } + }, + { + "id": "bizppurio_tpl_request_comment", + "type": "basic", + "name": "Div", + "comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(PO 결정 2026-08-23 §18.7). 신청 시에만 전송되며 행에 저장되지 않는다.", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.request_comment" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.request_comment ?? '').length}}/500", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_request_comment", + "rows": 3, + "maxLength": 500, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.request_comment_placeholder", + "value": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.request_comment": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.request_comment_hint" + } + ] + } + ] + } + ] + }, + { + "id": "bizppurio_tpl_sms_section", + "type": "basic", + "name": "Div", + "comment": "문자(SMS) — 검수 대상 아님(카카오 검수는 알림톡 템플릿만). 대체 SMS/SMS 단독 토글 + 로케일별 본문. 통합 [저장]으로 delivery 필드와 함께 저장된다.", + "props": { + "className": "rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-sms", + "className": "text-base text-gray-500 dark:text-gray-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "H4", + "props": { + "className": "text-sm font-semibold text-gray-800 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.sms_title" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-3" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "inline-flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.fallback_sms_enabled": "{{$event.target.checked}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms" + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "inline-flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.sms_only": "{{$event.target.checked}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_only" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-gray-700" + }, + "text": "$t:sirsoft-message_bizppurio.template.sms.hint" + }, + { + "id": "bz_sms_lang_tabs", + "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", + "type": "basic", + "name": "Div", + "props": { + "className": "flex border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{$locales}}", + "item_var": "loc" + }, + "props": { + "type": "button", + "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" + }, + "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.editLang": "{{loc}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_sms_body", + "rows": 4, + "maxLength": 2000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_sms_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_sms_body", + "stateTarget": "global", + "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" + } + ] + } + ] + } + ] +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx index 38c35348..ac84a90c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx @@ -8,15 +8,16 @@ * * 검증 방식: comment 필드 제거 후 JSON 문자열에서 면 고유 토큰(target_layout·target_id· * 컴포넌트 id 접두)만 코어 형으로 치환하면 코어 오버레이와 완전 동일해야 한다. - * 반대로 데이터소스 id·모달 id·전역 상태 경로(bz_tpl_modal 등)는 치환 없이도 3면 동일해야 - * 한다 — notification_row_footer.json(전역 매칭)이 이 이름들을 공유 참조하기 때문이다. + * 반대로 데이터소스 id 는 치환 없이도 3면 동일해야 한다 — notification_row_footer.json 과 + * notification_template_form_sections.json(전역 매칭, 3면 공유 1본)이 이 이름들을 공유 참조하기 때문이다. + * 편집 UI(작성 폼·SMS·저장)는 코어 [편집] 모달의 extension_point 에 주입되는 공유 1본이라 + * 면 패리티 대상이 아니다(PO 결정 2026-08-23). * * 세 오버레이가 전문 동일하다는 것이 board/ecommerce 면의 화면 효과를 떠받치는 근거다 — * core 면에서 단언한 것들(탭 통합·행 하단 UI·업로드 잠금·SMS 언어 탭)이 두 면에도 있다는 * 보장은 이 패리티뿐이고, 그래서 이 파일이 사라지면 두 면은 미측정으로 남는다. * - * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions, - * upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_summary_only */ import { describe, it, expect } from 'vitest'; @@ -68,13 +69,8 @@ const normalizedCore = JSON.stringify(stripComments(coreOverlay)); /** 3면 공유 이름(치환 없이 동일해야 하는 축) */ const SHARED_DATA_SOURCE_IDS = ['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles']; -const SHARED_MODAL_IDS = [ - 'modal_bizppurio_template', - 'modal_bizppurio_sms', - 'modal_bizppurio_cancel_approval', - 'modal_bizppurio_rejection', -]; -const SHARED_GLOBAL_STATE_PATHS = ['bz_tpl_modal', 'bz_sms_modal', 'bz_cancel_approval', 'bz_reject_view', 'bz_tpl_upload']; +// 모달·전역 상태(bz_*)는 오버레이가 아니라 3면 공유 1본(notification_template_form_sections.json / +// _footer_actions.json)이 소유한다(PO 결정 2026-08-23 — 편집 모달 통합). 오버레이는 모달을 등록하지 않는다. describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, overlay, targetLayout, targetId, idPrefix }) => { it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => { @@ -102,27 +98,19 @@ describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, over expect(ids).toEqual(SHARED_DATA_SOURCE_IDS); }); - it('모달 id 4종이 코어와 완전 동일하다(행 footer 의 openModal target 공유)', () => { - const ids = ((overlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); - expect(ids).toEqual(SHARED_MODAL_IDS); - }); - - it('전역 상태 경로(bz_*)가 접두 치환 없이 그대로 사용된다(면 전용 상태 분기 금지)', () => { + it('오버레이는 모달을 등록하지 않고, 면 전용 전역 상태(bz_board_* 등)도 만들지 않는다', () => { + expect((overlay as { modals?: AnyNode[] }).modals, `${label} 오버레이 modals`).toBeUndefined(); const raw = JSON.stringify(overlay); - for (const path of SHARED_GLOBAL_STATE_PATHS) { - expect(raw, `${label} 에 ${path} 없음`).toContain(`_global.${path}`); - // 면 접두가 붙은 변형(bz_board_* 등)이 존재하면 footer 공유가 깨진다 - expect(raw).not.toContain(path.replace('bz_', `bz_${idPrefix.replace('bizppurio_', '')}`)); - } + expect(raw).not.toContain(`bz_${idPrefix.replace('bizppurio_', '')}`); + expect(raw).not.toContain('openModal'); }); }); describe('3면 패리티 — 공유 축 교차 검증', () => { - it('코어 오버레이도 동일한 공유 데이터소스·모달 id 집합을 갖는다(패리티 기준점 고정)', () => { + it('코어 오버레이도 동일한 공유 데이터소스 id 집합을 갖고 모달은 없다(패리티 기준점 고정)', () => { const dsIds = ((coreOverlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); - const modalIds = ((coreOverlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); expect(dsIds).toEqual(SHARED_DATA_SOURCE_IDS); - expect(modalIds).toEqual(SHARED_MODAL_IDS); + expect((coreOverlay as { modals?: AnyNode[] }).modals).toBeUndefined(); }); it('세 오버레이의 init_actions(탭 진입 요약 맵 조회)가 comment 제외 완전 동일하다', () => { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx index 952a6e2b..06a91d28 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx @@ -1,19 +1,27 @@ // e2e:allow 구조 단언 + 조건식 실평가 Vitest(#597) — 브라우저 흐름은 -// tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당(발송 인프라 의존 축은 별도 계획). +// tests/Playwright/specs/admin/template-lifecycle*.spec.ts 가 담당(발송 인프라 의존 축은 별도 계획). // -// 행 하단 버튼의 노출 조건은 문자열 동일성이 아니라 `new Function` 실평가로 판정한다 +// 노출 조건·저장 분기는 문자열 동일성이 아니라 `new Function` 실평가로 판정한다 // (§14.2 T7) — 리터럴 비교는 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이라 회귀를 // 잡지 못한다. /** - * 알림 설정 '비즈뿌리오' 통합 탭 — 알림톡 템플릿 라이프사이클 UI 구조 검증 (#597) + * 알림 설정 '비즈뿌리오' 통합 탭 — [편집] 모달 통합 UI 구조 검증 (#597, PO 결정 2026-08-23) * - * @effects row_footer_shows_status_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type, sms_modal_saves_body_via_delivery_upsert, sms_modal_edits_body_per_locale_tab + * @effects row_footer_shows_status_summary_only, approval_badge_two_tier, edit_modal_hosts_alimtalk_and_sms_sections, + * unified_save_chain_alimtalk_then_core_recipients, compose_modal_switches_conditional_fields_by_type, + * sms_modal_saves_body_via_delivery_upsert, sms_modal_edits_body_per_locale_tab, + * inspection_request_carries_reviewer_comment * - * 두 확장 파일로 분리 구현: - * - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 모달 4종 - * (작성/신청·SMS 본문·승인 취소·반려 사유) + data_sources 3종. target_layout=admin_settings. - * - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯 - * (notification_definition_row_footer)에 알림톡 라이프사이클 줄 + SMS 설정 줄. + * 파일 구성(플러그인 resources/extensions): + * - notification_tab_core/board/ecommerce.json (Overlay): 상태 배너(injections) + 안내 박스 + data_sources 3종. + * 모달은 더 이상 등록하지 않는다(작성/SMS/승인취소/반려 모달 폐지). + * - notification_row_footer.json (ExtensionPoint notification_definition_row_footer): 행 하단 **상태 요약만** + * (승인 2단 배지 + 신청/동기화일 + SMS 설정 요약). 버튼 없음 — 조작은 코어 [편집] 모달로 일원화. + * - notification_template_form_sections.json (ExtensionPoint notification_template_form_sections): 코어 [편집] + * 모달 본문에 주입되는 알림톡 템플릿 섹션(작성 폼/잠금 요약/상태 액션) + 문자(SMS) 섹션. 3면 공유 1본. + * - notification_template_form_footer_actions.json (ExtensionPoint notification_template_form_footer_actions): + * 통합 [저장] / [저장 후 검수 신청] — 알림톡 upsert(또는 delivery upsert) → (검수 신청) → 수신자 규칙이 + * 바뀐 경우에만 코어 PUT → 마무리. * * 라이프사이클: 미작성(unwritten=행 없음/내용 없음) → 작성(draft) → 검수 신청(requested) * → 승인(approved) / 반려(rejected) / 휴면(dormant). 발송 판정은 DB 가 유일한 근거. @@ -24,30 +32,31 @@ import coreOverlay from '../../../extensions/notification_tab_core.json'; import boardOverlay from '../../../extensions/notification_tab_board.json'; import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json'; import footer from '../../../extensions/notification_row_footer.json'; +import sections from '../../../extensions/notification_template_form_sections.json'; +import footerActions from '../../../extensions/notification_template_form_footer_actions.json'; import ko from '../../../lang/ko.json'; import en from '../../../lang/en.json'; import { findById, findAllByName, type AnyNode, evalBinding } from './helpers'; const overlay = coreOverlay; -const overlayModalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; const bannerRoot = { children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []), } as AnyNode; const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; +const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode; +const actionsRoot = { children: (footerActions as { components?: AnyNode[] }).components ?? [] } as AnyNode; -/** 요약 맵의 def.type 행 접근 경로(행 하단 UI 전반이 공유하는 데이터 근거) */ +/** 요약 맵의 def.type 행 접근 경로(행 하단·섹션이 공유하는 데이터 근거) */ const MAP_PATH = "bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]"; -/** footer 에서 text 키로 버튼 노드를 찾는다. */ +/** text 키로 버튼 노드를 찾는다. */ const findButtonByTextKey = (root: AnyNode, key: string): AnyNode | undefined => findAllByName(root, 'Button').find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`); -/** overlay 텍스트에서 $t:key 및 $t('key'...) 형태의 플러그인 i18n 정적 키를 모두 수집한다. */ +/** JSON 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 정적 키를 모두 수집한다. */ const collectPluginKeys = (json: unknown): string[] => { const text = JSON.stringify(json); const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; - // $t('key') / $t('key', {...}) 정적 호출 — 동적 접두('... .' + expr)는 끝의 '.' 로 걸러 - // 접두 가족 검증(아래 별도 테스트)으로 넘긴다. const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'/g) ?? []; return Array.from(new Set([ ...prefixed.map((m) => m.replace('$t:', '')), @@ -58,33 +67,95 @@ const collectPluginKeys = (json: unknown): string[] => { const resolve = (root: unknown, path: string): unknown => path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); -describe('lifecycle UI — 파일 분리(Overlay vs ExtensionPoint)', () => { - it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => { +/** 문자열 리프 전수 수집 */ +const collectStrings = (node: unknown, acc: string[] = []): string[] => { + if (typeof node === 'string') acc.push(node); + else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc)); + else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc)); + return acc; +}; + +/** 모든 액션 노드(handler 보유 객체)를 재귀 수집한다. */ +const collectActions = (node: unknown, acc: AnyNode[] = []): AnyNode[] => { + if (Array.isArray(node)) node.forEach((c) => collectActions(c, acc)); + else if (node && typeof node === 'object') { + const rec = node as AnyNode; + if (typeof rec.handler === 'string') acc.push(rec); + Object.values(rec).forEach((v) => collectActions(v, acc)); + } + return acc; +}; + +/** + * `{{...}}` 텍스트/조건 식을 실제로 평가한다. `$t` 는 키를 그대로 돌려주는 스텁이라 + * 평가 결과로 "어느 i18n 키가 선택됐는가" 를 판정할 수 있다. + */ +const evalExpr = (expr: string, scope: Record): unknown => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + const fn = new Function('$t', '$locale', ...names, `return (${body});`); + return fn((k: string) => k, 'ko', ...names.map((n) => scope[n])); +}; + +/** 요약 맵 행 상태 픽스처 (bizppurioTemplates.data.templates[type]) */ +const ROWS: Record | undefined> = { + '행 없음': undefined, + '행만 있고 내용 없음': { id: 1, has_content: false, status: 'draft' }, + draft: { id: 1, has_content: true, status: 'draft', template_code: null }, + requested: { id: 1, has_content: true, status: 'requested', template_code: 'g7_a_1' }, + approved: { id: 1, has_content: true, status: 'approved', template_code: 'g7_a_1' }, + rejected: { id: 1, has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] }, + dormant: { id: 1, has_content: true, status: 'dormant', template_code: 'g7_a_1' }, + stopped: { id: 1, has_content: true, status: 'stopped', template_code: 'g7_a_1' }, + blocked: { id: 1, has_content: true, status: 'blocked', template_code: 'g7_a_1' }, +}; +const mapScope = (row: Record | undefined) => ({ + bizppurioTemplates: { data: { templates: row ? { welcome: row } : {} } }, + extensionPointProps: { definition: { type: 'welcome' }, activeChannel: 'alimtalk', channel: 'alimtalk' }, +}); + +describe('lifecycle UI — 파일 분리(Overlay vs ExtensionPoint 3종)', () => { + it('overlay 는 target_layout=admin_settings 이고 extension_point·modals 키가 없다(모달 폐지)', () => { expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings'); expect((overlay as Record).extension_point).toBeUndefined(); + for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) { + expect((o as Record).modals, `${label} 오버레이는 모달을 등록하지 않는다`).toBeUndefined(); + } }); it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => { expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer'); expect((footer as Record).target_layout).toBeUndefined(); }); + + it('섹션·푸터 액션은 코어 편집 모달의 extension_point 2종에 주입된다(3면 공유 1본)', () => { + expect((sections as { extension_point?: string }).extension_point).toBe('notification_template_form_sections'); + expect((footerActions as { extension_point?: string }).extension_point).toBe('notification_template_form_footer_actions'); + expect((sections as Record).target_layout).toBeUndefined(); + expect((footerActions as Record).target_layout).toBeUndefined(); + }); + + it('섹션·푸터 액션 루트는 alimtalk 채널 템플릿을 편집할 때만 노출된다(메일·사이트내 알림 모달 무오염)', () => { + for (const root of [findById(sectionsRoot, 'bizppurio_tpl_sections'), findById(actionsRoot, 'bizppurio_tpl_footer_actions')]) { + expect(root).toBeTruthy(); + const cond = (root as { if?: string }).if ?? ''; + expect(evalBinding(cond, { extensionPointProps: { channel: 'alimtalk' } })).toBe(true); + expect(evalBinding(cond, { extensionPointProps: { channel: 'mail' } })).toBe(false); + expect(evalBinding(cond, { extensionPointProps: { channel: '' } })).toBe(false); + } + }); }); describe('lifecycle UI — 데이터소스 3종', () => { const sources = ((overlay as { data_sources?: Array> }).data_sources ?? []); it('bizppurioTemplates / bizppurioCategories / bizppurioProfiles 3종을 등록한다', () => { - expect(sources.map((d) => d.id)).toEqual([ - 'bizppurioTemplates', - 'bizppurioCategories', - 'bizppurioProfiles', - ]); + expect(sources.map((d) => d.id)).toEqual(['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles']); }); it('3종 모두 auto_fetch:false — 설정 페이지 다른 탭에서 자동 호출되지 않는다', () => { - for (const ds of sources) { - expect(ds.auto_fetch, `${ds.id} auto_fetch`).toBe(false); - } + for (const ds of sources) expect(ds.auto_fetch, `${ds.id} auto_fetch`).toBe(false); }); it('요약 맵은 GET templates/map 이고 탭 진입 init_actions 로만 조회된다', () => { @@ -132,257 +203,491 @@ describe('lifecycle UI — 상태 배너 + 안내 박스', () => { expect(cond).toContain('is_test_mode === true'); }); - it('알림톡 탭 상시 안내 박스가 라이프사이클 안내(tab_guide)를 노출한다', () => { + it('알림톡 탭 상시 안내 박스가 [편집] 모달 통합 안내(tab_guide)를 노출한다', () => { const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); expect(guide).toBeTruthy(); expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); expect(JSON.stringify(guide)).toContain('template.tab_guide'); + expect((ko as Record).template.tab_guide).toContain('[편집]'); }); }); -describe('lifecycle UI — 행 하단(extension_point) 라이프사이클 줄', () => { - const row = findById(footerRoot, 'bizppurio_row_lifecycle'); +describe('lifecycle UI — 행 하단은 상태 요약만 (버튼 0)', () => { + const row = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode; it('행 UI 는 activeChannel === alimtalk 일 때만 노출된다', () => { expect(row).toBeTruthy(); - expect((row as { if?: string }).if).toBe("{{extensionPointProps.activeChannel === 'alimtalk'}}"); + expect(evalBinding((row as { if?: string }).if ?? '', { extensionPointProps: { activeChannel: 'alimtalk' } })).toBe(true); + expect(evalBinding((row as { if?: string }).if ?? '', { extensionPointProps: { activeChannel: 'mail' } })).toBe(false); }); - it('상태 배지는 has_content 로 미작성(unwritten)/저장 상태를 분기해 template.status.* 로 표기한다', () => { - const badge = findAllByName(row as AnyNode, 'Span') - .find((s) => (s.text ?? '').includes("template.status.' +")); - expect(badge).toBeTruthy(); - const text = badge?.text ?? ''; - expect(text).toContain(`${MAP_PATH}?.has_content`); - expect(text).toContain("'unwritten'"); - expect(text).toContain(`${MAP_PATH}?.status`); + it('행 하단에는 버튼·체크박스·apiCall 이 없다 — 조작은 코어 [편집] 모달로 일원화', () => { + expect(findAllByName(row, 'Button')).toHaveLength(0); + expect(findAllByName(row, 'Checkbox')).toHaveLength(0); + expect(findAllByName(row, 'Toggle')).toHaveLength(0); + expect(collectActions(row).filter((a) => a.handler === 'apiCall')).toHaveLength(0); + expect(JSON.stringify(row)).not.toContain('openModal'); }); - it('[작성] 버튼: 행이 없거나 내용이 없을 때(if) → bz_tpl_modal seed + 작성 모달 open', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_compose'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe(`{{!(${MAP_PATH}) || !(${MAP_PATH}?.has_content === true)}}`); - const raw = JSON.stringify(btn); - expect(raw).toContain('"bz_tpl_modal"'); - expect(raw).toContain('modal_bizppurio_template'); + it('SMS 요약 줄은 대체/단독 사용 여부와 서버 미리보기(has_sms_body / sms_body_preview)를 읽는다', () => { + const raw = JSON.stringify(row); + expect(raw).toContain(`${MAP_PATH}?.fallback_sms_enabled`); + expect(raw).toContain(`${MAP_PATH}?.sms_only`); + expect(raw).toContain(`${MAP_PATH}?.has_sms_body === true`); + expect(raw).toContain(`${MAP_PATH}?.sms_body_preview`); + expect(raw).toContain('template.row.sms_body_missing'); + expect(raw).not.toContain('.slice(0, 40)'); }); - it('[수정] 버튼: draft·rejected 에서만(if) — 상세 GET 후 모달 open', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe( - `{{(${MAP_PATH}?.has_content === true) && ((${MAP_PATH}?.status) === 'draft' || (${MAP_PATH}?.status) === 'rejected')}}`, - ); - const raw = JSON.stringify(btn); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/'); - expect(raw).toContain('"method":"GET"'); - expect(raw).toContain('modal_bizppurio_template'); - }); - - it('[수정(승인 취소)] 버튼: approved 에서만(if) — 승인 취소 경고 모달을 연다', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit_approved'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'approved'}}`); - expect(JSON.stringify(btn)).toContain('modal_bizppurio_cancel_approval'); - }); - - it('[검수 신청] 버튼: draft + 내용 보유에서만(if) — POST .../request 후 요약 맵 갱신', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_request'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe(`{{(${MAP_PATH}?.has_content === true) && (${MAP_PATH}?.status) === 'draft'}}`); - const raw = JSON.stringify(btn); - expect(raw).toContain('/request'); - expect(raw).toContain('"method":"POST"'); - expect(raw).toContain('bizppurioTemplates'); - }); - - it('[신청 취소] 버튼: requested 에서만(if) — POST .../cancel-request', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_cancel_request'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'requested'}}`); - expect(JSON.stringify(btn)).toContain('/cancel-request'); - }); - - it('[휴면 해제] 버튼: dormant 에서만(if) — POST .../release', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_release'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'dormant'}}`); - expect(JSON.stringify(btn)).toContain('/release'); - }); - - it('[새로고침] 버튼: template_code 보유 행에서만(if) — POST .../sync 수동 동기화', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_refresh'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe(`{{(${MAP_PATH}) && ${MAP_PATH}?.template_code}}`); - expect(JSON.stringify(btn)).toContain('/sync'); - }); - - it('[사유 보기] 버튼: rejected + 사유 보유에서만(if) — bz_reject_view seed 후 반려 모달 open', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_view_reason'); - expect(btn).toBeTruthy(); - expect(btn?.if).toBe( - `{{(${MAP_PATH}?.status) === 'rejected' && (${MAP_PATH}?.inspection_detail ?? []).length > 0}}`, - ); - const raw = JSON.stringify(btn); - expect(raw).toContain('"bz_reject_view"'); - expect(raw).toContain('modal_bizppurio_rejection'); - }); - - it('알림톡 발송 토글은 내용 보유 행에서만 노출되고 delivery upsert 로 즉시 저장한다', () => { - const toggle = findAllByName(row as AnyNode, 'Toggle')[0]; - expect(toggle).toBeTruthy(); - expect(toggle?.if).toContain('has_content === true'); - const raw = JSON.stringify(toggle); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}'); - expect(raw).toContain('"method":"PUT"'); - expect(raw).toContain('alimtalk_enabled'); + it('SMS 상태 3종은 문구가 아니라 배지다 — 사용=초록/미사용=회색(승인 배지와 같은 모양), 본문 미설정=외곽선 칩 (PO 지시 2026-08-23)', () => { + const APPROVED_SET = 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200'; + const GRAY_SET = 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'; + for (const [id, field] of [['bizppurio_row_sms_fallback_badge', 'fallback_sms_enabled'], ['bizppurio_row_sms_only_badge', 'sms_only']] as const) { + const badge = findById(row, id) as AnyNode; + expect(badge, id).toBeTruthy(); + const cls = String((badge.props as { className: string }).className); + const on = String(evalExpr(cls, mapScope({ id: 1, has_content: true, status: 'draft', [field]: true }))); + const off = String(evalExpr(cls, mapScope({ id: 1, has_content: true, status: 'draft', [field]: false }))); + expect(on).toContain('rounded'); + expect(on).toContain('px-2 py-0.5'); + expect(on).toContain(APPROVED_SET); + expect(off).toContain(GRAY_SET); + expect(evalExpr(String(badge.text), mapScope({ id: 1, [field]: true }))).toBe('sirsoft-message_bizppurio.template.row.on'); + expect(evalExpr(String(badge.text), mapScope({ id: 1, [field]: false }))).toBe('sirsoft-message_bizppurio.template.row.off'); + // 라벨(대체 SMS / SMS 단독)은 배지 앞 형제 + const group = findById(row, id.replace('_badge', '')) as AnyNode; + expect((group.children as AnyNode[])[0].text).toMatch(/template\.row\.(fallback_sms|sms_only)$/); + } + const missing = findById(row, 'bizppurio_row_sms_body_missing') as AnyNode; + expect(missing).toBeTruthy(); + expect(String((missing.props as { className: string }).className)).toContain('border border-gray-300'); + expect(missing.text).toBe('$t:sirsoft-message_bizppurio.template.row.sms_body_missing'); + // 평문 "라벨: 값" 조립은 남지 않는다 + expect(JSON.stringify(row)).not.toContain("+ ': ' +"); }); }); -describe('lifecycle UI — SMS 설정 줄(체크박스 + 본문 미리보기)', () => { - const row = findById(footerRoot, 'bizppurio_row_lifecycle'); - const checkboxes = findAllByName(row as AnyNode, 'Checkbox'); +describe('lifecycle UI — 승인 여부 2단 배지 실평가 (행 하단 + 섹션 헤더 동형)', () => { + /** 배지 = 1차(승인됨/미승인) Span + 2차(괄호 세부) Span 을 품은 Span */ + const badgeOf = (root: AnyNode): AnyNode => { + const badge = findAllByName(root, 'Span').find((s) => String((s.comment as string) ?? '').includes('승인 여부 2단 배지')); + expect(badge, '2단 배지 노드').toBeTruthy(); + return badge as AnyNode; + }; + + const EXPECTED: Record = { + '행 없음': { primary: 'not_approved', detail: 'unwritten' }, + '행만 있고 내용 없음': { primary: 'not_approved', detail: 'unwritten' }, + draft: { primary: 'not_approved', detail: 'draft' }, + requested: { primary: 'not_approved', detail: 'requested' }, + approved: { primary: 'approved', detail: null }, + rejected: { primary: 'not_approved', detail: 'rejected' }, + dormant: { primary: 'not_approved', detail: 'dormant' }, + stopped: { primary: 'not_approved', detail: 'stopped' }, + blocked: { primary: 'not_approved', detail: 'blocked' }, + }; + + /** 섹션 헤더 배지는 상세 GET 으로 갱신되는 모달 상태를 근거로 한다(요약 맵은 모달 안 액션 직후 stale — 실측) */ + const modalScope = (row: Record | undefined) => ({ + _global: { bz_tpl_modal: row ? { id: row.id, status: row.status, has_content: row.has_content } : { id: null } }, + }); it.each([ - ['fallback_sms_enabled', '대체 SMS'], - ['sms_only', 'SMS 단독'], - ])('%s 체크박스: autoBinding:false + checked !! 강제 + $event.target.checked 를 delivery PUT body 로 전송', (field) => { - const cb = checkboxes.find((c) => String((c.props as { checked?: string })?.checked ?? '').includes(field)); - expect(cb).toBeTruthy(); - expect((cb?.props as { autoBinding?: boolean })?.autoBinding).toBe(false); - expect((cb?.props as { checked?: string })?.checked).toBe(`{{!!(${MAP_PATH}?.${field})}}`); - const change = (cb?.actions ?? []).find((a) => a.type === 'change') as AnyNode; - expect(change?.handler).toBe('apiCall'); - expect(change?.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}'); - const params = change?.params as { method?: string; body?: Record }; - expect(params?.method).toBe('PUT'); - expect(params?.body?.[field]).toBe('{{$event.target.checked}}'); - }); - - it('SMS 본문 미리보기(40자 절단)와 미설정 문구가 상호 조건으로 존재한다', () => { - const raw = JSON.stringify(row); - expect(raw).toContain('sms_body_prefix'); - expect(raw).toContain('.slice(0, 40)'); - expect(raw).toContain('template.row.sms_body_missing'); - }); - - it('[SMS 본문 편집] 버튼이 bz_sms_modal seed 후 SMS 모달을 연다', () => { - const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit_sms'); - expect(btn).toBeTruthy(); - const raw = JSON.stringify(btn); - expect(raw).toContain('"bz_sms_modal"'); - expect(raw).toContain('modal_bizppurio_sms'); + ['행 하단', footerRoot, mapScope], + ['섹션 헤더', sectionsRoot, modalScope], + ] as const)('%s 배지가 상태 9종을 승인됨 / 미승인(세부) 로 표기한다', (_label, root, scopeOf) => { + const badge = badgeOf(root); + const [primary, detail] = badge.children as AnyNode[]; + for (const [state, row] of Object.entries(ROWS)) { + const scope = scopeOf(row) as Record; + const want = EXPECTED[state]; + expect(evalExpr(String(primary.text), scope), `${state} 1차`).toBe(`sirsoft-message_bizppurio.template.approval.${want.primary}`); + const detailVisible = evalBinding(String(detail.if), scope); + expect(detailVisible, `${state} 2차 노출`).toBe(want.detail !== null); + if (want.detail) { + expect(evalExpr(String(detail.text), scope), `${state} 2차`).toBe(`(sirsoft-message_bizppurio.template.status.${want.detail})`); + } + // 색상 클래스는 승인/검수중/반려/휴면/중지·차단/기타 6계열 — 승인됨만 green + const cls = String(evalExpr(String((badge.props as { className: string }).className), scope)); + expect(cls.includes('bg-green-100'), `${state} green`).toBe(state === 'approved'); + } }); }); -describe('lifecycle UI — 작성/신청 모달(modal_bizppurio_template)', () => { - const modal = findById(overlayModalRoot, 'modal_bizppurio_template'); +describe('lifecycle UI — [편집] 모달 섹션: 시딩(onMount) + 편집/잠금 분기 실평가', () => { + const root = findById(sectionsRoot, 'bizppurio_tpl_sections') as AnyNode; + const onMount = ((root.lifecycle as { onMount?: AnyNode[] })?.onMount ?? []); + const steps = ((onMount[0]?.params as { actions?: AnyNode[] })?.actions ?? []); - it('작성 모달이 3면(코어/게시판/이커머스) 오버레이 모두에 등록된다(행 footer 가 공유 참조)', () => { - for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) { - const ids = ((o as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); - expect(ids, `${label} 오버레이 modals`).toContain('modal_bizppurio_template'); + it('마운트 시 sequence 1개: 상태 시딩 → 상세 GET(행 있을 때) → 카테고리·발신프로필 조회', () => { + expect(onMount).toHaveLength(1); + expect(onMount[0].handler).toBe('sequence'); + expect(steps.map((s) => s.handler)).toEqual(['setState', 'apiCall', 'refetchDataSource', 'refetchDataSource']); + const seed = steps[0].params as Record; + for (const key of ['bz_tpl_modal', 'bz_sms_modal', 'bz_tpl_upload', 'bz_tpl_ui']) expect(seed[key], key).toBeTruthy(); + expect((steps[2].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioCategories'); + expect((steps[3].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioProfiles'); + }); + + it('상세 GET 은 요약 맵의 행 id 로 판정·호출한다(같은 sequence 의 setState 값은 다음 액션 if 에 반영되지 않는다 — 실측)', () => { + const detail = steps[1]; + expect(detail.target).toBe(`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{${MAP_PATH}?.id}}`); + expect(String(detail.if)).not.toContain('_global.bz_tpl_modal'); + expect(evalBinding(String(detail.if), mapScope(ROWS.draft))).toBe(true); + expect(evalBinding(String(detail.if), mapScope(ROWS['행만 있고 내용 없음']))).toBe(true); + expect(evalBinding(String(detail.if), mapScope(ROWS['행 없음']))).toBe(false); + const onSuccess = JSON.stringify(detail.onSuccess); + expect(onSuccess).toContain('bz_tpl_modal.content'); + expect(onSuccess).toContain('bz_tpl_modal.status'); + expect(onSuccess).toContain('bz_tpl_modal.has_content'); + }); + + it('시딩값: SMS 본문·토글은 요약 맵에서, 알림톡 본문 프리필은 코어 alimtalk 템플릿 body 에서(#{var} 변환)', () => { + const seed = steps[0].params as { bz_tpl_modal: Record; bz_sms_modal: Record }; + expect(seed.bz_sms_modal.body).toBe(`{{Object.assign({}, ${MAP_PATH}?.sms_body)}}`); + expect(seed.bz_sms_modal.fallback_sms_enabled).toBe(`{{!!(${MAP_PATH}?.fallback_sms_enabled)}}`); + expect(seed.bz_sms_modal.sms_only).toBe(`{{!!(${MAP_PATH}?.sms_only)}}`); + expect(seed.bz_sms_modal.editLang).toBe('{{$locale}}'); + expect(seed.bz_tpl_modal.has_content).toBe(`{{!!(${MAP_PATH}?.has_content)}}`); + expect(String((seed.bz_tpl_modal.content as Record).templateContent)).toContain("tpl.channel === 'alimtalk'"); + expect(String((seed.bz_tpl_modal.content as Record).templateContent)).toContain(".split('{').join('#{')"); + }); + + const editor = findById(sectionsRoot, 'bizppurio_tpl_editor') as AnyNode; + const locked = findById(sectionsRoot, 'bizppurio_tpl_locked') as AnyNode; + const loadingNotice = findById(sectionsRoot, 'bizppurio_tpl_loading') as AnyNode; + /** 자기 알림 유형으로 시딩이 끝나고 상세 GET 도 끝난 상태(READY) */ + const readyScope = (modal: Record) => ({ + _global: { bz_tpl_modal: { notification_type: 'welcome', loading: false, ...modal } }, + extensionPointProps: { definition: { type: 'welcome' }, channel: 'alimtalk' }, + }); + + it.each([ + ['draft', true], ['rejected', true], [undefined, true], + ['requested', false], ['approved', false], ['dormant', false], ['stopped', false], ['blocked', false], + ])('상태 %s → 작성 폼 노출=%s, 잠금 요약은 그 반대 (READY 상태에서)', (status, editable) => { + const scope = readyScope(status === undefined ? {} : { status }); + expect(evalBinding(String(editor.if), scope)).toBe(editable); + expect(evalBinding(String(locked.if), scope)).toBe(!editable); + }); + + it('READY(자기 유형 시딩 + 상세 GET 완료) 전에는 작성 폼·잠금 요약이 모두 숨고 로딩 안내만 보인다 — 시딩 전/이전 알림 상태로 저장되는 경쟁 차단', () => { + expect(loadingNotice).toBeTruthy(); + const seed = (steps[0].params as { bz_tpl_modal: Record }).bz_tpl_modal; + expect(seed.loading).toBe(`{{!!(${MAP_PATH}?.id)}}`); + expect(seed.notification_type).toBe('{{extensionPointProps.definition?.type}}'); + expect(JSON.stringify(steps[1].onSuccess)).toContain('"bz_tpl_modal.loading":false'); + expect(JSON.stringify(steps[1].onError)).toContain('"bz_tpl_modal.loading":false'); + + const NOT_READY = [ + ['시딩 전(상태 없음)', { _global: {}, extensionPointProps: { definition: { type: 'welcome' } } }], + ['이전 알림의 상태', { _global: { bz_tpl_modal: { notification_type: 'reset_password', status: 'draft', loading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }], + ['상세 GET 진행 중', { _global: { bz_tpl_modal: { notification_type: 'welcome', status: 'draft', loading: true } }, extensionPointProps: { definition: { type: 'welcome' } } }], + ] as const; + for (const [label, scope] of NOT_READY) { + expect(evalBinding(String(editor.if), scope as Record), `${label}: 폼`).toBe(false); + expect(evalBinding(String(locked.if), scope as Record), `${label}: 잠금 요약`).toBe(false); + expect(evalBinding(String(loadingNotice.if), scope as Record), `${label}: 로딩 안내`).toBe(true); + } + for (const status of ['draft', 'requested', 'approved']) { + const ready = readyScope({ status }); + expect(evalBinding(String(loadingNotice.if), ready)).toBe(false); + expect(evalBinding(String(editor.if), ready) || evalBinding(String(locked.if), ready), `${status}: READY 후 둘 중 하나`).toBe(true); } }); - it('저장 버튼은 id 유무로 POST(신규)/PUT(기존) 2벌 if 분기다', () => { - const saves = findAllByName(modal as AnyNode, 'Button') - .filter((b) => b.text === '$t:sirsoft-message_bizppurio.template.form.btn_save'); - expect(saves).toHaveLength(2); - const create = saves.find((b) => b.if === '{{!(_global.bz_tpl_modal?.id)}}'); - const update = saves.find((b) => b.if === '{{_global.bz_tpl_modal?.id}}'); - expect(create).toBeTruthy(); - expect(update).toBeTruthy(); - const createRaw = JSON.stringify(create); - expect(createRaw).toContain('"/api/plugins/sirsoft-message_bizppurio/admin/templates"'); - expect(createRaw).toContain('"method":"POST"'); - expect(createRaw).toContain('notification_type'); - const updateRaw = JSON.stringify(update); - expect(updateRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}'); - expect(updateRaw).toContain('"method":"PUT"'); + it('모달이 닫히면(언마운트) 폼 상태를 비워 다음 오픈이 시딩 전에서 시작한다', () => { + const root = findById(sectionsRoot, 'bizppurio_tpl_sections') as AnyNode; + const onUnmount = ((root.lifecycle as { onUnmount?: AnyNode[] })?.onUnmount ?? []); + expect(onUnmount).toHaveLength(1); + expect(onUnmount[0].handler).toBe('setState'); + const params = onUnmount[0].params as Record; + expect(params.bz_tpl_modal).toBeNull(); + expect(params.bz_sms_modal).toBeNull(); }); - it('[저장 후 검수 신청] 버튼은 저장 onSuccess 로 request 엔드포인트를 체인한다(신규/기존 2벌)', () => { - const saveRequests = findAllByName(modal as AnyNode, 'Button') - .filter((b) => b.text === '$t:sirsoft-message_bizppurio.template.form.btn_save_request'); - expect(saveRequests).toHaveLength(2); - const create = saveRequests.find((b) => b.if === '{{!(_global.bz_tpl_modal?.id)}}'); - const update = saveRequests.find((b) => b.if === '{{_global.bz_tpl_modal?.id}}'); - // 신규: POST 저장 응답의 id 로 request - const createRaw = JSON.stringify(create); - expect(createRaw).toContain('"method":"POST"'); - expect(createRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request'); - // 기존: PUT 저장 후 자기 id 로 request - const updateRaw = JSON.stringify(update); - expect(updateRaw).toContain('"method":"PUT"'); - expect(updateRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request'); + it('작성 폼에는 구 작성 모달의 필드 전부가 이식됐다(유형·강조·본문·버튼·바로연결·업로드)', () => { + const raw = JSON.stringify(editor); + for (const needle of ['templateMessageType', 'templateEmphasizeType', 'bz_template_content', 'buttons', 'quickReplies', 'sirsoft-message_bizppurio.uploadTemplateImage', 'templateImageUrl', 'templateItem']) { + expect(raw, needle).toContain(needle); + } }); - it('강조 유형 조건부 섹션: TEXT(타이틀/서브타이틀)·IMAGE(업로드)·ITEM_LIST(아이템) 이 if 로 전환된다', () => { - const raw = JSON.stringify(modal); - expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'"); - expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'"); - expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'"); - // TEXT 섹션 필드 - expect(raw).toContain('templateTitle'); - expect(raw).toContain('templateSubtitle'); + it('상태 액션 4종의 노출 조건이 상태별로 정확히 평가된다', () => { + const BUTTONS: Record) => boolean> = { + 'template.row.btn_refresh': (s) => Boolean(s.template_code), + 'template.row.btn_cancel_request': (s) => s.status === 'requested', + 'template.row.btn_release': (s) => s.status === 'dormant', + 'template.row.btn_edit_approved': (s) => s.status === 'approved', + }; + for (const [key, expected] of Object.entries(BUTTONS)) { + const btn = findButtonByTextKey(sectionsRoot, key); + expect(btn, key).toBeTruthy(); + for (const [state, row] of Object.entries(ROWS)) { + if (!row) continue; + const scope = { _global: { bz_tpl_modal: { status: row.status, template_code: row.template_code ?? null }, bz_tpl_ui: { confirmCancelApproval: false } } }; + expect(evalBinding(String(btn?.if), scope), `${key} @ ${state}`).toBe(expected(row)); + } + } }); - it('부가정보(templateExtra) 는 EX·MI 메시지 유형에서만 노출된다', () => { - const raw = JSON.stringify(modal); - expect(raw).toContain("_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'"); + it('승인 취소는 인라인 확인 박스(모달 중첩 없음) — 확정 시 POST .../cancel-approval 후 draft 로 되돌린다', () => { + const confirmBtn = findButtonByTextKey(sectionsRoot, 'template.cancel_approval.btn_confirm'); + expect(confirmBtn).toBeTruthy(); + const raw = JSON.stringify(confirmBtn); + expect(raw).toContain(`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-approval`); + expect(raw).toContain('"bz_tpl_modal.status":"draft"'); + expect(JSON.stringify(sectionsRoot)).not.toContain('openModal'); + expect(JSON.stringify(sectionsRoot)).toContain('template.cancel_approval.warning'); }); - it('버튼 그룹은 최대 5개, 바로연결은 최대 10개에서 추가 버튼이 disabled 된다', () => { - const raw = JSON.stringify(modal); - expect(raw).toContain('"{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}"'); - expect(raw).toContain('"{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}"'); + it('발송 토글(알림톡 사용·대체 SMS·SMS 단독)은 상태에만 쓰고 즉시 저장하지 않는다(통합 저장 원칙)', () => { + const boxes = findAllByName(sectionsRoot, 'Checkbox'); + const bound = boxes.map((c) => String((c.props as { checked?: string })?.checked ?? '')); + expect(bound.some((b) => b.includes('bz_tpl_modal?.alimtalk_enabled'))).toBe(true); + expect(bound.some((b) => b.includes('bz_sms_modal?.fallback_sms_enabled'))).toBe(true); + expect(bound.some((b) => b.includes('bz_sms_modal?.sms_only'))).toBe(true); + for (const c of boxes) { + expect((c.props as { autoBinding?: boolean }).autoBinding).toBe(false); + const change = (c.actions as AnyNode[]).find((a) => a.type === 'change') as AnyNode; + expect(change.handler, '토글은 setState 만').toBe('setState'); + } }); - it('이미지 업로드는 커스텀 핸들러(uploadTemplateImage)로 URL 을 상태에 기입한다', () => { - const raw = JSON.stringify(modal); - expect(raw).toContain('sirsoft-message_bizppurio.uploadTemplateImage'); - expect(raw).toContain('bz_tpl_modal.content.templateImageUrl'); + it('반려 배너는 inspection_detail 스냅샷을 반복 렌더한다(반려 사유 모달 대체)', () => { + const raw = JSON.stringify(sectionsRoot); + expect(raw).toContain('template.form.rejected_banner'); + expect(raw).toContain('_global.bz_tpl_modal?.inspection_detail ?? []'); }); }); -describe('lifecycle UI — 승인 취소·반려·SMS 모달', () => { - it('승인 취소 모달: 경고 문구(cancel_approval.warning) + POST .../cancel-approval 배선', () => { - const modal = findById(overlayModalRoot, 'modal_bizppurio_cancel_approval'); - expect(modal).toBeTruthy(); - const raw = JSON.stringify(modal); - expect(raw).toContain('template.cancel_approval.warning'); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval'); - expect(raw).toContain('"method":"POST"'); +describe('lifecycle UI — 통합 [저장] 체인 (푸터 액션)', () => { + const buttons = findAllByName(actionsRoot, 'Button'); + const saveBtn = buttons.find((b) => !b.text && JSON.stringify(b).includes('$t:common.save')) as AnyNode; + const requestBtn = findButtonByTextKey(actionsRoot, 'template.form.btn_save_request') as AnyNode; + + it('[저장] + [저장 후 검수 신청] 두 버튼이며, 검수 신청은 수정 가능 상태 + 본문 입력이 있을 때만 노출된다', () => { + expect(buttons).toHaveLength(2); + expect(saveBtn).toBeTruthy(); + expect(requestBtn).toBeTruthy(); + expect(saveBtn.if).toBeUndefined(); + const cond = String(requestBtn.if); + const sc = (modal: Record) => ({ _global: { bz_tpl_modal: { notification_type: 'welcome', loading: false, ...modal } }, extensionPointProps: { definition: { type: 'welcome' } } }); + expect(evalBinding(cond, sc({ status: 'draft', content: { templateContent: '본문' } }))).toBe(true); + expect(evalBinding(cond, sc({ status: 'rejected', content: { templateContent: '본문' } }))).toBe(true); + expect(evalBinding(cond, sc({ content: { templateContent: '본문' } })), '행 없음(신규)').toBe(true); + expect(evalBinding(cond, sc({ status: 'draft', content: { templateContent: ' ' } })), '공백 본문').toBe(false); + expect(evalBinding(cond, sc({ status: 'requested', content: { templateContent: '본문' } }))).toBe(false); + expect(evalBinding(cond, sc({ status: 'approved', content: { templateContent: '본문' } }))).toBe(false); + expect(evalBinding(cond, sc({ status: 'draft', loading: true, content: { templateContent: '본문' } })), '상세 GET 전').toBe(false); }); - it('반려 사유 모달: inspection_detail 스냅샷(comments)을 반복 렌더한다', () => { - const modal = findById(overlayModalRoot, 'modal_bizppurio_rejection'); - expect(modal).toBeTruthy(); - const raw = JSON.stringify(modal); - expect(raw).toContain('_global.bz_reject_view?.comments'); - expect(raw).toContain('template.rejection.modal_title'); + it('두 버튼 모두 클릭 if 재진입 가드 + isSaving/uploading disabled 바인딩을 갖는다', () => { + for (const b of [saveBtn, requestBtn]) { + const click = (b.actions as AnyNode[])[0]; + expect(click.type).toBe('click'); + expect(click.if).toMatch(/!\(_global\.bz_tpl_modal\?\.isSaving \?\? false\)/); + const disabled = String((b.props as { disabled?: string }).disabled); + const ep = { definition: { type: 'welcome' } }; + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: true, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: true } }, extensionPointProps: ep })).toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(false); + const ready = { _global: { bz_tpl_modal: { isSaving: false, loading: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }; + const notReady = { _global: { bz_tpl_modal: { isSaving: false, loading: true, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }; + const otherType = { _global: { bz_tpl_modal: { isSaving: false, loading: false, notification_type: 'reset_password' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }; + expect(evalBinding(disabled, ready), 'READY 후 저장 열림').toBe(false); + expect(evalBinding(disabled, notReady), '상세 GET 전 저장 잠금').toBe(true); + expect(evalBinding(disabled, otherType), '이전 알림 상태로는 저장 잠금').toBe(true); + expect(evalBinding(String(click.if), notReady), '상세 GET 전 클릭 가드').toBe(false); + expect(evalBinding(String(click.if), ready), 'READY 후 클릭 허용').toBe(true); + } }); - it('SMS 본문 모달: delivery upsert(PUT) 로 sms_body 를 저장한다', () => { - const modal = findById(overlayModalRoot, 'modal_bizppurio_sms'); - expect(modal).toBeTruthy(); - const raw = JSON.stringify(modal); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}'); - expect(raw).toContain('"method":"PUT"'); - expect(raw).toContain('sms_body'); + /** 저장 상태 매트릭스 → 실행돼야 하는 분기 번호(①POST ②PUT ③delivery) */ + const MATRIX: Array<[string, Record, 1 | 2 | 3]> = [ + ['행 없음 + 본문 입력', { content: { templateContent: '본문' } }, 1], + ['행 없음 + 본문 없음(SMS 만)', { content: { templateContent: '' } }, 3], + ['draft + 본문 입력', { id: 1, status: 'draft', content: { templateContent: '본문' } }, 2], + ['rejected + 본문 입력', { id: 1, status: 'rejected', content: { templateContent: '본문' } }, 2], + ['draft + 본문 없음', { id: 1, status: 'draft', content: { templateContent: '' } }, 3], + ['requested(잠금) + 본문 있음', { id: 1, status: 'requested', content: { templateContent: '본문' } }, 3], + ['approved(잠금) + 본문 있음', { id: 1, status: 'approved', content: { templateContent: '본문' } }, 3], + ['dormant(잠금)', { id: 1, status: 'dormant', content: { templateContent: '본문' } }, 3], + ]; + + it.each(MATRIX)('[저장] %s → 분기 정확히 하나만 실행된다', (_label, modal, want) => { + const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions; + expect(steps[0].handler).toBe('setState'); + const branches = steps.slice(1); + expect(branches).toHaveLength(3); + const fired = branches.map((b) => evalBinding(String(b.if), { _global: { bz_tpl_modal: modal } })); + expect(fired.filter(Boolean)).toHaveLength(1); + expect(fired.indexOf(true) + 1).toBe(want); + }); + + it('분기 엔드포인트/메서드: ① POST templates ② PUT templates/{id} ③ PUT templates/delivery/{type} — delivery 필드 4종은 셋 다 싣는다', () => { + const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + expect([steps[0].target, (steps[0].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates', 'POST']); + expect([steps[1].target, (steps[1].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}', 'PUT']); + expect([steps[2].target, (steps[2].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_tpl_modal?.notification_type}}', 'PUT']); + for (const s of steps) { + const body = (s.params as { body: Record }).body; + for (const k of ['alimtalk_enabled', 'fallback_sms_enabled', 'sms_only', 'sms_body']) expect(body[k], `${s.comment} ${k}`).toBeTruthy(); + expect(body.sms_body).toBe('{{Object.assign({}, _global.bz_sms_modal?.body)}}'); + } + expect((steps[0].params as { body: Record }).body.content).toBeTruthy(); + expect((steps[1].params as { body: Record }).body.content).toBeTruthy(); + expect((steps[2].params as { body: Record }).body.content, '잠금 상태에서는 content 를 보내지 않는다(422 content_locked 회피)').toBeUndefined(); + }); + + it('각 분기 성공 후: 수신자 규칙이 바뀐 경우에만 코어 PUT(extensionPointProps.saveEndpoint) → 마무리, 아니면 바로 마무리', () => { + const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + for (const s of steps) { + const tail = s.onSuccess as AnyNode[]; + expect(tail).toHaveLength(2); + const [corePut, skip] = tail; + expect(corePut.handler).toBe('apiCall'); + expect(corePut.target).toBe('{{extensionPointProps.saveEndpoint}}'); + expect((corePut.params as { method: string }).method).toBe('PUT'); + expect(skip.handler).toBe('sequence'); + const changed = { _global: { notification_template_form_modal: { recipients: [{ type: 'role', role_ids: [1] }], template: { recipients: [{ type: 'trigger_user' }] } } }, extensionPointProps: { stateKey: 'notification_template_form_modal' } }; + const same = { _global: { notification_template_form_modal: { recipients: [{ type: 'trigger_user' }], template: { recipients: [{ type: 'trigger_user' }] } } }, extensionPointProps: { stateKey: 'notification_template_form_modal' } }; + expect(evalBinding(String(corePut.if), changed)).toBe(true); + expect(evalBinding(String(skip.if), changed)).toBe(false); + expect(evalBinding(String(corePut.if), same)).toBe(false); + expect(evalBinding(String(skip.if), same)).toBe(true); + // 코어 PUT 본문은 코어 모달의 저장 계약과 같은 4필드, 값은 extensionPointProps.stateKey 로 간접 참조 + const body = (corePut.params as { body: Record }).body; + expect(Object.keys(body).sort()).toEqual(['body', 'click_url', 'recipients', 'subject']); + for (const v of Object.values(body)) expect(v).toContain('_global?.[extensionPointProps.stateKey]'); + // 마무리: 요약 맵 + 면별 정의 목록 재조회 → 토스트 → closeModal + for (const fin of [corePut.onSuccess as AnyNode[], (skip.params as { actions: AnyNode[] }).actions]) { + const handlers = fin.map((a) => a.handler); + expect(handlers).toEqual(['setState', 'refetchDataSource', 'refetchDataSource', 'toast', 'closeModal']); + expect((fin[1].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioTemplates'); + expect((fin[2].params as { dataSourceId: string }).dataSourceId).toBe('{{extensionPointProps.refetchDataSourceId}}'); + } + } + }); + + it('[저장 후 검수 신청]: 알림톡 upsert(①/②) 성공 → POST .../request → 코어 PUT(조건) → 마무리; delivery 전용 분기 ③ 은 없다', () => { + const steps = ((requestBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + expect(steps).toHaveLength(2); + const [create, update] = steps; + const createReq = (create.onSuccess as AnyNode[])[0]; + expect(createReq.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request'); + const updateReq = (update.onSuccess as AnyNode[])[0]; + expect(updateReq.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request'); + for (const req of [createReq, updateReq]) { + expect((req.params as { method: string }).method).toBe('POST'); + expect((req.onSuccess as AnyNode[])[0].target).toBe('{{extensionPointProps.saveEndpoint}}'); + expect(JSON.stringify(req.onSuccess)).toContain('template.form.requested_toast'); + } + }); + + it('[저장 후 검수 신청]의 request 2노드는 검수자 전달 의견(bz_tpl_modal.request_comment)을 comment 로 싣는다 (#597 §18.7)', () => { + const steps = ((requestBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + const reqs = steps.map((s) => (s.onSuccess as AnyNode[])[0]); + expect(reqs).toHaveLength(2); + for (const req of reqs) { + const body = (req.params as { body: Record }).body; + expect(Object.keys(body)).toEqual(['comment']); + expect(evalExpr(body.comment, { _global: { bz_tpl_modal: { request_comment: '변수 예시: #{name}=홍길동' } } })).toBe('변수 예시: #{name}=홍길동'); + expect(evalExpr(body.comment, { _global: { bz_tpl_modal: {} } }), '미입력 시 빈 문자열(서버가 미전달로 처리)').toBe(''); + } + // 일반 [저장] 3분기에는 request 호출 자체가 없으므로 comment 도 없다 + const saveSteps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + expect(JSON.stringify(saveSteps)).not.toContain('/request"'); + }); + + it('실패 시 isSaving 해제 + 사유(bizppurio_message 우선)·필드 오류 객체를 상태에 싣는다(토스트가 아니라 섹션 배너가 표시)', () => { + const fails = collectActions(actionsRoot).filter((a) => a.handler === 'apiCall').map((a) => JSON.stringify(a.onError)); + expect(fails.length).toBeGreaterThan(0); + for (const f of fails) { + expect(f).toContain('"bz_tpl_modal.isSaving":false'); + expect(f).toContain('error.errors?.bizppurio_message ?? error.message'); + expect(f).toContain('"bz_tpl_modal.errors"'); + } + const raw = JSON.stringify(sectionsRoot); + expect(raw).toContain("Object.values(_global.bz_tpl_modal?.errors ?? {}).flat()"); }); }); -describe('lifecycle UI — 코어 무오염 + i18n 정합', () => { - it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { - const all = JSON.stringify(overlay) + JSON.stringify(footer); +describe('lifecycle UI — 검수자 전달 의견 입력란 (#597 §18.7, PO 결정 2026-08-23)', () => { + const editor = findById(sectionsRoot, 'bizppurio_tpl_editor') as AnyNode; + const block = findById(editor, 'bizppurio_tpl_request_comment') as AnyNode; + + it('작성 폼(bizppurio_tpl_editor) 안에만 있다 — 잠금 요약에는 없다', () => { + expect(block).toBeTruthy(); + expect(findById(findById(sectionsRoot, 'bizppurio_tpl_locked') as AnyNode, 'bizppurio_tpl_request_comment')).toBeFalsy(); + }); + + it('Textarea(bz_request_comment, ≤500) 가 bz_tpl_modal.request_comment 를 읽고 쓰며 카운터가 /500 이다', () => { + const ta = findAllByName(block, 'Textarea')[0] as AnyNode; + const props = ta.props as Record; + expect(props.name).toBe('bz_request_comment'); + expect(props.maxLength).toBe(500); + expect(props.value).toBe("{{_global.bz_tpl_modal?.request_comment ?? ''}}"); + const change = (ta.actions as AnyNode[])[0]; + expect(change.type).toBe('change'); + expect((change.params as Record)['bz_tpl_modal.request_comment']).toBe('{{$event.target.value}}'); + const counter = findAllByName(block, 'Span').map((s) => String(s.text)).find((t) => t.endsWith('/500')) as string; + expect(evalExpr(counter.replace('/500', ''), { _global: { bz_tpl_modal: { request_comment: '12345' } } })).toBe(5); + }); + + it('모달 시딩이 request_comment 를 빈 문자열로 초기화한다(이전 알림의 의견 이월 금지)', () => { + const seeds = collectActions(sectionsRoot) + .filter((a) => a.handler === 'setState' && (a.params as Record)?.bz_tpl_modal); + expect(seeds.length).toBeGreaterThan(0); + for (const s of seeds) { + expect(((s.params as Record).bz_tpl_modal as Record).request_comment).toBe(''); + } + }); + + it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => { + for (const key of ['request_comment', 'request_comment_placeholder', 'request_comment_hint']) { + expect(resolve(ko, `sirsoft-message_bizppurio.template.form.${key}`), `ko ${key}`).toBeTruthy(); + expect(resolve(en, `sirsoft-message_bizppurio.template.form.${key}`), `en ${key}`).toBeTruthy(); + } + }); + + /** children 경로의 조상 체인(루트→부모). id 가 없으면 null. */ + const ancestorsOf = (root: AnyNode, id: string, anc: AnyNode[] = []): AnyNode[] | null => { + for (const c of (root.children as AnyNode[] | undefined) ?? []) { + if (c.id === id) return anc.concat(root); + const r = ancestorsOf(c, id, anc.concat(root)); + if (r) return r; + } + return null; + }; + + it('바로연결 블록(헤더+목록) 바로 뒤 형제로 놓이고, 라벨+[바로연결 추가] flex 행 안에 끼지 않는다 (회귀: PO 화면 지적 2026-08-23)', () => { + const chain = ancestorsOf(editor, 'bizppurio_tpl_request_comment') as AnyNode[]; + expect(chain).toBeTruthy(); + const parent = chain[chain.length - 1]; + const siblings = parent.children as AnyNode[]; + const idx = siblings.findIndex((c) => c.id === 'bizppurio_tpl_request_comment'); + const prev = siblings[idx - 1]; + // 직전 형제 = 바로연결 블록 전체(추가 버튼 + 목록 iteration 을 모두 품는다) + expect(JSON.stringify(prev)).toContain('template.form.quick_reply_add'); + expect(JSON.stringify(prev)).toContain('quickRepliesItem'); + // 의견 블록의 조상 어디에도 flex 행이 없다(헤더 행 내부 삽입이면 라벨이 세로로 찌그러진다) + for (const a of chain) { + expect(String((a.props as { className?: string } | undefined)?.className ?? ''), `ancestor ${a.id ?? a.name}`).not.toMatch(/\bflex\b/); + } + }); +}); + +describe('lifecycle UI — 코어 계약 준수 + i18n 정합', () => { + it('플러그인 파일 어디에도 코어 엔드포인트·코어 모달 상태 키 리터럴이 없다(전부 extensionPointProps 간접 참조)', () => { + const all = JSON.stringify([overlay, footer, sections, footerActions]); expect(all).not.toContain('/api/admin/notification-templates/'); expect(all).not.toContain('notification_template_form_modal'); + expect(all).not.toContain('modal_notification_template_form'); }); it('참조하는 모든 플러그인 i18n 정적 키가 ko·en 에 존재한다', () => { - const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)]; + const keys = [overlay, footer, sections, footerActions].flatMap(collectPluginKeys); expect(keys.length).toBeGreaterThan(0); for (const key of Array.from(new Set(keys))) { expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); @@ -390,199 +695,84 @@ describe('lifecycle UI — 코어 무오염 + i18n 정합', () => { } }); - it("동적 접두 'template.status.' 가족: ko·en 에 상태 8종 키가 모두 존재한다", () => { + it("동적 접두 'template.status.' 8종 + 'template.approval.' 2종이 ko·en 에 모두 존재한다", () => { const statuses = ['unwritten', 'draft', 'requested', 'approved', 'rejected', 'stopped', 'blocked', 'dormant']; for (const dict of [ko, en] as Array>) { expect(Object.keys(dict.template.status).sort()).toEqual([...statuses].sort()); + expect(Object.keys(dict.template.approval).sort()).toEqual(['approved', 'not_approved']); + expect(Object.keys(dict.template.row)).toEqual(expect.arrayContaining(['on', 'off', 'sms_label', 'alimtalk_disabled'])); } }); - it("동적 접두 'templates.link_type.' 가족: 모달이 사용하는 링크 유형 코드가 ko·en 에 모두 존재한다", () => { - // 버튼 셀렉트 13종 + 바로연결 셀렉트 6종(부분집합) + it("동적 접두 'templates.link_type.' 가족: 작성 폼이 사용하는 링크 유형 코드가 ko·en 에 모두 존재한다", () => { const used = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP']; for (const dict of [ko, en] as Array>) { - for (const code of used) { - expect(dict.templates.link_type[code], `link_type.${code}`).toBeTruthy(); - } + for (const code of used) expect(dict.templates.link_type[code], `link_type.${code}`).toBeTruthy(); } }); }); describe('lifecycle UI — 바인딩 브레이스 위생 (회귀: 프리필 끝 `}}` 잔재)', () => { + const FILES = [ + ['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay], + ['footer', footer], ['sections', sections], ['footerActions', footerActions], + ] as const; + it('문자열 값 어디에도 4연속 브레이스(}}}}·{{{{)가 없다', () => { - // 회귀 배경(#597 브라우저 실측): 작성 모달 본문 프리필 표현식이 `}}}}` 로 끝나 - // 엔진이 바인딩을 조기 종료하고 잔여 `}}` 가 본문 값에 리터럴로 붙었다. - // 구조적 중첩 브레이스(minified JSON)와 구분하기 위해 문자열 리프만 검사한다. - const collectStrings = (node: unknown, acc: string[] = []): string[] => { - if (typeof node === 'string') acc.push(node); - else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc)); - else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc)); - return acc; - }; - for (const [name, json] of [ - ['core', coreOverlay], - ['board', boardOverlay], - ['ecommerce', ecommerceOverlay], - ['footer', footer], - ] as const) { + for (const [name, json] of FILES) { const offenders = collectStrings(json).filter((v) => v.includes('}}}}') || v.includes('{{{{')); expect(offenders, `${name} 브레이스 잔재: ${offenders.join(' | ')}`).toEqual([]); } }); it('표현식 $t 를 2-인자(파라미터 객체)로 호출하지 않는다 — 엔진은 단일 인자만 지원', () => { - // 회귀 배경(#597 §6.3 실측): `$t('key', {date: …})` 의 두 번째 인자는 엔진이 - // 무시해 "{date} 신청" 플레이스홀더 원문이 화면에 노출됐다. 파라미터 치환은 - // 텍스트 문법 `$t:key|param={{expr}}` 만 지원한다 (선례: admin_activity_log_list). - const collectStrings = (node: unknown, acc: string[] = []): string[] => { - if (typeof node === 'string') acc.push(node); - else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc)); - else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc)); - return acc; - }; - for (const [name, json] of [ - ['core', coreOverlay], - ['board', boardOverlay], - ['ecommerce', ecommerceOverlay], - ['footer', footer], - ] as const) { + for (const [name, json] of FILES) { const offenders = collectStrings(json).filter((v) => /\$t\([^)]*,\s*\{/.test(v)); - expect(offenders, `${name} 2-인자 $t 호출(파라미터 미치환 원문 노출): ${offenders.join(' | ')}`).toEqual([]); + expect(offenders, `${name} 2-인자 $t 호출: ${offenders.join(' | ')}`).toEqual([]); } }); }); -describe('lifecycle UI — 행 하단 버튼 노출 조건 실평가 (#597 §14.2 T7)', () => { - const row = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode; - - /** - * `{{...}}` 단일 바인딩 형태의 조건식을 실제로 평가한다. - * - * 문자열 동일성 단언(`expect(btn.if).toBe('{{...}}')`)은 표현식이 바뀌면 테스트도 같이 - * 바뀌므로 회귀를 잡지 못한다 — 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이다. - * 여기서는 식을 그대로 실행해 상태별 노출 결과를 판정한다. - */ - const evaluateIf = (expr: string, templates: Record, type: string): boolean => { - const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); - // eslint-disable-next-line no-new-func - const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`); - return Boolean(fn({ data: { templates } }, { definition: { type }, activeChannel: 'alimtalk' })); - }; - - /** 상태별 요약 맵 행 (bizppurioTemplates.data.templates[type]) */ - const ROWS: Record | undefined> = { - '행 없음': undefined, - '행만 있고 내용 없음': { has_content: false, status: 'draft' }, - draft: { has_content: true, status: 'draft', template_code: null }, - requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' }, - approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' }, - rejected: { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] }, - '반려(사유 없음)': { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [] }, - dormant: { has_content: true, status: 'dormant', template_code: 'g7_a_1' }, - stopped: { has_content: true, status: 'stopped', template_code: 'g7_a_1' }, - blocked: { has_content: true, status: 'blocked', template_code: 'g7_a_1' }, - }; - - /** 버튼 i18n 키 → 그 버튼이 보여야 하는 상태 이름 집합 */ - const EXPECTED_VISIBLE: Record = { - 'template.row.btn_compose': ['행 없음', '행만 있고 내용 없음'], - 'template.row.btn_edit': ['draft', 'rejected', '반려(사유 없음)'], - 'template.row.btn_edit_approved': ['approved'], - 'template.row.btn_request': ['draft'], - 'template.row.btn_cancel_request': ['requested'], - 'template.row.btn_release': ['dormant'], - 'template.row.btn_refresh': ['requested', 'approved', 'rejected', '반려(사유 없음)', 'dormant', 'stopped', 'blocked'], - 'template.row.btn_view_reason': ['rejected'], - }; - - it.each(Object.keys(EXPECTED_VISIBLE))('%s 의 노출 조건이 상태별로 정확히 평가된다', (key) => { - const btn = findButtonByTextKey(row, key); - expect(btn, `${key} 버튼이 레이아웃에 없다`).toBeTruthy(); - const expr = (btn as { if?: string }).if; - expect(expr, `${key} 에 if 조건이 없다`).toBeTruthy(); - - const expected = EXPECTED_VISIBLE[key]; - for (const [state, rowValue] of Object.entries(ROWS)) { - const templates = rowValue === undefined ? {} : { welcome: rowValue }; - const actual = evaluateIf(expr as string, templates, 'welcome'); - expect(actual, `${key} @ ${state}: ${expected.includes(state) ? '노출' : '숨김'} 이어야 한다`) - .toBe(expected.includes(state)); - } - }); - - it('상태 10종 × 버튼 8종을 모두 평가한다 (커버리지 하한 고정)', () => { - expect(Object.keys(ROWS)).toHaveLength(10); - expect(Object.keys(EXPECTED_VISIBLE)).toHaveLength(8); - }); -}); - /** * @effects upload_in_progress_locks_save_buttons_not_cancel */ -describe('lifecycle UI — 이미지 업로드 상태 배선 (#597 §14.4·§14.5 V2)', () => { - const modalRaw = JSON.stringify(overlayModalRoot); - - it('업로드 진행 중에는 저장 계열 버튼이 잠기고, 취소는 잠기지 않는다', () => { - const buttons = findAllByName(overlayModalRoot, 'Button'); - const saveButtons = buttons.filter((b) => /template\.form\.btn_save(_request)?$/.test((b.text ?? '').replace('$t:sirsoft-message_bizppurio.', ''))); - expect(saveButtons.length, '작성 모달의 저장 계열 버튼(신규/수정 × 저장/저장후신청) 4개').toBe(4); - - // 문자열 포함 확인이 아니라 두 상태를 실제로 태운다 — `... && false` 로 바꿔도 - // toContain 은 통과하지만 아래 평가는 red 가 된다. - for (const b of saveButtons) { +describe('lifecycle UI — 이미지 업로드 상태 배선', () => { + it('업로드 진행 중에는 통합 저장 계열 버튼이 잠긴다(취소는 코어 버튼이라 플러그인이 잠글 수 없다)', () => { + const buttons = findAllByName(actionsRoot, 'Button'); + expect(buttons).toHaveLength(2); + for (const b of buttons) { const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? ''); - expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }), - '업로드 in-flight 중 저장이 열려 있으면 빈 이미지 URL 로 저장된다').toBe(true); - expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: false } } }), - '업로드가 끝났는데도 저장이 잠겨 있으면 저장할 방법이 없다').toBe(false); + const ep = { definition: { type: 'welcome' } }; + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { notification_type: 'welcome' }, bz_tpl_upload: { uploading: true } }, extensionPointProps: ep })).toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(false); } - - const cancel = buttons.find((b) => b.text === '$t:common.cancel'); - const cancelDisabled = String((cancel?.props as { disabled?: string } | undefined)?.disabled ?? ''); - expect(evalBinding(cancelDisabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }), - '취소까지 잠그면 업로드가 매달렸을 때 모달을 빠져나갈 수 없다').toBe(false); + expect(JSON.stringify(actionsRoot)).not.toContain('$t:common.cancel'); }); it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => { - expect(modalRaw).toContain("_global.bz_tpl_upload?.error ?? ''"); + expect(JSON.stringify(sectionsRoot)).toContain("_global.bz_tpl_upload?.error ?? ''"); }); }); -describe('lifecycle UI — SMS 본문 다국어 (#597 §14.3)', () => { - const modalRaw = JSON.stringify(overlayModalRoot); +describe('lifecycle UI — SMS 본문 다국어', () => { + const raw = JSON.stringify(sectionsRoot); - it('SMS 모달은 로케일 탭을 제공하고 본문을 로케일별로 읽는다', () => { - const tabs = findById(overlayModalRoot, 'bz_sms_lang_tabs'); + it('SMS 섹션은 로케일 탭을 제공하고 본문을 로케일별로 읽는다', () => { + const tabs = findById(sectionsRoot, 'bz_sms_lang_tabs'); expect(tabs, 'SMS 본문 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy(); expect(JSON.stringify(tabs)).toContain('{{$locales}}'); - expect(modalRaw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]'); - }); - - it('SMS 본문 저장은 로케일 맵을 통째로 전송한다 (문자열 아님)', () => { - expect(modalRaw).toContain('"sms_body":"{{Object.assign({}, _global.bz_sms_modal?.body)}}"'); + expect(raw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]'); }); it('변수 삽입 대상 경로가 현재 편집 로케일을 가리킨다', () => { - expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)"); + expect(raw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)"); }); }); -describe('compose modal — 유형 전환·반복 입력 실평가 (#597 §14.2 T7 / §6.2)', () => { - /** - * `{{...}}` 조건식을 모달 폼 상태에 대해 실제로 평가한다 (바인딩 프로브). - * - * 화면을 렌더하지 않고도 "이 상태에서 이 블록이 보이는가 / 이 버튼이 잠기는가" 를 - * 판정할 수 있다 — 조건식이 소비하는 자유변수는 `_global` 하나뿐이기 때문이다. - * substring 존재 확인은 조건을 잘못 고쳐도 통과하므로 회귀를 잡지 못한다. - */ - const evalWithModal = (expr: string, modal: Record): boolean => { - const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); - // eslint-disable-next-line no-new-func - const fn = new Function('_global', `return (${body});`); - return Boolean(fn({ bz_tpl_modal: modal, bz_tpl_upload: {} })); - }; +describe('compose form — 유형 전환·반복 입력 실평가 (#597 §14.2 T7 / §6.2)', () => { + const evalWithModal = (expr: string, modal: Record): boolean => + evalBinding(expr, { _global: { bz_tpl_modal: modal, bz_tpl_upload: {} } }); - /** 강조유형별 조건부 블록의 if 식을 레이아웃에서 수집한다. */ const conditionalBlocks = (): Record => { const found: Record = {}; const walk = (node: unknown): void => { @@ -594,98 +784,58 @@ describe('compose modal — 유형 전환·반복 입력 실평가 (#597 §14.2 if (m && !found[m[1]]) found[m[1]] = cond; Object.values(rec).forEach(walk); }; - walk(overlayModalRoot); + walk(sectionsRoot); return found; }; it.each(['TEXT', 'IMAGE', 'ITEM_LIST'])('강조유형 %s 블록은 그 유형에서만 노출된다', (type) => { - const blocks = conditionalBlocks(); - const expr = blocks[type]; - expect(expr, `${type} 조건부 블록이 모달에 없다`).toBeTruthy(); - + const expr = conditionalBlocks()[type]; + expect(expr, `${type} 조건부 블록이 섹션에 없다`).toBeTruthy(); for (const current of ['NONE', 'TEXT', 'IMAGE', 'ITEM_LIST']) { - const visible = evalWithModal(expr, { content: { templateEmphasizeType: current } }); - expect(visible, `강조유형 ${current} 일 때 ${type} 블록은 ${current === type ? '노출' : '숨김'}`) - .toBe(current === type); + expect(evalWithModal(expr, { content: { templateEmphasizeType: current } }), `${current} → ${type}`).toBe(current === type); } }); - it('강조유형 미선택(undefined) 상태에서는 조건부 블록이 모두 숨는다', () => { - const blocks = conditionalBlocks(); - for (const [type, expr] of Object.entries(blocks)) { - expect(evalWithModal(expr, { content: {} }), `${type} 블록`).toBe(false); - } + it('부가정보(templateExtra) 는 EX·MI 메시지 유형에서만 노출된다', () => { + expect(JSON.stringify(sectionsRoot)).toContain("_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'"); }); - /** 반복 입력의 "추가" 버튼 상한 조건 — [상태 경로, 상한] */ - const REPEAT_CAPS: Array<[string, number]> = [ - ['templateItem?.list', 10], - ['buttons', 5], - ['quickReplies', 10], - ]; + const REPEAT_CAPS: Array<[string, number]> = [['templateItem?.list', 10], ['buttons', 5], ['quickReplies', 10]]; it.each(REPEAT_CAPS)('반복 입력 %s 는 상한 %i 에 도달하면 추가가 잠긴다', (pathFragment, cap) => { - const buttons = findAllByName(overlayModalRoot, 'Button'); - const addBtn = buttons.find((b) => { + const addBtn = findAllByName(sectionsRoot, 'Button').find((b) => { const d = String((b.props as { disabled?: string } | undefined)?.disabled ?? ''); return d.includes(pathFragment) && d.includes(`>= ${cap}`); }); expect(addBtn, `${pathFragment} 상한 ${cap} 을 거는 추가 버튼이 없다`).toBeTruthy(); - const expr = String((addBtn?.props as { disabled?: string }).disabled); const key = pathFragment.includes('templateItem') ? 'templateItem' : pathFragment; const makeContent = (n: number): Record => { const arr = Array.from({ length: n }, () => ({})); return key === 'templateItem' ? { templateItem: { list: arr } } : { [key]: arr }; }; - - // 경계 3점: 상한-1(열림) / 상한(잠김) / 상한+1(잠김) - expect(evalWithModal(expr, { content: makeContent(cap - 1) }), `${cap - 1}개: 열림`).toBe(false); - expect(evalWithModal(expr, { content: makeContent(cap) }), `${cap}개: 잠김`).toBe(true); - expect(evalWithModal(expr, { content: makeContent(cap + 1) }), `${cap + 1}개: 잠김`).toBe(true); - // 비어 있을 때(키 자체 부재)도 열려 있어야 한다 — ?? [] fallback 회귀 방지 - expect(evalWithModal(expr, { content: {} }), '항목 0개: 열림').toBe(false); + expect(evalWithModal(expr, { content: makeContent(cap - 1) })).toBe(false); + expect(evalWithModal(expr, { content: makeContent(cap) })).toBe(true); + expect(evalWithModal(expr, { content: makeContent(cap + 1) })).toBe(true); + expect(evalWithModal(expr, { content: {} })).toBe(false); }); - it('버튼 linkType 별 조건부 링크 필드가 선택한 유형에서만 노출된다', () => { - /** - * 기대값을 조건식에서 역산하지 않는다 — `linkType === 'X'` 를 조건식 자신에서 뽑아 - * 그 X 로 참이 되는지 보는 형태는 어떤 등가식이든 무조건 통과하는 준-항등식이고, - * "어느 입력칸이 어느 linkType 의 필드인가" 를 전혀 고정하지 못한다. - * 대응표는 부록 A-3(WL→linkMo/linkPc, AL→linkAnd+linkIos, TN→telNumber, - * P1~P3→pluginId)에서 가져와 **필드 바인딩 기준으로** 적는다. - */ + it('버튼 linkType 별 조건부 링크 필드가 선택한 유형에서만 노출된다(부록 A-3 대응표 기준)', () => { const EXPECTED_FOR_FIELD: Record = { - linkMo: ['WL'], - linkPc: ['WL'], - linkAnd: ['AL'], - linkIos: ['AL'], - telNumber: ['TN'], - pluginId: ['P1', 'P2', 'P3'], + linkMo: ['WL'], linkPc: ['WL'], linkAnd: ['AL'], linkIos: ['AL'], telNumber: ['TN'], pluginId: ['P1', 'P2', 'P3'], }; const ALL_LINK_TYPES = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP']; - - const inputs = findAllByName(overlayModalRoot, 'Input') - .filter((n) => /buttonsItem\.linkType === '/.test(String((n as { if?: string }).if ?? ''))); - - // 조건식이 아니라 value 바인딩으로 필드 정체를 식별한다. + const inputs = findAllByName(sectionsRoot, 'Input').filter((n) => /buttonsItem\.linkType === '/.test(String((n as { if?: string }).if ?? ''))); const seen = new Set(); for (const node of inputs) { const value = String((node.props as { value?: string } | undefined)?.value ?? ''); - const field = (value.match(/buttonsItem\.([A-Za-z]+)/) ?? [])[1]; - expect(field, `value 바인딩에서 필드명을 못 읽었다: ${value}`).toBeTruthy(); - const expected = EXPECTED_FOR_FIELD[field as string]; - expect(expected, `부록 A-3 에 없는 조건부 필드: ${field}`).toBeTruthy(); - seen.add(field as string); - - const cond = String((node as { if?: string }).if); + const field = (value.match(/buttonsItem\.([A-Za-z]+)/) ?? [])[1] as string; + expect(EXPECTED_FOR_FIELD[field], `부록 A-3 에 없는 조건부 필드: ${field}`).toBeTruthy(); + seen.add(field); for (const lt of ALL_LINK_TYPES) { - expect(evalBinding(cond, { buttonsItem: { linkType: lt } }), `${field} @ ${lt}`) - .toBe(expected!.includes(lt)); + expect(evalBinding(String((node as { if?: string }).if), { buttonsItem: { linkType: lt } }), `${field} @ ${lt}`).toBe(EXPECTED_FOR_FIELD[field].includes(lt)); } } - - // 부록 A-3 의 조건부 필드가 하나라도 화면에서 사라지면 red (하한 단언이 아니라 전수 대조) expect([...seen].sort()).toEqual(Object.keys(EXPECTED_FOR_FIELD).sort()); }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx index 11b79b4e..06da5b5e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx @@ -3,7 +3,7 @@ /** * 플러그인 설정 — 알림 템플릿 관리 탭(DB 목록) 구조 검증 (#597 §4.3) * - * @effects manage_screen_lists_db_rows_with_merge_query_round_trip + * @effects manage_screen_lists_db_rows_with_merge_query_round_trip, inspection_request_carries_reviewer_comment * * templates 탭은 DB 목록 관리 화면이다: * - 데이터소스 bizppurio_templates_list(GET /admin/templates) — 필터·검색·페이지는 @@ -15,7 +15,8 @@ import { describe, it, expect } from 'vitest'; import layout from '../../../layouts/admin/plugin_settings.json'; -import footer from '../../../extensions/notification_row_footer.json'; +import sections from '../../../extensions/notification_template_form_sections.json'; +import footerActions from '../../../extensions/notification_template_form_footer_actions.json'; import ko from '../../../lang/ko.json'; import en from '../../../lang/en.json'; import { findById, findAllByName, evalBinding, type AnyNode } from './helpers'; @@ -105,7 +106,7 @@ describe('manage — 필터·검색·페이지 (mergeQuery 왕복 규약)', () = it('새로고침 버튼은 URL 을 건드리지 않고 목록만 refetch 한다(보던 목록 유지)', () => { const refreshBtn = findAllByName(manageView, 'Button') - .find((b) => b.text === '$t:sirsoft-message_bizppurio.templates.list.refresh'); + .find((b) => (b.props as Record)?.['aria-label'] === '$t:sirsoft-message_bizppurio.templates.list.refresh'); expect(refreshBtn).toBeTruthy(); const raw = JSON.stringify(refreshBtn); expect(raw).toContain('bizppurio_templates_list'); @@ -157,15 +158,14 @@ describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포 }); /** - * 관리 화면(bzRow)과 행 하단(요약 맵)의 노출 조건을 같은 상태 집합에 **실제로 태워** 대조한다. + * 관리 화면(bzRow)과 코어 [편집] 모달 섹션(_global.bz_tpl_modal)의 노출 조건을 같은 상태 + * 집합에 **실제로 태워** 대조한다(PO 결정 2026-08-23 — 행 하단 버튼 폐지, 섹션이 SSoT). * - * 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 실제로 - * `btn_refresh` 는 footer 가 `(row) && row?.template_code`, manage 는 `bzRow.template_code` - * 로 서로 다른 모양이고, `=== true` 와 truthy 도 갈린다. 두 식을 각자의 컨텍스트로 평가해 - * 상태별 결과가 일치하는지를 본다. + * 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 두 식을 + * 각자의 컨텍스트로 평가해 상태별 결과가 일치하는지를 본다. */ const MANAGE_ROWS: Record> = { - '행만 있고 내용 없음': { has_content: false, status: 'draft' }, + '행만 있고 내용 없음': { has_content: false, status: 'draft', template_code: null }, draft: { has_content: true, status: 'draft', template_code: null }, requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' }, approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' }, @@ -178,60 +178,64 @@ describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포 /** manage 화면 식을 bzRow 컨텍스트로 평가 */ const evalManage = (expr: string, bzRow: Record): boolean => { - const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const body = expr.trim().replace(/^{{/, '').replace(/}}$/, ''); // eslint-disable-next-line no-new-func return Boolean(new Function('bzRow', `return (${body});`)(bzRow)); }; - /** footer 식을 요약 맵 컨텍스트로 평가 */ - const evalFooter = (expr: string, bzRow: Record): boolean => { - const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + /** 편집 모달 섹션 식을 _global.bz_tpl_modal 컨텍스트로 평가(섹션은 상세 GET 으로 status/template_code 를 시딩한다) */ + const evalSection = (expr: string, bzRow: Record): boolean => { + const body = expr.trim().replace(/^{{/, '').replace(/}}$/, ''); // eslint-disable-next-line no-new-func - const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`); - return Boolean(fn({ data: { templates: { welcome: bzRow } } }, { definition: { type: 'welcome' } })); + // 섹션은 READY(자기 알림 유형으로 시딩 + 상세 GET 완료) 컨텍스트에서 평가한다 + const fn = new Function('_global', 'extensionPointProps', `return (${body});`); + return Boolean(fn( + { bz_tpl_modal: { notification_type: 'welcome', loading: false, status: bzRow.status, template_code: bzRow.template_code ?? null }, bz_tpl_ui: { confirmCancelApproval: false } }, + { definition: { type: 'welcome' }, channel: 'alimtalk' }, + )); }; - const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; - const footerRow = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode; + const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode; const byTextKey = (root: AnyNode, key: string) => findAllByName(root, 'Button') .find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`); - /** 관리 화면 ↔ 행 하단에서 같은 의미를 갖는 버튼 쌍 */ + /** 관리 화면 ↔ 편집 모달 섹션에서 같은 의미를 갖는 버튼 쌍 */ const PAIRS = [ - 'template.row.btn_request', 'template.row.btn_cancel_request', 'template.row.btn_release', 'template.row.btn_refresh', 'template.row.btn_edit_approved', ]; - it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 행 하단에서 상태별로 같은 결과를 낸다', (key) => { + it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 편집 모달 섹션에서 상태별로 같은 결과를 낸다', (key) => { const m = byTextKey(manageView, key); - const f = byTextKey(footerRow, key); + const f = byTextKey(sectionsRoot, key); expect(m?.if, `관리 화면에 ${key} if 가 없다`).toBeTruthy(); - expect(f?.if, `행 하단에 ${key} if 가 없다`).toBeTruthy(); + expect(f?.if, `편집 모달 섹션에 ${key} if 가 없다`).toBeTruthy(); for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { expect(evalManage(m!.if as string, bzRow), `${key} @ ${state}`) - .toBe(evalFooter(f!.if as string, bzRow)); + .toBe(evalSection(f!.if as string, bzRow)); } }); - it('작성/수정 버튼은 행 하단의 작성+수정 두 버튼을 합친 것과 같은 상태에서 노출된다', () => { - // 관리 화면은 DB 목록이라 "행 없음" 이 없다 — footer 의 btn_compose(!row || !has_content)와 - // btn_edit(has_content && draft|rejected)를 합집합으로 놓고 대조한다. + it('관리 화면 [검수 신청]은 draft + 내용 보유에서만 노출된다(편집 모달은 [저장 후 검수 신청]이 본문 입력으로 판정)', () => { + const m = byTextKey(manageView, 'template.row.btn_request'); + expect(m?.if).toBeTruthy(); + for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { + expect(evalManage(m!.if as string, bzRow), `검수 신청 @ ${state}`).toBe(bzRow.status === 'draft' && bzRow.has_content === true); + } + }); + + it('작성/수정 버튼은 편집 모달의 작성 폼이 열리는 상태(draft/rejected)에서만 노출된다', () => { const m = findAllByName(manageView, 'Button') .find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content")); expect(m?.if, '관리 화면 작성/수정 버튼을 찾지 못했다').toBeTruthy(); - const compose = byTextKey(footerRow, 'template.row.btn_compose'); - const edit = byTextKey(footerRow, 'template.row.btn_edit'); - expect(compose?.if).toBeTruthy(); - expect(edit?.if).toBeTruthy(); - + const editor = findById(sectionsRoot, 'bizppurio_tpl_editor'); + expect(editor?.if).toBeTruthy(); for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { - const footerVisible = evalFooter(compose!.if as string, bzRow) || evalFooter(edit!.if as string, bzRow); - expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(footerVisible); + expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(evalSection(editor!.if as string, bzRow)); } }); @@ -246,15 +250,14 @@ describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포 expect(en.manage.btn_compose).toBeTruthy(); }); - it('승인 취소·SMS 본문·delivery upsert 는 행 하단(footer)과 같은 엔드포인트 계열을 쓴다', () => { + it('상태 전이·delivery upsert 엔드포인트 계열이 편집 모달 섹션·푸터 액션과 같다', () => { const layoutEndpoints = collectEndpoints(layout); - const footerEndpoints = collectEndpoints(footer); - // footer 가 쓰는 계열(map 조회 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다 - for (const ep of footerEndpoints) { - if (ep.endsWith('/map')) continue; + const sharedEndpoints = collectEndpoints([sections, footerActions]); + // 편집 모달이 쓰는 계열(map 조회·상세 GET 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다 + for (const ep of sharedEndpoints) { + if (ep.endsWith('/map') || ep === '/api/plugins/sirsoft-message_bizppurio/admin/templates/{id}') continue; expect(layoutEndpoints, `관리 화면에 ${ep} 누락`).toContain(ep); } - // 승인 취소 모달 공유 확인 expect(JSON.stringify(layout)).toContain( '/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval', ); @@ -371,3 +374,193 @@ describe('manage 모달 — 이미지 업로드 배선·SMS 언어 탭 (#597 §1 expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)"); }); }); + +describe('manage 모달 — 검수자 전달 의견 (#597 §18.7, PO 결정 2026-08-23)', () => { + const composeModal = findById(modalRoot, 'modal_bizppurio_template') as AnyNode; + const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode; + const collectHandlers = (node: unknown, acc: AnyNode[] = []): AnyNode[] => { + if (Array.isArray(node)) node.forEach((c) => collectHandlers(c, acc)); + else if (node && typeof node === 'object') { + const rec = node as AnyNode; + if (typeof rec.handler === 'string') acc.push(rec); + Object.values(rec).forEach((v) => collectHandlers(v, acc)); + } + return acc; + }; + + it('작성 모달에 bz_manage_request_comment 입력란(≤500)이 있고 편집 모달 섹션과 같은 상태 키·바인딩을 쓴다', () => { + const block = findById(composeModal, 'bz_manage_request_comment') as AnyNode; + expect(block).toBeTruthy(); + const ta = findAllByName(block, 'Textarea')[0] as AnyNode; + expect((ta.props as { name: string }).name).toBe('bz_request_comment'); + expect((ta.props as { maxLength: number }).maxLength).toBe(500); + const sectionsTa = findAllByName(findById(sectionsRoot, 'bizppurio_tpl_request_comment') as AnyNode, 'Textarea')[0] as AnyNode; + expect((ta.props as { value: string }).value).toBe((sectionsTa.props as { value: string }).value); + expect(JSON.stringify(ta.actions)).toBe(JSON.stringify(sectionsTa.actions)); + }); + + it('작성 모달의 request 2노드는 comment 를 싣고, 행 단위 [검수 신청](입력란 없음)은 싣지 않는다', () => { + const all = collectHandlers(root).filter((a) => a.handler === 'apiCall' && String(a.target).endsWith('/request')); + const modalReqs = all.filter((a) => !String(a.target).includes('{{bzRow.id}}')); + const rowReqs = all.filter((a) => String(a.target).includes('{{bzRow.id}}')); + expect(modalReqs).toHaveLength(2); + expect(rowReqs).toHaveLength(1); + for (const r of modalReqs) { + expect((r.params as { body: { comment: string } }).body.comment).toBe("{{_global.bz_tpl_modal?.request_comment ?? ''}}"); + } + for (const r of rowReqs) expect((r.params as { body?: unknown }).body).toBeUndefined(); + }); + + it('바로연결 블록(헤더+목록) 바로 뒤 형제로 놓이고, 라벨+[바로연결 추가] flex 행 안에 끼지 않는다 (회귀: PO 화면 지적 2026-08-23)', () => { + const ancestorsOf = (root: AnyNode, id: string, anc: AnyNode[] = []): AnyNode[] | null => { + for (const c of (root.children as AnyNode[] | undefined) ?? []) { + if (c.id === id) return anc.concat(root); + const r = ancestorsOf(c, id, anc.concat(root)); + if (r) return r; + } + return null; + }; + const chain = ancestorsOf(composeModal, 'bz_manage_request_comment') as AnyNode[]; + expect(chain).toBeTruthy(); + const parent = chain[chain.length - 1]; + const siblings = parent.children as AnyNode[]; + const idx = siblings.findIndex((c) => c.id === 'bz_manage_request_comment'); + const prev = siblings[idx - 1]; + expect(JSON.stringify(prev)).toContain('template.form.quick_reply_add'); + expect(JSON.stringify(prev)).toContain('quickRepliesItem'); + for (const a of chain) { + expect(String((a.props as { className?: string } | undefined)?.className ?? ''), `ancestor ${a.id ?? a.name}`).not.toMatch(/\bflex\b/); + } + }); + + it('행에서 작성 모달을 열 때 request_comment 를 빈 문자열로 시딩한다(다른 행의 의견 이월 금지)', () => { + const seeds = collectHandlers(root) + .filter((a) => a.handler === 'setState' && (a.params as Record)?.bz_tpl_modal); + expect(seeds.length).toBeGreaterThan(0); + for (const s of seeds) { + expect(((s.params as Record).bz_tpl_modal as Record).request_comment).toBe(''); + } + }); + + it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => { + const pick = (json: unknown, key: string): unknown => key.split('.') + .reduce((acc, seg) => (acc as Record)?.[seg], json); + for (const key of ['request_comment', 'request_comment_placeholder', 'request_comment_hint']) { + expect(pick(ko, `template.form.${key}`), `ko ${key}`).toBeTruthy(); + expect(pick(en, `template.form.${key}`), `en ${key}`).toBeTruthy(); + } + }); +}); + +describe('manage — 목록 SMS 열은 상태 배지 (행 하단과 같은 규칙, PO 지시 2026-08-23)', () => { + // iteration(bzRow) 안이라 정적 id 를 둘 수 없다 — 텍스트 식으로 찾는다 + const badge = findAllByName(manageView, 'Span').find((n) => String(n.text).includes("manage.sms_fallback') : $t('sirsoft-message_bizppurio.template.row.off')")) as AnyNode; + const evalCell = (expr: string, row: Record): unknown => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + return new Function('$t', 'bzRow', `return (${body});`)((k: string) => k, row); + }; + + it('단독/대체는 초록, 미사용은 회색 배지이며 평문 "-" 는 남지 않는다', () => { + expect(badge).toBeTruthy(); + const cls = String((badge.props as { className: string }).className); + expect(String(evalCell(cls, { sms_only: true }))).toContain('bg-green-100'); + expect(String(evalCell(cls, { fallback_sms_enabled: true }))).toContain('bg-green-100'); + expect(String(evalCell(cls, {}))).toContain('bg-gray-100'); + expect(String(evalCell(cls, {}))).toContain('rounded'); + expect(evalCell(String(badge.text), { sms_only: true })).toBe('sirsoft-message_bizppurio.manage.sms_only'); + expect(evalCell(String(badge.text), { fallback_sms_enabled: true })).toBe('sirsoft-message_bizppurio.manage.sms_fallback'); + expect(evalCell(String(badge.text), {})).toBe('sirsoft-message_bizppurio.template.row.off'); + expect(JSON.stringify(manageView)).not.toContain("sms_fallback') : '-')"); + }); +}); + +describe('manage — 목록 모바일 카드 전환 + 도구줄 버튼 일관성 (PO 지시 2026-08-24)', () => { + const header = findById(manageView, 'bz_manage_list_header') as AnyNode; + const rowsWrap = findById(manageView, 'bz_manage_list_rows') as AnyNode; + const findIterationRow = (n: AnyNode | null): AnyNode | null => { + if (!n) return null; + if (n.iteration?.item_var === 'bzRow') return n; + for (const c of n.children ?? []) { + const found = findIterationRow(c); + if (found) return found; + } + return null; + }; + const portableClass = (n: AnyNode | null | undefined): string => String( + ((n?.responsive as Record | undefined)?.portable?.props?.className) ?? '', + ); + + it('Table 마크업이 사라지고 grid 목록으로 렌더된다(헤더 7컬럼 키 유지, portable 에서 숨김)', () => { + expect(findAllByName(manageView, 'Table').length).toBe(0); + expect(header).toBeTruthy(); + expect(String((header.props as { className?: string })?.className)).toContain('grid-cols-12'); + expect(portableClass(header)).toBe('hidden'); + const raw = JSON.stringify(header); + for (const c of ['notification', 'owner', 'status', 'sms', 'requested_at', 'synced_at', 'actions']) { + expect(raw, `columns.${c}`).toContain('manage.columns.' + c); + } + }); + + it('행은 데스크톱 grid, portable 에서 카드(flex-wrap)로 전환된다 — responsive 는 반복 노드가 아니라 내부 래퍼에 둔다', () => { + const row = findIterationRow(rowsWrap); + expect(row).toBeTruthy(); + expect(row!.responsive, '반복 노드 자체에 responsive 를 두지 않는다(내부 래퍼 담당)').toBeUndefined(); + const inner = (row!.children ?? [])[0] as AnyNode; + expect(String((inner.props as { className?: string })?.className)).toContain('grid grid-cols-12'); + expect(portableClass(inner)).toContain('flex flex-wrap'); + }); + + it('소속·신청일·동기화일 셀은 portable 에서 숨고, 카드 메타 줄이 대신 노출된다', () => { + const row = findIterationRow(rowsWrap) as AnyNode; + const rowInner = (row.children ?? [])[0] as AnyNode; + const hiddenCells = (rowInner.children ?? []).filter((c) => portableClass(c) === 'hidden'); + expect(hiddenCells.length).toBeGreaterThanOrEqual(3); + // 메타 줄: 신청일+동기화일을 한 텍스트에 담고, 데스크톱(hidden)→portable 에서만 보인다 + const all: AnyNode[] = []; + const walk = (n: AnyNode) => { all.push(n); (n.children ?? []).forEach(walk); }; + walk(row); + const meta = all.find((n) => typeof n.text === 'string' + && n.text.includes("requested_at ?? '').slice(0, 10)") + && n.text.includes('last_synced_at')); + expect(meta, '카드 메타 줄(신청·동기화 합본)이 없다').toBeTruthy(); + expect(String((meta!.props as { className?: string })?.className)).toContain('hidden'); + expect(portableClass(meta)).not.toContain('hidden'); + expect(portableClass(meta).length).toBeGreaterThan(0); + }); + + it('행 액션 묶음은 카드 전환 후에도 단일 인스턴스다(삭제 버튼 1개)', () => { + const delBtns = findAllByName(manageView, 'Button') + .filter((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_delete'); + expect(delBtns.length).toBe(1); + }); + + it('검색 버튼이 btn btn-primary 계열로 통일된다(구 미니 버튼 조합 제거)', () => { + const searchBtn = findAllByName(manageView, 'Button') + .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_search') as AnyNode; + expect(searchBtn).toBeTruthy(); + const cls = String((searchBtn.props as { className?: string })?.className); + expect(cls).toContain('btn btn-primary'); + expect(cls).not.toContain('text-xs'); + }); + + it('새로고침이 btn-icon 아이콘 버튼으로 통일된다(fa-arrows-rotate + aria-label, refetch 전용 유지)', () => { + const refreshBtn = findAllByName(manageView, 'Button') + .find((b) => (b.props as Record)?.['aria-label'] === '$t:sirsoft-message_bizppurio.templates.list.refresh') as AnyNode; + expect(refreshBtn).toBeTruthy(); + expect(String((refreshBtn.props as { className?: string })?.className)).toContain('btn-icon'); + const raw = JSON.stringify(refreshBtn); + expect(raw).toContain('fa-arrows-rotate'); + expect(raw).toContain('bizppurio_templates_list'); + expect(raw).not.toContain('"handler":"navigate"'); + }); + + it('검색 입력·상태 필터가 portable 에서 전폭 계열로 전환된다', () => { + const input = findAllByName(manageView, 'Input') + .find((i) => String((i.props as { value?: string })?.value ?? '').includes('bzSearchDraft')) as AnyNode; + expect(portableClass(input)).toContain('w-full'); + const select = findAllByName(manageView, 'Select') + .find((sel) => (sel.props as { value?: string })?.value === "{{query.bz_status ?? ''}}") as AnyNode; + expect(portableClass(select)).toContain('flex-1'); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts index 5cf6b09a..922c925a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts @@ -12,7 +12,7 @@ * 디스패치 시점의 동기 검사라 리렌더를 기다리지 않는다. * * 이 테스트는 신청 체인을 포함하는 모든 click 액션이 재진입 가드(if)를 - * 보유하는지 4개 산출 파일 전수로 고정한다. 가드가 한 파일에서라도 빠지면 + * 보유하는지 산출 파일 전수(편집 모달 푸터 1본 + 관리 화면)로 고정한다. 가드가 한 파일에서라도 빠지면 * 그 면에서만 중복 신청이 재발한다. * * 실행 검증은 §6.3 10c 브라우저 실측이 담당한다. 라운드 5 실측 결과를 여기 남긴다: @@ -36,10 +36,9 @@ import { describe, it, expect } from 'vitest'; const BASE = path.resolve(__dirname, '../../../..'); const FILES = [ - 'resources/extensions/notification_tab_core.json', - 'resources/extensions/notification_tab_board.json', - 'resources/extensions/notification_tab_ecommerce.json', - 'resources/extensions/notification_row_footer.json', + // 3면 공유 1본 — 코어 [편집] 모달 푸터에 주입되는 통합 저장/검수 신청 버튼(PO 결정 2026-08-23) + 'resources/extensions/notification_template_form_footer_actions.json', + // 플러그인 관리 화면의 자기 모달 'resources/layouts/admin/plugin_settings.json', ]; @@ -113,8 +112,8 @@ describe('검수 신청 체인 재진입 가드 (#597 10c)', () => { const all = FILES.flatMap(collectRequestClickActions); it('신청 체인을 포함한 click 액션이 산출 파일 전체에서 수집된다 (0건이면 아래 단언은 공회전한다)', () => { - // 모달 저장 후 신청(신규/수정) ×4파일 + row footer 신청 + 관리 화면 행 신청 - expect(all.length).toBeGreaterThanOrEqual(6); + // 편집 모달 푸터 [저장 후 검수 신청] 1 + 관리 화면(모달 저장 후 신청 신규/수정 + 행 신청) + expect(all.length).toBeGreaterThanOrEqual(3); }); it.each(FILES)('%s 의 신청 체인 click 액션은 모두 재진입 if 가드를 갖는다', (file) => { @@ -172,9 +171,11 @@ describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파 if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } const n = node as Record; + // 관리 화면 모달: text=form.btn_save / 편집 모달 푸터(통합 저장): 자식 Span 이 $t:common.save const isSave = n.name === 'Button' - && typeof n.text === 'string' - && n.text.endsWith('sirsoft-message_bizppurio.template.form.btn_save'); + && ((typeof n.text === 'string' && n.text.endsWith('sirsoft-message_bizppurio.template.form.btn_save')) + || (Array.isArray(n.children) && JSON.stringify(n.children).includes('$t:common.save') + && JSON.stringify(n.props ?? {}).includes('bz_tpl_modal'))); if (isSave && Array.isArray(n.actions)) { for (const a of n.actions) { if (a?.type === 'click') found.push({ file, ifExpr: a.if }); @@ -188,14 +189,15 @@ describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파 return found; }; - it('저장 버튼의 click 액션이 4개 산출 파일 전수에서 재진입 가드를 보유한다', () => { + it('저장 버튼의 click 액션이 산출 파일 전수에서 재진입 가드를 보유한다', () => { const all = FILES.flatMap(collectSaveClickActions); - // 3면 오버레이 + 관리 화면 = 4면 × (신규/수정) 2버튼 - expect(all.length, '저장 버튼 click 액션 수').toBe(8); + // 편집 모달 푸터 통합 [저장] 1 + 관리 화면 모달 (신규/수정) 2버튼 + expect(all.length, '저장 버튼 click 액션 수').toBe(3); for (const a of all) { + // 편집 모달 푸터의 통합 [저장]은 isSaving 가드에 상세 GET 완료 전 가드(loading)를 AND 로 더 단다 — 포함 판정 expect(a.ifExpr, `${a.file}: 저장 체인에 재진입 가드(if)가 없다 — 더블 클릭 시 중복 저장`) - .toBe(GUARD); + .toContain(GUARD.slice(2, -2)); } }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index bb2ebd21..269433fd 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -105,7 +105,7 @@ } }, "template": { - "tab_guide": "Compose an alimtalk template per notification and request inspection. Once Kakao approves it, alimtalk is sent automatically. SMS fallback and SMS-only delivery are also configured here.", + "tab_guide": "Open [Edit] on each notification to write the alimtalk template and the SMS body together and request review. Alimtalk is sent automatically after Kakao approval; fallback SMS and SMS-only sending are configured in the same window.", "status": { "unwritten": "Not composed", "draft": "Draft", @@ -137,7 +137,11 @@ "sms_only": "SMS only", "sms_body_prefix": "Body:", "sms_body_missing": "No SMS body", - "btn_edit_sms": "Edit SMS body" + "btn_edit_sms": "Edit SMS body", + "sms_label": "SMS", + "on": "On", + "off": "Off", + "alimtalk_disabled": "Sending off" }, "form": { "modal_title": "Alimtalk Template — {name}", @@ -199,6 +203,9 @@ "btn_remove_quick_reply": "Remove quick reply", "btn_save": "Save", "btn_save_request": "Save & request inspection", + "request_comment": "Note to reviewer", + "request_comment_placeholder": "e.g. #{name}=Jane Doe, #{order_number}=20260823-0001", + "request_comment_hint": "Sent to the Kakao reviewer together with the inspection request (optional, up to 500 characters). Kakao review guidelines ask for an example value per body variable, so listing them here reduces rejections. It is not stored — it is only sent when you request inspection.", "saved_toast": "Notification template saved.", "requested_toast": "Inspection requested. Alimtalk delivery starts once approved." }, @@ -217,6 +224,18 @@ "rejection": { "modal_title": "Rejection Reasons — {name}", "btn_close": "Close" + }, + "approval": { + "approved": "Approved", + "not_approved": "Not approved" + }, + "section": { + "alimtalk_title": "Alimtalk template", + "sms_title": "SMS", + "alimtalk_enabled": "Send alimtalk", + "locked_hint": "Templates under review or already approved cannot be edited. Press [Cancel request] or [Edit (cancel approval)] first.", + "saved_toast": "Notification template saved.", + "loading": "Loading template…" } }, "manage": { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 05787c4e..1c06fbfc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -105,7 +105,7 @@ } }, "template": { - "tab_guide": "알림별로 알림톡 템플릿을 작성해 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 SMS·SMS 단독 발송도 이 화면에서 설정합니다.", + "tab_guide": "각 알림의 [편집]에서 알림톡 템플릿과 문자(SMS) 본문을 함께 작성하고 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 SMS·SMS 단독 발송도 같은 창에서 설정합니다.", "status": { "unwritten": "미작성", "draft": "작성중", @@ -137,7 +137,11 @@ "sms_only": "SMS 단독", "sms_body_prefix": "본문:", "sms_body_missing": "SMS 본문 미설정", - "btn_edit_sms": "SMS 본문 편집" + "btn_edit_sms": "SMS 본문 편집", + "sms_label": "문자(SMS)", + "on": "사용", + "off": "미사용", + "alimtalk_disabled": "발송 사용 안 함" }, "form": { "modal_title": "알림톡 템플릿 — {name}", @@ -199,6 +203,9 @@ "btn_remove_quick_reply": "바로연결 삭제", "btn_save": "저장", "btn_save_request": "저장 후 검수 신청", + "request_comment": "검수자 전달 의견", + "request_comment_placeholder": "예: #{name}=홍길동, #{order_number}=20260823-0001", + "request_comment_hint": "검수 신청 시 카카오 검수자에게 함께 전달됩니다(선택, 500자 이내). 카카오 심사 가이드는 본문 변수마다 예시값을 요구하므로 변수별 예시값을 적어 두면 반려를 줄일 수 있습니다. 저장되지 않으며 신청할 때만 전송됩니다.", "saved_toast": "알림 템플릿을 저장했습니다.", "requested_toast": "검수를 신청했습니다. 승인되면 알림톡 발송이 시작됩니다." }, @@ -217,6 +224,18 @@ "rejection": { "modal_title": "반려 사유 — {name}", "btn_close": "닫기" + }, + "approval": { + "approved": "승인됨", + "not_approved": "미승인" + }, + "section": { + "alimtalk_title": "알림톡 템플릿", + "sms_title": "문자(SMS)", + "alimtalk_enabled": "알림톡 발송 사용", + "locked_hint": "검수중이거나 승인된 템플릿은 내용을 수정할 수 없습니다. 수정하려면 [신청 취소] 또는 [수정 (승인 취소)]를 먼저 누르세요.", + "saved_toast": "알림 템플릿을 저장했습니다.", + "loading": "템플릿 내용을 불러오는 중…" } }, "manage": { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index a5151018..1bfdb6c2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -1648,7 +1648,7 @@ "type": "composite", "name": "Select", "props": { - "className": "w-40 text-sm", + "className": "w-40 text-sm flex-shrink-0", "value": "{{query.bz_status ?? ''}}", "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.manage.filter_all')}, ...['draft','requested','approved','rejected','stopped','blocked','dormant'].map(s => ({value: s, label: $t('sirsoft-message_bizppurio.template.status.' + s)}))]}}" }, @@ -1677,13 +1677,20 @@ } ] } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "flex-1 min-w-0 text-sm" + } + } + } }, { "type": "basic", "name": "Input", "props": { - "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 w-56", + "className": "flex-1 min-w-0 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", "placeholder": "$t:sirsoft-message_bizppurio.manage.search_placeholder", "value": "{{_local.bzSearchDraft ?? (query.bz_search ?? '')}}" }, @@ -1696,14 +1703,21 @@ "bzSearchDraft": "{{$event.target.value}}" } } - ] + ], + "responsive": { + "portable": { + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100" + } + } + } }, { "type": "basic", "name": "Button", "props": { "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" + "className": "btn btn-primary whitespace-nowrap flex-shrink-0" }, "actions": [ { @@ -1737,7 +1751,7 @@ } ], "props": { - "className": "flex-center gap-2 flex-wrap" + "className": "flex-center gap-2 flex-wrap flex-1 min-w-0" } }, { @@ -1745,7 +1759,8 @@ "name": "Button", "props": { "type": "button", - "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" + "aria-label": "$t:sirsoft-message_bizppurio.templates.list.refresh", + "className": "btn-icon flex-shrink-0" }, "actions": [ { @@ -1756,7 +1771,17 @@ } } ], - "text": "$t:sirsoft-message_bizppurio.templates.list.refresh" + "comment": "새로고침 — 코어 관리자 목록 관례(btn-icon + 회전 아이콘)와 통일 (PO 지시 2026-08-24).", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-arrows-rotate", + "className": "text-base" + } + } + ] } ], "props": { @@ -1776,105 +1801,125 @@ "name": "Div", "children": [ { + "id": "bz_manage_list_header", "type": "basic", - "name": "Table", + "name": "Div", + "comment": "데스크톱 grid 헤더 — portable 에서는 카드가 자기 서술이므로 숨긴다 (PO 지시 2026-08-24 모바일 카드뷰).", "props": { - "className": "w-full text-left" + "className": "grid grid-cols-12 gap-2 px-4 py-2.5 bg-gray-50 dark:bg-gray-900 text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } }, "children": [ { "type": "basic", - "name": "Thead", + "name": "Div", "props": { - "className": "bg-gray-50 dark:bg-gray-900" + "className": "col-span-3" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.notification" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-2" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.owner" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.status" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.sms" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.requested_at" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.synced_at" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-3 text-right" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.actions" + } + ] + }, + { + "id": "bz_manage_list_rows", + "type": "basic", + "name": "Div", + "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length > 0}}", + "props": { + "className": "divide-y divide-gray-100 dark:divide-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "comment": "반복 노드에는 responsive 를 두지 않는다(저장소 관례 — 실측상 반복 노드 위 오버라이드는 화면 전환에 반영되지 않아, 클래스·responsive 는 내부 래퍼가 담당한다. 2026-08-24 Chrome 실측).", + "iteration": { + "source": "{{bizppurio_templates_list?.data?.templates ?? []}}", + "item_var": "bzRow", + "index_var": "bzRowIdx" }, "children": [ { "type": "basic", - "name": "Tr", - "children": [ - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.notification" - }, - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.owner" - }, - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.status" - }, - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.sms" - }, - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.requested_at" - }, - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.synced_at" - }, - { - "type": "basic", - "name": "Th", - "props": { - "className": "px-4 py-2.5 text-right text-xs font-medium text-gray-500 dark:text-gray-400" - }, - "text": "$t:sirsoft-message_bizppurio.manage.columns.actions" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Tbody", - "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length > 0}}", - "children": [ - { - "type": "basic", - "name": "Tr", - "iteration": { - "source": "{{bizppurio_templates_list?.data?.templates ?? []}}", - "item_var": "bzRow", - "index_var": "bzRowIdx" - }, + "name": "Div", + "comment": "행 — 데스크톱 grid 12열, portable 에서 카드(flex-wrap: 이름 전폭 → 배지 줄 → 메타 줄 → 액션 전폭).", "props": { - "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50" + "className": "grid grid-cols-12 gap-2 px-4 py-3 items-center hover:bg-gray-50 dark:hover:bg-gray-800/50" + }, + "responsive": { + "portable": { + "props": { + "className": "flex flex-wrap gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50" + } + } }, "children": [ { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + "className": "col-span-3" + }, + "responsive": { + "portable": { + "props": { + "className": "w-full" + } + } }, "children": [ { @@ -1892,22 +1937,45 @@ "props": { "className": "text-xs text-gray-400 dark:text-gray-500" } + }, + { + "type": "basic", + "name": "P", + "comment": "카드 메타 줄 — 데스크톱에서 별도 열인 소속·신청일·동기화일을 portable 카드에서 한 줄로 요약.", + "props": { + "className": "hidden" + }, + "responsive": { + "portable": { + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + }, + "text": "{{$t('sirsoft-message_bizppurio.manage.owner.' + (bzRow.definition_extension_type ?? 'core'))}} {{(bzRow.definition_extension_type ?? 'core') === 'core' ? '' : ('(' + (bzRow.definition_extension_identifier ?? '') + ')')}} · {{$t('sirsoft-message_bizppurio.manage.columns.requested_at')}} {{(bzRow.requested_at ?? '').slice(0, 10) || '-'}} · {{$t('sirsoft-message_bizppurio.manage.columns.synced_at')}} {{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}" } ] }, { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + "className": "col-span-2 text-sm text-gray-700 dark:text-gray-200" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } }, "text": "{{$t('sirsoft-message_bizppurio.manage.owner.' + (bzRow.definition_extension_type ?? 'core'))}} {{(bzRow.definition_extension_type ?? 'core') === 'core' ? '' : ('(' + (bzRow.definition_extension_identifier ?? '') + ')')}}" }, { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + "className": "col-span-1" }, "children": [ { @@ -1922,33 +1990,64 @@ }, { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + "className": "col-span-1" }, - "text": "{{bzRow.sms_only ? $t('sirsoft-message_bizppurio.manage.sms_only') : (bzRow.fallback_sms_enabled ? $t('sirsoft-message_bizppurio.manage.sms_fallback') : '-')}}" + "comment": "SMS 발송 모드 배지 — 단독/대체(초록), 미사용(회색). 행 하단 상태 배지와 같은 규칙(PO 지시 2026-08-23).", + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ' + ((bzRow.sms_only || bzRow.fallback_sms_enabled) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "text": "{{bzRow.sms_only ? $t('sirsoft-message_bizppurio.manage.sms_only') : (bzRow.fallback_sms_enabled ? $t('sirsoft-message_bizppurio.manage.sms_fallback') : $t('sirsoft-message_bizppurio.template.row.off'))}}" + } + ] }, { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + "className": "col-span-1 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } }, "text": "{{(bzRow.requested_at ?? '').slice(0, 10) || '-'}}" }, { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + "className": "col-span-1 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } }, "text": "{{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}" }, { "type": "basic", - "name": "Td", + "name": "Div", "props": { - "className": "px-4 py-3" + "className": "col-span-3" + }, + "responsive": { + "portable": { + "props": { + "className": "w-full pt-1" + } + } }, "children": [ { @@ -2072,7 +2171,8 @@ "description": "", "imageUrl": "" } - } + }, + "request_comment": "" } } }, @@ -2540,7 +2640,7 @@ } ], "props": { - "className": "admin-card overflow-x-auto p-0" + "className": "admin-card p-0 overflow-hidden" } }, { @@ -3421,7 +3521,7 @@ } ], "props": { - "className": "space-y-1.5" + "className": "space-y-2" }, "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" }, @@ -3654,7 +3754,7 @@ "index_var": "buttonsIdx" }, "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" }, "children": [ { @@ -3866,7 +3966,7 @@ } ], "props": { - "className": "space-y-1.5" + "className": "space-y-2" } }, { @@ -3919,7 +4019,7 @@ "index_var": "quickRepliesIdx" }, "props": { - "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" }, "children": [ { @@ -4131,8 +4231,71 @@ } ], "props": { - "className": "space-y-1.5" + "className": "space-y-2" } + }, + { + "id": "bz_manage_request_comment", + "type": "basic", + "name": "Div", + "comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(PO 결정 2026-08-23 §18.7). 신청 시에만 전송되며 행에 저장되지 않는다.", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.request_comment" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.request_comment ?? '').length}}/500", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_request_comment", + "rows": 3, + "maxLength": 500, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.request_comment_placeholder", + "value": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.request_comment": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.request_comment_hint" + } + ] } ] }, @@ -4354,7 +4517,10 @@ "auth_required": true, "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", "params": { - "method": "POST" + "method": "POST", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } }, "onSuccess": [ { @@ -4452,7 +4618,10 @@ "auth_required": true, "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", "params": { - "method": "POST" + "method": "POST", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } }, "onSuccess": [ { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php index c114a620..85cf056b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php @@ -12,6 +12,7 @@ use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioTemplateStateException; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateImageRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateListRequest; +use Plugins\Sirsoft\MessageBizppurio\Http\Requests\RequestBizppurioInspectionRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreBizppurioTemplateRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioDeliveryRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioTemplateRequest; @@ -164,10 +165,11 @@ class BizppurioTemplateController extends AdminBaseController /** * 검수를 신청합니다 (채번 → kapi add/update → request → status=requested). * + * @param RequestBizppurioInspectionRequest $request 검수자 전달 의견(comment, 선택 ≤500) * @param int $id 템플릿 PK * @return JsonResponse data.template 에 갱신된 행 */ - public function requestInspection(int $id): JsonResponse + public function requestInspection(RequestBizppurioInspectionRequest $request, int $id): JsonResponse { $template = $this->service->find($id); if ($template === null) { @@ -176,7 +178,7 @@ class BizppurioTemplateController extends AdminBaseController return $this->guardLifecycle(fn () => ResponseHelper::success( 'sirsoft-message_bizppurio::messages.template.requested', - ['template' => $this->detailRow($this->service->requestInspection($template))], + ['template' => $this->detailRow($this->service->requestInspection($template, $request->comment()))], )); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php new file mode 100644 index 00000000..a44d8b23 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php @@ -0,0 +1,63 @@ +> + */ + public function rules(): array + { + return [ + 'comment' => ['sometimes', 'nullable', 'string', 'max:500'], + ]; + } + + /** + * 검수자 전달 의견을 반환합니다 (앞뒤 공백 제거, 비어 있으면 null). + * + * @return string|null 전달 의견 + */ + public function comment(): ?string + { + $comment = trim((string) ($this->validated()['comment'] ?? '')); + + return $comment === '' ? null : $comment; + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + return [ + 'comment' => __('sirsoft-message_bizppurio::messages.validation.attributes.comment'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index e2f49828..bcd678a8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -270,6 +270,11 @@ class RegisterNotificationChannelsListener implements HookListenerInterface * - sms.hidden_tab: 탭만 숨긴다(채널 토글 카드·발송 축은 그대로). * - alimtalk.tab_channels: 이 목록 중 하나라도 활성 저장이면 탭을 노출한다. * - alimtalk.tab_label_key: 탭 라벨용 프론트 lang 키($t 해석 — 카드 라벨(name)과 분리). + * - alimtalk.hidden_template_editor: 코어 알림 템플릿 [편집] 모달의 제목/본문 입력·미리보기· + * 코어 [저장]을 숨긴다(PO 결정 2026-08-23). 알림톡 본문은 카카오 규격(#{var}, 유형별 필드)이라 + * 코어 본문과 다른 편집기가 필요하고, 그 편집기·통합 저장은 플러그인이 같은 모달의 + * extension_point(notification_template_form_sections / _footer_actions)에 주입한다. + * 코어 채널 템플릿 행(제목/본문)은 발송에 쓰이지 않으므로 숨겨도 동작 손실이 없다. * 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다 * (is_test_mode 와 동일 경로). * @@ -298,6 +303,7 @@ class RegisterNotificationChannelsListener implements HookListenerInterface 'allow_guest' => true, 'tab_channels' => ['sms', 'alimtalk'], 'tab_label_key' => self::PLUGIN_IDENTIFIER.'.channels.bizppurio_tab', + 'hidden_template_editor' => true, ], ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php index 01adcf9e..62ebab88 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php @@ -16,14 +16,20 @@ use App\Contracts\Extension\HookListenerInterface; * (NotificationSyncHelper::syncTemplate), cleanupStaleTemplates 의 definedChannels 에 우리 * 채널이 포함되므로 재시딩·업데이트에도 삭제되지 않는다. 코어·모듈 파일은 수정하지 않는다. * - * 구독 훅(3영역, 정의 배열을 넘기는 필터): + * 구독 훅(정의 배열을 넘기는 필터 4종): * - seed.notifications.translations (코어 — config/core.php) * - seed.sirsoft-board.notifications.translations (게시판) * - seed.sirsoft-ecommerce.notifications.translations (이커머스) + * - core.notification.filter_default_definitions (코어 [기본값 복원] 경로 — + * NotificationTemplateService::getDefaultTemplateData). 시딩 3훅과 같은 증강을 적용해야 + * 우리가 시드한 sms·alimtalk 행의 [기본값 복원]이 "기본 템플릿 데이터를 찾을 수 없습니다" 로 + * 끝나지 않는다 — 시드 출처와 복원 출처가 같은 함수여야 한다(#597 §18.7 C2 실측 결함). + * 게시판·이커머스가 자기 정의를 이 필터에 보태는 priority 20 뒤(50)에 돌아야 모듈 정의도 + * 증강된다. * * 배열 형태 차이: * - 코어: 연관 배열 `['welcome' => [...], ...]` (키가 type) - * - 모듈: 순차 배열 `[['type' => 'order_confirmed', ...], ...]` + * - 모듈·기본값 복원 경로: 순차 배열 `[['type' => 'order_confirmed', ...], ...]` * 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다. * * 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은 @@ -54,6 +60,7 @@ class SeedChannelTemplatesListener implements HookListenerInterface 'seed.notifications.translations', 'seed.sirsoft-board.notifications.translations', 'seed.sirsoft-ecommerce.notifications.translations', + 'core.notification.filter_default_definitions', ] as $hook) { $hooks[$hook] = [ 'method' => 'augment', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php index 92a2ae61..2f749d09 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php @@ -193,14 +193,17 @@ class BizppurioTemplateService * - template_code 가 없으면(최초) 자체 채번 후 kapi add, 있으면(재신청) kapi update. * add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음 * 시도에서 다시 채번부터 진행된다. + * - 검수자 전달 의견(comment)은 kapi request 에만 싣고 행에 저장하지 않는다 — 카카오 심사 + * 가이드가 요구하는 변수 '예시 텍스트' 를 전할 유일한 통로다(PO 결정 2026-08-23, §18.7). * * @param BizppurioTemplate $template 대상 행 + * @param string|null $comment 검수자 전달 의견 (≤500, 비어 있으면 미전달) * @return BizppurioTemplate 갱신된 행 (status=requested) * * @throws BizppurioTemplateStateException 신청 불가 상태·content 미작성 * @throws BizppurioApiException kapi 실패 (사유 원문 보존) */ - public function requestInspection(BizppurioTemplate $template): BizppurioTemplate + public function requestInspection(BizppurioTemplate $template, ?string $comment = null): BizppurioTemplate { if (! is_array($template->content) || $template->content === []) { throw new BizppurioTemplateStateException( @@ -248,7 +251,11 @@ class BizppurioTemplateService $this->assertSuccess($this->kakao->updateTemplate($payload)); } - $this->assertSuccess($this->kakao->requestInspection($senderKey, (string) $template->template_code)); + $this->assertSuccess($this->kakao->requestInspection( + $senderKey, + (string) $template->template_code, + trim((string) $comment), + )); } catch (\Throwable $e) { // 선점 해제: kapi 미완료 상태로 requested 가 남지 않도록 원래 상태로 복귀. $this->templates->update($template, ['status' => $originalStatus->value]); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php new file mode 100644 index 00000000..ded621b0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php @@ -0,0 +1,124 @@ +fail('config/core.php welcome 정의에 database 채널 template 이 없다.'); + } + + /** welcome 정의 + 운영자가 손본(is_default=false) alimtalk 채널 행. */ + private function modifiedAlimtalkTemplate(): NotificationTemplate + { + $definition = NotificationDefinition::create([ + 'type' => 'welcome', + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '회원가입 환영', 'en' => 'Welcome'], + 'variables' => [['key' => 'name', 'description' => '이름']], + 'channels' => ['mail', 'database', 'alimtalk'], + 'hooks' => ['core.auth.after_register'], + 'is_active' => true, + ]); + + return NotificationTemplate::create([ + 'definition_id' => $definition->id, + 'channel' => 'alimtalk', + 'subject' => ['ko' => '바뀐 제목'], + 'body' => ['ko' => '바뀐 본문 {name}'], + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'is_active' => true, + 'is_default' => false, + ]); + } + + /** core.settings.update 권한을 가진 관리자의 인증 헤더. */ + private function adminHeaders(): array + { + $user = User::factory()->create(); + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + $permission = Permission::firstOrCreate( + ['identifier' => 'core.settings.update'], + ['name' => json_encode(['ko' => '설정 수정', 'en' => 'Settings update']), 'type' => 'admin'] + ); + $adminRole->permissions()->syncWithoutDetaching([$permission->id]); + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$user->fresh()->createToken('test')->plainTextToken, + ]; + } + + /** + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_리스너_등록_후_코어_기본값_조회가_시드와_같은_alimtalk_기본값을_돌려준다(): void + { + $this->registerListener(); + + $data = app(NotificationTemplateService::class)->getDefaultTemplateData('welcome', 'alimtalk'); + + $this->assertNotEmpty($data, '복원 경로가 alimtalk 기본값을 찾아야 한다(시드만 하고 복원에 빠지면 빈 배열).'); + $this->assertSame([['type' => 'trigger_user']], $data['recipients']); + $this->assertSame($this->welcomeDatabaseBodyKo(), $data['body']['ko'], '시딩과 같은 database 평문 body 가 복원값이다.'); + } + + /** + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_기본값_복원_엔드포인트가_시드된_alimtalk_행을_복원한다(): void + { + $this->registerListener(); + $template = $this->modifiedAlimtalkTemplate(); + + $this->postJson('/api/admin/notification-templates/'.$template->id.'/reset', [], $this->adminHeaders()) + ->assertOk(); + + $fresh = $template->fresh(); + $this->assertTrue((bool) $fresh->is_default, '복원 후 "사용자 수정" 표시가 사라져야 한다.'); + $this->assertSame([['type' => 'trigger_user']], $fresh->recipients); + $this->assertSame($this->welcomeDatabaseBodyKo(), $fresh->body['ko']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php index 361fe238..70db9919 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php @@ -609,6 +609,46 @@ class BizppurioTemplateControllerTest extends PluginTestCase Http::assertSentCount(3); // codeCheck → add → request } + /** + * 검수자 전달 의견(comment)은 선택 입력이며 kapi request 로만 전달된다 (#597 §18.7 — PO 결정 2026-08-23). + * + * 500자 초과는 FormRequest 가 kapi 호출 전에 422 로 끊고(행 상태 불변), 유효한 의견은 + * `template/request` 의 comment 로 원문 그대로 실린다. + * + * @effects inspection_request_carries_reviewer_comment + */ + public function test_검수_신청의_comment는_kapi_request에_실리고_500자를_넘으면_422다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'draft', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE.'/'.$template->id.'/request', ['comment' => str_repeat('가', 501)], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors('comment'); + $this->assertSame(BizppurioTemplateStatus::Draft, $template->fresh()->status, '검증 실패는 kapi 호출 전에 끝나야 한다(선점 없음).'); + Http::assertNothingSent(); + + $this->postJson(self::BASE.'/'.$template->id.'/request', ['comment' => '변수 예시: #{name}=홍길동'], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'requested'); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/request') { + return false; + } + $this->assertSame('변수 예시: #{name}=홍길동', $request->data()['comment'] ?? null); + + return true; + }); + } + /** * @scenario content_variant=ad_image, transition=draft_to_requested * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts index edbda223..b789f869 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts @@ -1,14 +1,18 @@ /** * E2E 정밀 점검 매트릭스: 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §6.3) * - * @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e + * @scenario bizppurio_tab_integration_e2e, bizppurio_edit_modal_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active, - * row_footer_shows_status_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type, + * row_footer_shows_status_summary_only, approval_badge_two_tier, edit_modal_hosts_alimtalk_and_sms_sections, + * core_modal_hides_editor_for_hidden_template_editor_channel, unified_save_chain_alimtalk_then_core_recipients, + * compose_modal_switches_conditional_fields_by_type, * manage_screen_lists_db_rows_with_merge_query_round_trip, sms_modal_edits_body_per_locale_tab, * upload_in_progress_locks_save_buttons_not_cancel, manage_row_actions_match_row_footer_visibility, - * sms_body_presence_and_preview_resolved_by_server + * sms_body_presence_and_preview_resolved_by_server, inspection_request_carries_reviewer_comment * * 계획서 §6.3 이 요구한 7대 축(T1~T7) + 선택 축(T8~T10) + 회귀 축(R1~R6)을 브라우저에서 실측한다. + * PO 결정(2026-08-23, 계획서 §18)으로 알림톡 작성·SMS 본문 편집은 행 하단 버튼/별도 모달이 아니라 + * 코어 [편집] 모달 안의 섹션으로 통합됐다 — 모달 진입은 언제나 행의 [편집] 이고, 저장은 하단 [저장] 하나다. * 이 매트릭스는 라운드 1~4 동안 "수행 증거 없음" 으로 남아 있었고, 그 근본 원인은 플러그인 * package.json 에 실행 진입점(test:e2e)이 없었다는 것이다 — 라운드 5 에서 진입점을 만들고 * 매트릭스를 spec 으로 고정해 다음 라운드부터는 명령 하나로 재실측된다. @@ -71,7 +75,7 @@ function dialog(page: Page) { return page.getByRole('dialog'); } -/** 모달 안의 저장 버튼(신규/수정 공통 — if 로 하나만 렌더된다) */ +/** 편집 모달 푸터의 통합 [저장](코어 [저장]은 hidden_template_editor 게이트로 숨는다 — 하나만 렌더된다) */ function modalSave(page: Page) { return dialog(page).getByRole('button', { name: '저장', exact: true }); } @@ -94,12 +98,18 @@ async function seedTemplate(page: Page, wanted = 'welcome'): Promise { Accept: 'application/json', Authorization: `Bearer ${token}`, }; + // 카테고리 코드는 kapi 실조회 목록에 있는 값이어야 한다 — 편집 모달의 카테고리 셀렉트는 + // 목록에 없는 값을 빈 값으로 정규화하므로(실측: 더미 코드 '001001' 이 실자격증명 환경에서 + // 비워져 저장이 422 "카테고리 필수"), 목록이 있으면 첫 코드를, 없으면(자격증명 미설정) 더미를 쓴다. + const catRes = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories', { headers }); + const catJson = catRes.ok ? await catRes.json() : null; + const firstCategory = catJson?.data?.categories?.[0]?.code; const content = { templateName: '매트릭스 측정용', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '초기 본문 #{site_name}', - categoryCode: '001001', + categoryCode: typeof firstCategory === 'string' && firstCategory ? firstCategory : '001001', }; const listRes = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers }); @@ -110,6 +120,19 @@ async function seedTemplate(page: Page, wanted = 'welcome'): Promise { const existing = rows.find((r: { notification_type?: string; status?: string }) => r.notification_type === wantedType && r.status === 'draft'); + // 유효 자격증명 환경에서는 10c 의 [저장 후 검수 신청]이 실제로 성립해 행이 requested 로 남는다 — + // 다음 실행이 같은 행을 다시 쓰려면 신청을 취소해 draft 로 되돌린다(kapi cancel_request 실호출). + const requested = rows.find((r: { notification_type?: string; status?: string }) => + r.notification_type === wantedType && r.status === 'requested'); + if (!existing && requested) { + await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${requested.id}/cancel-request`, { method: 'POST', headers }); + await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${requested.id}`, { + method: 'PUT', headers, body: JSON.stringify({ content }), + }); + + return requested.notification_type as string; + } + if (existing) { await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${existing.id}`, { method: 'PUT', headers, body: JSON.stringify({ content }), @@ -128,22 +151,35 @@ async function seedTemplate(page: Page, wanted = 'welcome'): Promise { } /** - * 시드된 행의 수정 모달을 연다. + * 알림 행의 코어 [편집] 버튼(그 행 카드 안의 것)을 찾는다. + * + * 행 카드는 유형 코드(welcome 등)를 font-mono 텍스트로 그린다 — 그 텍스트에서 가장 가까운 + * "[편집] 버튼을 품은 조상" 이 행 카드다. 목록 순서 의존(first())을 없애 테스트 간 간섭을 막는다. + */ +function rowEditButton(page: Page, type: string) { + return page.getByText(type, { exact: true }) + .locator('xpath=ancestor::*[.//button[normalize-space()="편집"]][1]') + .getByRole('button', { name: '편집', exact: true }) + .first(); +} + +/** + * 알림 행의 코어 [편집] 모달을 열고 플러그인 섹션 루트(알림톡 템플릿 + 문자 SMS)를 돌려준다. * * @param page 페이지 + * @param type 알림 유형 코드(없으면 첫 행) */ -async function openEditModal(page: Page) { +async function openEditModal(page: Page, type?: string) { await page.goto(SETTINGS_URL); await page.waitForLoadState('networkidle', { timeout: 30_000 }); - // 행 컨테이너 id 는 모든 행이 공유하므로 유형으로 특정할 수 없다. 목록 순서는 재조회 - // 후에도 동일하므로, 같은 테스트 안에서 first() 는 항상 같은 행을 연다 — 저장→새로고침→ - // 재오픈 왕복 비교에는 그것으로 충분하다. (테스트 간 간섭은 알림 유형 분리로 막는다.) - const edit = page.getByRole('button', { name: '수정', exact: true }).first(); + const edit = type ? rowEditButton(page, type) : page.getByRole('button', { name: '편집', exact: true }).first(); await expect(edit).toBeVisible({ timeout: 20_000 }); await edit.click(); - const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); + const modal = dialog(page).locator('#bizppurio_tpl_sections'); await expect(modal).toBeVisible({ timeout: 15_000 }); + // 작성 폼은 draft/rejected 에서만 열린다 — 시드 행은 draft 이므로 본문 textarea 가 보여야 한다 + await expect(modal.locator('textarea[name="bz_template_content"]')).toBeVisible({ timeout: 15_000 }); return modal; } @@ -196,6 +232,13 @@ test.describe('T1 진입/표시', () => { evidence('1b 행 하단 블록 수', rowCount); expect(rowCount).toBeGreaterThan(0); + // 행 하단은 상태 요약만 — 버튼 0 + 승인 2단 배지(승인됨 | 미승인 (세부)) + const footerButtons = await rows.locator('button').count(); + const badges = (await rows.allInnerTexts()).map((t) => (t.match(/승인됨|미승인\s*\([^)]+\)/) ?? [''])[0]); + evidence('1b 행 하단 버튼 수 / 배지', { footerButtons, badges }); + expect(footerButtons).toBe(0); + expect(badges.every((b) => b.length > 0)).toBe(true); + const content = await page.locator('#notif_channel_content').innerText(); const rawKeys = (content.match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length; evidence('1b i18n 원문 노출 수', rawKeys); @@ -255,9 +298,23 @@ test.describe('T2·T3 입력', () => { await authenticatePage(page, messagingManageToken); await openBizppurioTab(page); - await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); - const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); - await expect(modal).toBeVisible({ timeout: 15_000 }); + await seedTemplate(page, 'welcome'); + const modal = await openEditModal(page, 'welcome'); + + // 2c: 코어 제목/본문 입력은 숨고(hidden_template_editor) 플러그인 섹션 2종이 그 자리를 채운다 + const coreHidden = { + subjectInputs: await dialog(page).locator('#template_subject_input').count(), + bodyInputs: await dialog(page).locator('#template_body_input').count(), + previewButtons: await dialog(page).getByRole('button', { name: '미리보기', exact: true }).count(), + alimtalkSection: await dialog(page).locator('#bizppurio_tpl_alimtalk_section').count(), + smsSection: await dialog(page).locator('#bizppurio_tpl_sms_section').count(), + saveButtons: await dialog(page).getByRole('button', { name: '저장', exact: true }).count(), + }; + evidence('2c 코어 입력 숨김 / 섹션 2종 / 저장 버튼 수', coreHidden); + expect(coreHidden.subjectInputs + coreHidden.bodyInputs + coreHidden.previewButtons).toBe(0); + expect(coreHidden.alimtalkSection).toBe(1); + expect(coreHidden.smsSection).toBe(1); + expect(coreHidden.saveButtons, '저장은 하나여야 한다(코어 저장은 숨김)').toBe(1); const name = await modal.locator('input[name="bz_template_name"]').inputValue(); evidence('2a 템플릿명 프리필', name); @@ -303,9 +360,8 @@ test.describe('T2·T3 입력', () => { test('3a 버튼 3회 추가 + 1회 삭제 → 카운트 정합, 5개 도달 시 추가 잠김', async ({ page, messagingManageToken }) => { await authenticatePage(page, messagingManageToken); await openBizppurioTab(page); - await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); - const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); - await expect(modal).toBeVisible({ timeout: 15_000 }); + await seedTemplate(page, 'welcome'); + const modal = await openEditModal(page, 'welcome'); const addBtn = modal.getByRole('button', { name: '버튼 추가' }); const rowCount = () => modal.getByPlaceholder('버튼명', { exact: false }).count(); @@ -360,7 +416,7 @@ test.describe('T4 제출/응답 + T10 경계', () => { const results: Record = {}; const typed: Record = {}; for (const len of [999, 1000, 1001]) { - const modal = await openEditModal(page); + const modal = await openEditModal(page, 'reset_password'); const ta = modal.locator('textarea[name="bz_template_content"]'); await ta.fill('가'.repeat(len)); @@ -387,10 +443,13 @@ test.describe('T4 제출/응답 + T10 경계', () => { }); test('10c [저장] 더블클릭 시 요청이 1회만 나간다', async ({ page, messagingManageToken }) => { + // 유효 자격증명 환경에서는 kapi 실호출(add·request·cancel_request·delete) 4회 + 고정 대기 7초가 + // 한 테스트에 들어가므로 기본 30초를 넘긴다(실측 31.3초). 판정 축이 시간은 아니다. + test.setTimeout(90_000); await authenticatePage(page, messagingManageToken); await openBizppurioTab(page); await seedTemplate(page, 'password_changed'); - const modal = await openEditModal(page); + const modal = await openEditModal(page, 'password_changed'); await modal.locator('textarea[name="bz_template_content"]').fill('중복 제출 테스트 #{site_name}'); let saves = 0; @@ -418,13 +477,40 @@ test.describe('T4 제출/응답 + T10 경계', () => { // 판정 축은 "요청 수" 가 아니라 **중복 신청 0** 이다. 클라이언트 if 가드는 // setState 반영 전에 두 번째 click 이 디스패치되면 뚫린다(실측). 실제 방어는 // 서버의 원자 선점(claimForInspection)이며, 두 번째 신청은 422 로 거부된다. - const modal2 = await openEditModal(page); + const modal2 = await openEditModal(page, 'password_changed'); await modal2.locator('textarea[name="bz_template_content"]').fill('중복 신청 테스트 #{site_name}'); + // 검수자 전달 의견(PO 결정 2026-08-23 §18.7) — 신청 요청 본문의 comment 로 실려야 한다. + const REVIEW_COMMENT = '변수 예시: #{site_name}=G7 데모 사이트'; + await modal2.locator('textarea[name="bz_request_comment"]').fill(REVIEW_COMMENT); + const requestBodies: string[] = []; + page.on('request', (r) => { + if (r.method() === 'POST' && /\/admin\/templates\/\d+\/request$/.test(r.url())) requestBodies.push(r.postData() ?? ''); + }); await dialog(page).getByRole('button', { name: '저장 후 검수 신청', exact: true }) .first().click({ clickCount: 2, delay: 30 }); await page.waitForTimeout(4_000); evidence('10c 저장후신청 더블클릭', { 신청요청수: requests, 신청성공수: okRequests }); expect(okRequests, '중복 검수 신청이 성립하면 카카오측 중복 등록이 된다').toBeLessThanOrEqual(1); + evidence('10c 신청 comment 전달', { 본문수: requestBodies.length, comment일치: requestBodies.filter((b) => b.includes(REVIEW_COMMENT)).length }); + expect(requestBodies.length, '검수 신청 요청이 관측되지 않았다').toBeGreaterThan(0); + for (const body of requestBodies) expect(JSON.parse(body).comment, '신청 본문의 comment').toBe(REVIEW_COMMENT); + + // 정리: 유효 자격증명 환경에서는 신청이 실제로 성립한다 — 신청 취소 후 행을 지워 + // 카카오측에 측정용 템플릿이 남지 않게 한다(자격증명 미설정 환경에서는 둘 다 no-op 에 가깝다). + const cleanup = await page.evaluate(async () => { + const headers = { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` }; + const list = await (await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers })).json(); + const row = (list?.data?.templates ?? []).find((r: { notification_type?: string }) => r.notification_type === 'password_changed'); + if (!row) return { row: null }; + let cancel: number | null = null; + if (row.status === 'requested') { + cancel = (await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${row.id}/cancel-request`, { method: 'POST', headers })).status; + } + const del = (await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${row.id}`, { method: 'DELETE', headers })).status; + + return { row: row.id, status: row.status, cancel, del }; + }); + evidence('10c 정리(신청 취소 + 삭제)', cleanup); }); }); @@ -441,7 +527,7 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => { page.evaluate(() => ((window as any).G7Core?.state?.get?.()?.bz_tpl_modal?.id ?? (window as any).G7Core?.state?.getGlobal?.()?.bz_tpl_modal?.id) ?? null); const body = `영속성 확인 ${'가'.repeat(5)} #{site_name} !@#$%^&*() 🎉`; - let modal = await openEditModal(page); + let modal = await openEditModal(page, 'welcome'); const targetId = await openedId(); evidence('6a 측정 대상 행 id', targetId); expect(targetId, '수정 모달이 연 행의 id 를 읽지 못했다').not.toBeNull(); @@ -450,7 +536,7 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => { const beforeSave = await page.evaluate(() => { const g = (window as any).G7Core?.state?.get?.() ?? {}; const m = g.bz_tpl_modal ?? {}; - return { id: m.id ?? null, len: (m.content?.templateContent ?? '').length, modals: document.querySelectorAll('[id^="bizppurio_tpl_modal_body"]').length }; + return { id: m.id ?? null, len: (m.content?.templateContent ?? '').length, modals: document.querySelectorAll('#bizppurio_tpl_sections').length }; }); evidence('6a 저장 직전 전역 상태', beforeSave); @@ -474,7 +560,7 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => { expect(stored, '이모지·특수문자·#{var} 가 섞인 본문이 그대로 저장되어야 한다').toBe(body); // ② 새로고침 후 화면이 그 값을 복원하는가 (영속성 UI 축) - modal = await openEditModal(page); + modal = await openEditModal(page, 'welcome'); const reopenedId = await openedId(); const restored = await modal.locator('textarea[name="bz_template_content"]').inputValue(); const expected = reopenedId === targetId @@ -509,15 +595,16 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => { evidence('7a 탭 왕복 전/후 쿼리', { before, after }); expect(after).toContain('channel=alimtalk'); - // 7b: 모달에 값을 넣고 저장 없이 닫은 뒤 다시 열면 이전 입력이 남지 않는다 - const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); - await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + // 7b: 모달에 값을 넣고 저장 없이 닫은 뒤 다시 열면 이전 입력이 남지 않는다(마운트 시 재시딩) + await seedTemplate(page, 'welcome'); + const modal = dialog(page).locator('#bizppurio_tpl_sections'); + await rowEditButton(page, 'welcome').click(); await expect(modal).toBeVisible({ timeout: 15_000 }); await modal.locator('textarea[name="bz_template_content"]').fill('이월되면 안 되는 값'); await dialog(page).getByRole('button', { name: '취소', exact: true }).first().click(); await expect(modal).toBeHidden({ timeout: 10_000 }); - await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + await rowEditButton(page, 'welcome').click(); await expect(modal).toBeVisible({ timeout: 15_000 }); const reopened = await modal.locator('textarea[name="bz_template_content"]').inputValue(); evidence('7b 재오픈 시 이전 값 잔존', reopened.includes('이월되면 안 되는 값')); @@ -559,6 +646,26 @@ test.describe('T8 3면 패리티·다크·로케일', () => { if (visible && tabs.length > 0) { expect(tabs.join('|')).not.toContain('알림톡'); } + + // 8c: 그 면의 코어 [편집] 모달에도 섹션 2종이 주입되고 코어 본문 입력은 숨는다(3면 공유 1본) + if (rows > 0) { + await page.getByRole('button', { name: '편집', exact: true }).first().click(); + const sections = dialog(page).locator('#bizppurio_tpl_sections'); + await expect(sections).toBeVisible({ timeout: 15_000 }); + const prefix = url.includes('/boards/') ? 'board_' : 'ecommerce_'; + const measured = { + alimtalk: await dialog(page).locator('#bizppurio_tpl_alimtalk_section').count(), + sms: await dialog(page).locator('#bizppurio_tpl_sms_section').count(), + coreBody: await dialog(page).locator(`#${prefix}template_body_input`).count(), + saveButtons: await dialog(page).getByRole('button', { name: '저장', exact: true }).count(), + }; + evidence(`8c ${face} 편집 모달 섹션`, measured); + expect(measured.alimtalk).toBe(1); + expect(measured.sms).toBe(1); + expect(measured.coreBody).toBe(0); + expect(measured.saveButtons).toBe(1); + await dialog(page).getByRole('button', { name: '취소', exact: true }).first().click(); + } expect(errors).toHaveLength(0); }); } @@ -616,9 +723,8 @@ test.describe('라운드5 신규 축 — 업로드 가드·관리 라벨·SMS test('U1 업로드 진행 중 저장 계열 버튼이 잠기고 취소는 열려 있다', async ({ page, messagingManageToken }) => { await authenticatePage(page, messagingManageToken); await openBizppurioTab(page); - await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); - const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); - await expect(modal).toBeVisible({ timeout: 15_000 }); + await seedTemplate(page, 'welcome'); + const modal = await openEditModal(page, 'welcome'); await modal.getByRole('button', { name: '이미지', exact: true }).click(); // 업로드 응답을 붙잡아 in-flight 상태를 만든다(네트워크 지연 재현). diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts index 81da85d3..574784eb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts @@ -3,7 +3,8 @@ * * @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions, - * compose_modal_switches_conditional_fields_by_type, manage_screen_lists_db_rows_with_merge_query_round_trip + * compose_modal_switches_conditional_fields_by_type, manage_screen_lists_db_rows_with_merge_query_round_trip, + * manage_list_mobile_card_view_and_unified_toolbar * * 검증 대상(브라우저에서만 잡히는 축): * 1. 코어 알림 설정 서브탭 통합 — '비즈뿌리오' 단일 탭 노출, 문자·알림톡 개별 탭 부재 @@ -66,8 +67,8 @@ test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => { }); // @scenario bizppurio_tab_integration_e2e - // @effects row_footer_shows_status_badge_and_lifecycle_actions - test('알림 행 하단에 알림톡 라이프사이클 줄과 SMS 설정 줄이 렌더된다', async ({ page, messagingManageToken }) => { + // @effects row_footer_shows_status_summary_only, approval_badge_two_tier + test('알림 행 하단에 알림톡 승인 2단 배지와 SMS 설정 요약만 렌더된다(버튼 0)', async ({ page, messagingManageToken }) => { await authenticatePage(page, messagingManageToken); await page.goto(SETTINGS_URL); await page.waitForLoadState('networkidle', { timeout: 30_000 }); @@ -75,11 +76,16 @@ test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => { const rows = page.locator('[id^="bizppurio_row_lifecycle"]'); await expect(rows.first()).toBeVisible({ timeout: 30_000 }); - // 알림톡 라벨 + 상태 배지(미작성/작성중/…) + SMS 체크박스 라벨 + // 알림톡 라벨 + 승인 2단 배지(승인됨 | 미승인 (세부)) + SMS 설정 요약 — 버튼은 없다(조작은 [편집] 모달) const firstRow = rows.first(); await expect(firstRow.getByText('알림톡', { exact: false }).first()).toBeVisible(); + await expect(firstRow.getByText(/승인됨|미승인/).first()).toBeVisible(); await expect(firstRow.getByText('대체 SMS', { exact: false }).first()).toBeVisible(); await expect(firstRow.getByText('SMS 단독', { exact: false }).first()).toBeVisible(); + // SMS 사용 여부는 문구("대체 SMS: 미사용")가 아니라 배지(사용/미사용)로 렌더된다 (PO 지시 2026-08-23) + expect(await firstRow.locator('span.rounded', { hasText: /^(사용|미사용)$/ }).count()).toBeGreaterThanOrEqual(2); + expect(await firstRow.getByText(/대체 SMS:|SMS 단독:/).count()).toBe(0); + expect(await rows.locator('button').count()).toBe(0); // i18n 원문 키 노출 0 (탭 콘텐츠 영역 전체) const content = await page.locator('#notif_channel_content').innerText(); @@ -87,24 +93,41 @@ test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => { expect(content).not.toContain('sirsoft-message_bizppurio.'); }); - // @scenario bizppurio_compose_modal_e2e - // @effects compose_modal_switches_conditional_fields_by_type - test('작성 모달의 강조 유형 전환 시 조건부 필드가 등장·소멸한다', async ({ page, messagingManageToken }) => { + // @scenario bizppurio_edit_modal_e2e, bizppurio_compose_modal_e2e + // @effects edit_modal_hosts_alimtalk_and_sms_sections, core_modal_hides_editor_for_hidden_template_editor_channel, compose_modal_switches_conditional_fields_by_type + test('[편집] 모달의 알림톡 섹션에서 강조 유형 전환 시 조건부 필드가 등장·소멸한다', async ({ page, messagingManageToken }) => { await authenticatePage(page, messagingManageToken); await page.goto(SETTINGS_URL); await page.waitForLoadState('networkidle', { timeout: 30_000 }); - // 미작성 행의 [알림톡 템플릿 작성] → 모달 오픈 - const composeButton = page.getByRole('button', { name: '알림톡 템플릿 작성' }).first(); - await expect(composeButton).toBeVisible({ timeout: 30_000 }); - await composeButton.click(); + // 행의 코어 [편집] → 같은 모달 안에 알림톡 템플릿·문자(SMS) 섹션이 주입된다(작성 폼은 draft/rejected/미작성 행에서 열린다) + const editButton = page.getByRole('button', { name: '편집', exact: true }).first(); + await expect(editButton).toBeVisible({ timeout: 30_000 }); + await editButton.click(); - const modal = page.locator('[id^="bizppurio_tpl_modal_body"]').first(); + const dialog = page.getByRole('dialog'); + const modal = dialog.locator('#bizppurio_tpl_sections'); await expect(modal).toBeVisible({ timeout: 15_000 }); + await expect(dialog.locator('#bizppurio_tpl_sms_section')).toBeVisible(); + // 코어 제목/본문 입력은 hidden_template_editor 게이트로 숨는다 — 본문 입력은 알림톡 섹션 하나뿐 + await expect(dialog.locator('#template_body_input')).toHaveCount(0); + await expect(dialog.getByRole('button', { name: '저장', exact: true })).toHaveCount(1); // 본문 textarea 는 상시 await expect(modal.locator('textarea[name="bz_template_content"]')).toBeVisible(); + // 검수자 전달 의견 입력란은 바로연결 블록 아래 한 줄을 통째로 차지한다 + // (회귀: 라벨+[바로연결 추가] flex 행 안에 끼어 라벨이 세로로 찌그러짐 — PO 화면 지적 2026-08-23) + const commentBox = modal.locator('textarea[name="bz_request_comment"]'); + await expect(commentBox).toBeVisible(); + const [quickAddBox, commentBounds, bodyBox] = await Promise.all([ + modal.getByRole('button', { name: '바로연결 추가' }).boundingBox(), + commentBox.boundingBox(), + modal.locator('textarea[name="bz_template_content"]').boundingBox(), + ]); + expect(commentBounds!.y, '의견란은 [바로연결 추가] 아래에 있어야 한다').toBeGreaterThanOrEqual(quickAddBox!.y + quickAddBox!.height); + expect(Math.abs(commentBounds!.x - bodyBox!.x), '의견란은 본문 입력과 같은 왼쪽 선에 맞는다').toBeLessThan(4); + // 없음(NONE): 강조표기·이미지·아이템 필드 부재 await expect(modal.getByText('강조표기 타이틀', { exact: false })).toHaveCount(0); @@ -147,6 +170,9 @@ test.describe('비즈뿌리오 알림 템플릿 관리 (플러그인 설정)', ( await expect(manageView).toBeVisible({ timeout: 30_000 }); + // 목록 SMS 열은 평문(단독/대체/-)이 아니라 상태 배지(단독·대체=초록, 미사용=회색) — 행 하단과 같은 규칙 (PO 지시 2026-08-23) + await expect(manageView.locator('span.rounded', { hasText: /^(단독|대체|미사용)$/ }).first()).toBeVisible({ timeout: 15_000 }); + // 상태 필터 변경 → URL bz_status 반영 (mergeQuery — tab 파라미터 유지) const filterRoot = manageView.locator('[aria-haspopup]').first(); await filterRoot.click(); @@ -160,4 +186,42 @@ test.describe('비즈뿌리오 알림 템플릿 관리 (플러그인 설정)', ( expect(text).not.toContain('$t:'); expect(text).not.toContain('sirsoft-message_bizppurio.'); }); + // @scenario bizppurio_manage_round_trip_e2e + // @effects manage_list_mobile_card_view_and_unified_toolbar + test('portable(375px) 에서 목록이 카드로 전환되고 도구줄 버튼이 btn 계열로 통일된다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(MANAGE_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const manageView = page.locator('#templates_manage_view'); + const readiness = page.locator('#templates_readiness'); + if (await readiness.isVisible().catch(() => false)) { + await expect(readiness.getByText('환경설정', { exact: false }).first()).toBeVisible(); + return; + } + await expect(manageView).toBeVisible({ timeout: 30_000 }); + + // 데스크톱: grid 헤더 노출 + 도구줄 버튼 계열 + await expect(manageView.locator('#bz_manage_list_header')).toBeVisible(); + const searchBtn = manageView.getByRole('button', { name: '검색', exact: true }); + await expect(searchBtn).toHaveClass(/btn-primary/); + // 행 액션에도 동명 [새로고침](행 동기화) 버튼이 있어 aria-label 로 도구줄 버튼만 집는다 + const refreshBtn = manageView.getByLabel('새로고침', { exact: true }); + await expect(refreshBtn).toHaveClass(/btn-icon/); + await expect(refreshBtn.locator('i.fa-arrows-rotate')).toBeVisible(); + + // portable 로 전환 → 헤더 숨김 + 카드 메타 줄(신청·동기화 합본) 노출 + await page.setViewportSize({ width: 375, height: 812 }); + await expect(manageView.locator('#bz_manage_list_header')).toBeHidden(); + const firstRowInner = manageView.locator('#bz_manage_list_rows > div > div').first(); + if (await firstRowInner.isVisible().catch(() => false)) { + await expect(firstRowInner.getByText(/동기화/).first()).toBeVisible(); + // 카드에서 별도 열(소속/신청일/동기화일 셀)은 숨는다 — grid 클래스가 아닌 flex 컨테이너 + const rowClass = (await firstRowInner.getAttribute('class')) ?? ''; + expect(rowClass).toContain('flex-wrap'); + expect(rowClass).not.toContain('grid-cols-12'); + } + await page.setViewportSize({ width: 1280, height: 800 }); + }); + }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php index d4d7a57a..213c8684 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php @@ -146,4 +146,44 @@ class SeedChannelTemplatesListenerTest extends PluginTestCase $alimtalk = collect($result['generic']['templates'])->firstWhere('channel', 'alimtalk'); $this->assertSame('trigger_user', $alimtalk['recipients'][0]['type']); } + + /** + * 코어 [기본값 복원] 경로(getDefaultTemplateData)가 통과하는 필터에도 같은 증강을 건다 + * (#597 §18.7 C2 — 시드만 하고 복원 출처에 빠져 있어 "기본 템플릿 데이터를 찾을 수 없습니다" 로 실패했다). + * + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_기본값_복원_필터를_시딩_훅과_같은_메서드로_구독한다(): void + { + $hooks = SeedChannelTemplatesListener::getSubscribedHooks(); + + $this->assertArrayHasKey('core.notification.filter_default_definitions', $hooks); + $this->assertSame('augment', $hooks['core.notification.filter_default_definitions']['method']); + $this->assertSame('filter', $hooks['core.notification.filter_default_definitions']['type'], '반환값을 쓰는 필터다 — type 누락 시 증강이 버려진다.'); + $this->assertGreaterThan(20, $hooks['core.notification.filter_default_definitions']['priority'], '게시판·이커머스가 자기 정의를 보태는 priority 20 뒤에 돌아야 모듈 정의도 증강된다.'); + + // 시딩 3훅과 동일 메서드 — 시드 출처와 복원 출처가 갈리면 복원값이 시드값과 달라진다. + foreach (['seed.notifications.translations', 'seed.sirsoft-board.notifications.translations', 'seed.sirsoft-ecommerce.notifications.translations'] as $seedHook) { + $this->assertSame($hooks[$seedHook]['method'], $hooks['core.notification.filter_default_definitions']['method']); + } + } + + /** + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_기본값_복원_경로의_순차_정의_배열에서_alimtalk_기본값을_찾을_수_있다(): void + { + // getDefaultTemplateData 는 config/core.php 정의에 type/extension_* 를 붙인 순차 배열을 필터에 넘긴다 + $definitions = [ + array_merge($this->coreDefinition()['welcome'], ['type' => 'welcome', 'extension_type' => 'core', 'extension_identifier' => 'core']), + ]; + + $result = $this->listener()->augment($definitions, ['type' => 'welcome', 'channel' => 'alimtalk']); + + $alimtalk = collect($result[0]['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertNotNull($alimtalk, '복원 경로가 찾는 channel=alimtalk template 이 있어야 한다.'); + $this->assertSame([['type' => 'trigger_user']], $alimtalk['recipients']); + $this->assertSame('{name}님 환영합니다', $alimtalk['body']['ko'], '시딩과 같은 database 평문 body 가 복원값이다.'); + $this->assertSame('welcome', $result[0]['type'], '기존 필드는 보존된다.'); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php index c635a05e..562beb59 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php @@ -733,4 +733,56 @@ class BizppurioTemplateServiceTest extends PluginTestCase app()->setLocale('ko'); } + + /** + * 검수자 전달 의견은 kapi request 의 comment 로만 실린다 (#597 §18.7 — PO 결정 2026-08-23). + * + * 카카오 심사 가이드가 요구하는 변수 '예시 텍스트' 를 전할 유일한 통로가 request comment 다. + * 등록(add) 페이로드에는 섞이지 않고, 행에도 남지 않는다. + * + * @effects inspection_request_carries_reviewer_comment + */ + public function test_검수_신청의_comment는_kapi_request에만_실린다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row(); + + $this->makeService()->requestInspection($template, ' 변수 예시: #{name}=홍길동, #{order_number}=20260823-0001 '); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/request') { + return false; + } + $this->assertSame('변수 예시: #{name}=홍길동, #{order_number}=20260823-0001', $request->data()['comment'] ?? null, '앞뒤 공백만 제거하고 원문 그대로 싣는다.'); + + return true; + }); + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/add') { + return false; + } + $this->assertArrayNotHasKey('comment', $request->data(), 'comment 는 등록(add) 페이로드에 섞이지 않는다.'); + + return true; + }); + } + + /** + * @effects inspection_request_carries_reviewer_comment + */ + public function test_검수_신청의_comment가_공백이면_kapi_request에_comment_키를_싣지_않는다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + + $this->makeService()->requestInspection($this->row(), ' '); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/request') { + return false; + } + $this->assertArrayNotHasKey('comment', $request->data(), '공백만 있는 의견은 미전달 — 클라이언트가 빈 comment 를 생략한다.'); + + return true; + }); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml index afca18d9..c9a376e4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml @@ -15,7 +15,8 @@ description: | - 동기화: 스케줄러(30분, requested 있을 때만 list 일괄 대조) + 수동 새로고침(detail 1건). 승인 전이 시 content 를 approved_content 로 동결(발송 SSoT), 반려 시 comments 저장 - 화면: 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭(sms 탭 숨김 + tab_channels 규칙) - 행 하단 라이프사이클 UI + 작성 모달(4×4 유형) + 플러그인 관리 화면(DB 목록) + 행 하단 상태 요약(승인 2단 배지) + 코어 [편집] 모달에 녹인 알림톡·SMS 섹션(통합 저장, + 4×4 유형) + 플러그인 관리 화면(DB 목록). PO 결정 2026-08-23: 별도 작성/SMS 모달 폐지 axis 설계 노트: - content_variant 는 카카오 메시지유형×강조유형의 대표 변형 4종이다 — 유형별 조건부 @@ -45,6 +46,7 @@ effects: - rerequest_calls_update_not_add - request_without_content_rejected_422 - request_snapshots_sender_key + - inspection_request_carries_reviewer_comment - kapi_failure_reason_surfaced_in_errors_bizppurio_message # 전이 가드 (S2) - content_edit_locked_outside_draft_rejected @@ -73,14 +75,22 @@ effects: - approval_transition_freezes_content_into_approved_content - rejection_transition_stores_comments_detail - manual_sync_updates_single_row - # 화면 (S5) + # 화면 (S5 — PO 결정 2026-08-23: 편집 모달 통합) - bizppurio_tab_replaces_sms_and_alimtalk_tabs - tab_visible_when_any_of_tab_channels_active - - row_footer_shows_status_badge_and_lifecycle_actions + - row_footer_shows_status_summary_only + - approval_badge_two_tier + - edit_modal_hosts_alimtalk_and_sms_sections + - core_modal_hides_editor_for_hidden_template_editor_channel + - unified_save_chain_alimtalk_then_core_recipients - compose_modal_switches_conditional_fields_by_type - manage_screen_lists_db_rows_with_merge_query_round_trip + # 관리 목록 반응형 (PO 지시 2026-08-24) — portable 카드 전환 + 도구줄 btn 계열 통일 + - manage_list_mobile_card_view_and_unified_toolbar - sms_modal_saves_body_via_delivery_upsert - sms_modal_edits_body_per_locale_tab + # 코어 [기본값 복원] — 플러그인이 시드한 alimtalk/sms 행도 복원된다 (§18.7 C2 실측 결함) + - core_reset_restores_seeded_channel_template_defaults # 업로드 진행 상태 배선 (라운드 5 — 이 축이 effect 에 없어 배선 단언만 지우면 룰이 조용했다) - upload_in_progress_locks_save_buttons_not_cancel - upload_rejects_concurrent_selection_while_in_flight @@ -92,12 +102,20 @@ effects: scenarios: - id: bizppurio_tab_integration_e2e - description: 코어 알림 설정에서 문자·알림톡 개별 탭이 사라지고 '비즈뿌리오' 통합 탭이 노출되며, 행 하단에 알림톡·SMS 라이프사이클 UI 가 렌더된다 (i18n 원문 노출 0) + description: 코어 알림 설정에서 문자·알림톡 개별 탭이 사라지고 '비즈뿌리오' 통합 탭이 노출되며, 행 하단에 승인 2단 배지 + SMS 설정 요약만 렌더된다 (버튼 0, i18n 원문 노출 0) effects: - bizppurio_tab_replaces_sms_and_alimtalk_tabs - - row_footer_shows_status_badge_and_lifecycle_actions + - row_footer_shows_status_summary_only + - approval_badge_two_tier + - id: bizppurio_edit_modal_e2e + description: 행 [편집]을 누르면 코어 모달의 제목/본문이 숨고 알림톡 템플릿·문자(SMS) 섹션이 렌더되며, 하단 [저장] 한 번으로 알림톡 내용·발송 설정·SMS 본문(·바뀐 수신자 규칙)이 저장된다 + effects: + - edit_modal_hosts_alimtalk_and_sms_sections + - core_modal_hides_editor_for_hidden_template_editor_channel + - unified_save_chain_alimtalk_then_core_recipients + - inspection_request_carries_reviewer_comment - id: bizppurio_compose_modal_e2e - description: 작성 모달을 열고 강조 유형(NONE→TEXT→IMAGE→ITEM_LIST)을 전환하면 조건부 필드 블록이 등장/소멸한다 + description: 편집 모달의 알림톡 섹션에서 강조 유형(NONE→TEXT→IMAGE→ITEM_LIST)을 전환하면 조건부 필드 블록이 등장/소멸한다 effects: - compose_modal_switches_conditional_fields_by_type - id: bizppurio_manage_round_trip_e2e @@ -105,6 +123,7 @@ scenarios: effects: - manage_screen_lists_db_rows_with_merge_query_round_trip - sms_modal_saves_body_via_delivery_upsert + - manage_list_mobile_card_view_and_unified_toolbar test_files: - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/BizppurioTemplateStatusTest.php @@ -112,6 +131,8 @@ test_files: - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/SyncTemplateStatusCommandTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx @@ -119,6 +140,10 @@ test_files: # 3면 패리티(오버레이 전문 동일성) — 파일째 지워도 룰이 울지 않던 사각을 닫는다. # plugin_settings.test.tsx 는 plugin-settings.yaml 이 소유하므로 여기에 중복 등재하지 않는다. - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx + # 코어 [편집] 모달의 확장 지점 2종 + hidden_template_editor 게이트 — 소유 확장(템플릿/모듈)의 자기 러너가 수집한다 + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts # E2E spec — 통합 탭 노출(tab_channels 활성 규칙 포함)은 브라우저 실측이 SSoT 라 여기에도 선언 - plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts - plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts diff --git a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap index 9ce67254..ef9ba3bf 100644 --- a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap +++ b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap @@ -86,7 +86,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: greedy ] `; -exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `513`; +exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `516`; exports[`BindingShape 라우팅 diff 하네스 > 판정이 갈리는 식의 분류별 건수 요약 1`] = ` { @@ -282,6 +282,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구 "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_mail_templates.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json", + "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_manual_install.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_tab_admin.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_tab_user.json", diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 2eee62d5..4ea4a831 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -13,6 +13,11 @@ ### Changed - 관리자 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 확장이 여러 발송 채널을 하나의 탭으로 묶어 표시할 수 있으며(예: 비즈뿌리오 문자·알림톡 → '비즈뿌리오' 단일 탭), 묶인 채널 중 하나라도 켜져 있으면 탭이 노출됩니다. +- 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다. + +### Fixed + +- 알림 설정 화면에서 「사이트맵 재생성 완료」·「사이트맵 재생성 실패」 알림의 이름이 다국어 키 원문으로 표시되던 문제를 수정했습니다. ## [1.0.6] - 2026-08-22 diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts new file mode 100644 index 00000000..bfd46b73 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts @@ -0,0 +1,138 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio) +// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다. +/** + * 코어 알림 템플릿 [편집] 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (범용 seam) + * + * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록: + * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞) + * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이) + * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다 + * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일. + * + * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은 + * 기대값을 함께 고치면 통과한다. + * + * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections + */ + +import { describe, it, expect } from 'vitest'; +import modal from '../../layouts/partials/admin_settings/_modal_notification_template_form.json'; + +type Node = Record; +const PREFIX = ''; +const STATE_KEY = 'notification_template_form_modal'; +const MODAL_ID = 'modal_notification_template_form'; +const REFETCH_ID = 'notificationDefinitions'; + +const findById = (node: unknown, id: string): Node | null => { + if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; } + if (node && typeof node === 'object') { + if ((node as Node).id === id) return node as Node; + for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; } + } + return null; +}; +const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => { + if (Array.isArray(node)) { + const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id); + if (idx >= 0) return { arr: node, idx }; + for (const n of node) { const r = findParentArray(n, id); if (r) return r; } + return null; + } + if (node && typeof node === 'object') { + for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; } + } + return null; +}; +const evalIf = (expr: string, scope: Record): boolean => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n]))); +}; +const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({ + availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } }, + _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } }, +}); + +const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId']; + +describe('알림 템플릿 편집 모달 — 확장 슬롯 2종', () => { + it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => { + const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`); + expect(loc).toBeTruthy(); + const { arr, idx } = loc!; + expect(arr[idx].type).toBe('extension_point'); + expect(arr[idx].name).toBe('notification_template_form_sections'); + expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`); + expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`); + }); + + it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const right = footer.children[1].children as Node[]; + expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']); + expect(right[1].name).toBe('notification_template_form_footer_actions'); + expect(JSON.stringify(right[0])).toContain('$t:common.cancel'); + expect(JSON.stringify(right[2])).toContain('$t:common.save'); + }); + + it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => { + const ep = findById(modal, id)!; + expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort()); + expect(ep.props.modalId).toBe(MODAL_ID); + expect(ep.props.stateKey).toBe(STATE_KEY); + expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID); + expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`); + expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`); + expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`); + expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`); + }); +}); + +describe('알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => { + const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info']; + + it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node.if, `${id} if`).toBeTruthy(); + expect(evalIf(node.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(node.if, scope('mail', undefined))).toBe(true); + expect(evalIf(node.if, scope('sms', false))).toBe(true); + }); + + it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_click_url_input`)!; + expect(evalIf(node.if, scope('database', undefined))).toBe(true); + expect(evalIf(node.if, scope('mail', undefined))).toBe(false); + expect(evalIf(node.if, scope('database', true))).toBe(false); + }); + + it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_variables_info`)!; + const noVars = { ...scope('mail', undefined) } as Node; + noVars._global[STATE_KEY].definition = { variables: [] }; + expect(evalIf(node.if, noVars)).toBe(false); + }); + + it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const preview = footer.children[0].children[0] as Node; + const right = footer.children[1].children as Node[]; + const cancel = right[0]; + const save = right[2]; + for (const b of [preview, save]) { + expect(evalIf(b.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(b.if, scope('mail', undefined))).toBe(true); + } + expect(cancel.if).toBeUndefined(); + }); + + it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => { + for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node, id).toBeTruthy(); + expect(String(node.if ?? '')).not.toContain('hidden_template_editor'); + } + }); +}); 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 a0961cea..8be629f6 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1858,7 +1858,9 @@ "types": { "welcome": "Welcome", "reset_password": "Password Reset", - "password_changed": "Password Changed" + "password_changed": "Password Changed", + "sitemap_regenerated": "Sitemap Regeneration Complete", + "sitemap_regenerate_failed": "Sitemap Regeneration Failed" }, "channels_label": "Channels:", "edit_modal": { 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 636c0507..8b70a95d 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1862,7 +1862,9 @@ "types": { "welcome": "회원가입 환영", "reset_password": "비밀번호 재설정", - "password_changed": "비밀번호 변경 알림" + "password_changed": "비밀번호 변경 알림", + "sitemap_regenerated": "사이트맵 재생성 완료", + "sitemap_regenerate_failed": "사이트맵 재생성 실패" }, "channels_label": "채널:", "edit_modal": { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json index fb44af34..03582b6d 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json @@ -533,6 +533,21 @@ } ] }, + { + "id": "template_form_sections_ext", + "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.", + "type": "extension_point", + "name": "notification_template_form_sections", + "props": { + "definition": "{{_global.notification_template_form_modal?.definition}}", + "template": "{{_global.notification_template_form_modal?.template}}", + "channel": "{{_global.notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_notification_template_form", + "stateKey": "notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "notificationDefinitions" + } + }, { "id": "template_lang_tabs", "type": "basic", @@ -564,7 +579,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "template_subject_input", @@ -621,7 +637,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "template_body_input", @@ -679,13 +696,14 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "template_click_url_input", "type": "basic", "name": "Div", - "if": "{{_global.notification_template_form_modal?.template?.channel === 'database'}}", + "if": "{{(_global.notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "row-stack" }, @@ -738,7 +756,7 @@ "id": "template_variables_info", "type": "basic", "name": "Div", - "if": "{{(_global.notification_template_form_modal?.definition?.variables ?? []).length > 0}}", + "if": "{{((_global.notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4" }, @@ -855,17 +873,18 @@ "target": "modal_notification_template_preview" } ], - "onError": [ - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message}}" - } - } - ] + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] }, @@ -893,6 +912,21 @@ } ] }, + { + "id": "template_form_footer_ext", + "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.", + "type": "extension_point", + "name": "notification_template_form_footer_actions", + "props": { + "definition": "{{_global.notification_template_form_modal?.definition}}", + "template": "{{_global.notification_template_form_modal?.template}}", + "channel": "{{_global.notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_notification_template_form", + "stateKey": "notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "notificationDefinitions" + } + }, { "type": "basic", "name": "Button", @@ -992,7 +1026,8 @@ ] } } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] } diff --git a/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php b/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php index ce2649fe..e36f008d 100644 --- a/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php +++ b/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php @@ -104,7 +104,10 @@ class AdminIdentityMessageControllerTest extends TestCase $template->refresh(); $this->assertSame('운영자 제목', $template->subject['ko']); - $this->assertContains('subject', $template->user_overrides ?? []); + // subject/body 는 translatableTrackableFields — 수정이 dot-path sub-key 단위로 + // 기록되어야 시더 재실행·언어팩 병합이 수정된 로케일만 보존한다 (#597 교정) + $this->assertContains('subject.ko', $template->user_overrides ?? []); + $this->assertContains('subject.en', $template->user_overrides ?? []); $this->assertFalse($template->is_default, '운영자 편집 후 is_default 가 false 로 전환되어야 합니다.'); } diff --git a/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php b/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php index 4e86e3aa..5a6bd38d 100644 --- a/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php +++ b/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php @@ -5,7 +5,10 @@ namespace Tests\Feature\LanguagePack; use App\Enums\LanguagePackScope; use App\Enums\LanguagePackStatus; use App\Extension\HookManager; +use App\Models\IdentityMessageDefinition; +use App\Models\IdentityMessageTemplate; use App\Models\LanguagePack; +use App\Services\IdentityMessageTemplateService; use App\Services\LanguagePack\LanguagePackRegistry; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; @@ -39,6 +42,11 @@ class IdentitySeedInjectionTest extends TestCase parent::tearDown(); } + /** + * @scenario domain=identity, entry=seeder_filter + * + * @effects seeder_filter_injects_active_pack_locales + */ public function test_apply_filters_injects_ja_translation_when_active_pack_has_seed(): void { File::put($this->packRoot.'/seed/identity_messages.json', json_encode([ @@ -111,4 +119,77 @@ class IdentitySeedInjectionTest extends TestCase // 활성 ja 팩 없음 → 입력 그대로 반환 $this->assertSame($definitions, $result); } + + /** + * [기본값 복원] 경로 회귀 — IdentityMessageTemplateService::resetToDefault 는 + * core.identity.filter_default_message_definitions 필터의 기본값(ko/en)으로 + * subject/body 를 대체하므로, 언어팩 주입에 참여하지 않으면 팩이 주입해 둔 + * 로케일(ja)이 복원 시 영구 소실된다 (notification 쪽 실사례의 동형 — #597 보완 실측). + * + * @scenario domain=identity, entry=reset_default + * + * @effects identity_reset_default_preserves_lang_pack_locales + */ + public function test_reset_to_default_preserves_lang_pack_locales(): void + { + File::put($this->packRoot.'/seed/identity_messages.json', json_encode([ + 'mail.purpose.signup' => [ + 'definition' => ['name' => '会員登録認証', 'description' => ''], + 'templates' => ['mail' => ['subject' => '[アプリ] 認証コード', 'body' => '

{code}

']], + ], + ])); + + LanguagePack::query()->create([ + 'identifier' => 'test-feature-idv-ja', + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + $this->app->make(LanguagePackRegistry::class)->invalidate(); + + // config/core.php 실항목을 그대로 픽스처로 사용 — 복원 매칭(provider/scope) 정합 보장. + $cfg = config('core.identity_messages')['mail.purpose.signup']; + + $definition = IdentityMessageDefinition::query()->create([ + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'provider_id' => $cfg['provider_id'], + 'scope_type' => $cfg['scope_type'], + 'scope_value' => $cfg['scope_value'], + 'name' => $cfg['name'], + 'description' => $cfg['description'] ?? null, + 'channels' => $cfg['channels'] ?? ['mail'], + 'variables' => [], + 'is_active' => true, + 'is_default' => true, + ]); + + $template = IdentityMessageTemplate::query()->create([ + 'definition_id' => $definition->id, + 'channel' => 'mail', + 'subject' => ['ko' => '운영자가 바꾼 제목'], + 'body' => ['ko' => '운영자가 바꾼 본문 {code}'], + 'is_active' => true, + 'is_default' => false, + ]); + + $updated = $this->app->make(IdentityMessageTemplateService::class) + ->resetToDefault($template); + + $this->assertTrue((bool) $updated->is_default, '복원 후 is_default 가 참이어야 한다'); + $this->assertSame('[アプリ] 認証コード', $updated->subject['ja'] ?? null, + '복원 기본값에 활성 팩 seed 의 ja subject 가 병합되어야 한다 — 없으면 복원이 팩 번역을 지운다'); + $this->assertSame('

{code}

', $updated->body['ja'] ?? null, + '복원 기본값에 활성 팩 seed 의 ja body 가 병합되어야 한다'); + $this->assertArrayHasKey('ko', $updated->subject, 'ko 는 config 원본에서 보존'); + } } diff --git a/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php b/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php index 60e16d00..993a4051 100644 --- a/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php +++ b/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php @@ -7,6 +7,7 @@ use App\Enums\LanguagePackStatus; use App\Extension\HookManager; use App\Models\LanguagePack; use App\Services\LanguagePack\LanguagePackRegistry; +use App\Services\NotificationTemplateService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; use Tests\TestCase; @@ -40,6 +41,11 @@ class NotificationSeedInjectionTest extends TestCase parent::tearDown(); } + /** + * @scenario domain=notification, entry=seeder_filter + * + * @effects seeder_filter_injects_active_pack_locales + */ public function test_apply_filters_injects_ja_translation_when_active_pack_has_seed(): void { File::put($this->packRoot.'/seed/notifications.json', json_encode([ @@ -93,6 +99,114 @@ class NotificationSeedInjectionTest extends TestCase $this->assertSame('Welcome', $result[0]['name']['en']); } + /** + * 실경로 회귀 — 코어 시더(loadConfigSeed)는 config/core.php 원형(연관 배열, + * 항목에 type 키 없음)을 그대로 필터에 넘긴다. injector 가 $def['type'] 만 보면 + * 전 항목이 스킵되어 ja 가 조용히 주입되지 않는다 (2026-08-24 실측 — #597 보완 실측). + * + * @scenario domain=notification, entry=seeder_filter + * + * @effects seeder_filter_injects_ja_for_real_seeder_payload + */ + public function test_apply_filters_injects_ja_for_config_shaped_associative_definitions(): void + { + File::put($this->packRoot.'/seed/notifications.json', json_encode([ + 'welcome' => [ + 'definition' => ['name' => 'ようこそ通知', 'description' => '新規会員へのウェルカム'], + 'templates' => ['mail' => ['subject' => 'ようこそ', 'body' => '

{user_name}

']], + ], + ])); + + LanguagePack::query()->create([ + 'identifier' => 'test-feature-notif-ja', + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + $this->app->make(LanguagePackRegistry::class)->invalidate(); + + // config/core.php notification_definitions 원형과 동일한 형태 — 키가 곧 type. + $definitions = [ + 'welcome' => [ + 'name' => ['ko' => '회원가입 환영', 'en' => 'Welcome'], + 'description' => ['ko' => '신규 회원 환영', 'en' => 'New member welcome'], + 'templates' => [ + [ + 'channel' => 'mail', + 'subject' => ['ko' => '환영합니다', 'en' => 'Welcome'], + 'body' => ['ko' => '

{user_name}

', 'en' => '

{user_name}

'], + ], + ], + ], + ]; + + $result = HookManager::applyFilters('seed.notifications.translations', $definitions); + + $this->assertSame('ようこそ通知', $result['welcome']['name']['ja'] ?? null, + '연관 배열(실제 시더 페이로드) 형태에서 definition name 에 ja 가 병합되어야 한다'); + $this->assertSame('ようこそ', $result['welcome']['templates'][0]['subject']['ja'] ?? null, + '연관 배열 형태에서 템플릿 subject 에 ja 가 병합되어야 한다'); + // ko/en 보존 + $this->assertSame('회원가입 환영', $result['welcome']['name']['ko']); + } + + /** + * [기본값 복원] 경로 회귀 — NotificationTemplateService::getDefaultTemplateData 는 + * core.notification.filter_default_definitions 필터만 타고 언어팩 주입에는 참여하지 + * 않아, 복원을 누르면 팩이 주입해 둔 로케일(ja)이 ko/en 대체로 영구 소실됐다 + * (실사례: 2026-08-23 복원 실측이 welcome ja 를 지움 — #597 보완 실측에서 확정). + * 복원 기본값에도 활성 팩 seed 로케일이 병합되어야 한다. + * + * @scenario domain=notification, entry=reset_default + * + * @effects reset_default_data_includes_lang_pack_locales + */ + public function test_reset_default_data_includes_lang_pack_locales(): void + { + File::put($this->packRoot.'/seed/notifications.json', json_encode([ + 'welcome' => [ + 'definition' => ['name' => 'ようこそ通知', 'description' => '新規会員へのウェルカム'], + 'templates' => ['mail' => ['subject' => 'ようこそ', 'body' => '

{user_name}

']], + ], + ])); + + LanguagePack::query()->create([ + 'identifier' => 'test-feature-notif-ja', + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + $this->app->make(LanguagePackRegistry::class)->invalidate(); + + $defaultData = $this->app->make(NotificationTemplateService::class) + ->getDefaultTemplateData('welcome', 'mail'); + + $this->assertNotEmpty($defaultData, 'config/core.php 의 welcome/mail 기본 템플릿을 찾아야 한다'); + $this->assertSame('ようこそ', $defaultData['subject']['ja'] ?? null, + '[기본값 복원] 기본값에 활성 팩 seed 의 ja subject 가 병합되어야 한다 — 없으면 복원이 팩 번역을 지운다'); + $this->assertSame('

{user_name}

', $defaultData['body']['ja'] ?? null, + '[기본값 복원] 기본값에 활성 팩 seed 의 ja body 가 병합되어야 한다'); + $this->assertArrayHasKey('ko', $defaultData['subject'], 'ko 는 config 원본에서 보존'); + } + public function test_apply_filters_passthrough_when_no_active_pack(): void { $definitions = [ diff --git a/tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php b/tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php new file mode 100644 index 00000000..e5007b90 --- /dev/null +++ b/tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php @@ -0,0 +1,207 @@ +packRoot = base_path('lang-packs/'.$this->packIdentifier); + File::ensureDirectoryExists($this->packRoot.'/seed'); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->packRoot); + parent::tearDown(); + } + + /** + * 활성 팩 행을 생성합니다 (resolveDirectory → lang-packs/{identifier}). + * + * @return LanguagePack 생성된 팩 + */ + private function createActivePack(): LanguagePack + { + return LanguagePack::query()->create([ + 'identifier' => $this->packIdentifier, + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + } + + /** + * 마이그레이션을 업그레이드 컨텍스트로 실행합니다. + * + * @return void + */ + private function runMigration(): void + { + (new ResyncActiveLanguagePackTranslations)->run( + new UpgradeContext('7.0.8', '7.0.9', '7.0.9') + ); + } + + /** + * [기본값 복원]이 지운 알림 템플릿 ja 가 업그레이드 재동기화로 복구되고, + * 운영자가 수정한(user_overrides 등록) 템플릿의 ja 는 덮이지 않는다. + * + * @scenario domain=notification, entry=upgrade_resync + * + * @effects upgrade_resync_restores_wiped_pack_locales + */ + public function test_resync_restores_wiped_notification_locale_and_preserves_overrides(): void + { + File::put($this->packRoot.'/seed/notifications.json', json_encode([ + 'welcome' => [ + 'definition' => ['name' => 'ようこそ通知'], + 'templates' => ['mail' => ['subject' => 'ようこそ', 'body' => '

{user_name}

']], + ], + 'password_changed' => [ + 'templates' => ['mail' => ['subject' => 'パスワード変更', 'body' => '

{user_name}

']], + ], + ])); + $this->createActivePack(); + + // 복원 결함이 ja 를 지운 상태 재현: ko/en 만 남고 user_overrides 는 null. + $wipedDef = NotificationDefinition::create([ + 'type' => 'welcome', + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '환영', 'en' => 'Welcome'], + 'channels' => ['mail'], + 'hooks' => ['core.auth.after_register'], + ]); + $wiped = NotificationTemplate::create([ + 'definition_id' => $wipedDef->id, + 'channel' => 'mail', + 'subject' => ['ko' => '환영합니다', 'en' => 'Welcome'], + 'body' => ['ko' => '

환영

', 'en' => '

welcome

'], + 'is_active' => true, + 'is_default' => true, + ]); + + // 운영자 수정 상태 재현: ja 커스텀 + user_overrides 보존 선언. + $customDef = NotificationDefinition::create([ + 'type' => 'password_changed', + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '비밀번호 변경', 'en' => 'Password changed'], + 'channels' => ['mail'], + 'hooks' => ['core.auth.after_password_change'], + ]); + $custom = NotificationTemplate::create([ + 'definition_id' => $customDef->id, + 'channel' => 'mail', + 'subject' => ['ko' => '커스텀 제목', 'ja' => 'カスタム件名'], + 'body' => ['ko' => '

커스텀

', 'ja' => '

カスタム

'], + 'is_active' => true, + 'is_default' => false, + ]); + // 운영자 수정 시 updating 이벤트가 기록하는 실제 형식 = dot-path 리스트 "{column}.{locale}" + $custom->user_overrides = ['subject.ja', 'body.ja']; + $custom->saveQuietly(); + + $this->assertArrayNotHasKey('ja', $wiped->fresh()->subject, '사전 조건: 소실 상태(ja 부재)'); + + $this->runMigration(); + + $this->assertSame('ようこそ', $wiped->fresh()->subject['ja'] ?? null, + '소실된 템플릿 subject 에 활성 팩 seed 의 ja 가 병합 복구되어야 한다'); + $this->assertSame('ようこそ通知', $wipedDef->fresh()->name['ja'] ?? null, + '정의 name 에도 ja 가 병합되어야 한다'); + $this->assertSame('カスタム件名', $custom->fresh()->subject['ja'] ?? null, + '운영자 수정(user_overrides 등록) 템플릿의 ja 는 seed 로 덮이지 않아야 한다'); + } + + /** + * 본인인증 메시지 템플릿의 소실된 ja 도 같은 재동기화로 복구된다. + * + * @scenario domain=identity, entry=upgrade_resync + * + * @effects upgrade_resync_restores_wiped_identity_locales + */ + public function test_resync_restores_wiped_identity_message_locale(): void + { + File::put($this->packRoot.'/seed/identity_messages.json', json_encode([ + 'mail.purpose.signup' => [ + 'definition' => ['name' => '会員登録認証'], + 'templates' => ['mail' => ['subject' => '[アプリ] 認証コード', 'body' => '

{code}

']], + ], + ])); + $this->createActivePack(); + + $cfg = config('core.identity_messages')['mail.purpose.signup']; + $definition = IdentityMessageDefinition::query()->create([ + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'provider_id' => $cfg['provider_id'], + 'scope_type' => $cfg['scope_type'], + 'scope_value' => $cfg['scope_value'], + 'name' => ['ko' => '회원가입 인증'], + 'channels' => ['mail'], + 'variables' => [], + 'is_active' => true, + 'is_default' => true, + ]); + $template = IdentityMessageTemplate::query()->create([ + 'definition_id' => $definition->id, + 'channel' => 'mail', + 'subject' => ['ko' => '[앱] 인증 코드'], + 'body' => ['ko' => '

{code}

'], + 'is_active' => true, + 'is_default' => true, + ]); + + $this->assertArrayNotHasKey('ja', $template->fresh()->subject, '사전 조건: 소실 상태(ja 부재)'); + + $this->runMigration(); + + $this->assertSame('[アプリ] 認証コード', $template->fresh()->subject['ja'] ?? null, + '소실된 본인인증 템플릿 subject 에 활성 팩 seed 의 ja 가 병합 복구되어야 한다'); + } +} diff --git a/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php b/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php index 388046b3..086c2a75 100644 --- a/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php +++ b/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php @@ -98,12 +98,15 @@ class SyncDatabaseTranslationsTest extends TestCase public function test_case_b_skips_locale_when_user_overrides_registered(): void { // Permission 은 HasUserOverrides 미사용이므로 Role 로 검증 (Role/Menu/Notification 모두 동일 정책) + // user_overrides 는 실제 생산 형식(운영자 수정 시 updating 이벤트가 기록하는 + // dot-path 리스트 "{column}.{locale}")으로 둔다 — 과거 픽스처는 어떤 생산자도 + // 쓰지 않는 연관 형식이라 보존 판정 결함(isset 사문)을 green 으로 가렸다. $role = Role::query()->create([ 'identifier' => 'test_role_b', 'name' => ['ko' => '바', 'en' => 'Bar', 'ja' => '사용자수정'], 'description' => ['ko' => 'D', 'en' => 'D'], 'is_active' => true, - 'user_overrides' => ['name' => ['updated_at' => now()->toIso8601String()]], + 'user_overrides' => ['name.ja'], ]); $pack = $this->setupPackWithPermissionsSeed([ @@ -167,12 +170,13 @@ class SyncDatabaseTranslationsTest extends TestCase public function test_deactivate_preserves_locale_when_user_overrides_registered(): void { + // dot-path 리스트 = 실제 생산 형식 (test_case_b 주석 참조) $role = Role::query()->create([ 'identifier' => 'test_role_zed', 'name' => ['ko' => '제드', 'en' => 'Zed', 'ja' => '사용자수정ja'], 'description' => ['ko' => '', 'en' => ''], 'is_active' => true, - 'user_overrides' => ['name' => ['updated_at' => now()->toIso8601String()]], + 'user_overrides' => ['name.ja'], ]); $pack = $this->setupPackWithPermissionsSeed([ diff --git a/tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php b/tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php new file mode 100644 index 00000000..29417b61 --- /dev/null +++ b/tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php @@ -0,0 +1,111 @@ + 'templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json', + 'en' => 'templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json', + ]; + + /** + * 코어 정의 타입 전수가 ko 라벨을 가진다 + * + * @scenario locale=ko + * + * @effects core_definition_types_have_admin_template_labels + */ + public function test_every_core_definition_type_has_ko_label(): void + { + $this->assertLabelsCoverCoreTypes('ko'); + } + + /** + * 코어 정의 타입 전수가 en 라벨을 가진다 + * + * @scenario locale=en + * + * @effects core_definition_types_have_admin_template_labels + */ + public function test_every_core_definition_type_has_en_label(): void + { + $this->assertLabelsCoverCoreTypes('en'); + } + + /** + * 코어 정의 전수 ↔ 해당 로케일 라벨 대조 + * + * @param string $locale 검사할 로케일 (ko|en) + */ + private function assertLabelsCoverCoreTypes(string $locale): void + { + $types = array_keys((array) config('core.notification_definitions')); + + // 모집단 검증 — config 구조가 바뀌어 빈 배열이 되면 초록이 무의미하다. + $this->assertGreaterThanOrEqual( + 5, + count($types), + 'config/core.php notification_definitions 에서 코어 정의를 읽지 못했다 — 테스트 경로/구조 확인 필요' + ); + + $relPath = self::LABEL_FILES[$locale]; + $labels = $this->loadTypeLabels($relPath); + + $missing = array_values(array_filter( + $types, + fn (string $type) => ! array_key_exists($type, $labels) + || ! is_string($labels[$type]) + || trim($labels[$type]) === '' + )); + + $this->assertSame( + [], + $missing, + "admin 템플릿 {$locale} 라벨 누락 — 알림 설정 화면 제목에 원시 키가 그대로 노출된다.\n" + ."{$relPath} 의 settings.notification_definitions.types 에 다음 타입을 추가할 것:\n" + .implode(', ', $missing) + ); + } + + /** + * partial admin.json 에서 타입 라벨 맵을 읽는다 + * + * @param string $relPath 저장소 루트 기준 경로 + * @return array 타입 → 라벨 + */ + private function loadTypeLabels(string $relPath): array + { + $path = base_path($relPath); + $this->assertFileExists($path, "라벨 파일이 없다: {$relPath}"); + + $data = json_decode((string) file_get_contents($path), true); + $this->assertIsArray($data, "라벨 파일 JSON 파싱 실패: {$relPath}"); + + $labels = $data['settings']['notification_definitions']['types'] ?? null; + $this->assertIsArray( + $labels, + "settings.notification_definitions.types 경로가 없다: {$relPath} — 파일 구조가 바뀌었으면 본 테스트의 경로를 갱신할 것" + ); + + return $labels; + } +} diff --git a/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php b/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php index 84a5270a..cc9288e7 100644 --- a/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php +++ b/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php @@ -34,13 +34,40 @@ class LanguagePackServiceTest extends TestCase * * @return void */ + /** 실번들 케이스가 삭제하는 실 설치본의 백업 경로 (테스트 종료 시 복원). */ + private ?string $protectedRealInstallDir = null; + protected function setUp(): void { parent::setUp(); $this->service = $this->app->make(LanguagePackService::class); + + // 실번들(g7-core-ja) 케이스들은 활성 설치본 디렉토리를 만들고 cleanup 에서 삭제한다. + // DB 는 RefreshDatabase 로 격리되지만 파일 시스템은 실 저장소를 공유하므로, + // 개발 환경에 이미 설치된 실 설치본이 있으면 백업해 두고 tearDown 에서 복원한다 + // (미보호 시 실환경 팩이 files_missing 드리프트에 빠진다 — 2026-08-24 실측). + $realDir = base_path('lang-packs/g7-core-ja'); + if (File::isDirectory($realDir)) { + $backup = base_path('lang-packs/.test-backup-g7-core-ja'); + File::deleteDirectory($backup); + File::moveDirectory($realDir, $backup); + $this->protectedRealInstallDir = $backup; + } $this->registry = $this->app->make(LanguagePackRegistry::class); } + protected function tearDown(): void + { + if ($this->protectedRealInstallDir !== null) { + $realDir = base_path('lang-packs/g7-core-ja'); + File::deleteDirectory($realDir); + File::moveDirectory($this->protectedRealInstallDir, $realDir); + $this->protectedRealInstallDir = null; + } + + parent::tearDown(); + } + /** * 테스트 언어팩 1건을 DB 에 직접 생성합니다. * @@ -1085,4 +1112,73 @@ class LanguagePackServiceTest extends TestCase File::deleteDirectory($path); } } + + /** + * 회귀 가드 — performUpdate 중 설치 트랜잭션이 status 를 installed 로 기록한 뒤 + * (활성화 단계 이전/도중) 실패하면, catch 의 상태 복원이 Updating 만 보던 탓에 + * active 였던 팩이 installed 로 방치됐다. 파일은 백업으로 원상 복원되므로 + * 상태도 이전 상태(active)로 복원되어야 한다 — 방치되면 해당 로케일의 백엔드 + * 번역이 통째로 폴백되는데 오류도 로그도 없다 (#597 보완 실측에서 유사 상태 실측). + * + * @scenario vector=update_failure_after_install_transaction, actor_permission=cli_system + * + * @effects failed_update_restores_previous_active_status + */ + public function test_failed_update_restores_previous_active_status(): void + { + $identifier = 'test-update-restore-ja'; + $bundledDir = base_path('lang-packs/_bundled/'.$identifier); + File::ensureDirectoryExists($bundledDir); + File::put($bundledDir.'/language-pack.json', json_encode(['identifier' => $identifier])); + + try { + $pack = LanguagePack::query()->create([ + 'identifier' => $identifier, + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'bundled', + ]); + + // Laravel partialMock 은 생성자를 건너뛰어 readonly 의존성이 미초기화되므로, + // 생성자 인자를 컨테이너에서 구성해 넘기는 Mockery partial 을 사용한다. + $ctorArgs = array_map( + static fn (\ReflectionParameter $param) => app($param->getType()->getName()), + (new \ReflectionClass(LanguagePackService::class))->getConstructor()->getParameters() + ); + $service = \Mockery::mock(LanguagePackService::class, $ctorArgs)->makePartial(); + $service->shouldReceive('installFromBundled') + ->once() + ->andReturnUsing(function () use ($pack) { + // 설치 트랜잭션이 성공적으로 installed 를 기록한 직후 실패하는 상황 재현 + $pack->newQuery()->whereKey($pack->id) + ->update(['status' => LanguagePackStatus::Installed->value]); + + throw new \RuntimeException('activate step failed (재현)'); + }); + + try { + $service->performUpdate($pack->fresh(), true); + $this->fail('installFromBundled 실패가 전파되어야 한다'); + } catch (\RuntimeException $e) { + // 예상된 실패 — 상태 복원만 검증 + } + + $this->assertSame( + LanguagePackStatus::Active->value, + $pack->fresh()->status, + '실패한 update 는 이전 상태(active)를 복원해야 한다 — installed 로 방치되면 해당 로케일 번역이 조용히 폴백된다' + ); + } finally { + File::deleteDirectory($bundledDir); + } + } } diff --git a/tests/scenarios/language-pack-locale-injection-parity.yaml b/tests/scenarios/language-pack-locale-injection-parity.yaml new file mode 100644 index 00000000..f4300f63 --- /dev/null +++ b/tests/scenarios/language-pack-locale-injection-parity.yaml @@ -0,0 +1,43 @@ +feature: 언어팩 로케일 주입·복원 일관성 (#597 보완 실측 후속) + +description: | + 활성 언어팩 seed 의 로케일(ja 등)이 시더 필터와 [기본값 복원] 양쪽에서 같은 SSoT 로 + 병합되는지 고정한다. + + 배경 (2026-08-24 #597 보완 실측에서 실측·확정): + - [기본값 복원] 경로(core.notification.filter_default_definitions / + core.identity.filter_default_message_definitions)가 언어팩 주입에 참여하지 않아, + 복원을 누르면 팩이 주입해 둔 로케일이 config 의 ko/en 만으로 대체되어 영구 소실됐다 + (실사례: 2026-08-23 복원 실측이 welcome ja 를 지움 — 오류·로그 없음). + - 시더 필터(seed.notifications.translations)의 주입기가 리스트+type 형태만 읽어, + 코어 시더의 실제 페이로드(config 원형 — 연관 배열, 항목에 type 없음)에서는 + 전 항목이 스킵되어 주입이 오류 없이 전면 불능이었다. identity 축은 시더가 + config 복합 키를 버리고 리스트로 넘겨 같은 방식으로 전면 불능이었다. + - 커밋 전 점검(같은 날)에서 추가 확정: 7.0.9 업그레이드 스텝이 활성 팩마다 + 활성화 훅을 재발화해 기존 설치본의 소실분을 병합 복구한다(upgrade_resync 축). + 이 백필의 안전 전제인 사용자 수정 보존은 dot-path(`"컬럼.로케일"`) 판정으로 + 정합화했고(종전 isset 판정은 사문), 주입 저장의 user_overrides 오염(시더 동결 + 유발)과 같은 프로세스 활성화의 시더 필터 stale(캡처 인스턴스 캐시)도 함께 교정. + +axes: + domain: [notification, identity] + entry: [seeder_filter, reset_default, upgrade_resync] + +effects: + - seeder_filter_injects_active_pack_locales + - seeder_filter_injects_ja_for_real_seeder_payload + - reset_default_data_includes_lang_pack_locales + - identity_reset_default_preserves_lang_pack_locales + - upgrade_resync_restores_wiped_pack_locales + - upgrade_resync_restores_wiped_identity_locales + +test_files: + - tests/Feature/LanguagePack/NotificationSeedInjectionTest.php + - tests/Feature/LanguagePack/IdentitySeedInjectionTest.php + - tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php + +validation: + automated: "php artisan test --filter='NotificationSeedInjectionTest|IdentitySeedInjectionTest|Upgrade709LangPackResyncTest|SyncDatabaseTranslationsTest'" + manual: | + 관리자 알림 설정에서 언어팩이 번역을 제공하는 템플릿(예: welcome)에 [기본값 복원] 실행 후, + 일본어 화면에서 제목·본문 번역이 유지되는지 확인한다. diff --git a/tests/scenarios/notification-definition-type-labels.yaml b/tests/scenarios/notification-definition-type-labels.yaml new file mode 100644 index 00000000..087c9715 --- /dev/null +++ b/tests/scenarios/notification-definition-type-labels.yaml @@ -0,0 +1,27 @@ +feature: 코어 알림 정의 타입 ↔ 관리자 화면 라벨 패리티 + +description: | + 관리자 알림 설정 화면의 정의 목록 제목은 DB name 이 아니라 프론트 다국어 키 + admin.settings.notification_definitions.types.{type} 로 렌더된다. 이 네임스페이스의 + 코어 소유분은 admin 템플릿(sirsoft-admin_basic)의 lang/partial/{ko,en}/admin.json 이 + 공급한다(모듈 소유 타입은 각 모듈 화면·lang 담당, ja 는 템플릿 언어팩 담당). + + 키가 없으면 예외·경고 없이 원시 키 문자열이 제목으로 그대로 노출된다 + (실사례: #481 이 sitemap_regenerated / sitemap_regenerate_failed 정의를 config 에만 + 추가하고 템플릿 라벨을 누락 — 2026-08-24 PO 제보). config/core.php 의 코어 정의 + 전수를 양 로케일 라벨과 자동 대조해 재발을 차단한다. + +axes: + locale: [ko, en] + +effects: + - core_definition_types_have_admin_template_labels + +test_files: + - tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php + +validation: + manual: | + /admin/settings?tab=notification_definitions 의 메일·사이트내 알림 탭에서 각 행 제목이 + 번역 문구로 표시되는지(원시 admin.settings.* 키 노출 0) 브라우저 실측. ja locale 은 + g7-template-sirsoft-admin_basic-ja 팩 활성 상태에서 동일 확인. diff --git a/upgrades/Upgrade_7_0_9.php b/upgrades/Upgrade_7_0_9.php new file mode 100644 index 00000000..db5459ad --- /dev/null +++ b/upgrades/Upgrade_7_0_9.php @@ -0,0 +1,34 @@ +logger->info('[core:7.0.9] language_packs 테이블 부재 — 언어팩 seed 재동기화 스킵'); + + return; + } + + $packs = LanguagePack::query()->where('status', 'active')->get(); + + if ($packs->isEmpty()) { + $context->logger->info('[core:7.0.9] 활성 언어팩 없음 — 언어팩 seed 재동기화 스킵'); + + return; + } + + foreach ($packs as $pack) { + try { + HookManager::doAction('core.language_packs.activated', $pack); + $context->logger->info('[core:7.0.9] 언어팩 seed 번역 재동기화 완료: '.$pack->identifier); + } catch (Throwable $e) { + // 개별 팩 실패(디렉토리 손상 등)가 업그레이드 전체를 막지 않도록 격리 — + // 남은 팩은 계속 처리하고, 실패 팩은 로그로 남겨 운영자가 후속 조치한다. + $context->logger->warning( + '[core:7.0.9] 언어팩 seed 재동기화 실패(건너뜀): '.$pack->identifier.' — '.$e->getMessage() + ); + } + } + } +}