diff --git "a/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" new file mode 100644 index 00000000..7edef8ea --- /dev/null +++ "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" @@ -0,0 +1,2633 @@ +# 제작의뢰 모듈 — Backend API + 사용자 프론트 (Plan 2/4) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Plan 1 백엔드 기반 위에 의뢰자(클라이언트)가 사용하는 Public REST API와 sirsoft-basic 템플릿의 사용자 화면(목록·작성·상세+채팅) 세 페이지를 추가하여, 의뢰 접수와 메시지·첨부 소통이 실제로 동작하는 상태에 도달. + +**Architecture:** Spec §11.1 의 Public 라우트 11개 중 견적 accept/reject 2개(Plan 3)를 제외한 9개를 구현. Controller 3개(Inquiry/Message/Attachment) + Form Request 4개 + Resource 5개 + Frontend composite 3개 + Layout JSON 3개. 모든 상태 변경은 Plan 1 의 `InquiryStateMachine` / `InquiryPolicy` / Repositories 를 통과. 견적 카드는 read-only placeholder(Plan 3에서 활성). + +**Tech Stack:** Laravel 11 / Sanctum / Eloquent API Resource / React 18 + sirsoft-basic composite components / layout JSON DSL. + +**Spec:** `docs/superpowers/specs/2026-05-20-제작의뢰-design.md` +**Plan 1:** `docs/superpowers/plans/2026-05-21-제작의뢰-backend-foundation.md` (완료, PR #42) + +--- + +## Module Routing Pattern (확정 사실) + +`app/Providers/ModuleRouteServiceProvider.php:110` 가 모든 모듈 routes/api.php 를 `api/modules/{module}` prefix 안에서 group 등록한다. 따라서 inquiry 모듈의 `src/routes/api.php` 안에서는 `Route::prefix('inquiries')->...` 만 선언하면 실제 URL은 `/api/modules/sirsoft-inquiry/inquiries/...` 가 된다. + +`module.php` 에서 `getRoutes()` 메서드로 routes 파일 경로를 노출해야 ModuleRouteServiceProvider 가 발견한다. board 모듈 패턴: + +```php +public function getRoutes(): array +{ + return [ + 'api' => $this->getModulePath() . '/src/routes/api.php', + ]; +} +``` + +Plan 1에서 만든 `modules/_bundled/sirsoft-inquiry/module.php` 는 빈 골격(getRoutes 미정의). Task 9에서 이 메서드를 추가한다. + +--- + +## File Structure + +``` +modules/_bundled/sirsoft-inquiry/ + module.php # MODIFY: add getRoutes() + src/ + Http/ + Controllers/ + User/ + InquiryController.php # index/show/store/update/cancel + InquiryMessageController.php # index/store + InquiryAttachmentController.php # upload (inquiry body & message), download + Requests/ + StoreInquiryRequest.php + UpdateInquiryRequest.php + StoreInquiryMessageRequest.php + UploadInquiryAttachmentRequest.php + Resources/ + InquiryResource.php + InquiryQuoteResource.php + InquiryQuoteItemResource.php + InquiryMessageResource.php + InquiryAttachmentResource.php + routes/ + api.php # Public route group + +tests/Feature/Modules/Inquiry/Api/ + InquiryCrudTest.php + InquiryMessageTest.php + InquiryAttachmentTest.php + InquiryFlowTest.php + +templates/_bundled/sirsoft-basic/ + src/components/composite/ + InquiryStatusBar.tsx + InquiryCard.tsx + InquiryMessageThread.tsx + index.ts # MODIFY: export new composites + layouts/inquiry/ + index.json + new.json + show.json + partials/ + _modal_inquiry_cancel.json + routes.json # MODIFY: register 3 inquiry routes +``` + +**파일 책임 요약** +- `Controllers/User/Inquiry*`: HTTP 진입점. Form Request에서 검증 후 Repository/StateMachine 위임. 직접 Eloquent 호출 금지. +- `Requests/*`: 입력 검증 + Policy 호출. +- `Resources/*`: 직렬화. 사용자 권한에 따라 표시 필드 결정(`is_owner`, `abilities.*`). +- Composites: stateless presentational + 최소 local state. 데이터·액션은 layout JSON DSL이 props로 주입. +- Layouts: 데이터 소스 + 슬롯 구성 + 액션 시퀀스. 직접 fetch 금지(서버 통신은 `dataSource` + `apiCall`). + +--- + +## Pre-check (작업 전 1회) + +- [ ] **Pre-1: Plan 1 commit 위에서 시작** + +```bash +git branch --show-current +git log --oneline | head -5 +``` + +기대: 현재 branch가 `feature/sirsoft-inquiry-foundation` 의 head(또는 그 위) 거나, 별도 branch 생성된 경우 그 base 가 같음. + +- [ ] **Pre-2: 테스트 환경 확인** + +```bash +php artisan test --filter="Modules\\\\Inquiry" 2>&1 | tail -3 +``` + +기대: 31 tests pass (Plan 1 회귀). + +- [ ] **Pre-3: board User controller·Form Request·Resource·routes 예시 미리 훑기** + +작업 중 참고할 파일들: +- `modules/_bundled/sirsoft-board/src/Http/Controllers/User/BoardController.php` +- `modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php` +- `modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php` +- `modules/_bundled/sirsoft-board/src/routes/api.php` + +특히 인증 미들웨어(`auth:sanctum` vs `optional.sanctum`), throttle, Policy 호출 시점 패턴을 동일하게 따른다. + +--- + +## Task 1: Resources — Message + Attachment + +**Files:** +- Create: `src/Http/Resources/InquiryMessageResource.php` +- Create: `src/Http/Resources/InquiryAttachmentResource.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php`: + +```php +create(); + return Inquiry::create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ]); + } + + public function test_message_resource_shape(): void + { + $inquiry = $this->makeInquiry(); + $msg = $inquiry->messages()->create([ + 'sender_user_id' => $inquiry->user_id, + 'sender_role' => 'client', + 'body' => '안녕하세요', + ]); + + $array = (new InquiryMessageResource($msg))->resolve(); + + $this->assertSame($msg->id, $array['id']); + $this->assertSame('client', $array['sender_role']); + $this->assertSame('안녕하세요', $array['body']); + $this->assertArrayHasKey('created_at', $array); + $this->assertNull($array['meta']); + } + + public function test_attachment_resource_shape(): void + { + $inquiry = $this->makeInquiry(); + $att = $inquiry->attachments()->create([ + 'uploader_user_id' => $inquiry->user_id, + 'disk' => 'local', + 'path' => 'inquiries/x/plan.pdf', + 'original_name' => 'plan.pdf', + 'mime' => 'application/pdf', + 'size' => 1234, + ]); + + $array = (new InquiryAttachmentResource($att))->resolve(); + + $this->assertSame($att->id, $array['id']); + $this->assertSame('plan.pdf', $array['original_name']); + $this->assertSame('application/pdf', $array['mime']); + $this->assertSame(1234, $array['size']); + $this->assertStringContainsString("/api/modules/sirsoft-inquiry/attachments/{$att->id}", $array['download_url']); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +``` + +기대: "Class ... not found" (2건). + +- [ ] **Step 3: Resource 작성** + +`src/Http/Resources/InquiryMessageResource.php`: + +```php + $this->id, + 'inquiry_id' => $this->inquiry_id, + 'sender_user_id' => $this->sender_user_id, + 'sender_role' => $this->sender_role?->value, + 'body' => $this->body, + 'meta' => $this->meta, + 'read_at' => $this->read_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'attachments' => InquiryAttachmentResource::collection($this->whenLoaded('attachments')), + ]; + } +} +``` + +`src/Http/Resources/InquiryAttachmentResource.php`: + +```php + $this->id, + 'inquiry_id' => $this->inquiry_id, + 'message_id' => $this->message_id, + 'original_name' => $this->original_name, + 'mime' => $this->mime, + 'size' => $this->size, + 'download_url' => url("/api/modules/sirsoft-inquiry/attachments/{$this->id}"), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} +``` + +- [ ] **Step 4: 테스트 실행 — 성공** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +``` + +기대: 2 passes. + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryMessageResource.php \ + modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryAttachmentResource.php \ + tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php +git commit -m "feat(inquiry): add Message + Attachment API resources" +``` + +--- + +## Task 2: Resources — Quote + QuoteItem (read-only for Plan 2) + +**Files:** +- Create: `src/Http/Resources/InquiryQuoteItemResource.php` +- Create: `src/Http/Resources/InquiryQuoteResource.php` + +견적 발행/수락 액션은 Plan 3 범위이지만, 의뢰 상세 응답에 견적 이력을 read-only 로 노출해 두면 Plan 3에서 액션만 추가하면 된다. + +- [ ] **Step 1: 테스트 추가** (`ResourceShapeTest::test_quote_resource_shape`): + +```php +public function test_quote_resource_shape(): void +{ + $inquiry = $this->makeInquiry(); + $quote = $inquiry->quotes()->create([ + 'version' => 1, + 'total_amount' => 1000000, + 'tax_amount' => 0, + 'currency' => 'KRW', + 'status' => 'issued', + 'issued_at' => now(), + ]); + $quote->items()->create([ + 'position' => 1, + 'name' => '메인 페이지 디자인', + 'qty' => 1, + 'unit_price' => 1000000, + 'amount' => 1000000, + ]); + + $array = (new \Modules\Sirsoft\Inquiry\Http\Resources\InquiryQuoteResource($quote->load('items')))->resolve(); + + $this->assertSame(1, $array['version']); + $this->assertSame('issued', $array['status']); + $this->assertSame('1000000', (string) $array['total_amount']); + $this->assertCount(1, $array['items']); + $this->assertSame('메인 페이지 디자인', $array['items'][0]['name']); +} +``` + +```bash +php artisan test --filter=test_quote_resource_shape 2>&1 | tail -10 +``` + +기대: 실패. + +- [ ] **Step 2: Resource 작성** + +`src/Http/Resources/InquiryQuoteItemResource.php`: + +```php + $this->id, + 'position' => $this->position, + 'name' => $this->name, + 'description' => $this->description, + 'qty' => (string) $this->qty, + 'unit_price' => (string) $this->unit_price, + 'amount' => (string) $this->amount, + ]; + } +} +``` + +`src/Http/Resources/InquiryQuoteResource.php`: + +```php + $this->id, + 'inquiry_id' => $this->inquiry_id, + 'version' => $this->version, + 'total_amount' => (string) $this->total_amount, + 'tax_amount' => (string) $this->tax_amount, + 'currency' => $this->currency, + 'valid_until' => $this->valid_until?->toDateString(), + 'note' => $this->note, + 'status' => $this->status?->value, + 'issued_at' => $this->issued_at?->toIso8601String(), + 'accepted_at' => $this->accepted_at?->toIso8601String(), + 'rejected_at' => $this->rejected_at?->toIso8601String(), + 'items' => InquiryQuoteItemResource::collection($this->whenLoaded('items')), + ]; + } +} +``` + +- [ ] **Step 3: 테스트 PASS + Commit** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +git add modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryQuoteResource.php \ + modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryQuoteItemResource.php \ + tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php +git commit -m "feat(inquiry): add Quote + QuoteItem API resources (read-only)" +``` + +--- + +## Task 3: Resource — Inquiry + +**Files:** +- Create: `src/Http/Resources/InquiryResource.php` + +Inquiry 응답에는 다음을 포함: 본문 + 견적 이력(read-only) + 본문 첨부 + abilities/is_owner 메타. + +- [ ] **Step 1: 테스트 추가** (`ResourceShapeTest`): + +```php +public function test_inquiry_resource_shape_for_owner(): void +{ + $inquiry = $this->makeInquiry(); // makeInquiry() always uses $user + $inquiry->load(['quotes.items', 'attachments']); + + $request = \Illuminate\Http\Request::create('/'); + $request->setUserResolver(fn () => $inquiry->user); // owner + + $array = (new \Modules\Sirsoft\Inquiry\Http\Resources\InquiryResource($inquiry))->toArray($request); + + $this->assertSame($inquiry->uuid, $array['uuid']); + $this->assertSame('received', $array['status']); + $this->assertTrue($array['is_owner']); + $this->assertIsArray($array['abilities']); + $this->assertTrue($array['abilities']['update']); + $this->assertTrue($array['abilities']['cancel']); + $this->assertArrayHasKey('quotes', $array); + $this->assertArrayHasKey('attachments', $array); +} +``` + +```bash +php artisan test --filter=test_inquiry_resource_shape_for_owner 2>&1 | tail -10 +``` + +기대: 실패. + +- [ ] **Step 2: InquiryResource 작성** + +`src/Http/Resources/InquiryResource.php`: + +```php +user(); + $isOwner = $user && $user->id === $this->user_id; + + return [ + 'uuid' => $this->uuid, + 'id' => $this->id, + 'user_id' => $this->user_id, + 'title' => $this->title, + 'content' => $this->content, + 'category' => $this->category, + 'budget_range' => $this->budget_range, + 'desired_due_at' => $this->desired_due_at?->toDateString(), + 'status' => $this->status?->value, + 'accepted_quote_id' => $this->accepted_quote_id, + 'payment_id' => $this->payment_id, + 'received_at' => $this->received_at?->toIso8601String(), + 'quoted_at' => $this->quoted_at?->toIso8601String(), + 'started_at' => $this->started_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'canceled_at' => $this->canceled_at?->toIso8601String(), + 'is_owner' => $isOwner, + 'abilities' => [ + 'update' => $user ? $user->can('update', $this->resource) : false, + 'cancel' => $user ? $user->can('cancel', $this->resource) : false, + 'postMessage' => $user ? $user->can('postMessage', $this->resource) : false, + 'acceptQuote' => $user ? $user->can('acceptQuote', $this->resource) : false, + 'rejectQuote' => $user ? $user->can('rejectQuote', $this->resource) : false, + ], + 'quotes' => InquiryQuoteResource::collection($this->whenLoaded('quotes')), + 'attachments' => InquiryAttachmentResource::collection( + $this->whenLoaded('attachments', fn () => $this->attachments->whereNull('message_id')) + ), + ]; + } +} +``` + +- [ ] **Step 3: 테스트 PASS + Commit** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +git add modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryResource.php \ + tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php +git commit -m "feat(inquiry): add Inquiry API resource with abilities meta" +``` + +--- + +## Task 4: Form Requests — Store/Update Inquiry + +**Files:** +- Create: `src/Http/Requests/StoreInquiryRequest.php` +- Create: `src/Http/Requests/UpdateInquiryRequest.php` + +- [ ] **Step 1: Request 작성** + +`src/Http/Requests/StoreInquiryRequest.php`: + +```php +user() !== null; // 회원만 (v1 spec) + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:200'], + 'content' => ['required', 'string'], + 'category' => ['nullable', 'string', 'in:' . implode(',', config('inquiry.categories', []))], + 'budget_range' => ['nullable', 'string', 'max:100'], + 'desired_due_at' => ['nullable', 'date', 'after_or_equal:today'], + ]; + } +} +``` + +`src/Http/Requests/UpdateInquiryRequest.php`: + +```php +route('inquiry'); + if (! $inquiry instanceof Inquiry) { + return false; + } + return $this->user()?->can('update', $inquiry) ?? false; + } + + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:200'], + 'content' => ['sometimes', 'required', 'string'], + 'category' => ['nullable', 'string', 'in:' . implode(',', config('inquiry.categories', []))], + 'budget_range' => ['nullable', 'string', 'max:100'], + 'desired_due_at' => ['nullable', 'date', 'after_or_equal:today'], + ]; + } +} +``` + +- [ ] **Step 2: Syntax check + Commit** + +```bash +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/StoreInquiryRequest.php +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/UpdateInquiryRequest.php +git add modules/_bundled/sirsoft-inquiry/src/Http/Requests/Store* \ + modules/_bundled/sirsoft-inquiry/src/Http/Requests/Update* +git commit -m "feat(inquiry): add Inquiry Store/Update form requests" +``` + +--- + +## Task 5: Form Requests — Message + Attachment + +**Files:** +- Create: `src/Http/Requests/StoreInquiryMessageRequest.php` +- Create: `src/Http/Requests/UploadInquiryAttachmentRequest.php` + +- [ ] **Step 1: 작성** + +`src/Http/Requests/StoreInquiryMessageRequest.php`: + +```php +route('inquiry'); + if (! $inquiry instanceof Inquiry) { + return false; + } + return $this->user()?->can('postMessage', $inquiry) ?? false; + } + + public function rules(): array + { + return [ + 'body' => ['required_without:attachment_ids', 'nullable', 'string', 'max:10000'], + 'attachment_ids' => ['nullable', 'array', 'max:10'], + 'attachment_ids.*' => ['integer', 'exists:inquiry_attachments,id'], + ]; + } +} +``` + +`src/Http/Requests/UploadInquiryAttachmentRequest.php`: + +```php +route('inquiry'); + if (! $inquiry instanceof Inquiry) { + return false; + } + return $this->user()?->can('uploadAttachment', $inquiry) ?? false; + } + + public function rules(): array + { + $context = $this->route('inquiryMessage') ? 'message' : 'inquiry'; + $maxBytes = (int) config( + $context === 'message' + ? 'inquiry.attachment.max_size_message' + : 'inquiry.attachment.max_size_inquiry' + ); + + return [ + 'file' => [ + 'required', + 'file', + 'max:' . (int) ($maxBytes / 1024), // Laravel expects KB + ], + ]; + } +} +``` + +- [ ] **Step 2: Syntax check + Commit** + +```bash +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/StoreInquiryMessageRequest.php +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/UploadInquiryAttachmentRequest.php +git add modules/_bundled/sirsoft-inquiry/src/Http/Requests/StoreInquiryMessageRequest.php \ + modules/_bundled/sirsoft-inquiry/src/Http/Requests/UploadInquiryAttachmentRequest.php +git commit -m "feat(inquiry): add Message/Attachment form requests" +``` + +--- + +## Task 6: Controller — InquiryController (index + show) + +**Files:** +- Create: `src/Http/Controllers/User/InquiryController.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/Api/InquiryCrudTest.php`: + +```php +create(); + $other = User::factory()->create(); + Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'Mine', 'content' => 'x', 'status' => 'received']); + Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $other->id, 'title' => 'Theirs', 'content' => 'x', 'status' => 'received']); + + Sanctum::actingAs($me); + $res = $this->getJson('/api/modules/sirsoft-inquiry/inquiries'); + $res->assertOk(); + $titles = array_column($res->json('data'), 'title'); + $this->assertContains('Mine', $titles); + $this->assertNotContains('Theirs', $titles); + } + + public function test_show_returns_inquiry_for_owner(): void + { + $me = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'X', 'content' => 'Y', 'status' => 'received']); + + Sanctum::actingAs($me); + $res = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}"); + $res->assertOk(); + $res->assertJsonPath('data.uuid', $inquiry->uuid); + $res->assertJsonPath('data.is_owner', true); + } + + public function test_show_returns_403_for_others(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $owner->id, 'title' => 'X', 'content' => 'Y', 'status' => 'received']); + + Sanctum::actingAs($other); + $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}") + ->assertForbidden(); + } + + public function test_index_requires_auth(): void + { + $this->getJson('/api/modules/sirsoft-inquiry/inquiries') + ->assertUnauthorized(); + } +} +``` + +```bash +php artisan test --filter=InquiryCrudTest 2>&1 | tail -15 +``` + +기대: 4 fails (route not found / class not found). + +- [ ] **Step 2: Controller 작성 (index + show 만)** + +`src/Http/Controllers/User/InquiryController.php`: + +```php +query('status'); + $perPage = (int) $request->query('per_page', 20); + + $paginator = $this->inquiries->listByUser($request->user()->id, $status ?: null, $perPage); + + return InquiryResource::collection($paginator); + } + + public function show(Request $request, Inquiry $inquiry) + { + $this->authorize('view', $inquiry); + + $inquiry->load(['quotes.items', 'attachments']); + + return new InquiryResource($inquiry); + } +} +``` + +(`Inquiry` 모델은 implicit binding으로 `{inquiry:uuid}` URL 파라미터에서 자동 해석 — route 정의에서 `->whereUuid()` 또는 explicit binding 사용. Task 8에서 명시.) + +- [ ] **Step 3: routes/api.php 임시 등록 (Task 8에서 정식 정리)** + +이번 Task 6 단계에서는 라우트가 아직 없어 테스트가 404로 실패한다. Task 8에서 정식 등록할 때까지 일단 commit 후 다음 task에서 라우트 등록 + 테스트 PASS 확인. 즉 Step 4는 commit만: + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryController.php \ + tests/Feature/Modules/Inquiry/Api/InquiryCrudTest.php +git commit -m "feat(inquiry): add InquiryController (index + show)" +``` + +테스트는 Task 8 완료 후 일괄 PASS 확인. + +--- + +## Task 7: Controller — store + update + cancel + +**Files:** +- Modify: `src/Http/Controllers/User/InquiryController.php` + +- [ ] **Step 1: 테스트 추가** (`InquiryCrudTest`) + +```php +public function test_store_creates_inquiry(): void +{ + $me = User::factory()->create(); + Sanctum::actingAs($me); + + $res = $this->postJson('/api/modules/sirsoft-inquiry/inquiries', [ + 'title' => '홈페이지 리뉴얼', + 'content' => '기존 사이트를 모던하게 개편 부탁드립니다.', + 'category' => 'web', + 'budget_range' => '300-500만원', + 'desired_due_at' => now()->addMonth()->toDateString(), + ]); + + $res->assertCreated(); + $res->assertJsonPath('data.title', '홈페이지 리뉴얼'); + $res->assertJsonPath('data.status', 'received'); + $res->assertJsonPath('data.is_owner', true); + + $this->assertDatabaseHas('inquiries', [ + 'user_id' => $me->id, + 'title' => '홈페이지 리뉴얼', + 'status' => 'received', + ]); +} + +public function test_update_only_in_received_state(): void +{ + $me = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'old', 'content' => 'old', 'status' => 'received']); + + Sanctum::actingAs($me); + $this->patchJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}", ['title' => 'new']) + ->assertOk() + ->assertJsonPath('data.title', 'new'); + + $inquiry->update(['status' => 'quoted', 'quoted_at' => now()]); + $this->patchJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}", ['title' => 'newer']) + ->assertForbidden(); +} + +public function test_cancel_transitions_status(): void +{ + $me = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'X', 'content' => 'Y', 'status' => 'received']); + + Sanctum::actingAs($me); + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/cancel") + ->assertOk() + ->assertJsonPath('data.status', 'canceled'); + + $this->assertNotNull($inquiry->fresh()->canceled_at); +} +``` + +- [ ] **Step 2: Controller 확장** + +`src/Http/Controllers/User/InquiryController.php` 의 use 절·생성자·메서드 추가: + +```php +use Illuminate\Support\Str; +use Modules\Sirsoft\Inquiry\Enums\TransitionEvent; +use Modules\Sirsoft\Inquiry\Http\Requests\StoreInquiryRequest; +use Modules\Sirsoft\Inquiry\Http\Requests\UpdateInquiryRequest; +use Modules\Sirsoft\Inquiry\Services\InquiryStateMachine; +``` + +생성자 시그니처: +```php +public function __construct( + private readonly InquiryRepositoryInterface $inquiries, + private readonly InquiryStateMachine $stateMachine, +) {} +``` + +메서드 추가: + +```php +public function store(StoreInquiryRequest $request) +{ + $inquiry = $this->inquiries->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $request->user()->id, + 'title' => $request->string('title'), + 'content' => $request->string('content'), + 'category' => $request->input('category'), + 'budget_range' => $request->input('budget_range'), + 'desired_due_at' => $request->input('desired_due_at'), + 'status' => 'received', + ]); + + return (new InquiryResource($inquiry->load(['quotes.items', 'attachments']))) + ->response() + ->setStatusCode(201); +} + +public function update(UpdateInquiryRequest $request, Inquiry $inquiry) +{ + $this->inquiries->update($inquiry, $request->validated()); + return new InquiryResource($inquiry->fresh()->load(['quotes.items', 'attachments'])); +} + +public function cancel(Request $request, Inquiry $inquiry) +{ + $this->authorize('cancel', $inquiry); + + $this->stateMachine->transition( + $inquiry, + TransitionEvent::Cancel, + actorUserId: $request->user()->id, + payload: ['actor' => 'client'], + ); + + return new InquiryResource($inquiry->fresh()->load(['quotes.items', 'attachments'])); +} +``` + +- [ ] **Step 3: Commit (테스트는 Task 8 후 일괄)** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryController.php \ + tests/Feature/Modules/Inquiry/Api/InquiryCrudTest.php +git commit -m "feat(inquiry): add Inquiry store/update/cancel endpoints" +``` + +--- + +## Task 8: routes/api.php + module.php::getRoutes() + +**Files:** +- Create: `src/routes/api.php` +- Modify: `module.php` + +- [ ] **Step 1: routes/api.php 작성** + +`src/routes/api.php`: + +```php + Inquiry::where('uuid', $value)->firstOrFail()); + +Route::prefix('inquiries') + ->middleware(['auth:sanctum', 'throttle:600,1']) + ->name('inquiries.') + ->group(function () { + Route::get('/', [InquiryController::class, 'index'])->name('index'); + Route::post('/', [InquiryController::class, 'store'])->name('store'); + Route::get('/{inquiry}', [InquiryController::class, 'show'])->name('show'); + Route::patch('/{inquiry}', [InquiryController::class, 'update'])->name('update'); + Route::post('/{inquiry}/cancel', [InquiryController::class, 'cancel'])->name('cancel'); + }); +``` + +- [ ] **Step 2: module.php에 getRoutes() 추가** + +`modules/_bundled/sirsoft-inquiry/module.php` 의 클래스 안에 추가: + +```php +public function getRoutes(): array +{ + return [ + 'api' => $this->getModulePath() . '/src/routes/api.php', + ]; +} +``` + +(`getModulePath()` 는 `AbstractModule` 의 메서드. board 모듈도 동일.) + +- [ ] **Step 3: 라우트 캐시 + 테스트 실행** + +```bash +php artisan route:clear +php artisan test --filter=InquiryCrudTest 2>&1 | tail -15 +``` + +기대: 7 passes (Task 6의 4개 + Task 7의 3개). + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/routes/api.php \ + modules/_bundled/sirsoft-inquiry/module.php +git commit -m "feat(inquiry): register Inquiry API routes via module.php" +``` + +--- + +## Task 9: InquiryMessageController + 라우트 + +**Files:** +- Create: `src/Http/Controllers/User/InquiryMessageController.php` +- Modify: `src/routes/api.php` + +- [ ] **Step 1: 실패 테스트** + +`tests/Feature/Modules/Inquiry/Api/InquiryMessageTest.php`: + +```php +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + return [$user, $inquiry]; + } + + public function test_post_message_creates_record(): void + { + [$user, $inquiry] = $this->setupInquiry(); + Sanctum::actingAs($user); + + $res = $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages", [ + 'body' => '추가 자료 첨부합니다', + ]); + + $res->assertCreated(); + $res->assertJsonPath('data.body', '추가 자료 첨부합니다'); + $res->assertJsonPath('data.sender_role', 'client'); + + $this->assertDatabaseHas('inquiry_messages', [ + 'inquiry_id' => $inquiry->id, + 'body' => '추가 자료 첨부합니다', + 'sender_role' => 'client', + ]); + } + + public function test_index_returns_messages_ordered(): void + { + [$user, $inquiry] = $this->setupInquiry(); + $inquiry->messages()->create(['sender_user_id' => $user->id, 'sender_role' => 'client', 'body' => 'first', 'created_at' => now()->subHour()]); + $inquiry->messages()->create(['sender_user_id' => $user->id, 'sender_role' => 'client', 'body' => 'second', 'created_at' => now()]); + + Sanctum::actingAs($user); + $res = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages"); + + $res->assertOk(); + $bodies = array_column($res->json('data'), 'body'); + $this->assertSame(['first', 'second'], $bodies); + } + + public function test_post_message_marks_opposite_role_messages_as_read(): void + { + [$user, $inquiry] = $this->setupInquiry(); + $op = User::factory()->create(); + $inquiry->messages()->create(['sender_user_id' => $op->id, 'sender_role' => 'operator', 'body' => 'hi', 'read_at' => null]); + + Sanctum::actingAs($user); + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages", ['body' => 'reply']); + + $this->assertNotNull($inquiry->fresh()->messages()->where('sender_role', 'operator')->first()->read_at); + } + + public function test_index_requires_owner(): void + { + [, $inquiry] = $this->setupInquiry(); + $stranger = User::factory()->create(); + Sanctum::actingAs($stranger); + $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages") + ->assertForbidden(); + } +} +``` + +```bash +php artisan test --filter=InquiryMessageTest 2>&1 | tail -15 +``` + +기대: 4 fails. + +- [ ] **Step 2: Controller 작성** + +`src/Http/Controllers/User/InquiryMessageController.php`: + +```php +authorize('view', $inquiry); + + // 상대 역할 메시지 읽음 처리 + $myRole = $request->user()->id === $inquiry->user_id + ? SenderRole::Client + : SenderRole::Operator; + $opposite = $myRole === SenderRole::Client ? SenderRole::Operator : SenderRole::Client; + $this->messages->markReadFor($inquiry, $opposite); + + $perPage = (int) $request->query('per_page', 50); + $paginator = $this->messages->listForInquiry($inquiry, $perPage); + + return InquiryMessageResource::collection($paginator); + } + + public function store(StoreInquiryMessageRequest $request, Inquiry $inquiry) + { + $role = $request->user()->id === $inquiry->user_id + ? SenderRole::Client + : SenderRole::Operator; + + $msg = $this->messages->append( + $inquiry, + $request->user()->id, + $role, + $request->string('body', '') + ); + + $attachmentIds = $request->input('attachment_ids', []); + foreach ($attachmentIds as $attId) { + $att = $this->attachments->findOrFail((int) $attId); + if ($att->inquiry_id !== $inquiry->id) { + abort(422, 'Attachment does not belong to this inquiry'); + } + $this->attachments->attachToMessage($att, $msg); + } + + // 상대 역할 이전 메시지 읽음 처리 + $opposite = $role === SenderRole::Client ? SenderRole::Operator : SenderRole::Client; + $this->messages->markReadFor($inquiry, $opposite); + + InquiryMessagePosted::dispatch($msg); + + return (new InquiryMessageResource($msg->load('attachments'))) + ->response() + ->setStatusCode(201); + } +} +``` + +- [ ] **Step 3: 라우트 추가** + +`src/routes/api.php` 의 inquiries 그룹 안에 추가: + +```php +Route::get('/{inquiry}/messages', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryMessageController::class, 'index'])->name('messages.index'); +Route::post('/{inquiry}/messages', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryMessageController::class, 'store'])->name('messages.store'); +``` + +- [ ] **Step 4: 테스트 PASS + Commit** + +```bash +php artisan route:clear +php artisan test --filter=InquiryMessageTest 2>&1 | tail -15 +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryMessageController.php \ + modules/_bundled/sirsoft-inquiry/src/routes/api.php \ + tests/Feature/Modules/Inquiry/Api/InquiryMessageTest.php +git commit -m "feat(inquiry): add InquiryMessageController (index/store) + routes" +``` + +--- + +## Task 10: InquiryAttachmentController — upload + +**Files:** +- Create: `src/Http/Controllers/User/InquiryAttachmentController.php` +- Modify: `src/routes/api.php` + +- [ ] **Step 1: 실패 테스트** + +`tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php`: + +```php +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + Sanctum::actingAs($user); + $file = UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf'); + + $res = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => $file] + ); + + $res->assertCreated(); + $res->assertJsonPath('data.original_name', 'plan.pdf'); + $res->assertJsonPath('data.mime', 'application/pdf'); + $this->assertDatabaseHas('inquiry_attachments', [ + 'inquiry_id' => $inquiry->id, + 'message_id' => null, + 'original_name' => 'plan.pdf', + ]); + } + + public function test_upload_rejects_disallowed_mime(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + Sanctum::actingAs($user); + $file = UploadedFile::fake()->create('bad.exe', 10, 'application/x-msdownload'); + + $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => $file] + )->assertStatus(422); + } + + public function test_upload_requires_owner(): void + { + Storage::fake('local'); + $owner = User::factory()->create(); + $other = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $owner->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + Sanctum::actingAs($other); + $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => UploadedFile::fake()->create('a.pdf', 1, 'application/pdf')] + )->assertForbidden(); + } +} +``` + +- [ ] **Step 2: Controller 작성** + +`src/Http/Controllers/User/InquiryAttachmentController.php`: + +```php +storage->store( + $inquiry, + $request->user()->id, + $request->file('file'), + context: 'inquiry', + ); + } catch (InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } + + return (new InquiryAttachmentResource($att)) + ->response() + ->setStatusCode(201); + } + + public function uploadMessage(UploadInquiryAttachmentRequest $request, Inquiry $inquiry) + { + try { + $att = $this->storage->store( + $inquiry, + $request->user()->id, + $request->file('file'), + context: 'message', + ); + } catch (InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } + + return (new InquiryAttachmentResource($att)) + ->response() + ->setStatusCode(201); + } + + public function download(Request $request, InquiryAttachment $attachment): StreamedResponse + { + $inquiry = $attachment->inquiry; + $this->authorize('viewAttachment', $inquiry); + + return \Illuminate\Support\Facades\Storage::disk($attachment->disk)->download( + $attachment->path, + $attachment->original_name, + ['Content-Type' => $attachment->mime] + ); + } +} +``` + +- [ ] **Step 3: 라우트 추가** + +`src/routes/api.php` 에 추가: + +```php +Route::post('/{inquiry}/attachments', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryAttachmentController::class, 'uploadInquiryBody']) + ->name('attachments.inquiry-body'); +Route::post('/{inquiry}/messages/attachments', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryAttachmentController::class, 'uploadMessage']) + ->name('attachments.message'); +``` + +그리고 inquiries 그룹 **밖에** (별도 그룹) 다운로드 라우트 추가: + +```php +Route::middleware(['auth:sanctum', 'throttle:600,1']) + ->name('inquiry-attachments.') + ->group(function () { + Route::get('/attachments/{attachment}', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryAttachmentController::class, 'download']) + ->name('download'); + }); +``` + +`attachment` 바인딩(implicit binding on id) 은 기본 동작 사용. + +- [ ] **Step 4: 테스트 PASS + Commit** + +```bash +php artisan route:clear +php artisan test --filter=InquiryAttachmentTest 2>&1 | tail -15 +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryAttachmentController.php \ + modules/_bundled/sirsoft-inquiry/src/routes/api.php \ + tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php +git commit -m "feat(inquiry): add InquiryAttachmentController upload endpoints" +``` + +--- + +## Task 11: Attachment download test + 권한 검증 + +**Files:** +- Modify: `tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php` + +- [ ] **Step 1: 다운로드 테스트 추가** + +```php +public function test_download_returns_file_for_owner(): void +{ + Storage::fake('local'); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + Sanctum::actingAs($user); + $upload = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf')] + ); + $attId = $upload->json('data.id'); + + $res = $this->get("/api/modules/sirsoft-inquiry/attachments/{$attId}"); + $res->assertOk(); + $res->assertHeader('content-type', 'application/pdf'); +} + +public function test_download_forbidden_for_strangers(): void +{ + Storage::fake('local'); + $owner = User::factory()->create(); + $other = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $owner->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + Sanctum::actingAs($owner); + $upload = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf')] + ); + $attId = $upload->json('data.id'); + + Sanctum::actingAs($other); + $this->get("/api/modules/sirsoft-inquiry/attachments/{$attId}") + ->assertForbidden(); +} +``` + +- [ ] **Step 2: 테스트 실행 + Commit** + +```bash +php artisan test --filter=InquiryAttachmentTest 2>&1 | tail -15 +git add tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php +git commit -m "test(inquiry): cover attachment download permission" +``` + +--- + +## Task 12: E2E flow test + +**Files:** +- Create: `tests/Feature/Modules/Inquiry/Api/InquiryFlowTest.php` + +사용자 시나리오 골든패스를 한 테스트로 묶는다. + +- [ ] **Step 1: 테스트 작성** + +```php +create(); + Sanctum::actingAs($user); + + // 1) Create inquiry + $created = $this->postJson('/api/modules/sirsoft-inquiry/inquiries', [ + 'title' => '쇼핑몰 리뉴얼', + 'content' => '디자인 + 결제 연동', + 'category' => 'web', + ])->assertCreated()->json('data'); + $uuid = $created['uuid']; + + // 2) Upload inquiry body attachment + $upload = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$uuid}/attachments", + ['file' => UploadedFile::fake()->create('brief.pdf', 50, 'application/pdf')] + )->assertCreated()->json('data'); + + // 3) Post message + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/messages", [ + 'body' => '추가 안내 드립니다', + ])->assertCreated(); + + // 4) List inquiries — should appear + $list = $this->getJson('/api/modules/sirsoft-inquiry/inquiries')->assertOk()->json('data'); + $this->assertTrue(collect($list)->contains('uuid', $uuid)); + + // 5) Show with attachments + messages embedded + $show = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}") + ->assertOk() + ->assertJsonPath('data.status', 'received') + ->json('data'); + $this->assertCount(1, $show['attachments']); + + $msgs = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/messages") + ->assertOk() + ->json('data'); + $this->assertCount(1, $msgs); + + // 6) Cancel + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/cancel") + ->assertOk() + ->assertJsonPath('data.status', 'canceled'); + + // 7) After cancel, system message appended + $msgs = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/messages")->json('data'); + $systemMsg = collect($msgs)->firstWhere('sender_role', 'system'); + $this->assertNotNull($systemMsg); + $this->assertSame('inquiry::system.message.canceled_by_client', $systemMsg['meta']['key']); + } +} +``` + +- [ ] **Step 2: 실행 + Commit** + +```bash +php artisan test --filter=InquiryFlowTest 2>&1 | tail -15 +git add tests/Feature/Modules/Inquiry/Api/InquiryFlowTest.php +git commit -m "test(inquiry): cover end-to-end client flow (create→msg→cancel)" +``` + +기대: 1 test PASS, 12+ assertions. + +--- + +## Task 13: Composite — InquiryStatusBar + +**Files:** +- Create: `templates/_bundled/sirsoft-basic/src/components/composite/InquiryStatusBar.tsx` +- Modify: `templates/_bundled/sirsoft-basic/src/components/composite/index.ts` + +- [ ] **Step 1: 컴포넌트 작성** + +`InquiryStatusBar.tsx`: + +```tsx +import React from 'react'; +import { Div, Span } from '../basic'; + +export interface InquiryStatusBarProps { + /** 현재 의뢰 상태 */ + status: 'received' | 'quoted' | 'in_progress' | 'completed' | 'canceled'; + className?: string; +} + +const STEPS: Array<{ key: string; label: string }> = [ + { key: 'received', label: '접수' }, + { key: 'quoted', label: '견적' }, + { key: 'in_progress', label: '진행' }, + { key: 'completed', label: '완료' }, +]; + +const stepIndex = (status: string): number => { + if (status === 'canceled') return -1; + return STEPS.findIndex((s) => s.key === status); +}; + +const InquiryStatusBar: React.FC = ({ status, className = '' }) => { + const current = stepIndex(status); + const canceled = status === 'canceled'; + + return ( +
+ {canceled ? ( +
+ 취소된 의뢰 +
+ ) : ( +
+ {STEPS.map((step, i) => { + const active = i <= current; + const isCurrent = i === current; + return ( + +
+ {i + 1} + {step.label} +
+ {i < STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ )} +
+ ); +}; + +export default InquiryStatusBar; +``` + +- [ ] **Step 2: index.ts 에 export 추가** + +`templates/_bundled/sirsoft-basic/src/components/composite/index.ts` 에 라인 추가: + +```ts +export { default as InquiryStatusBar } from './InquiryStatusBar'; +``` + +- [ ] **Step 3: 빌드 검증 + Commit** + +```bash +cd templates/_bundled/sirsoft-basic && npm run build 2>&1 | tail -10 +cd ../../.. +git add templates/_bundled/sirsoft-basic/src/components/composite/InquiryStatusBar.tsx \ + templates/_bundled/sirsoft-basic/src/components/composite/index.ts +git commit -m "feat(inquiry): add InquiryStatusBar composite" +``` + +기대: 빌드 에러 없음. + +--- + +## Task 14: Composite — InquiryCard + +**Files:** +- Create: `templates/_bundled/sirsoft-basic/src/components/composite/InquiryCard.tsx` +- Modify: `templates/_bundled/sirsoft-basic/src/components/composite/index.ts` + +목록 화면에서 사용. 상태 뱃지 + 제목 + 안 읽은 메시지 수 + 받은 날짜 + 클릭 시 상세 이동. + +- [ ] **Step 1: 작성** + +```tsx +import React from 'react'; +import { A, Div, H3, Span } from '../basic'; + +export interface InquiryCardProps { + uuid: string; + title: string; + status: 'received' | 'quoted' | 'in_progress' | 'completed' | 'canceled'; + category?: string; + receivedAt?: string; + unreadCount?: number; + className?: string; +} + +const STATUS_STYLES: Record = { + received: 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300', + quoted: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300', + in_progress: 'bg-purple-50 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300', + completed: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300', + canceled: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300', +}; + +const STATUS_LABEL: Record = { + received: '접수', + quoted: '견적', + in_progress: '진행', + completed: '완료', + canceled: '취소', +}; + +const InquiryCard: React.FC = ({ + uuid, + title, + status, + category, + receivedAt, + unreadCount = 0, + className = '', +}) => ( + +
+

{title}

+ + {STATUS_LABEL[status] || status} + +
+
+ {category && {category}} + {receivedAt && {new Date(receivedAt).toLocaleDateString('ko-KR')}} + {unreadCount > 0 && ( + + 새 메시지 {unreadCount} + + )} +
+
+); + +export default InquiryCard; +``` + +- [ ] **Step 2: index.ts 갱신 + 빌드 + Commit** + +```ts +// composite/index.ts 에 추가 +export { default as InquiryCard } from './InquiryCard'; +``` + +```bash +cd templates/_bundled/sirsoft-basic && npm run build 2>&1 | tail -10 +cd ../../.. +git add templates/_bundled/sirsoft-basic/src/components/composite/InquiryCard.tsx \ + templates/_bundled/sirsoft-basic/src/components/composite/index.ts +git commit -m "feat(inquiry): add InquiryCard composite for list view" +``` + +--- + +## Task 15: Composite — InquiryMessageThread + +**Files:** +- Create: `templates/_bundled/sirsoft-basic/src/components/composite/InquiryMessageThread.tsx` +- Modify: `templates/_bundled/sirsoft-basic/src/components/composite/index.ts` + +채팅형 메시지 스레드 + 입력창. 데이터·전송 액션은 layout JSON DSL 에서 props 로 주입. + +- [ ] **Step 1: 작성** + +```tsx +import React, { useState } from 'react'; +import { Button, Div, P, Span, Textarea } from '../basic'; + +export interface InquiryMessage { + id: number; + sender_role: 'client' | 'operator' | 'system'; + body: string | null; + meta?: { key?: string; params?: Record } | null; + created_at?: string; +} + +export interface InquiryMessageThreadProps { + messages: InquiryMessage[]; + /** 현재 사용자 역할 (대개 'client') */ + myRole?: 'client' | 'operator'; + /** 메시지 전송 콜백 — layout JSON 에서 onSend 액션으로 바인딩 */ + onSend?: (body: string) => void; + /** 전송 중 비활성화 */ + submitting?: boolean; + /** placeholder 텍스트 */ + placeholder?: string; + className?: string; +} + +const renderSystemBody = (meta?: InquiryMessage['meta']): string => { + if (!meta?.key) return '시스템 메시지'; + // 간단한 키 표시 — i18n 보간은 향후 강화 + const keySuffix = meta.key.split('.').pop() || ''; + const params = meta.params || {}; + switch (keySuffix) { + case 'quote_issued': + return `운영자가 견적을 발행했습니다 (회차 #${params.version ?? '?'}, 합계 ${params.total ?? '-'}원)`; + case 'quote_revoked': + return `운영자가 견적을 철회했습니다 (회차 #${params.version ?? '?'})`; + case 'quote_rejected': + return `의뢰자가 견적을 거절했습니다 (회차 #${params.version ?? '?'})`; + case 'payment_confirmed': + return '결제가 확인되었습니다'; + case 'payment_confirmed_offline': + return '운영자가 결제를 수동 확인했습니다'; + case 'completed': + return '의뢰가 완료되었습니다'; + case 'canceled_by_client': + return '의뢰자가 의뢰를 취소했습니다'; + case 'canceled_by_operator': + return '운영자가 의뢰를 취소했습니다'; + default: + return meta.key; + } +}; + +const InquiryMessageThread: React.FC = ({ + messages, + myRole = 'client', + onSend, + submitting = false, + placeholder = '메시지를 입력하세요', + className = '', +}) => { + const [draft, setDraft] = useState(''); + + const handleSend = () => { + const trimmed = draft.trim(); + if (!trimmed) return; + onSend?.(trimmed); + setDraft(''); + }; + + return ( +
+
+ {messages.map((msg) => { + if (msg.sender_role === 'system') { + return ( +
+ + {renderSystemBody(msg.meta)} + +
+ ); + } + const mine = msg.sender_role === myRole; + return ( +
+
+ {msg.body &&

{msg.body}

} +
+
+ ); + })} + {messages.length === 0 && ( +
+ 아직 메시지가 없습니다 +
+ )} +
+ +
+