diff --git a/.gitea/workflows/browser-e2e.yml b/.gitea/workflows/browser-e2e.yml index e0a6c64..2fd8b82 100644 --- a/.gitea/workflows/browser-e2e.yml +++ b/.gitea/workflows/browser-e2e.yml @@ -69,44 +69,69 @@ jobs: echo "✗ TIMEOUT: Deployment failed to publish ${SHORT_VERSION} within 60 seconds" >&2 exit 1 - - name: Browser Smoke verification + - name: Browser verifications in parallel env: # Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅 E2E_BASE_URL: https://www.taxbaik.com/taxbaik # E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리) E2E_ADMIN_USERNAME: test_admin E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} - run: | - echo "Running smoke checks on Desktop Chrome (production verification)" - npm run test:e2e:public-smoke - npm run test:e2e:admin-smoke - - - name: Browser E2E verification - env: - # Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅 - E2E_BASE_URL: https://www.taxbaik.com/taxbaik - # E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리) - E2E_ADMIN_USERNAME: test_admin - E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} - run: | - echo "Running full E2E on Desktop Chrome (production verification)" - npx playwright test --project="Desktop Chrome" --reporter=html --reporter=list - - - name: API smoke verification - env: - E2E_ADMIN_USERNAME: test_admin - E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} run: | set -e - TOKEN="$(curl -s -X POST "https://www.taxbaik.com/taxbaik/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c 'import sys, json; print(json.load(sys.stdin)["accessToken"])')" - test -n "$TOKEN" - curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null - curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/faq" >/dev/null - curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/announcement" >/dev/null - curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null - curl -fsS "https://www.taxbaik.com/taxbaik/favicon.svg" >/dev/null - curl -fsS "https://www.taxbaik.com/taxbaik/favicon.ico" >/dev/null - curl -fsS "https://www.taxbaik.com/taxbaik/robots.txt" >/dev/null + pids=() + names=() + + run_job() { + local name="$1" + shift + ( + echo "=== START ${name} ===" + "$@" + echo "=== DONE ${name} ===" + ) & + pids+=($!) + names+=("$name") + } + + run_job "browser-smoke" bash -lc 'npm run test:e2e:public-smoke && npm run test:e2e:admin-smoke' + run_job "browser-e2e" bash -lc 'npm run test:e2e:ci' + run_job "api-smoke" bash -lc ' + set -e + TOKEN="$(curl -s -X POST "https://www.taxbaik.com/taxbaik/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"${E2E_ADMIN_USERNAME}\",\"password\":\"${E2E_ADMIN_PASSWORD}\"}" | python3 -c '\''import sys, json; print(json.load(sys.stdin)["accessToken"])'\'')" + test -n "$TOKEN" + curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/blog/admin?page=1&pageSize=1" >/dev/null + curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/faq" >/dev/null + curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/announcement" >/dev/null + curl -fsS -H "Authorization: Bearer $TOKEN" "https://www.taxbaik.com/taxbaik/api/inquiry?page=1&pageSize=1" >/dev/null + curl -fsS "https://www.taxbaik.com/taxbaik/favicon.svg" >/dev/null + curl -fsS "https://www.taxbaik.com/taxbaik/favicon.ico" >/dev/null + curl -fsS "https://www.taxbaik.com/taxbaik/robots.txt" >/dev/null + ' + + failures=0 + for idx in "${!pids[@]}"; do + pid="${pids[$idx]}" + name="${names[$idx]}" + if wait "$pid"; then + echo "✓ ${name} passed" + else + echo "✗ ${name} failed" >&2 + failures=1 + fi + done + + exit "$failures" + + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + playwright-report/ + test-results/ + evidence/ + if-no-files-found: ignore - name: Browser E2E summary if: always() diff --git a/docs/ENGINEERING_HARNESS.md b/docs/ENGINEERING_HARNESS.md index de1ad80..199e2cc 100644 --- a/docs/ENGINEERING_HARNESS.md +++ b/docs/ENGINEERING_HARNESS.md @@ -31,6 +31,7 @@ - Infrastructure는 Dapper SQL과 외부 시스템 구현을 가진다. - Web은 Controller, 공개 Razor Pages SSR, Blazor host, 인증/서빙 설정을 가진다. - Web.Client/Admin UI는 클라이언트 사이드 Blazor WebAssembly로 본다. 서버 DI 서비스에 의존하지 않고 API client만 호출한다. +- 로그인 화면은 정적 SSR 또는 Razor Page form으로 처리하고, 홈/대시보드 같은 상호작용 화면만 Interactive WebAssembly + MudBlazor를 사용한다. - 관리자 호스트가 prerender를 사용하더라도 데이터 접근 원칙은 WASM + API-first다. prerender는 초기 마크업용이며 비즈니스 로직의 근거가 아니다. - 어드민 기본 렌더는 WASM이다. 다만 초기 흰 화면 방지 목적의 셸 프리렌더와 로그인 화면의 서버 프리렌더는 허용한다. 비즈니스 로직은 여전히 API-first다. - 로그인 화면은 예외적으로 “먼저 보여야 하는 화면”이다. JS 바인딩/텔레메트리/하이드레이션이 실패해도 로그인 폼 자체는 화면에 남아 있어야 하며, 실패 시 흰 화면이나 빈 본문을 허용하지 않는다. @@ -58,6 +59,9 @@ | 리팩토링 | 동작 보존 테스트를 먼저 추가하고 작은 단위로 이동 | | 일관성 | 오류 응답은 ProblemDetails, 페이징은 `{ data, total, page, pageSize }` | | 파편화 방지 | 같은 필드/상태/서비스유형 문자열은 enum/상수/공통 코드 중 하나로 단일화 | +| UI 상태 | Blazor 화면의 폼/목록 상태는 MVVM 스타일의 ViewModel로 분리 | +| DTO 검증 | DTO에는 `System.ComponentModel.DataAnnotations` 어노테이션을 1차 기준으로 둔다 | +| 추가 검증 | 어노테이션만으로 표현이 부족할 때만 FluentValidation을 보완으로 쓴다 | | 과유불급 | 추상화는 2개 이상 실제 사용처가 생긴 뒤 도입 | | 정규화 | 고객, 문의, 상담, 계약, 세금신고는 원천 테이블을 분리 | | 역정규화 | 대시보드/검색/운영 요약용 스냅샷만 허용하고 원천 id와 갱신 시점을 저장 | @@ -71,6 +75,7 @@ - DB 제약 조건이 1차 방어선이다: NOT NULL, UNIQUE, FK, CHECK, index. - Application validation은 사용자 메시지와 use case 규칙을 담당한다. +- DTO validation은 `DataAnnotations`를 1차 기준으로 하고, 표현력이 부족한 경우에만 FluentValidation으로 보완한다. - UI validation은 빠른 피드백일 뿐이며 유일한 검증으로 보지 않는다. - 관리자 수정 화면에 노출한 필드는 실제 저장되어야 한다. 저장하지 않는 필드는 read-only로 표시한다. - 상태 전이는 허용 목록을 둔다. 임의 문자열 저장을 금지한다. @@ -79,6 +84,7 @@ - 로그인 화면은 배포 전 브라우저 실증이 필수다. `dotnet build`만으로 로그인 화면 정상 표시를 완료로 선언하지 않는다. - 로그인 화면 실증 기준은 최소 1회 실제 브라우저 응답, 로그인 폼 렌더, 입력 포커스 가능 여부 확인이다. - 공개 루트와 관리자 루트는 반드시 분리 검증한다. `https://www.taxbaik.com/` 은 공용 홈페이지 제목을 가져야 하고, `https://www.taxbaik.com/taxbaik/admin/login` 은 관리자 제목을 가져야 한다. +- 최종 완료 검증은 실제 서비스 도메인에서만 수행한다. IP 직결 주소는 로컬/임시 확인용으로만 사용한다. - 배포 후 smoke 기준은 `scripts/taxbaik-smoke.sh`를 1차 기준으로 사용한다. CI와 브라우저 E2E는 이 스크립트를 재사용해야 한다. - `UsePathBase("/taxbaik")`가 있는 Web 앱에서 `MapGet("/")` 같은 루트 리다이렉트를 추가할 때는, 반드시 루프 여부를 `curl`로 먼저 확인하고 나서만 반영한다. `/`와 `/taxbaik/`가 서로를 다시 가리키면 안 된다. - nginx의 `location /`와 `location /taxbaik`를 건드린 뒤에는, 변경 직후 `curl -I https://www.taxbaik.com/`, `curl -I https://www.taxbaik.com/taxbaik/`, `curl -I https://www.taxbaik.com/taxbaik/admin/login` 순서로 확인하고 결과를 기록한다. @@ -109,7 +115,7 @@ | Naming | `Create[Entity]Endpoint`, `Get[Entity]Endpoint`, `List[Entity]Endpoint` 등 | | Location | `src/TaxBaik.Web/Features/[DomainName]/` | | Pattern | Request DTO → Endpoint class → Response DTO | -| Validation | FluentValidation (FastEndpoints 내장) | +| Validation | DTO는 `DataAnnotations` 우선, 부족할 때만 FluentValidation 보완 | | Registration | `builder.Services.AddFastEndpoints()` + `app.MapFastEndpoints()` | | Coexistence | Controllers와 FastEndpoints는 PathBase 내에서 병행 가능 (URL 충돌만 피함) | @@ -143,7 +149,8 @@ public class GetBlogEndpoint : Endpoint ## CI Harness -완료는 로컬 성공이 아니라 CI와 배포본 성공이다. +완료는 단순 로컬 성공이 아니라, 아래 필수 하네스를 모두 통과하고 그 증빙이 남아야 한다. +실행 순서는 항상 `로컬 1차 검증 -> CI 배포 -> 실제 도메인 최종 검증`이다. | Gate | Command/Check | Target | | --- | --- | --- | @@ -155,6 +162,42 @@ public class GetBlogEndpoint : Endpoint | Deploy | `.gitea/workflows/deploy.yml` | success | | Post Deploy | `.gitea/workflows/browser-e2e.yml` | success | +### Validation Order + +1. 로컬에서 `dotnet build`와 필요한 unit/Playwright 시나리오를 먼저 통과시킨다. +1. 로컬 검증이 끝나야 CI 배포를 진행한다. +1. CI 배포가 성공해야 실제 도메인에서 브라우저 E2E와 smoke를 수행한다. +1. 실제 도메인 결과와 증빙이 있어야만 최종 완료로 기록한다. + +### Definition Of Done + +다음 조건을 모두 만족해야 `완료`로 본다. + +1. `dotnet build`가 경고/오류 없이 성공한다. +1. Playwright가 실제 사용자 시나리오를 직접 호출한다. +1. 시나리오에서 기대한 데이터가 실제 응답 데이터로 검증된다. +1. 화면 캡처와 DOM 캡처를 남긴다. +1. 기대값이 실제 화면 결과와 일치한다는 증빙이 데이터로 남는다. +1. 위 증빙이 없으면 기능이 동작해 보여도 완료로 선언하지 않는다. + +### Mandatory Harness + +아래 하네스는 선택 사항이 아니라 필수다. + +- 빌드 하네스: `dotnet build src/TaxBaik.sln -c Release --no-restore` +- 브라우저 하네스: Playwright로 실제 사용자 흐름 실행 +- 데이터 하네스: API 응답, DB 값, 또는 화면 데이터가 기대값과 일치하는지 검증 +- 증빙 하네스: 스크린샷, DOM 스냅샷, 테스트 로그를 함께 보관 +- 판정 하네스: 사람의 육안 판단이 아니라 캡처와 로그로 성공 여부를 판정 + +### Evidence Rules + +- Playwright는 단순 페이지 열기만으로 끝내지 않는다. +- 사용자 관점에서 버튼 클릭, 입력, 저장, 조회 같은 실제 시나리오를 실행해야 한다. +- 각 시나리오는 기대값과 실제값을 비교하는 assert를 포함해야 한다. +- 스크린샷은 결과 화면을 증빙하고, DOM 캡처는 화면이 그렇게 나온 구조적 근거를 제공해야 한다. +- 수동 확인만 있고 데이터 증빙이 없으면 실패다. + ### Gitea Auth Harness - Gitea API와 workflow dispatch에는 `GITEA_TOKEN_TAXBAIK`만 사용한다. @@ -168,6 +211,7 @@ public class GetBlogEndpoint : Endpoint - 동일 개념이 3곳 이상 다른 이름/계약으로 구현되면 기능 추가를 중단하고 정리한다. - UI가 저장한다고 보이는 필드를 API/Application이 저장하지 않으면 릴리스하지 않는다. - 운영 배포 검증이 CI 밖에서만 가능하면 완료로 보지 않는다. +- 빌드, Playwright 시나리오, 화면 캡처, DOM 캡처, 데이터 일치 증빙이 없는 상태에서 완료라고 쓰지 않는다. - `UsePathBase` 아래에서 루트 기본값을 만들려면 리다이렉트보다 실제 최종 콘텐츠가 나오는지 먼저 확인한다. 루트가 `/taxbaik/`를 다시 반환하면 즉시 중단한다. - lint가 필요하면 build보다 먼저, 그리고 배포보다 훨씬 앞단에서 실패시키고, lint가 없는 프로젝트에 억지로 새 lint 체계를 만들지 않는다. - 데이터 모델을 추측해서 세무 규칙이나 더존 UX 관습을 왜곡해 구현하지 않는다. diff --git a/docs/INDEX.md b/docs/INDEX.md index beae1b0..011e9e1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,6 +7,7 @@ | 문서 | 용도 | 변경 조건 | | --- | --- | --- | | [ENGINEERING_HARNESS.md](./ENGINEERING_HARNESS.md) | 아키텍처, 코드 품질, 배포, 데이터 정합성 하네스 | 방향성 변경 또는 반복 위반 발견 | +| [STACK_TEMPLATE_WBS.md](./STACK_TEMPLATE_WBS.md) | MudBlazor + ASP.NET Core 10 + Dapper + PostgreSQL 18.4 + API-first + SSR/Interactive render mode 기준 템플릿, 로드맵, WBS | 새 스택 채택 또는 렌더/인증 기준 변경 | | [DOUZONE_UX_GUIDE.md](./DOUZONE_UX_GUIDE.md) | 더존식 어드민 UX 원칙, 템플릿, 컴포넌트, 서빙 규칙 | 화면 패턴 변경 또는 신규 템플릿 추가 | | [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md) | 공통코드, 저장값, 컬럼 길이, 하드코딩 금지 규칙 | 공통코드 또는 콤보 추가/수정 | | [COMBO_POLICY.md](./COMBO_POLICY.md) | 콤보/선택/검색 입력 정책과 저장값 규칙 | 상태/유형/선택 입력 정책 변경 | diff --git a/docs/STACK_TEMPLATE_WBS.md b/docs/STACK_TEMPLATE_WBS.md new file mode 100644 index 0000000..e3dc0f8 --- /dev/null +++ b/docs/STACK_TEMPLATE_WBS.md @@ -0,0 +1,160 @@ +# Stack Template And WBS + +이 문서는 `MudBlazor + ASP.NET Core 10 + Dapper + PostgreSQL 18.4 + API-first` 기준의 기본 템플릿과 작업 순서를 정의한다. 기준은 공식 문서로 확인한 다음 반영한다. + +## Verified Assumptions + +- MudBlazor는 static rendering을 지원하지 않는다. MudBlazor를 쓰는 화면은 interactive render mode가 필요하다. +- Blazor Web App은 static rendering과 interactive rendering을 구분한다. +- Interactive WebAssembly 컴포넌트는 `.Client` 프로젝트에 두는 것이 공식 구조다. +- ASP.NET Core 10은 `Microsoft.AspNetCore.OpenApi` 기반 OpenAPI 문서 생성을 지원한다. +- PostgreSQL 18.4는 2026-05-14 릴리스이며, 18.x 내 minor upgrade는 dump/restore가 필요하지 않다. +- Dapper는 SQL 중심의 micro ORM으로 repository layer에 맞는다. +- DTO 검증은 `DataAnnotations`를 기본으로 하고, 표현력이 부족한 경우에만 FluentValidation을 보완으로 둔다. +- Blazor UI의 화면 상태는 MVVM 스타일 ViewModel로 분리한다. + +## Target Template + +| 영역 | 권장 방식 | 비고 | +| --- | --- | --- | +| Public Home | Static SSR + 필요한 부분만 Interactive WebAssembly | 초기 로딩과 SEO 우선 | +| Login | Static SSR 또는 Razor Page form | MudBlazor 의존 금지 | +| Home/Dashboard | Interactive WebAssembly + MudBlazor | 사용자 인터랙션 중심 | +| Admin CRUD | Interactive WebAssembly + API-first | BrowserClient 경유 | +| API | Minimal API 또는 Controller + OpenAPI | 계약 우선 | +| Persistence | Dapper + `NpgsqlDataSource` singleton | connection pool 직접 관리 금지 | +| Auth | 브라우저는 HttpOnly Secure Cookie 우선, 외부 클라이언트만 JWT | localStorage 저장 금지 | + +현재 저장소 기준 예외: + +- 포털 로그인은 이미 Razor Page SSR로 구현돼 있다. +- 관리자 로그인은 아직 Interactive WebAssembly 경로를 사용한다. +- 따라서 본 템플릿의 로그인 SSR 기준은 포털/공개 사용자 흐름에 우선 적용하고, 관리자 로그인은 별도 마이그레이션 WBS로 분리한다. + +## Authentication Rules + +- 브라우저 사용자 흐름은 SSR 로그인 form으로 시작한다. +- 서버는 자격 증명을 검증한 뒤 HttpOnly + Secure + SameSite cookie를 발급한다. +- 같은 origin에서 WebAssembly 화면은 쿠키 기반 API 호출을 우선 사용한다. +- JWT는 외부 API 클라이언트, 모바일, 파트너용으로만 분리한다. +- 토큰 원문은 로그에 남기지 않는다. + +## Render Mode Rules + +- `/account/login` 은 Static SSR 또는 Razor Page로 구현한다. +- `/` 또는 홈 대시는 Interactive WebAssembly로 구현한다. +- MudBlazor 컴포넌트는 interactive render mode가 없는 페이지에 두지 않는다. +- 로그인 화면에서 `MudButton`, `MudTextField`, `MudDialog`, `MudSnackbar` 같은 interactive 의존은 금지한다. + +## Architecture Skeleton + +```text +src/ + TaxBaik.Web/ # Host, SSR shell, auth, API, OpenAPI + TaxBaik.Web.Client/ # Interactive WebAssembly UI + TaxBaik.Contracts/ # DTO, request/response, validation contracts + TaxBaik.Application/ # Use cases, interfaces, policies + TaxBaik.Infrastructure/ # Dapper repositories, external integrations + TaxBaik.Migrations/ # SQL migration scripts + +tests/ + TaxBaik.UnitTests/ + TaxBaik.ApiTests/ + TaxBaik.Playwright/ + +scripts/ + smoke/ + deploy/ + evidence/ +``` + +## Roadmap + +| Phase | Goal | Exit Criteria | +| --- | --- | --- | +| P0 Baseline | 공식 전제와 템플릿 기준 고정 | 본 문서와 `ENGINEERING_HARNESS.md`가 `INDEX.md`에 연결 | +| P1 Local Verify | 로컬 빌드/테스트/증빙 먼저 통과 | 로컬 `dotnet build`, unit, Playwright 1차 통과 | +| P2 CI Deploy | CI 배포와 중간 smoke 통과 | 배포 workflow success | +| P3 Domain Verify | 실제 도메인 최종 검증 | `https://www.taxbaik.com/taxbaik` 기준 브라우저 E2E 성공 | +| P1 Shell | SSR login / interactive home / API host 분리 | 로그인 SSR, 홈 WASM, API 계약 분리 확인 | +| P2 Auth | Cookie/JWT 이중 경로 정착 | 브라우저 cookie, 외부 JWT 경로 분리 완료 | +| P3 Data | Dapper + PostgreSQL 18.4 안정화 | repository, migration, index, connection 규칙 확정 | +| P4 Verify | Playwright와 데이터 증빙 자동화 | 화면 캡처, DOM 캡처, 실제 데이터 비교 통과 | +| P5 Operate | 배포/장애/FAQ 표준화 | CI/CD, smoke, rollback, incident FAQ 정착 | + +## Detailed WBS + +WBS 항목은 다음 데이터가 모두 있어야 완료다. + +- 변경 diff 링크 또는 파일명 +- 로컬 빌드 결과 +- 로컬 Playwright 결과 +- Playwright 시나리오 이름 +- 화면 캡처 파일 경로 +- DOM 캡처 파일 경로 +- 기대값과 실제값 비교 결과 +- CI 배포 결과 +- 실제 도메인 최종 검증 결과 +- CI 또는 배포 로그 링크 + +| ID | 작업 | 산출물 | 완료 데이터 정의 | +| --- | --- | --- | --- | +| P0-01 | 공식 전제 검증 문서화 | Verified Assumptions | MudBlazor static rendering 비지원, Blazor render mode, OpenAPI, PostgreSQL 18.4, Dapper 근거 링크 | +| P0-02 | 엔지니어링 하네스 반영 | `ENGINEERING_HARNESS.md` | 완료 정의에 build + Playwright + screenshot + DOM + data proof 포함 | +| P0-03 | 인덱스 연결 | `INDEX.md` | 새 템플릿 문서가 canonical list에 포함 | +| P1-01 | 솔루션 구조 확정 | `src/`, `tests/`, `scripts/` tree | 호스트/클라이언트/계약/애플리케이션/인프라 분리 확인 | +| P1-02 | 로그인 SSR 구현 가이드 | `/account/login` 기준 | static SSR form, antiforgery, cookie issuance 흐름 문서화 | +| P1-03 | 홈 WASM 가이드 | `/` 또는 dashboard | interactive WebAssembly + MudBlazor 사용 경계 문서화 | +| P1-04 | 관리자 로그인 마이그레이션 분리 | `/admin/login` 또는 equivalent | 현재 WASM 로그인과 목표 SSR 경계를 문서화하고 후속 WBS로 분리 | +| P1-05 | API-first 계약 정의 | `/api/v1/*` | DTO, OpenAPI, ProblemDetails, auth policy 정의 | +| P1-06 | DTO/MVVM 규칙 확정 | 모든 Blazor form/list | DTO는 DataAnnotations, UI는 ViewModel, 보완 검증은 FluentValidation | +| P2-01 | Cookie auth 템플릿 | auth middleware | HttpOnly/Secure/SameSite settings, 401/403 behavior 확인 | +| P2-02 | JWT 분리 템플릿 | external auth endpoint | refresh rotation, revocation, expiry, no localStorage rule | +| P2-03 | CSRF/antiforgery 검증 | login POST flow | token present, POST accepted, stale tab failure case documented | +| P3-01 | Dapper repository pattern | singleton `NpgsqlDataSource` | open-conn-per-method, transaction short-lived, query mapping test | +| P3-02 | PostgreSQL 18.4 migration policy | SQL scripts | versioned migrations, restore drill evidence | +| P3-03 | schema/index baseline | core tables | PK/FK/unique/check/index list and explain output | +| P4-01 | Playwright user journeys | login/home/admin scenario suite | each test has screenshot, DOM snapshot, assert on real data | +| P4-02 | evidence capture harness | scripts/evidence | screenshot + DOM + response capture persisted per run | +| P4-03 | build gate | CI/local build | 로컬 `dotnet build` warning/error zero evidence | +| P5-01 | deployment smoke | deploy workflow | CI 배포 success + 실제 도메인 smoke success | +| P5-02 | incident FAQ | docs FAQ section | common failure, root cause, and recovery steps documented | + +## Playwright Evidence Standard + +- 시나리오는 실제 사용자 관점이어야 한다. +- 버튼 클릭, 입력, 저장, 조회 같은 동작을 포함해야 한다. +- 결과는 화면 캡처만으로 끝내지 않고 DOM 캡처를 함께 남긴다. +- API 응답 또는 화면 텍스트를 기준으로 기대값과 실제값을 assert한다. +- 실패 시에는 어떤 데이터가 달랐는지 로그로 남긴다. +- 최종 완료 검증은 `https://www.taxbaik.com/taxbaik` 도메인에서 수행한다. IP 직결 주소는 로컬/임시 점검용으로만 쓴다. + +## Deployment and Trouble FAQ + +### Q1. 로그인 화면에 MudBlazor를 쓰면 안 되나? + +Static SSR 로그인이라면 쓰지 않는 것이 맞다. MudBlazor는 static rendering을 지원하지 않으므로 login은 HTML form 또는 Razor Page가 안전하다. + +### Q2. 브라우저 앱에 JWT를 localStorage에 저장해도 되나? + +피해야 한다. 브라우저 사용자 인증은 HttpOnly cookie가 기본이고, JWT는 외부 API 클라이언트용으로 분리한다. + +### Q3. WebAssembly 홈에서 cookie 인증 API 호출이 복잡한가? + +same-origin이면 단순하다. cross-origin이면 CORS, SameSite, credentials 정책이 추가되어 장애 포인트가 늘어난다. + +### Q4. Dapper를 쓰면 ORM의 변경 추적이 없는데 괜찮나? + +이 구조는 조회와 명령을 분리하는 쪽에 맞는다. SQL을 명시적으로 관리하고 repository 테스트와 migration으로 정합성을 유지한다. + +### Q5. PostgreSQL 18.4로 업그레이드할 때 dump/restore가 필요한가? + +같은 18.x minor 업그레이드는 dump/restore가 필요하지 않다. 다만 운영 적용 전 staging restore drill은 필수다. + +## Stop Conditions + +- MudBlazor를 static rendering 화면에 억지로 넣는 시도는 중단한다. +- 로그인 SSR과 WASM home 경계를 흐리는 구현은 중단한다. +- 관리자 로그인 WASM을 즉시 제거하지 말고, 먼저 migration WBS와 대체 SSR 경로를 정의한다. +- JWT를 브라우저 저장소에 넣는 설계는 중단한다. +- 완료 데이터 정의가 없는 WBS는 완료로 보지 않는다. diff --git a/package.json b/package.json index bf1b935..a89d563 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "test:e2e:headed": "playwright test --headed", "test:e2e:public-smoke": "playwright test --project=\"Public Smoke\" tests/e2e/public-smoke.spec.ts", "test:e2e:admin-smoke": "playwright test --project=\"Admin Smoke\" tests/e2e/admin-smoke.spec.ts", + "test:e2e:ci": "playwright test --project=\"Desktop Chrome\"", "test:local": "python scripts/run_local_tests.py" }, "devDependencies": { diff --git a/scripts/run_local_tests.py b/scripts/run_local_tests.py index a4606c7..e791c01 100644 --- a/scripts/run_local_tests.py +++ b/scripts/run_local_tests.py @@ -23,6 +23,13 @@ def wait_for_server(url, timeout=30): time.sleep(1) return False +def is_server_healthy(url): + try: + with urllib.request.urlopen(url, timeout=3) as response: + return response.status == 200 + except Exception: + return False + def main(): port = 5001 base_url = f"http://localhost:{port}/taxbaik" @@ -51,9 +58,12 @@ def main(): else: print("Database port 5432 is already active.") - if is_port_in_use(port): - print(f"Warning: Port {port} is already in use. Assuming local server is already running.") + server_healthy = is_server_healthy(health_url) + if is_port_in_use(port) and server_healthy: + print(f"Warning: Port {port} is already in use and the local server is healthy.") else: + if is_port_in_use(port): + print(f"Warning: Port {port} is already in use but the local server is not healthy. Starting a new server attempt may fail if another process is bound.") print(f"Starting dotnet local server on port {port}...") # Start dotnet run in background env = os.environ.copy() @@ -105,10 +115,14 @@ def main(): test_env["E2E_BASE_URL"] = base_url # Set default test credentials test_env["E2E_ADMIN_USERNAME"] = "test_admin" - test_env["E2E_ADMIN_PASSWORD"] = "TestAdmin@123456" + test_env["E2E_ADMIN_PASSWORD"] = "admin123" # Run playwright test command + # Local 1차 검증은 Desktop Chrome만 대상으로 잡아 불필요한 브라우저 설치 의존을 줄인다. cmd = ["npx", "playwright", "test"] + has_project_arg = any(arg.startswith("--project=") or arg == "--project" for arg in sys.argv[1:]) + if not has_project_arg: + cmd.append('--project="Desktop Chrome"') if len(sys.argv) > 1: cmd.extend(sys.argv[1:]) diff --git a/src/TaxBaik.Application/DTOs/AnnouncementDto.cs b/src/TaxBaik.Application/DTOs/AnnouncementDto.cs index d3da0ba..60f864b 100644 --- a/src/TaxBaik.Application/DTOs/AnnouncementDto.cs +++ b/src/TaxBaik.Application/DTOs/AnnouncementDto.cs @@ -1,13 +1,27 @@ namespace TaxBaik.Application.DTOs; +using System.ComponentModel.DataAnnotations; + public class AnnouncementDto { public int Id { get; set; } + + [Required(ErrorMessage = "제목을 입력하세요.")] + [StringLength(100, ErrorMessage = "제목은 최대 100자까지 입력 가능합니다.")] public string Title { get; set; } = ""; + + [StringLength(2000, ErrorMessage = "상세 내용은 최대 2000자까지 입력 가능합니다.")] public string? Content { get; set; } + + [Required(ErrorMessage = "표시 유형을 선택하세요.")] + [StringLength(20, ErrorMessage = "표시 유형은 최대 20자까지 입력 가능합니다.")] public string DisplayType { get; set; } = "info"; + public bool IsActive { get; set; } = true; + public DateTime? StartsAt { get; set; } + public DateTime? EndsAt { get; set; } + public int SortOrder { get; set; } } diff --git a/src/TaxBaik.Application/DTOs/ClientDto.cs b/src/TaxBaik.Application/DTOs/ClientDto.cs index d99b04f..cfb2303 100644 --- a/src/TaxBaik.Application/DTOs/ClientDto.cs +++ b/src/TaxBaik.Application/DTOs/ClientDto.cs @@ -1,5 +1,7 @@ namespace TaxBaik.Application.DTOs; +using System.ComponentModel.DataAnnotations; + public class ClientDto { public int Id { get; set; } @@ -18,13 +20,31 @@ public class ClientDto public class CreateClientDto { + [Required(ErrorMessage = "고객명을 입력하세요.")] + [StringLength(100, ErrorMessage = "고객명은 최대 100자까지 입력 가능합니다.")] public string Name { get; set; } = null!; + + [StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")] public string? CompanyName { get; set; } + + [StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")] public string? Phone { get; set; } + + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] public string? Email { get; set; } + + [StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")] public string? ServiceType { get; set; } + + [StringLength(50, ErrorMessage = "세금 유형은 최대 50자까지 입력 가능합니다.")] public string? TaxType { get; set; } + + [Required(ErrorMessage = "상태를 선택하세요.")] public string Status { get; set; } = "active"; + + [StringLength(50, ErrorMessage = "유입 경로는 최대 50자까지 입력 가능합니다.")] public string? Source { get; set; } + + [StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")] public string? Memo { get; set; } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/App.razor b/src/TaxBaik.Web.Client/Components/Admin/App.razor index c32732a..5a9e111 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/App.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/App.razor @@ -19,7 +19,6 @@ window.location.pathname.toLowerCase().endsWith('/admin/login')); -
diff --git a/src/TaxBaik.Web.Client/Components/Admin/Forms/AdminFormModels.cs b/src/TaxBaik.Web.Client/Components/Admin/Forms/AdminFormModels.cs new file mode 100644 index 0000000..20a31f8 --- /dev/null +++ b/src/TaxBaik.Web.Client/Components/Admin/Forms/AdminFormModels.cs @@ -0,0 +1,217 @@ +namespace TaxBaik.WasmClient.Components.Admin.Forms; + +using System.ComponentModel.DataAnnotations; + +public sealed class CompanyFormModel +{ + [Required(ErrorMessage = "회사 코드를 입력하세요.")] + [StringLength(50, ErrorMessage = "회사 코드는 최대 50자까지 입력 가능합니다.")] + public string CompanyCode { get; set; } = ""; + + [Required(ErrorMessage = "회사명을 입력하세요.")] + [StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")] + public string CompanyName { get; set; } = ""; + + [StringLength(100, ErrorMessage = "담당자명은 최대 100자까지 입력 가능합니다.")] + public string? ContactPerson { get; set; } + + [StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")] + public string? Phone { get; set; } + + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string? Email { get; set; } + + [StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")] + public string? Memo { get; set; } + + public bool IsActive { get; set; } = true; +} + +public sealed class InquiryFormModel +{ + [Required(ErrorMessage = "이름을 입력하세요.")] + [StringLength(100, ErrorMessage = "이름은 최대 100자까지 입력 가능합니다.")] + public string Name { get; set; } = ""; + + [Required(ErrorMessage = "전화번호를 입력하세요.")] + [RegularExpression( + @"^(0(?:2|3[1-3]|4[1-4]|5[1-5]|6[1-4]|70|50[5-9]|[7-9](?:\d{1,2})?)\d{7,8}|0\d{9,10})$", + ErrorMessage = "올바른 전화번호 형식이 아닙니다.")] + public string Phone { get; set; } = ""; + + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string? Email { get; set; } + + [StringLength(50, ErrorMessage = "상담분야는 최대 50자까지 입력 가능합니다.")] + public string ServiceType { get; set; } = "기타"; + + [Required(ErrorMessage = "문의 내용을 입력하세요.")] + [StringLength(5000, MinimumLength = 10, ErrorMessage = "문의 내용은 최소 10자, 최대 5000자까지 입력 가능합니다.")] + public string Message { get; set; } = ""; + + [Required(ErrorMessage = "상태를 선택하세요.")] + public string Status { get; set; } = "new"; + + [StringLength(1000, ErrorMessage = "관리 메모는 최대 1000자까지 입력 가능합니다.")] + public string? AdminMemo { get; set; } +} + +public sealed class BlogFormModel +{ + [Required(ErrorMessage = "제목을 입력하세요.")] + [StringLength(100, ErrorMessage = "제목은 최대 100자까지 입력 가능합니다.")] + public string Title { get; set; } = ""; + + [Required(ErrorMessage = "본문 내용을 입력하세요.")] + [StringLength(30000, MinimumLength = 10, ErrorMessage = "본문은 최소 10자 이상이어야 합니다.")] + public string Content { get; set; } = ""; + + public int? CategoryId { get; set; } + + [StringLength(500, ErrorMessage = "태그는 최대 500자까지 입력 가능합니다.")] + public string? Tags { get; set; } + + [StringLength(120, ErrorMessage = "SEO 제목은 최대 120자까지 입력 가능합니다.")] + public string? SeoTitle { get; set; } + + [StringLength(300, ErrorMessage = "SEO 설명은 최대 300자까지 입력 가능합니다.")] + public string? SeoDescription { get; set; } + + public bool IsPublished { get; set; } +} + +public sealed class FaqFormModel +{ + [Required(ErrorMessage = "질문을 입력하세요.")] + [StringLength(300, ErrorMessage = "질문은 최대 300자까지 입력 가능합니다.")] + public string Question { get; set; } = ""; + + [Required(ErrorMessage = "답변을 입력하세요.")] + [StringLength(5000, MinimumLength = 10, ErrorMessage = "답변은 최소 10자, 최대 5000자까지 입력 가능합니다.")] + public string Answer { get; set; } = ""; + + [StringLength(50, ErrorMessage = "카테고리는 최대 50자까지 입력 가능합니다.")] + public string? Category { get; set; } + + [Range(0, 9999, ErrorMessage = "정렬 순서는 0 이상 9999 이하이어야 합니다.")] + public int SortOrder { get; set; } + + public bool IsActive { get; set; } +} + +public sealed class ClientFormModel +{ + [Required(ErrorMessage = "고객명을 입력하세요.")] + [StringLength(100, ErrorMessage = "고객명은 최대 100자까지 입력 가능합니다.")] + public string Name { get; set; } = ""; + + [StringLength(100, ErrorMessage = "회사명은 최대 100자까지 입력 가능합니다.")] + public string? CompanyName { get; set; } + + [StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")] + public string? Phone { get; set; } + + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string? Email { get; set; } + + [StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")] + public string? ServiceType { get; set; } + + [StringLength(50, ErrorMessage = "세금 유형은 최대 50자까지 입력 가능합니다.")] + public string? TaxType { get; set; } + + [Required(ErrorMessage = "상태를 선택하세요.")] + public string Status { get; set; } = "active"; + + [StringLength(50, ErrorMessage = "유입 경로는 최대 50자까지 입력 가능합니다.")] + public string? Source { get; set; } + + [StringLength(1000, ErrorMessage = "메모는 최대 1000자까지 입력 가능합니다.")] + public string? Memo { get; set; } +} + +public sealed class ContractFormModel +{ + [Required(ErrorMessage = "고객을 선택하세요.")] + public int? ClientId { get; set; } + + [Required(ErrorMessage = "계약번호를 입력하세요.")] + [StringLength(50, ErrorMessage = "계약번호는 최대 50자까지 입력 가능합니다.")] + public string ContractNumber { get; set; } = ""; + + [Required(ErrorMessage = "서비스 유형을 선택하세요.")] + [StringLength(50, ErrorMessage = "서비스 유형은 최대 50자까지 입력 가능합니다.")] + public string ServiceType { get; set; } = ""; + + [Required(ErrorMessage = "계약 시작일을 입력하세요.")] + public DateTime? StartDate { get; set; } + + [Range(0, 999999999, ErrorMessage = "월 수수료는 0 이상이어야 합니다.")] + public decimal? MonthlyFee { get; set; } +} + +public sealed class TaxFilingScheduleFormModel +{ + [Required(ErrorMessage = "고객을 선택하세요.")] + public int? ClientId { get; set; } + + [Required(ErrorMessage = "신고 유형을 선택하세요.")] + [StringLength(50, ErrorMessage = "신고 유형은 최대 50자까지 입력 가능합니다.")] + public string FilingType { get; set; } = ""; + + [Required(ErrorMessage = "마감일을 입력하세요.")] + public DateTime? DueDate { get; set; } + + [Range(2000, 2100, ErrorMessage = "신고연도는 2000년부터 2100년 사이여야 합니다.")] + public int FilingYear { get; set; } = DateTime.Now.Year; +} + +public sealed class TaxProfileFormModel +{ + [Required(ErrorMessage = "고객을 선택하세요.")] + public int? ClientId { get; set; } + + [Required(ErrorMessage = "사업 유형을 선택하세요.")] + [StringLength(50, ErrorMessage = "사업 유형은 최대 50자까지 입력 가능합니다.")] + public string BusinessType { get; set; } = ""; + + [Required(ErrorMessage = "위험도를 선택하세요.")] + [StringLength(20, ErrorMessage = "위험도는 최대 20자까지 입력 가능합니다.")] + public string TaxRiskLevel { get; set; } = "normal"; + + public DateTime? NextFilingDueDate { get; set; } + + [StringLength(1000, ErrorMessage = "특수 사항은 최대 1000자까지 입력 가능합니다.")] + public string? SpecialNotes { get; set; } +} + +public sealed class SiteSettingsFormModel +{ + [Required(ErrorMessage = "전화번호를 입력하세요.")] + [StringLength(20, ErrorMessage = "전화번호는 최대 20자까지 입력 가능합니다.")] + public string Phone { get; set; } = ""; + + [Required(ErrorMessage = "이메일을 입력하세요.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { get; set; } = ""; + + [Url(ErrorMessage = "올바른 카카오채널 URL 형식이 아닙니다.")] + public string? KakaoUrl { get; set; } + + [Url(ErrorMessage = "올바른 인스타그램 URL 형식이 아닙니다.")] + public string? InstagramUrl { get; set; } +} + +public sealed class PasswordChangeFormModel +{ + [Required(ErrorMessage = "현재 비밀번호를 입력하세요.")] + public string CurrentPassword { get; set; } = ""; + + [Required(ErrorMessage = "새 비밀번호를 입력하세요.")] + [StringLength(128, MinimumLength = 12, ErrorMessage = "새 비밀번호는 최소 12자 이상이어야 합니다.")] + public string NewPassword { get; set; } = ""; + + [Required(ErrorMessage = "새 비밀번호 확인을 입력하세요.")] + [Compare(nameof(NewPassword), ErrorMessage = "새 비밀번호와 확인이 일치하지 않습니다.")] + public string ConfirmNewPassword { get; set; } = ""; +} diff --git a/src/TaxBaik.Web.Client/Components/Admin/Forms/CompanyForm.razor b/src/TaxBaik.Web.Client/Components/Admin/Forms/CompanyForm.razor index 54d653d..5134928 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Forms/CompanyForm.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Forms/CompanyForm.razor @@ -75,14 +75,4 @@ await OnSubmit.InvokeAsync(model); } - public class CompanyFormModel - { - public string CompanyCode { get; set; } = ""; - public string CompanyName { get; set; } = ""; - public string? ContactPerson { get; set; } - public string? Phone { get; set; } - public string? Email { get; set; } - public string? Memo { get; set; } - public bool IsActive { get; set; } = true; - } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Forms/InquiryForm.razor b/src/TaxBaik.Web.Client/Components/Admin/Forms/InquiryForm.razor index 399ee36..8a0331a 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Forms/InquiryForm.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Forms/InquiryForm.razor @@ -83,14 +83,4 @@ await OnSubmit.InvokeAsync(model); } - public class InquiryFormModel - { - public string Name { get; set; } = ""; - public string Phone { get; set; } = ""; - public string? Email { get; set; } - public string ServiceType { get; set; } = "기타"; - public string Message { get; set; } = ""; - public string Status { get; set; } = "new"; - public string? AdminMemo { get; set; } - } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Announcements/AnnouncementEdit.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Announcements/AnnouncementEdit.razor index 9905b15..ce86f10 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Announcements/AnnouncementEdit.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Announcements/AnnouncementEdit.razor @@ -2,6 +2,7 @@ @page "/admin/announcements/{Id:int}/edit" @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) @attribute [Authorize] +@using System.ComponentModel.DataAnnotations @using TaxBaik.Application.DTOs @using TaxBaik.Web.Services @using TaxBaik.WasmClient.Components.Admin.Shared diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogCreate.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogCreate.razor index b4ab993..c5b97eb 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogCreate.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogCreate.razor @@ -22,7 +22,7 @@ @code { private IReadOnlyList categories = []; - private BlogForm.BlogFormModel model = new(); + private BlogFormModel model = new(); protected override async Task OnInitializedAsync() { diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogEdit.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogEdit.razor index ef0faaf..baa690b 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogEdit.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogEdit.razor @@ -38,7 +38,7 @@ else private TaxBaik.Application.DTOs.BlogPostResponseDto? post; private IReadOnlyList categories = []; - private BlogForm.BlogFormModel model = new(); + private BlogFormModel model = new(); private bool isLoading = true; private RenderFragment EditorSkeleton => builder => @@ -129,9 +129,9 @@ else return; var result = await DialogService.ShowMessageBox( - "포스트 삭제", - "정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", - "삭제", "취소"); + "포스트 임시 내리기", + "정말 이 포스트를 임시로 내리시겠습니까? 필요하면 나중에 다시 올릴 수 있습니다.", + "임시 내리기", "취소"); if (result != true) return; @@ -141,15 +141,15 @@ else var deleted = await BlogClient.DeleteAsync(post.Id); if (!deleted) { - Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error); + Snackbar.Add("임시 내리기 실패: 포스트를 내리지 못했습니다.", Severity.Error); return; } - Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); + Snackbar.Add("포스트를 임시로 내렸습니다.", Severity.Success); Navigation.NavigateTo("/taxbaik/admin/blog"); } catch (Exception ex) { - Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error); + Snackbar.Add($"임시 내리기 실패: {ex.Message}", Severity.Error); } } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogForm.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogForm.razor index 920cfd2..c1db05f 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogForm.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogForm.razor @@ -72,14 +72,4 @@ await OnSubmit.InvokeAsync(); } - public class BlogFormModel - { - public string Title { get; set; } = ""; - public string Content { get; set; } = ""; - public int? CategoryId { get; set; } - public string? Tags { get; set; } - public string? SeoTitle { get; set; } - public string? SeoDescription { get; set; } - public bool IsPublished { get; set; } - } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogList.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogList.razor index 919380d..6113ed9 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogList.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Blog/BlogList.razor @@ -10,7 +10,7 @@ - @(showArchived ? "전체 글 보기" : "숨김 글 보기") + @(showArchived ? "전체 글 보기" : "내린 글 보기") @@ -50,12 +50,12 @@ @if (showArchived) { 복원 + @onclick="@(async () => await RestorePost(cell.Item.Id))">다시 올리기 } else { 삭제 + @onclick="@(async () => await DeletePost(cell.Item.Id))">임시 내리기 } @@ -177,10 +177,10 @@ var deleted = await BlogClient.DeleteAsync(postId); if (!deleted) { - Snackbar.Add("포스트 삭제에 실패했습니다.", Severity.Error); + Snackbar.Add("포스트를 내리지 못했습니다.", Severity.Error); return; } - Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success); + Snackbar.Add("포스트를 임시로 내렸습니다.", Severity.Success); await LoadPosts(); } @@ -189,11 +189,11 @@ var restored = await BlogClient.RestoreAsync(postId); if (!restored) { - Snackbar.Add("포스트 복원에 실패했습니다.", Severity.Error); + Snackbar.Add("포스트를 다시 올리지 못했습니다.", Severity.Error); return; } - Snackbar.Add("포스트가 복원되었습니다.", Severity.Success); + Snackbar.Add("포스트를 다시 올렸습니다.", Severity.Success); await LoadPosts(); } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Clients/ClientEdit.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Clients/ClientEdit.razor index c24bddd..eb7db3a 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Clients/ClientEdit.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Clients/ClientEdit.razor @@ -98,7 +98,7 @@ [Parameter] public int? Id { get; set; } private MudForm form = null!; - private CreateClientDto dto = new() { Status = "active" }; + private ClientFormModel dto = new() { Status = "active" }; private bool isValid; private bool isLoading = true; private bool isSaving; @@ -123,7 +123,7 @@ Navigation.NavigateTo("/taxbaik/admin/clients"); return; } - dto = new CreateClientDto + dto = new ClientFormModel { Name = client.Name, CompanyName = client.CompanyName, @@ -156,7 +156,18 @@ { if (Id.HasValue) { - var result = await ClientClient.UpdateAsync(Id.Value, dto); + var result = await ClientClient.UpdateAsync(Id.Value, new CreateClientDto + { + Name = dto.Name, + CompanyName = dto.CompanyName, + Phone = dto.Phone, + Email = dto.Email, + ServiceType = dto.ServiceType, + TaxType = dto.TaxType, + Status = dto.Status, + Source = dto.Source, + Memo = dto.Memo + }); if (result != null) Snackbar.Add("고객 정보가 수정되었습니다.", Severity.Success); else @@ -164,7 +175,18 @@ } else { - var result = await ClientClient.CreateAsync(dto); + var result = await ClientClient.CreateAsync(new CreateClientDto + { + Name = dto.Name, + CompanyName = dto.CompanyName, + Phone = dto.Phone, + Email = dto.Email, + ServiceType = dto.ServiceType, + TaxType = dto.TaxType, + Status = dto.Status, + Source = dto.Source, + Memo = dto.Memo + }); if (result != null) Snackbar.Add("고객이 등록되었습니다.", Severity.Success); else diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyCreate.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyCreate.razor index 531a82d..cc45068 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyCreate.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyCreate.razor @@ -27,7 +27,7 @@ Navigation.NavigateTo("/taxbaik/admin/companies"); } - private async Task HandleCreate(CompanyForm.CompanyFormModel model) + private async Task HandleCreate(CompanyFormModel model) { try { diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyEdit.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyEdit.razor index 4bc3416..8ac339b 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyEdit.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Companies/CompanyEdit.razor @@ -41,7 +41,7 @@ [Parameter] public int Id { get; set; } - private CompanyForm.CompanyFormModel? formModel; + private CompanyFormModel? formModel; private bool isLoading = true; private RenderFragment CompanySkeleton => builder => @@ -60,7 +60,7 @@ IDictionary? dict = company as IDictionary; if (dict != null) { - formModel = new CompanyForm.CompanyFormModel + formModel = new CompanyFormModel { CompanyCode = (string)dict["companyCode"], CompanyName = (string)dict["companyName"], @@ -87,7 +87,7 @@ Navigation.NavigateTo("/taxbaik/admin/companies"); } - private async Task HandleUpdate(CompanyForm.CompanyFormModel model) + private async Task HandleUpdate(CompanyFormModel model) { try { diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Contracts.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Contracts.razor index 05602ad..2549534 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Contracts.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Contracts.razor @@ -147,7 +147,7 @@ else private MudForm? form; private bool isEditMode; private Contract? selectedContract; - private ContractForm contractForm = new(); + private ContractFormModel contractForm = new(); private RenderFragment ContractSkeleton => builder => { @@ -198,7 +198,7 @@ else { selectedContract = null; isEditMode = false; - contractForm = new ContractForm + contractForm = new ContractFormModel { ClientId = clients.FirstOrDefault()?.Id, StartDate = DateTime.Today @@ -210,7 +210,7 @@ else if (contract == null) return; selectedContract = contract; isEditMode = true; - contractForm = new ContractForm + contractForm = new ContractFormModel { ClientId = contract.ClientId, ContractNumber = contract.ContractNumber, @@ -292,12 +292,4 @@ else ? client.Name : $"Client #{client.Id}"; - private class ContractForm - { - public int? ClientId { get; set; } - public string ContractNumber { get; set; } = ""; - public string ServiceType { get; set; } = ""; - public DateTime? StartDate { get; set; } - public decimal? MonthlyFee { get; set; } - } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Faqs/FaqEdit.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Faqs/FaqEdit.razor index 9e221cf..598924b 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Faqs/FaqEdit.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Faqs/FaqEdit.razor @@ -3,7 +3,6 @@ @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) @attribute [Authorize] @using TaxBaik.Web.Services -@using TaxBaik.Domain.Entities @inject IFaqBrowserClient FaqClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @@ -24,7 +23,7 @@ - - - + - - + @@ -71,7 +70,7 @@ [Parameter] public int? Id { get; set; } private MudForm form = null!; - private Faq faq = new() { SortOrder = 10, IsActive = true }; + private FaqFormModel model = new() { SortOrder = 10, IsActive = true }; private bool isValid; private bool isLoading = true; private bool isSaving; @@ -97,7 +96,14 @@ Navigation.NavigateTo("/taxbaik/admin/faqs"); return; } - faq = existing; + model = new FaqFormModel + { + Question = existing.Question, + Answer = existing.Answer, + Category = existing.Category, + SortOrder = existing.SortOrder, + IsActive = existing.IsActive + }; } catch (Exception ex) { @@ -117,6 +123,16 @@ isSaving = true; try { + var faq = new Faq + { + Id = Id ?? 0, + Question = model.Question, + Answer = model.Answer, + Category = model.Category, + SortOrder = model.SortOrder, + IsActive = model.IsActive + }; + if (Id.HasValue) { var result = await FaqClient.UpdateAsync(Id.Value, faq); @@ -144,4 +160,5 @@ isSaving = false; } } + } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryCreate.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryCreate.razor index 9c55687..f173fb0 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryCreate.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryCreate.razor @@ -25,10 +25,11 @@ Navigation.NavigateTo("/taxbaik/admin/inquiries"); } - private async Task HandleCreate(InquiryForm.InquiryFormModel model) + private async Task HandleCreate(InquiryFormModel model) { try { + // ViewModel validation is the first line; DTO validation remains the contract boundary. var result = await InquiryClient.CreateAsync(new SubmitInquiryDto { Name = model.Name, diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryEdit.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryEdit.razor index 988c6f1..cf90ed5 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryEdit.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Inquiries/InquiryEdit.razor @@ -39,7 +39,7 @@ else public int Id { get; set; } private Domain.Entities.Inquiry? inquiry; - private InquiryForm.InquiryFormModel? formModel; + private InquiryFormModel? formModel; private bool isLoading = true; private RenderFragment EditorSkeleton => builder => @@ -57,7 +57,7 @@ else inquiry = await InquiryClient.GetByIdAsync(Id); if (inquiry != null) { - formModel = new InquiryForm.InquiryFormModel + formModel = new InquiryFormModel { Name = inquiry.Name, Phone = inquiry.Phone, @@ -84,7 +84,7 @@ else Navigation.NavigateTo("/taxbaik/admin/inquiries"); } - private async Task HandleUpdate(InquiryForm.InquiryFormModel model) + private async Task HandleUpdate(InquiryFormModel model) { if (inquiry == null) return; @@ -109,7 +109,7 @@ else } inquiry = updated; - formModel = new InquiryForm.InquiryFormModel + formModel = new InquiryFormModel { Name = inquiry.Name, Phone = inquiry.Phone, diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor deleted file mode 100644 index 1524f6c..0000000 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Login.razor +++ /dev/null @@ -1,6 +0,0 @@ -@page "/admin/login" -@layout TaxBaik.WasmClient.Components.Admin.Layout.BlankLayout -@attribute [AllowAnonymous] -@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) -로그인 - diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/Settings/SiteSettings.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/Settings/SiteSettings.razor index 60448b6..50ec7c8 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/Settings/SiteSettings.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/Settings/SiteSettings.razor @@ -1,7 +1,6 @@ @page "/settings" @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) @attribute [Authorize] -@using System.ComponentModel.DataAnnotations @using System.Collections.Generic @using TaxBaik.Web.Services @using TaxBaik.Domain.Interfaces @@ -30,16 +29,16 @@
- - - - - - - @code { - private string phone = "010-4122-8268"; - private string email = "taxbaik5668@gmail.com"; - private string kakaoUrl = "http://pf.kakao.com/_xoxchTX"; - private string instagramUrl = "https://www.instagram.com/taxtory5668/"; - private string currentPassword = ""; - private string newPassword = ""; - private string confirmNewPassword = ""; private bool isChangingPassword; private bool isLoadingSettings; + private SiteSettingsFormModel form = new() + { + Phone = "010-4122-8268", + Email = "taxbaik5668@gmail.com", + KakaoUrl = "http://pf.kakao.com/_xoxchTX", + InstagramUrl = "https://www.instagram.com/taxtory5668/" + }; + private PasswordChangeFormModel passwordForm = new(); protected override async Task OnInitializedAsync() { @@ -106,16 +106,16 @@ return; if (settings.TryGetValue("PhoneNumber", out var loadedPhone) && !string.IsNullOrWhiteSpace(loadedPhone)) - phone = loadedPhone; + form.Phone = loadedPhone; if (settings.TryGetValue("EmailAddress", out var loadedEmail) && !string.IsNullOrWhiteSpace(loadedEmail)) - email = loadedEmail; + form.Email = loadedEmail; if (settings.TryGetValue("KakaoChannelUrl", out var loadedKakao) && !string.IsNullOrWhiteSpace(loadedKakao)) - kakaoUrl = loadedKakao; + form.KakaoUrl = loadedKakao; if (settings.TryGetValue("InstagramUrl", out var loadedInstagram) && !string.IsNullOrWhiteSpace(loadedInstagram)) - instagramUrl = loadedInstagram; + form.InstagramUrl = loadedInstagram; } catch { @@ -134,10 +134,10 @@ var response = await ApiClient.PutAsync("site-settings", new { - Phone = phone, - Email = email, - KakaoUrl = kakaoUrl, - InstagramUrl = instagramUrl + Phone = form.Phone, + Email = form.Email, + KakaoUrl = form.KakaoUrl, + InstagramUrl = form.InstagramUrl }); if (response?.Message is null) @@ -154,13 +154,13 @@ if (isChangingPassword) return; - if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword)) + if (string.IsNullOrWhiteSpace(passwordForm.CurrentPassword) || string.IsNullOrWhiteSpace(passwordForm.NewPassword)) { Snackbar.Add("현재 비밀번호와 새 비밀번호를 입력하세요.", Severity.Warning); return; } - if (newPassword != confirmNewPassword) + if (passwordForm.NewPassword != passwordForm.ConfirmNewPassword) { Snackbar.Add("새 비밀번호 확인이 일치하지 않습니다.", Severity.Warning); return; @@ -172,8 +172,8 @@ { var response = await ApiClient.PostAsync("auth/change-password", new { - CurrentPassword = currentPassword, - NewPassword = newPassword + CurrentPassword = passwordForm.CurrentPassword, + NewPassword = passwordForm.NewPassword }); if (response?.Message == null) @@ -183,9 +183,7 @@ } Snackbar.Add(response.Message, Severity.Success); - currentPassword = ""; - newPassword = ""; - confirmNewPassword = ""; + passwordForm = new PasswordChangeFormModel(); } catch { @@ -206,4 +204,5 @@ { public string Message { get; set; } = ""; } + } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxFilingSchedules.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxFilingSchedules.razor index bf0a538..1509df4 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxFilingSchedules.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxFilingSchedules.razor @@ -150,7 +150,7 @@ else private MudForm? form; private bool isEditMode; private TaxFilingSchedule? selectedSchedule; - private TaxFilingScheduleForm scheduleForm = new(); + private TaxFilingScheduleFormModel scheduleForm = new(); private RenderFragment ScheduleSkeleton => builder => { @@ -200,7 +200,7 @@ else { selectedSchedule = null; isEditMode = false; - scheduleForm = new TaxFilingScheduleForm + scheduleForm = new TaxFilingScheduleFormModel { FilingYear = DateTime.Now.Year, DueDate = DateTime.Today, @@ -214,7 +214,7 @@ else if (schedule == null) return; selectedSchedule = schedule; isEditMode = true; - scheduleForm = new TaxFilingScheduleForm + scheduleForm = new TaxFilingScheduleFormModel { ClientId = schedule.ClientId, FilingType = schedule.FilingType, @@ -315,11 +315,4 @@ else ? client.Name : $"Client #{client.Id}"; - private class TaxFilingScheduleForm - { - public int? ClientId { get; set; } - public string FilingType { get; set; } = ""; - public DateTime? DueDate { get; set; } - public int FilingYear { get; set; } = DateTime.Now.Year; - } } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxProfiles.razor b/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxProfiles.razor index d8aee9b..2b52a20 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxProfiles.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Pages/TaxProfiles.razor @@ -1,5 +1,6 @@ @page "/admin/tax-profiles" @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true)) +@using System.ComponentModel.DataAnnotations @using TaxBaik.Web.Services.AdminClients @using TaxBaik.WasmClient.Components.Admin.Shared @inject ITaxProfileBrowserClient TaxProfileClient @@ -123,7 +124,7 @@ else private MudForm? form; private bool isEditMode; private TaxProfile? selectedProfile; - private TaxProfileForm profileForm = new(); + private TaxProfileFormModel profileForm = new(); private RenderFragment ProfileSkeleton => builder => { @@ -174,7 +175,7 @@ else { selectedProfile = null; isEditMode = false; - profileForm = new TaxProfileForm + profileForm = new TaxProfileFormModel { ClientId = clients.FirstOrDefault()?.Id, TaxRiskLevel = "normal", @@ -187,7 +188,7 @@ else if (profile == null) return; selectedProfile = profile; isEditMode = true; - profileForm = new TaxProfileForm + profileForm = new TaxProfileFormModel { ClientId = profile.ClientId, BusinessType = profile.BusinessType ?? "", @@ -290,13 +291,4 @@ else ? client.Name : $"Client #{client.Id}"; - private class TaxProfileForm - { - public int? ClientId { get; set; } - public string BusinessType { get; set; } = ""; - public string TaxRiskLevel { get; set; } = "normal"; - public DateTime? NextFilingDueDate { get; set; } - public string? SpecialNotes { get; set; } - } - } diff --git a/src/TaxBaik.Web.Client/Components/Admin/RedirectToLogin.razor b/src/TaxBaik.Web.Client/Components/Admin/RedirectToLogin.razor index df1a791..0ae097c 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/RedirectToLogin.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/RedirectToLogin.razor @@ -1,5 +1,4 @@ @inject NavigationManager Navigation -@inject IJSRuntime Js @code { protected override async Task OnAfterRenderAsync(bool firstRender) @@ -7,7 +6,6 @@ if (!firstRender) return; - await Js.InvokeVoidAsync("taxbaikAdminSession.clearAuthToken"); var returnUrl = Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri)); Navigation.NavigateTo($"/taxbaik/admin/login?returnUrl={returnUrl}", replace: true); } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Services/CustomAuthenticationStateProvider.cs b/src/TaxBaik.Web.Client/Components/Admin/Services/CustomAuthenticationStateProvider.cs index 4a56092..6db091b 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Services/CustomAuthenticationStateProvider.cs +++ b/src/TaxBaik.Web.Client/Components/Admin/Services/CustomAuthenticationStateProvider.cs @@ -7,18 +7,15 @@ namespace TaxBaik.Web.Client.Components.Admin.Services; public class CustomAuthenticationStateProvider : AuthenticationStateProvider { - private readonly ILocalStorageService _localStorage; private readonly ITokenStore _tokenStore; private readonly IApiClient _apiClient; private readonly ILogger _logger; public CustomAuthenticationStateProvider( - ILocalStorageService localStorage, ITokenStore tokenStore, IApiClient apiClient, ILogger logger) { - _localStorage = localStorage; _tokenStore = tokenStore; _apiClient = apiClient; _logger = logger; @@ -30,24 +27,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider { var accessToken = _tokenStore.AccessToken; - // TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후) - if (string.IsNullOrEmpty(accessToken)) - { - var storedToken = await _localStorage.GetItemAsStringAsync("accessToken"); - if (!string.IsNullOrEmpty(storedToken)) - { - var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken"); - var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry"); - if (TryNormalizeExpiryTicks(ticksStr, out var ticks)) - { - _tokenStore.AccessToken = storedToken; - _tokenStore.RefreshToken = refreshToken; - _tokenStore.TokenExpiryTicks = ticks; - accessToken = storedToken; - } - } - } - if (string.IsNullOrEmpty(_tokenStore.AccessToken)) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); @@ -125,11 +104,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider _tokenStore.RefreshToken = refreshToken; _tokenStore.TokenExpiryTicks = tokenExpiryTicks; - // localStorage에도 저장 (페이지 리로드 후 복원) - await _localStorage.SetItemAsStringAsync("accessToken", accessToken); - await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken); - await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString()); - // Blazor에 인증 상태 변경을 알림 - 이 호출 자체는 async이지만 fire-and-forget OK // (NotifyAuthenticationStateChanged는 내부적으로 Task를 구독함) NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); @@ -183,11 +157,6 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider // TokenStore 초기화 _tokenStore.Clear(); - // localStorage 초기화 - await _localStorage.RemoveItemAsync("accessToken"); - await _localStorage.RemoveItemAsync("refreshToken"); - await _localStorage.RemoveItemAsync("tokenExpiry"); - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } diff --git a/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginForm.razor b/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginForm.razor index 75fb4bf..70fc5cd 100644 --- a/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginForm.razor +++ b/src/TaxBaik.Web.Client/Components/Admin/Shared/AdminLoginForm.razor @@ -1,6 +1,3 @@ -@inject ILocalStorageService LocalStorageService -@inject IJSRuntime Js -