diff --git a/CLAUDE.md b/CLAUDE.md index 07cc8080..94ea71e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,26 +174,57 @@ # API 앱에서 artisan 명령 실행 docker exec sam-api-1 php artisan <명령어> ``` -## 메뉴 관리 (DB 기반) +## 메뉴 관리 (DB 기반) - 수동 관리 필수! -### 메뉴 구조 -사이드바 메뉴는 DB에 저장되어 있으며 `MngMenuSeeder`로 관리합니다. +> **경고: 메뉴 시더(Seeder)를 절대 실행하지 마세요!** -### 메뉴 추가/수정 절차 (필수!) +### 배경 +사이드바 메뉴는 DB의 `menus` 테이블에 저장됩니다. +메뉴 시더 실행 시 **부서별 권한 설정(permission_overrides)이 초기화**되므로 시더 사용을 금지합니다. -1. **시더 파일 수정** - ``` - database/seeders/MngMenuSeeder.php - ``` +### 금지 사항 +``` +❌ php artisan db:seed --class=MngMenuSeeder 실행 금지 +❌ php artisan db:seed --class=*MenuSeeder 실행 금지 +❌ 메뉴 시더 파일 생성 금지 +❌ 메뉴 데이터를 일괄 삭제 후 재생성하는 방식 금지 +``` -2. **시더 실행 (Docker)** - ```bash - docker exec sam-mng-1 php artisan db:seed --class=MngMenuSeeder - ``` +### 메뉴 변경 시 올바른 절차 -3. **브라우저 새로고침**으로 확인 +메뉴 추가/수정/삭제/이동이 필요할 때는 **사용자에게 수동 실행 안내**를 제공합니다: + +1. **tinker 명령어를 안내** (사용자가 직접 실행) +2. **또는 SQL 쿼리를 안내** (사용자가 phpMyAdmin 등에서 직접 실행) +3. **절대 시더를 만들어 실행하지 않음** + +### 안내 예시 + +```bash +# 메뉴 추가 +ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\" +App\\\\Models\\\\Commons\\\\Menu::create([ + 'tenant_id' => 1, + 'parent_id' => <부모ID>, + 'name' => '새 메뉴', + 'url' => '/new-menu', + 'icon' => 'icon-name', + 'sort_order' => 1, + 'is_active' => true, +]); +\"" + +# 메뉴 이름 변경 +ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\" +App\\\\Models\\\\Commons\\\\Menu::withoutGlobalScopes()->find()->update(['name' => '새이름']); +\"" + +# 메뉴 비활성화 (삭제 대신) +ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\" +App\\\\Models\\\\Commons\\\\Menu::withoutGlobalScopes()->find()->update(['is_active' => false]); +\"" +``` ### 주의사항 -- 시더 실행 시 기존 메뉴(tenant_id=1)가 삭제 후 재생성됨 -- 메뉴 코드 수정만으로는 적용 안 됨 → **반드시 시더 실행 필요** -- 라우트(`routes/web.php`)와 컨트롤러도 함께 추가해야 함 +- 메뉴 변경 시 라우트(`routes/web.php`)와 컨트롤러도 함께 추가해야 함 +- 새 메뉴 추가 후 부서별 권한 설정이 필요하면 사용자에게 안내 diff --git a/app/Http/Controllers/Finance/VatRecordController.php b/app/Http/Controllers/Finance/VatRecordController.php index 4712cccf..e6443b07 100644 --- a/app/Http/Controllers/Finance/VatRecordController.php +++ b/app/Http/Controllers/Finance/VatRecordController.php @@ -98,13 +98,29 @@ public function index(Request $request): JsonResponse $splitsByKey = CardTransactionSplit::getByDateRange($tenantId, $startDateYmd, $endDateYmd); + // 분개가 존재하는 거래의 부분키(금액 제외) 인덱스 생성 + // 수동입력 등으로 금액이 달라져도 매칭되도록 함 + $splitsByPartialKey = []; + foreach ($splitsByKey as $fullKey => $splits) { + $parts = explode('|', $fullKey); + if (count($parts) >= 3) { + $partialKey = $parts[0] . '|' . $parts[1] . '|' . $parts[2]; + $splitsByPartialKey[$partialKey] = $splits; + } + } + foreach ($cardTransactions as $card) { // 숨김 처리된 거래는 skip if ($hiddenKeys->contains($card->unique_key)) { continue; } + // 분개 매칭: 정확한 키 → 부분키(금액 제외) 순으로 시도 $splits = $splitsByKey[$card->unique_key] ?? null; + if (!$splits) { + $cardPartialKey = $card->card_num . '|' . $card->use_dt . '|' . $card->approval_num; + $splits = $splitsByPartialKey[$cardPartialKey] ?? null; + } if ($splits && count($splits) > 0) { // 분개가 있으면: deductible 분개만 포함 diff --git a/app/Http/Controllers/Juil/PlanningController.php b/app/Http/Controllers/Juil/PlanningController.php new file mode 100644 index 00000000..ce26cc77 --- /dev/null +++ b/app/Http/Controllers/Juil/PlanningController.php @@ -0,0 +1,29 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.estimate')); + } + + return view('juil.estimate'); + } + + public function project(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.project')); + } + + return view('juil.project'); + } +} diff --git a/database/seeders/FixServerMenuSeeder.php b/database/seeders/FixServerMenuSeeder.php new file mode 100644 index 00000000..4560a0d5 --- /dev/null +++ b/database/seeders/FixServerMenuSeeder.php @@ -0,0 +1,107 @@ +where('tenant_id', $tenantId) + ->where('name', '고객/거래처/채권관리') + ->whereNull('parent_id') + ->first(); + + if (!$targetGroup) { + $this->command->error('고객/거래처/채권관리 그룹을 찾을 수 없습니다.'); + return; + } + + $this->command->info("대상 그룹: 고객/거래처/채권관리 (id: {$targetGroup->id})"); + + // 서버에서 실제 메뉴 이름으로 이동 (로컬과 서버 모두 대응) + $menuMoves = [ + // [검색 이름들, 새 이름, sort_order] + [['채권관리', '미수금 관리', '미수금관리'], '미수금관리', 3], + [['채무관리', '미지급금 관리', '미지급금관리'], '미지급금관리', 4], + [['환불관리', '환불/해지 관리', '환불/해지관리'], '환불/해지관리', 5], + ]; + + foreach ($menuMoves as [$searchNames, $newName, $sortOrder]) { + $menu = null; + foreach ($searchNames as $name) { + $menu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', $name) + ->where('is_active', true) + ->first(); + if ($menu) break; + } + + if (!$menu) { + // 이미 이동된 경우 확인 + $existing = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $targetGroup->id) + ->where('name', $newName) + ->first(); + + if ($existing) { + $this->command->info(" 이미 이동됨: {$newName} (id: {$existing->id})"); + } else { + $this->command->warn(" 메뉴 없음: " . implode(' / ', $searchNames)); + } + continue; + } + + $menu->update([ + 'parent_id' => $targetGroup->id, + 'sort_order' => $sortOrder, + 'name' => $newName, + ]); + $this->command->info(" 이동: {$menu->getOriginal('name')} → 고객/거래처/채권관리/{$newName} (sort: {$sortOrder})"); + } + + // 재무관리 부모 비활성화 (자식 없으면) + $financeParent = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '재무관리') + ->whereNull('parent_id') + ->first(); + + if ($financeParent) { + $remaining = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $financeParent->id) + ->where('is_active', true) + ->count(); + + if ($remaining === 0) { + $financeParent->update(['is_active' => false]); + $this->command->info(" 비활성화: 재무관리 (id: {$financeParent->id})"); + } else { + $this->command->warn(" 재무관리 자식 {$remaining}개 남음 - 비활성화 안 함"); + } + } + + // 결과 확인 + $this->command->info(''); + $this->command->info('=== 고객/거래처/채권관리 최종 구성 ==='); + $children = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $targetGroup->id) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + foreach ($children as $child) { + $this->command->info(" {$child->sort_order}. {$child->name} → {$child->url}"); + } + } +} diff --git a/database/seeders/JuilPlanningMenuSeeder.php b/database/seeders/JuilPlanningMenuSeeder.php new file mode 100644 index 00000000..6cdad4b5 --- /dev/null +++ b/database/seeders/JuilPlanningMenuSeeder.php @@ -0,0 +1,78 @@ +where('tenant_id', $tenantId) + ->where('name', '주일기업 기획') + ->whereNull('parent_id') + ->first(); + + if ($existingParent) { + $this->command->info('주일기업 기획 메뉴가 이미 존재합니다.'); + return; + } + + // 최상위 메뉴 중 가장 큰 sort_order 찾기 + $maxSortOrder = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereNull('parent_id') + ->max('sort_order') ?? 0; + + // 대분류 메뉴 생성 + $parent = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => null, + 'name' => '주일기업 기획', + 'url' => null, + 'icon' => 'building-2', + 'sort_order' => $maxSortOrder + 1, + 'is_active' => true, + ]); + + $this->command->info("대분류 메뉴 생성: 주일기업 기획 (sort_order: {$parent->sort_order})"); + + // 하위 메뉴 1: 견적/입찰/공사관리 + $menu1 = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parent->id, + 'name' => '견적/입찰/공사관리', + 'url' => '/juil/estimate', + 'icon' => 'clipboard-list', + 'sort_order' => 1, + 'is_active' => true, + ]); + + $this->command->info("하위 메뉴 생성: {$menu1->name} ({$menu1->url})"); + + // 하위 메뉴 2: 프로젝트관리/기성청구 + $menu2 = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parent->id, + 'name' => '프로젝트관리/기성청구', + 'url' => '/juil/project', + 'icon' => 'hard-hat', + 'sort_order' => 2, + 'is_active' => true, + ]); + + $this->command->info("하위 메뉴 생성: {$menu2->name} ({$menu2->url})"); + + // 결과 출력 + $this->command->info(''); + $this->command->info('=== 주일기업 기획 메뉴 구조 ==='); + $this->command->info("📁 {$parent->name} (id: {$parent->id})"); + $this->command->info(" ├─ {$menu1->name} → {$menu1->url}"); + $this->command->info(" └─ {$menu2->name} → {$menu2->url}"); + } +} diff --git a/database/seeders/ReorganizeFinanceMenuSeeder.php b/database/seeders/ReorganizeFinanceMenuSeeder.php new file mode 100644 index 00000000..86b5e9b3 --- /dev/null +++ b/database/seeders/ReorganizeFinanceMenuSeeder.php @@ -0,0 +1,373 @@ + ['icon' => 'currency-dollar', 'sort' => 6], + '회계/세무관리' => ['icon' => 'calculator', 'sort' => 7], + '카드/차량관리' => ['icon' => 'credit-card', 'sort' => 8], + '정산관리' => ['icon' => 'cash', 'sort' => 9], + '고객/거래처/채권관리' => ['icon' => 'users', 'sort' => 10], + '영업/매출관리' => ['icon' => 'briefcase', 'sort' => 11], + '시스템/설정/내부관리' => ['icon' => 'settings', 'sort' => 12], + ]; + + $parentIds = []; + foreach ($newGroups as $name => $config) { + $existing = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', $name) + ->whereNull('parent_id') + ->first(); + + if ($existing) { + $parentIds[$name] = $existing->id; + $this->command->info("기존 그룹 사용: {$name} (id: {$existing->id})"); + } else { + $menu = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => null, + 'name' => $name, + 'url' => null, + 'icon' => $config['icon'], + 'sort_order' => $config['sort'], + 'is_active' => true, + ]); + $parentIds[$name] = $menu->id; + $this->command->info("새 그룹 생성: {$name} (id: {$menu->id}, sort: {$config['sort']})"); + } + } + + // ============================== + // 2. 메뉴 이동 및 이름 변경 정의 + // ============================== + // [현재 이름 => [새 부모 그룹, 새 sort_order, 새 이름(null이면 변경 없음), 새 URL(null이면 변경 없음)]] + $menuMoves = [ + // --- 재무/자금관리 --- + '재무 대시보드' => ['재무/자금관리', 1, null, null], + '일일자금일보' => ['재무/자금관리', 2, null, null], + '자금계획일정' => ['재무/자금관리', 3, null, null], + '보유계좌관리' => ['재무/자금관리', 4, null, null], + '계좌입출금내역' => ['재무/자금관리', 5, null, null], + + // --- 회계/세무관리 --- + '일반전표입력' => ['회계/세무관리', 1, null, null], + '전자세금계산서' => ['회계/세무관리', 2, null, null], + '홈택스 매출/매입' => ['회계/세무관리', 3, '홈택스매출/매입', null], + '부가세관리' => ['회계/세무관리', 4, null, null], + + // --- 카드/차량관리 --- + '법인카드관리' => ['카드/차량관리', 1, null, null], + '카드사용내역' => ['카드/차량관리', 2, null, null], + '법인차량관리' => ['카드/차량관리', 3, '차량목록', '/finance/corporate-vehicles'], + // 차량일지는 아래에서 별도 생성 + '차량정비이력' => ['카드/차량관리', 5, '정비이력', null], + + // --- 정산관리 --- + '영업수수료 정산' => ['정산관리', 1, '영업수수료정산', null], + '컨설팅비용 정산' => ['정산관리', 2, '컨설팅비용정산', null], + '고객사 정산' => ['정산관리', 3, '고객사정산', null], + '구독료 정산' => ['정산관리', 4, '구독료정산', null], + + // --- 고객/거래처/채권관리 --- + '거래처 관리' => ['고객/거래처/채권관리', 1, '거래처관리', null], + '고객사관리' => ['고객/거래처/채권관리', 2, null, null], + '채권관리' => ['고객/거래처/채권관리', 3, '미수금관리', null], + '채무관리' => ['고객/거래처/채권관리', 4, '미지급금관리', null], + '환불관리' => ['고객/거래처/채권관리', 5, '환불/해지관리', null], + + // --- 영업/매출관리 --- (영업관리의 자식 중) + // 대시보드는 이름이 겹칠 수 있으므로 ID로 처리 + + // --- 시스템/설정/내부관리 --- + // 바로빌본사(15501)는 parent → child로 변환 (아래 별도 처리) + ]; + + // ============================== + // 3. 메뉴 이동 실행 + // ============================== + $movedCount = 0; + foreach ($menuMoves as $currentName => [$groupName, $sortOrder, $newName, $newUrl]) { + $menu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', $currentName) + ->where('is_active', true) + ->first(); + + if (!$menu) { + $this->command->warn("메뉴 없음: {$currentName}"); + continue; + } + + $updates = [ + 'parent_id' => $parentIds[$groupName], + 'sort_order' => $sortOrder, + ]; + + if ($newName) { + $updates['name'] = $newName; + } + if ($newUrl) { + $updates['url'] = $newUrl; + } + + $menu->update($updates); + $displayName = $newName ?? $currentName; + $this->command->info(" 이동: {$currentName} → {$groupName}/{$displayName} (sort: {$sortOrder})"); + $movedCount++; + } + + // ============================== + // 4. 차량일지 메뉴 이동 또는 생성 + // ============================== + $vehicleLog = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '차량일지') + ->first(); + + if ($vehicleLog) { + $vehicleLog->update([ + 'parent_id' => $parentIds['카드/차량관리'], + 'sort_order' => 4, + ]); + $this->command->info(" 이동: 차량일지 → 카드/차량관리 (sort: 4)"); + } else { + Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentIds['카드/차량관리'], + 'name' => '차량일지', + 'url' => '/finance/vehicle-logs', + 'icon' => 'clipboard-list', + 'sort_order' => 4, + 'is_active' => true, + ]); + $this->command->info(" 신규: 차량일지 → 카드/차량관리 (sort: 4)"); + } + + // ============================== + // 5. 영업관리 자식 메뉴 이동 (ID 기반) + // ============================== + $salesMoves = [ + // [현재 ID or name, new sort, new name] + ['대시보드', 1, '영업관리 대시보드'], // 15576 + ['파트너 관리', 2, '파트너관리'], // 15515 + ['영업파트너 승인', 3, '영업파트너승인'], // 15584 + ['상품관리', 4, null], // 15581 + ['세일즈 사이트', 5, '세일즈사이트'], // 15567 + ['렌딩페이지', 6, '랜딩페이지'], // 15568 + ]; + + // 현재 영업관리(15514) 그룹의 자식들 중에서 검색 + $salesParent = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '영업관리') + ->whereNull('parent_id') + ->first(); + + if ($salesParent) { + $salesChildren = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $salesParent->id) + ->get(); + + foreach ($salesMoves as [$name, $sort, $rename]) { + $child = $salesChildren->firstWhere('name', $name); + if ($child) { + $updates = [ + 'parent_id' => $parentIds['영업/매출관리'], + 'sort_order' => $sort, + ]; + if ($rename) { + $updates['name'] = $rename; + } + $child->update($updates); + $displayName = $rename ?? $name; + $this->command->info(" 이동: {$name} → 영업/매출관리/{$displayName} (sort: {$sort})"); + } else { + $this->command->warn(" 영업관리 자식 없음: {$name}"); + } + } + + // 영업관리의 나머지 자식들도 영업/매출관리로 이동 + $remainingSales = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $salesParent->id) + ->get(); + + $nextSort = 10; + foreach ($remainingSales as $child) { + $child->update([ + 'parent_id' => $parentIds['영업/매출관리'], + 'sort_order' => $nextSort++, + ]); + $this->command->info(" 이동(잔여): {$child->name} → 영업/매출관리 (sort: " . ($nextSort - 1) . ")"); + } + } + + // ============================== + // 6. 시스템/설정/내부관리 - 바로빌 관련 메뉴 이동 + // ============================== + $sysGroupId = $parentIds['시스템/설정/내부관리']; + + // 바로빌본사(15501) → 자식 메뉴로 변환 + $barobillHQ = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '바로빌본사') + ->whereNull('parent_id') + ->first(); + + if ($barobillHQ) { + // 바로빌본사의 자식들 먼저 이동 + $hqChildren = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $barobillHQ->id) + ->get(); + + // 회원사관리, 바로빌설정을 시스템/설정/내부관리로 이동 + $hqMoves = [ + '회원사관리' => 2, + '바로빌설정' => 3, + ]; + + foreach ($hqChildren as $child) { + $sort = $hqMoves[$child->name] ?? 10; + $child->update([ + 'parent_id' => $sysGroupId, + 'sort_order' => $sort, + ]); + $this->command->info(" 이동: {$child->name} → 시스템/설정/내부관리 (sort: {$sort})"); + } + + // 바로빌본사 자체를 자식 메뉴로 변환 + $barobillHQ->update([ + 'parent_id' => $sysGroupId, + 'url' => '/barobill/ecard', + 'sort_order' => 1, + ]); + $this->command->info(" 변환: 바로빌본사 → 시스템/설정/내부관리/바로빌본사 (sort: 1)"); + } + + // 바로빌(15504)의 자식들 이동 + $barobillTenant = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '바로빌') + ->whereNull('parent_id') + ->first(); + + if ($barobillTenant) { + $tenantMoves = [ + '사용량조회' => 4, + '과금관리' => 5, + ]; + + $barobillChildren = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $barobillTenant->id) + ->get(); + + foreach ($barobillChildren as $child) { + $sort = $tenantMoves[$child->name] ?? 10; + $child->update([ + 'parent_id' => $sysGroupId, + 'sort_order' => $sort, + ]); + $this->command->info(" 이동: {$child->name} → 시스템/설정/내부관리 (sort: {$sort})"); + } + + // 바로빌 부모 비활성화 + $barobillTenant->update(['is_active' => false]); + $this->command->info(" 비활성화: 바로빌 (id: {$barobillTenant->id})"); + } + + // 시스템(15518)의 자식도 이동 + $sysOld = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', '시스템') + ->whereNull('parent_id') + ->where('sort_order', 11) + ->first(); + + if ($sysOld) { + $sysOldChildren = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $sysOld->id) + ->get(); + + $nextSort = 20; + foreach ($sysOldChildren as $child) { + $child->update([ + 'parent_id' => $sysGroupId, + 'sort_order' => $nextSort++, + ]); + $this->command->info(" 이동: {$child->name} → 시스템/설정/내부관리 (sort: " . ($nextSort - 1) . ")"); + } + + $sysOld->update(['is_active' => false]); + $this->command->info(" 비활성화: 시스템 (id: {$sysOld->id})"); + } + + // ============================== + // 7. 빈 부모 그룹 비활성화 + // ============================== + $oldParents = ['재무관리', '영업관리']; + foreach ($oldParents as $name) { + $parent = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', $name) + ->whereNull('parent_id') + ->first(); + + if ($parent) { + // 자식이 남아있는지 확인 + $remaining = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $parent->id) + ->where('is_active', true) + ->count(); + + if ($remaining === 0) { + $parent->update(['is_active' => false]); + $this->command->info(" 비활성화: {$name} (id: {$parent->id})"); + } else { + $this->command->warn(" 유지: {$name} - 자식 {$remaining}개 남음"); + } + } + } + + // ============================== + // 8. 결과 출력 + // ============================== + $this->command->info(''); + $this->command->info('=== 새 메뉴 구조 ==='); + + foreach ($newGroups as $name => $config) { + $children = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('parent_id', $parentIds[$name]) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + $this->command->info("📁 {$name}"); + foreach ($children as $i => $child) { + $prefix = $i === $children->count() - 1 ? ' └─' : ' ├─'; + $this->command->info("{$prefix} {$child->name} → {$child->url}"); + } + } + + $this->command->info(''); + $this->command->info("완료: {$movedCount}개 메뉴 이동"); + } +} diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index c85820f6..8211be5d 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -83,6 +83,7 @@ manualDestroy: '{{ route("barobill.hometax.manual-destroy", ["id" => "__ID__"]) }}', createJournalEntry: '{{ route("barobill.hometax.create-journal-entry") }}', cardTransactions: '{{ route("barobill.hometax.card-transactions") }}', + accountCodes: '{{ route("barobill.ecard.account-codes") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -422,11 +423,15 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra const [showJournalModal, setShowJournalModal] = useState(false); const [journalInvoice, setJournalInvoice] = useState(null); + // 계정과목 목록 + const [accountCodes, setAccountCodes] = useState([]); + // 초기 로드 (매출만 먼저 조회) useEffect(() => { loadSalesData(); loadCollectStatus(); fetchTradingPartners(); + loadAccountCodes(); }, []); // 탭 변경 시 해당 탭 데이터 로드 (아직 로드되지 않은 경우) @@ -604,6 +609,16 @@ className="px-2 py-1 bg-red-50 text-red-700 rounded text-xs hover:bg-red-100 tra } }; + const loadAccountCodes = async () => { + try { + const res = await fetch(API.accountCodes); + const data = await res.json(); + if (data.success) setAccountCodes(data.data || []); + } catch (err) { + console.error('계정과목 목록 로드 오류:', err); + } + }; + const handleRequestCollect = async () => { if (!confirm('홈택스 데이터 수집을 요청하시겠습니까?\n수집에는 시간이 걸릴 수 있습니다.')) return; @@ -1368,6 +1383,7 @@ className="px-4 py-2 bg-stone-100 text-stone-700 rounded-lg text-sm font-medium onClose={() => { setShowJournalModal(false); setJournalInvoice(null); }} onSave={handleJournalSave} invoice={journalInvoice} + accountCodes={accountCodes} /> )} @@ -1873,10 +1889,147 @@ className="px-6 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hov ); }; + // ============================================ + // AccountCodeSelect - 계정과목 검색 드롭다운 + // ============================================ + const AccountCodeSelect = ({ value, onChange, accountCodes }) => { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); + const containerRef = useRef(null); + const listRef = useRef(null); + + const selectedItem = accountCodes.find(c => c.code === value); + const displayText = selectedItem ? `${selectedItem.code} ${selectedItem.name}` : ''; + + const filteredCodes = accountCodes.filter(code => { + if (!search) return true; + const s = search.toLowerCase(); + return code.code.toLowerCase().includes(s) || code.name.toLowerCase().includes(s); + }); + + useEffect(() => { setHighlightIndex(-1); }, [search]); + + useEffect(() => { + const handleClickOutside = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (code) => { + onChange(code.code, code.name); + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + }; + + const handleClear = (e) => { + e.stopPropagation(); + onChange('', ''); + setSearch(''); + setHighlightIndex(-1); + }; + + const handleKeyDown = (e) => { + const maxIndex = Math.min(filteredCodes.length, 50) - 1; + if (e.key === 'ArrowDown') { + e.preventDefault(); + const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0; + setHighlightIndex(newIndex); + setTimeout(() => { listRef.current?.children[newIndex]?.scrollIntoView({ block: 'nearest' }); }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex; + setHighlightIndex(newIndex); + setTimeout(() => { listRef.current?.children[newIndex]?.scrollIntoView({ block: 'nearest' }); }, 0); + } else if (e.key === 'Enter' && filteredCodes.length > 0) { + e.preventDefault(); + handleSelect(filteredCodes[highlightIndex >= 0 ? highlightIndex : 0]); + } else if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + } + }; + + return ( +
+
setIsOpen(!isOpen)} + className={`w-full px-2 py-1 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${ + isOpen ? 'border-violet-500 ring-2 ring-violet-500' : 'border-stone-200' + } bg-white`} + > + + {displayText || '선택'} + +
+ {value && ( + + )} + + + +
+
+ {isOpen && ( +
+
+ setSearch(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="코드 또는 이름 검색..." + className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-violet-500 outline-none" + autoFocus + /> +
+
+ {filteredCodes.length === 0 ? ( +
검색 결과 없음
+ ) : ( + filteredCodes.slice(0, 50).map((code, index) => ( +
handleSelect(code)} + className={`px-3 py-1.5 text-xs cursor-pointer ${ + index === highlightIndex + ? 'bg-violet-600 text-white font-semibold' + : value === code.code + ? 'bg-violet-100 text-violet-700' + : 'text-stone-700 hover:bg-violet-50' + }`} + > + {code.code} + {code.name} +
+ )) + )} + {filteredCodes.length > 50 && ( +
+{filteredCodes.length - 50}개 더 있음
+ )} +
+
+ )} +
+ ); + }; + // ============================================ // JournalEntryModal - 분개 생성 모달 // ============================================ - const JournalEntryModal = ({ isOpen, onClose, onSave, invoice }) => { + const JournalEntryModal = ({ isOpen, onClose, onSave, invoice, accountCodes = [] }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); const isSales = invoice.invoiceType === 'sales' || invoice.invoice_type === 'sales'; const supplyAmount = parseFloat(invoice.supplyAmount || invoice.supply_amount || 0); @@ -1955,8 +2108,7 @@ className="px-6 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hov 차/대 - 계정코드 - 계정과목 + 계정과목 차변금액 대변금액 @@ -1969,20 +2121,13 @@ className="px-6 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hov {line.dc_type === 'debit' ? '차변' : '대변'} - - + updateLine(idx, 'account_code', e.target.value)} - className="w-full px-2 py-1 border border-stone-200 rounded text-sm text-center focus:ring-1 focus:ring-violet-500 outline-none" - /> - - - updateLine(idx, 'account_name', e.target.value)} - className="w-full px-2 py-1 border border-stone-200 rounded text-sm focus:ring-1 focus:ring-violet-500 outline-none" + onChange={(code, name) => { + setLines(prev => prev.map((l, i) => i === idx ? { ...l, account_code: code, account_name: name } : l)); + }} + accountCodes={accountCodes} /> diff --git a/resources/views/finance/daily-fund.blade.php b/resources/views/finance/daily-fund.blade.php index 04f30c20..57cc6876 100644 --- a/resources/views/finance/daily-fund.blade.php +++ b/resources/views/finance/daily-fund.blade.php @@ -244,7 +244,7 @@ className="px-3 py-2 border border-gray-300 rounded-lg text-sm" {report.deposits.map((d, dIdx) => ( -
{d.summary}{d.cast ? ` - ${d.cast}` : ''}
+
{d.cast}{d.summary ? ` - ${d.summary}` : ''}
{d.bankName}
@@ -290,7 +290,7 @@ className="px-3 py-2 border border-gray-300 rounded-lg text-sm" {report.withdrawals.map((w, wIdx) => ( -
{w.summary}{w.cast ? ` - ${w.cast}` : ''}
+
{w.cast}{w.summary ? ` - ${w.summary}` : ''}
{w.bankName}
diff --git a/resources/views/juil/estimate.blade.php b/resources/views/juil/estimate.blade.php new file mode 100644 index 00000000..afd0c71a --- /dev/null +++ b/resources/views/juil/estimate.blade.php @@ -0,0 +1,640 @@ +@extends('layouts.app') + +@section('title', '견적/입찰/공사관리') + +@section('content') +
+@endsection + +@push('scripts') + + + + + +@endpush diff --git a/resources/views/juil/project.blade.php b/resources/views/juil/project.blade.php new file mode 100644 index 00000000..b307002d --- /dev/null +++ b/resources/views/juil/project.blade.php @@ -0,0 +1,429 @@ +@extends('layouts.app') + +@section('title', '프로젝트관리/기성청구') + +@section('content') +
+@endsection + +@push('scripts') + + + + + +@endpush diff --git a/routes/web.php b/routes/web.php index aa0dfb13..1200144b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -31,6 +31,7 @@ use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\Sales\SalesProductController; +use App\Http\Controllers\Juil\PlanningController; use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\AiConfigController; use App\Http\Controllers\System\AiTokenUsageController; @@ -1288,3 +1289,13 @@ Route::post('/api/sessions/{id}/complete', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'completeSession'])->name('api.sessions.complete'); }); }); + +/* +|-------------------------------------------------------------------------- +| 주일기업 기획 (Juil Planning) +|-------------------------------------------------------------------------- +*/ +Route::middleware('auth')->prefix('juil')->name('juil.')->group(function () { + Route::get('/estimate', [PlanningController::class, 'estimate'])->name('estimate'); + Route::get('/project', [PlanningController::class, 'project'])->name('project'); +});