Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
63
CLAUDE.md
63
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(<ID>)->update(['name' => '새이름']);
|
||||
\""
|
||||
|
||||
# 메뉴 비활성화 (삭제 대신)
|
||||
ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\"
|
||||
App\\\\Models\\\\Commons\\\\Menu::withoutGlobalScopes()->find(<ID>)->update(['is_active' => false]);
|
||||
\""
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
- 시더 실행 시 기존 메뉴(tenant_id=1)가 삭제 후 재생성됨
|
||||
- 메뉴 코드 수정만으로는 적용 안 됨 → **반드시 시더 실행 필요**
|
||||
- 라우트(`routes/web.php`)와 컨트롤러도 함께 추가해야 함
|
||||
- 메뉴 변경 시 라우트(`routes/web.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 분개만 포함
|
||||
|
||||
29
app/Http/Controllers/Juil/PlanningController.php
Normal file
29
app/Http/Controllers/Juil/PlanningController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Juil;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PlanningController extends Controller
|
||||
{
|
||||
public function estimate(Request $request): View|Response
|
||||
{
|
||||
if ($request->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');
|
||||
}
|
||||
}
|
||||
107
database/seeders/FixServerMenuSeeder.php
Normal file
107
database/seeders/FixServerMenuSeeder.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class FixServerMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = 1;
|
||||
|
||||
// 고객/거래처/채권관리 그룹 찾기
|
||||
$targetGroup = Menu::withoutGlobalScopes()
|
||||
->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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
78
database/seeders/JuilPlanningMenuSeeder.php
Normal file
78
database/seeders/JuilPlanningMenuSeeder.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class JuilPlanningMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = 1;
|
||||
|
||||
// 대분류 "주일기업 기획" 메뉴가 이미 있는지 확인
|
||||
$existingParent = Menu::withoutGlobalScopes()
|
||||
->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}");
|
||||
}
|
||||
}
|
||||
373
database/seeders/ReorganizeFinanceMenuSeeder.php
Normal file
373
database/seeders/ReorganizeFinanceMenuSeeder.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ReorganizeFinanceMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = 1;
|
||||
|
||||
// ==============================
|
||||
// 1. 새 대분류 그룹 생성
|
||||
// ==============================
|
||||
$newGroups = [
|
||||
'재무/자금관리' => ['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}개 메뉴 이동");
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
onClick={() => 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`}
|
||||
>
|
||||
<span className={displayText ? 'text-stone-900' : 'text-stone-400'}>
|
||||
{displayText || '선택'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{value && (
|
||||
<button onClick={handleClear} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<svg className={`w-3 h-3 text-stone-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute z-[9999] mt-1 w-56 bg-white border border-stone-200 rounded-lg shadow-xl">
|
||||
<div className="p-2 border-b border-stone-100">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-48 overflow-y-auto">
|
||||
{filteredCodes.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-stone-400 text-center">검색 결과 없음</div>
|
||||
) : (
|
||||
filteredCodes.slice(0, 50).map((code, index) => (
|
||||
<div
|
||||
key={code.code}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-violet-600'}`}>{code.code}</span>
|
||||
<span className="ml-1">{code.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{filteredCodes.length > 50 && (
|
||||
<div className="px-3 py-1 text-xs text-stone-400 text-center border-t">+{filteredCodes.length - 50}개 더 있음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
<thead>
|
||||
<tr className="bg-stone-100">
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차/대</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">계정코드</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">계정과목</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200" colSpan="2">계정과목</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">차변금액</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-stone-600 border-b border-stone-200">대변금액</th>
|
||||
</tr>
|
||||
@@ -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' ? '차변' : '대변'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
<td className="px-3 py-2" colSpan="2">
|
||||
<AccountCodeSelect
|
||||
value={line.account_code}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={line.account_name}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
|
||||
@@ -244,7 +244,7 @@ className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
{report.deposits.map((d, dIdx) => (
|
||||
<tr key={dIdx} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-sm text-gray-800 border">
|
||||
<div className="font-medium">{d.summary}{d.cast ? ` - ${d.cast}` : ''}</div>
|
||||
<div className="font-medium">{d.cast}{d.summary ? ` - ${d.summary}` : ''}</div>
|
||||
<div className="text-xs text-gray-500">{d.bankName}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-blue-600 font-medium border">
|
||||
@@ -290,7 +290,7 @@ className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
{report.withdrawals.map((w, wIdx) => (
|
||||
<tr key={wIdx} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-sm text-gray-800 border">
|
||||
<div className="font-medium">{w.summary}{w.cast ? ` - ${w.cast}` : ''}</div>
|
||||
<div className="font-medium">{w.cast}{w.summary ? ` - ${w.summary}` : ''}</div>
|
||||
<div className="text-xs text-gray-500">{w.bankName}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-right text-red-600 font-medium border">
|
||||
|
||||
640
resources/views/juil/estimate.blade.php
Normal file
640
resources/views/juil/estimate.blade.php
Normal file
@@ -0,0 +1,640 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '견적/입찰/공사관리')
|
||||
|
||||
@section('content')
|
||||
<div id="root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script type="text/babel">
|
||||
@verbatim
|
||||
const { useState, useMemo } = React;
|
||||
|
||||
// --- 데이터 ---
|
||||
const initialEstimates = [
|
||||
{
|
||||
id: 'EST-2024-001',
|
||||
siteName: '힐스테이트 선화더와이즈',
|
||||
client: '현대건설',
|
||||
status: '인수완료',
|
||||
estimateAmount: 104940000,
|
||||
contractAmount: 93500000,
|
||||
deadline: '2024-11-25',
|
||||
manager: '김영민',
|
||||
estimator: '박견적',
|
||||
items: [
|
||||
{ name: '블라인드 25mm', qty: 320, unit: 'EA', unitPrice: 45000 },
|
||||
{ name: '롤스크린 원단', qty: 180, unit: 'EA', unitPrice: 62000 },
|
||||
{ name: '시공비', qty: 1, unit: '식', unitPrice: 8500000 },
|
||||
],
|
||||
logs: [
|
||||
{ date: '2024-10-01', content: '견적서 작성 완료', author: '박견적' },
|
||||
{ date: '2024-10-15', content: '입찰 제출', author: '박견적' },
|
||||
{ date: '2024-11-01', content: '낙찰 확정', author: '김대표' },
|
||||
{ date: '2024-11-25', content: '계약 완료, 공사 인수', author: '김영민' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'EST-2024-002',
|
||||
siteName: '충주 현대모비스 공장동',
|
||||
client: '현대모비스',
|
||||
status: '입찰진행',
|
||||
estimateAmount: 63593000,
|
||||
contractAmount: null,
|
||||
deadline: '2024-10-15',
|
||||
manager: null,
|
||||
estimator: '박견적',
|
||||
items: [
|
||||
{ name: '산업용 롤스크린', qty: 95, unit: 'EA', unitPrice: 120000 },
|
||||
{ name: '차광 블라인드', qty: 210, unit: 'EA', unitPrice: 55000 },
|
||||
{ name: '시공비', qty: 1, unit: '식', unitPrice: 6500000 },
|
||||
],
|
||||
logs: [
|
||||
{ date: '2024-09-20', content: '견적 요청 접수', author: '박견적' },
|
||||
{ date: '2024-10-01', content: '현장 실측 완료', author: '박견적' },
|
||||
{ date: '2024-10-10', content: '견적서 제출', author: '박견적' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'EST-2024-003',
|
||||
siteName: '안성 SK하이닉스 신축',
|
||||
client: 'SK하이닉스',
|
||||
status: '결재대기',
|
||||
estimateAmount: 170709000,
|
||||
contractAmount: null,
|
||||
deadline: '2024-12-05',
|
||||
manager: null,
|
||||
estimator: '박견적',
|
||||
items: [
|
||||
{ name: '클린룸 전동블라인드', qty: 450, unit: 'EA', unitPrice: 185000 },
|
||||
{ name: '방화 롤스크린', qty: 120, unit: 'EA', unitPrice: 220000 },
|
||||
{ name: '설치 및 시공비', qty: 1, unit: '식', unitPrice: 18000000 },
|
||||
],
|
||||
logs: [
|
||||
{ date: '2024-11-15', content: '견적 요청 접수', author: '박견적' },
|
||||
{ date: '2024-11-28', content: '견적서 작성 완료, 결재 요청', author: '박견적' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'EST-2024-004',
|
||||
siteName: '강남 오피스빌딩 리모델링',
|
||||
client: '삼성물산',
|
||||
status: '현장설명회',
|
||||
estimateAmount: null,
|
||||
contractAmount: null,
|
||||
deadline: '2024-12-10',
|
||||
manager: null,
|
||||
estimator: null,
|
||||
items: [],
|
||||
logs: [
|
||||
{ date: '2024-12-01', content: '현장설명회 참석 예정', author: '박견적' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const constructionData = [
|
||||
{
|
||||
id: 'CON-2024-001',
|
||||
estimateId: 'EST-2024-001',
|
||||
siteName: '힐스테이트 선화더와이즈',
|
||||
client: '현대건설',
|
||||
contractAmount: 93500000,
|
||||
progress: 65,
|
||||
startDate: '2024-11-25',
|
||||
endDate: '2025-03-15',
|
||||
manager: '김영민',
|
||||
status: '시공중',
|
||||
expenditure: 42350000,
|
||||
tasks: [
|
||||
{ name: '블라인드 제작', progress: 100, status: '완료' },
|
||||
{ name: '롤스크린 제작', progress: 80, status: '진행중' },
|
||||
{ name: '1차 시공 (1~5층)', progress: 100, status: '완료' },
|
||||
{ name: '2차 시공 (6~10층)', progress: 30, status: '진행중' },
|
||||
{ name: '마감 점검', progress: 0, status: '대기' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{ value: 'ceo', label: '김대표 (CEO)' },
|
||||
{ value: 'estimator', label: '박견적 (견적담당)' },
|
||||
{ value: 'manager', label: '김영민 (공사관리)' },
|
||||
{ value: 'admin', label: '이경리 (경리)' },
|
||||
];
|
||||
|
||||
const statusConfig = {
|
||||
'현장설명회': { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-200' },
|
||||
'결재대기': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-200' },
|
||||
'입찰진행': { bg: 'bg-amber-100', text: 'text-amber-700', border: 'border-amber-200' },
|
||||
'낙찰': { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-200' },
|
||||
'인수완료': { bg: 'bg-teal-100', text: 'text-teal-700', border: 'border-teal-200' },
|
||||
'유찰': { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-200' },
|
||||
'계약완료': { bg: 'bg-emerald-100',text: 'text-emerald-700',border: 'border-emerald-200' },
|
||||
'시공중': { bg: 'bg-cyan-100', text: 'text-cyan-700', border: 'border-cyan-200' },
|
||||
};
|
||||
|
||||
function fmt(n) {
|
||||
if (n == null) return '-';
|
||||
return n.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
// --- 상태 배지 ---
|
||||
function StatusBadge({ status }) {
|
||||
const c = statusConfig[status] || { bg: 'bg-gray-100', text: 'text-gray-700', border: 'border-gray-200' };
|
||||
return <span className={`px-3 py-1 rounded border text-xs font-medium ${c.bg} ${c.text} ${c.border}`}>{status}</span>;
|
||||
}
|
||||
|
||||
// --- Toast ---
|
||||
function InlineToast({ message, onClose }) {
|
||||
React.useEffect(() => { const t = setTimeout(onClose, 2500); return () => clearTimeout(t); }, []);
|
||||
return (
|
||||
<div className="fixed top-6 right-6 z-50 bg-gray-800 text-white px-5 py-3 rounded-lg shadow-lg text-sm animate-pulse">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 견적 상세 모달 ---
|
||||
function EstimateDetailModal({ est, onClose }) {
|
||||
if (!est) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-3xl max-h-[85vh] overflow-y-auto m-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-gray-800">{est.id} - {est.siteName}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span className="text-gray-500">발주처:</span> <span className="font-medium">{est.client}</span></div>
|
||||
<div><span className="text-gray-500">상태:</span> <StatusBadge status={est.status} /></div>
|
||||
<div><span className="text-gray-500">견적금액:</span> <span className="font-medium">{fmt(est.estimateAmount)}원</span></div>
|
||||
<div><span className="text-gray-500">계약금액:</span> <span className="font-semibold text-emerald-600">{fmt(est.contractAmount)}원</span></div>
|
||||
<div><span className="text-gray-500">마감일:</span> <span className="font-medium">{est.deadline}</span></div>
|
||||
<div><span className="text-gray-500">담당자:</span> <span className="font-medium">{est.estimator || '-'}</span></div>
|
||||
</div>
|
||||
|
||||
{est.items.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">견적 항목</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">품목</th>
|
||||
<th className="px-3 py-2 text-right">수량</th>
|
||||
<th className="px-3 py-2 text-center">단위</th>
|
||||
<th className="px-3 py-2 text-right">단가</th>
|
||||
<th className="px-3 py-2 text-right">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{est.items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-right">{fmt(item.qty)}</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="px-3 py-2 text-right">{fmt(item.unitPrice)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{fmt(item.qty * item.unitPrice)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{est.logs.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">진행 이력</h4>
|
||||
<div className="space-y-2">
|
||||
{est.logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-3 text-sm">
|
||||
<span className="text-gray-400 shrink-0">{log.date}</span>
|
||||
<span className="text-gray-700">{log.content}</span>
|
||||
<span className="text-gray-400 ml-auto shrink-0">- {log.author}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t flex justify-end">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 새 견적 작성 모달 ---
|
||||
function NewEstimateModal({ onClose, onSave, nextId }) {
|
||||
const [form, setForm] = useState({ siteName: '', client: '', deadline: '', estimator: '박견적' });
|
||||
const handleSave = () => {
|
||||
if (!form.siteName || !form.client) return alert('현장명과 발주처를 입력해주세요.');
|
||||
onSave(form);
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg m-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h3 className="text-lg font-bold text-gray-800">새 견적 작성</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">견적번호: {nextId}</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">현장명 *</label>
|
||||
<input value={form.siteName} onChange={e => setForm({...form, siteName: e.target.value})}
|
||||
className="w-full border rounded px-3 py-2 text-sm" placeholder="예: 세종시 신축 아파트" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">발주처 *</label>
|
||||
<input value={form.client} onChange={e => setForm({...form, client: e.target.value})}
|
||||
className="w-full border rounded px-3 py-2 text-sm" placeholder="예: 현대건설" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">마감일</label>
|
||||
<input type="date" value={form.deadline} onChange={e => setForm({...form, deadline: e.target.value})}
|
||||
className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">견적 담당자</label>
|
||||
<select value={form.estimator} onChange={e => setForm({...form, estimator: e.target.value})}
|
||||
className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="박견적">박견적</option>
|
||||
<option value="김영민">김영민</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">취소</button>
|
||||
<button onClick={handleSave} className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 공사 상세 모달 ---
|
||||
function ConstructionDetailModal({ con, onClose }) {
|
||||
if (!con) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-3xl max-h-[85vh] overflow-y-auto m-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-gray-800">{con.siteName} - 공사관리</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div><span className="text-gray-500">발주처:</span> <span className="font-medium">{con.client}</span></div>
|
||||
<div><span className="text-gray-500">담당자:</span> <span className="font-medium">{con.manager}</span></div>
|
||||
<div><span className="text-gray-500">상태:</span> <StatusBadge status={con.status} /></div>
|
||||
<div><span className="text-gray-500">계약금액:</span> <span className="font-semibold text-emerald-600">{fmt(con.contractAmount)}원</span></div>
|
||||
<div><span className="text-gray-500">집행액:</span> <span className="font-medium">{fmt(con.expenditure)}원</span></div>
|
||||
<div><span className="text-gray-500">공기:</span> <span className="font-medium">{con.startDate} ~ {con.endDate}</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">전체 진행률</h4>
|
||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||
<div className="bg-blue-500 h-4 rounded-full transition-all" style={{width: `${con.progress}%`}}></div>
|
||||
</div>
|
||||
<p className="text-right text-sm text-gray-500 mt-1">{con.progress}%</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">공정 현황</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">공정</th>
|
||||
<th className="px-3 py-2 text-center">상태</th>
|
||||
<th className="px-3 py-2 text-right">진행률</th>
|
||||
<th className="px-3 py-2">진행바</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{con.tasks.map((t, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2">{t.name}</td>
|
||||
<td className="px-3 py-2 text-center"><StatusBadge status={t.status === '완료' ? '인수완료' : t.status === '진행중' ? '시공중' : '결재대기'} /></td>
|
||||
<td className="px-3 py-2 text-right">{t.progress}%</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className={`h-2 rounded-full ${t.progress === 100 ? 'bg-green-500' : t.progress > 0 ? 'bg-blue-500' : 'bg-gray-300'}`}
|
||||
style={{width: `${t.progress}%`}}></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t flex justify-end">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 메인 앱 ---
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('estimate');
|
||||
const [role, setRole] = useState('ceo');
|
||||
const [estimates, setEstimates] = useState(initialEstimates);
|
||||
const [constructions] = useState(constructionData);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [detailEst, setDetailEst] = useState(null);
|
||||
const [detailCon, setDetailCon] = useState(null);
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
|
||||
const showToast = (msg) => { setToast(msg); };
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = estimates.length;
|
||||
const waiting = estimates.filter(e => e.status === '결재대기').length;
|
||||
const bidding = estimates.filter(e => e.status === '입찰진행').length;
|
||||
const won = estimates.filter(e => ['낙찰', '인수완료', '계약완료'].includes(e.status)).length;
|
||||
const totalContract = estimates.reduce((s, e) => s + (e.contractAmount || 0), 0);
|
||||
return { total, waiting, bidding, won, totalContract };
|
||||
}, [estimates]);
|
||||
|
||||
const canApprove = role === 'ceo';
|
||||
const canEstimate = role === 'estimator' || role === 'ceo';
|
||||
const canManage = role === 'manager' || role === 'ceo';
|
||||
|
||||
const changeStatus = (id, newStatus) => {
|
||||
setEstimates(prev => prev.map(e => {
|
||||
if (e.id !== id) return e;
|
||||
const log = { date: new Date().toISOString().slice(0, 10), content: `상태 변경: ${e.status} → ${newStatus}`, author: roles.find(r => r.value === role).label.split(' (')[0] };
|
||||
return { ...e, status: newStatus, logs: [...e.logs, log] };
|
||||
}));
|
||||
showToast(`${id} → ${newStatus}`);
|
||||
};
|
||||
|
||||
const handleNewEstimate = (form) => {
|
||||
const num = estimates.length + 1;
|
||||
const newEst = {
|
||||
id: `EST-2024-${String(num).padStart(3, '0')}`,
|
||||
siteName: form.siteName,
|
||||
client: form.client,
|
||||
status: '현장설명회',
|
||||
estimateAmount: null,
|
||||
contractAmount: null,
|
||||
deadline: form.deadline || '-',
|
||||
manager: null,
|
||||
estimator: form.estimator,
|
||||
items: [],
|
||||
logs: [{ date: new Date().toISOString().slice(0, 10), content: '견적 등록', author: form.estimator }],
|
||||
};
|
||||
setEstimates(prev => [...prev, newEst]);
|
||||
setShowNewForm(false);
|
||||
showToast(`${newEst.id} 견적이 등록되었습니다.`);
|
||||
};
|
||||
|
||||
const renderActions = (est) => {
|
||||
switch (est.status) {
|
||||
case '현장설명회':
|
||||
return canEstimate
|
||||
? <button onClick={() => changeStatus(est.id, '결재대기')} className="px-3 py-1 bg-yellow-500 text-white rounded text-xs hover:bg-yellow-600">견적작성</button>
|
||||
: <span className="text-xs text-gray-400">권한 없음</span>;
|
||||
case '결재대기':
|
||||
return canApprove
|
||||
? <button onClick={() => changeStatus(est.id, '입찰진행')} className="px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600">결재</button>
|
||||
: <span className="text-xs text-gray-400">결재 대기중</span>;
|
||||
case '입찰진행':
|
||||
return canApprove ? (
|
||||
<div className="flex gap-1 justify-center">
|
||||
<button onClick={() => changeStatus(est.id, '인수완료')} className="px-2 py-1 bg-green-500 text-white rounded text-xs hover:bg-green-600">낙찰</button>
|
||||
<button onClick={() => changeStatus(est.id, '유찰')} className="px-2 py-1 bg-red-400 text-white rounded text-xs hover:bg-red-500">유찰</button>
|
||||
</div>
|
||||
) : <span className="text-xs text-gray-400">입찰 진행중</span>;
|
||||
case '인수완료':
|
||||
case '계약완료':
|
||||
return canManage
|
||||
? <button onClick={() => { setActiveTab('construction'); showToast('공사관리 탭으로 이동합니다.'); }} className="px-3 py-1 bg-emerald-500 text-white rounded text-xs hover:bg-emerald-600">공사관리</button>
|
||||
: <span className="text-xs text-gray-400">공사 진행중</span>;
|
||||
case '유찰':
|
||||
return <span className="text-xs text-gray-400">종료</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const conSummary = useMemo(() => {
|
||||
const total = constructions.length;
|
||||
const inProgress = constructions.filter(c => c.status === '시공중').length;
|
||||
const totalContract = constructions.reduce((s, c) => s + c.contractAmount, 0);
|
||||
const totalSpent = constructions.reduce((s, c) => s + c.expenditure, 0);
|
||||
const avgProgress = constructions.length > 0 ? Math.round(constructions.reduce((s, c) => s + c.progress, 0) / constructions.length) : 0;
|
||||
return { total, inProgress, totalContract, totalSpent, avgProgress };
|
||||
}, [constructions]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full w-full">
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* 헤더 */}
|
||||
<header className="bg-white border-b shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center">
|
||||
<h1 className="text-lg font-bold text-gray-800">주일기업 MES</h1>
|
||||
<select value={role} onChange={e => { setRole(e.target.value); showToast(`${roles.find(r => r.value === e.target.value).label}(으)로 전환`); }}
|
||||
className="border rounded px-3 py-1.5 text-sm bg-white">
|
||||
{roles.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 네비게이션 */}
|
||||
<nav className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-6 flex">
|
||||
<button onClick={() => setActiveTab('estimate')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 ${activeTab === 'estimate' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
견적/입찰 현황
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('construction')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 ${activeTab === 'construction' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
공사 관리
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||
{/* 견적/입찰 현황 탭 */}
|
||||
{activeTab === 'estimate' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">전체 견적</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{summary.total}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">결재 대기</p>
|
||||
<p className="text-2xl font-bold text-yellow-500">{summary.waiting}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">입찰 진행</p>
|
||||
<p className="text-2xl font-bold text-green-500">{summary.bidding}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">낙찰/계약</p>
|
||||
<p className="text-2xl font-bold text-purple-500">{summary.won}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">수주 금액</p>
|
||||
<p className="text-2xl font-bold text-blue-500">
|
||||
{summary.totalContract >= 100000000
|
||||
? (summary.totalContract / 100000000).toFixed(1) + '억'
|
||||
: (summary.totalContract / 10000000).toFixed(1) + '천만'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
<div className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-gray-800">견적/입찰 현황</h2>
|
||||
</div>
|
||||
{canEstimate && (
|
||||
<button onClick={() => setShowNewForm(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm font-medium hover:bg-blue-700">
|
||||
+ 새 견적 작성
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">견적번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">현장명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">발주처</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">상태</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">견적금액</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">계약금액</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">마감일</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{estimates.map(est => (
|
||||
<tr key={est.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4 text-sm font-medium text-gray-700">{est.id}</td>
|
||||
<td className="px-4 py-4">
|
||||
<button onClick={() => setDetailEst(est)}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline text-left">
|
||||
{est.siteName}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-gray-600">{est.client}</td>
|
||||
<td className="px-4 py-4 text-center"><StatusBadge status={est.status} /></td>
|
||||
<td className="px-4 py-4 text-sm text-right font-medium text-gray-800">{fmt(est.estimateAmount)}</td>
|
||||
<td className="px-4 py-4 text-sm text-right font-semibold text-emerald-600">{fmt(est.contractAmount)}</td>
|
||||
<td className="px-4 py-4 text-sm text-center text-gray-500">{est.deadline}</td>
|
||||
<td className="px-4 py-4 text-center">{renderActions(est)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공사 관리 탭 */}
|
||||
{activeTab === 'construction' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">전체 공사</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{conSummary.total}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">시공중</p>
|
||||
<p className="text-2xl font-bold text-cyan-500">{conSummary.inProgress}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">평균 진행률</p>
|
||||
<p className="text-2xl font-bold text-blue-500">{conSummary.avgProgress}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">계약 합계</p>
|
||||
<p className="text-2xl font-bold text-emerald-500">{(conSummary.totalContract / 10000000).toFixed(1)}천만</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">집행액 합계</p>
|
||||
<p className="text-2xl font-bold text-orange-500">{(conSummary.totalSpent / 10000000).toFixed(1)}천만</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-gray-800">공사 현황</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">공사번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">현장명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">발주처</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase">진행률</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">계약금액</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">집행액</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase">공기</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase">담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{constructions.map(con => (
|
||||
<tr key={con.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => setDetailCon(con)}>
|
||||
<td className="px-4 py-4 text-sm font-medium text-gray-700">{con.id}</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className="text-sm font-medium text-blue-600 hover:underline">{con.siteName}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-gray-600">{con.client}</td>
|
||||
<td className="px-4 py-4 text-center"><StatusBadge status={con.status} /></td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{width: `${con.progress}%`}}></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-8">{con.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-right font-medium text-gray-800">{fmt(con.contractAmount)}</td>
|
||||
<td className="px-4 py-4 text-sm text-right text-orange-600 font-medium">{fmt(con.expenditure)}</td>
|
||||
<td className="px-4 py-4 text-xs text-center text-gray-500">{con.startDate}<br/>~ {con.endDate}</td>
|
||||
<td className="px-4 py-4 text-sm text-center text-gray-600">{con.manager}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 모달 */}
|
||||
{detailEst && <EstimateDetailModal est={detailEst} onClose={() => setDetailEst(null)} />}
|
||||
{detailCon && <ConstructionDetailModal con={detailCon} onClose={() => setDetailCon(null)} />}
|
||||
{showNewForm && <NewEstimateModal onClose={() => setShowNewForm(false)} onSave={handleNewEstimate} nextId={`EST-2024-${String(estimates.length + 1).padStart(3, '0')}`} />}
|
||||
{toast && <InlineToast message={toast} onClose={() => setToast(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
@endverbatim
|
||||
</script>
|
||||
@endpush
|
||||
429
resources/views/juil/project.blade.php
Normal file
429
resources/views/juil/project.blade.php
Normal file
@@ -0,0 +1,429 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '프로젝트관리/기성청구')
|
||||
|
||||
@section('content')
|
||||
<div id="root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script type="text/babel">
|
||||
@verbatim
|
||||
const { useState, useMemo } = React;
|
||||
|
||||
// --- 데이터 ---
|
||||
const initialProjects = [
|
||||
{
|
||||
id: 'PJ-001',
|
||||
name: '힐스테이트 선화더와이즈',
|
||||
client: '현대건설',
|
||||
siteCount: 31,
|
||||
contractAmount: 4331000000,
|
||||
billedAmount: 289000000,
|
||||
laborPaid: 4580000,
|
||||
progress: 32,
|
||||
sites: [
|
||||
{ id: 'S-001', name: 'A동 지하1층', type: '방화셔터 2.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 18500000 },
|
||||
{ id: 'S-002', name: 'A동 1층 로비', type: '방화셔터 3.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '지급완료', amount: 22000000 },
|
||||
{ id: 'S-003', name: 'A동 2층', type: '방화셔터 2.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
||||
{ id: 'S-004', name: 'A동 3층', type: '방화셔터 2.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
||||
{ id: 'S-005', name: 'B동 지하1층', type: '방화셔터 2.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 18500000 },
|
||||
{ id: 'S-006', name: 'B동 1층', type: '방화셔터 3.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 22000000 },
|
||||
{ id: 'S-007', name: 'B동 2층', type: '방화셔터 2.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
||||
{ id: 'S-008', name: 'B동 3층', type: '방화셔터 2.0m', status: '미착수', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
||||
{ id: 'S-009', name: 'C동 지하1층', type: '방화셔터 2.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '미지급', amount: 18500000 },
|
||||
{ id: 'S-010', name: 'C동 1층', type: '방화셔터 3.5m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 28000000 },
|
||||
{ id: 'S-011', name: 'C동 2층', type: '방화셔터 2.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
||||
{ id: 'S-012', name: 'D동 지하1층', type: '방화셔터 2.5m', status: '미착수', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 18500000 },
|
||||
],
|
||||
pendingOrders: 8,
|
||||
pendingBilling: 6,
|
||||
pendingLabor: 7,
|
||||
},
|
||||
{
|
||||
id: 'PJ-002',
|
||||
name: '충주 현대모비스 공장동',
|
||||
client: '현대모비스',
|
||||
siteCount: 28,
|
||||
contractAmount: 3897000000,
|
||||
billedAmount: 684000000,
|
||||
laborPaid: 9130000,
|
||||
progress: 50,
|
||||
sites: [
|
||||
{ id: 'S-101', name: '1공장 A라인', type: '방화셔터 4.0m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 35000000 },
|
||||
{ id: 'S-102', name: '1공장 B라인', type: '방화셔터 4.0m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 35000000 },
|
||||
{ id: 'S-103', name: '1공장 C라인', type: '방화셔터 3.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 28000000 },
|
||||
{ id: 'S-104', name: '2공장 A라인', type: '방화셔터 4.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 35000000 },
|
||||
{ id: 'S-105', name: '2공장 B라인', type: '방화셔터 4.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 35000000 },
|
||||
{ id: 'S-106', name: '2공장 C라인', type: '방화셔터 3.5m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 28000000 },
|
||||
{ id: 'S-107', name: '3공장 메인홀', type: '방화셔터 5.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 42000000 },
|
||||
{ id: 'S-108', name: '3공장 A라인', type: '방화셔터 3.5m', status: '미착수', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 28000000 },
|
||||
{ id: 'S-109', name: '물류동 입구', type: '방화셔터 5.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 42000000 },
|
||||
{ id: 'S-110', name: '물류동 적재구역', type: '방화셔터 4.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 35000000 },
|
||||
],
|
||||
pendingOrders: 4,
|
||||
pendingBilling: 7,
|
||||
pendingLabor: 10,
|
||||
},
|
||||
];
|
||||
|
||||
function fmt(n) { return n == null ? '-' : n.toLocaleString('ko-KR'); }
|
||||
function fmtBillion(n) { return n >= 100000000 ? (n / 100000000).toFixed(2) + '억' : fmt(Math.round(n / 10000)) + '만원'; }
|
||||
|
||||
const statusColors = {
|
||||
'시공완료': 'bg-green-100 text-green-700',
|
||||
'시공중': 'bg-blue-100 text-blue-700',
|
||||
'자재대기': 'bg-yellow-100 text-yellow-700',
|
||||
'미착수': 'bg-gray-100 text-gray-600',
|
||||
'발주완료': 'bg-green-100 text-green-700',
|
||||
'발주대기': 'bg-red-100 text-red-700',
|
||||
'청구완료': 'bg-green-100 text-green-700',
|
||||
'청구대기': 'bg-orange-100 text-orange-700',
|
||||
'지급완료': 'bg-green-100 text-green-700',
|
||||
'미지급': 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
function Badge({ text }) {
|
||||
const cls = statusColors[text] || 'bg-gray-100 text-gray-600';
|
||||
return <span className={`px-2 py-0.5 rounded text-xs font-medium ${cls}`}>{text}</span>;
|
||||
}
|
||||
|
||||
function InlineToast({ message, onClose }) {
|
||||
React.useEffect(() => { const t = setTimeout(onClose, 2500); return () => clearTimeout(t); }, []);
|
||||
return (
|
||||
<div className="fixed top-6 right-6 z-50 bg-slate-800 text-white px-5 py-3 rounded-lg shadow-lg text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 사이트 상세 모달 ---
|
||||
function SiteDetailModal({ site, onClose, onUpdate }) {
|
||||
if (!site) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg m-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold">{site.name}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span className="text-gray-500">설비:</span> <span className="font-medium">{site.type}</span></div>
|
||||
<div><span className="text-gray-500">금액:</span> <span className="font-bold text-blue-600">{fmt(site.amount)}원</span></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div><p className="text-xs text-gray-500">시공 상태</p><Badge text={site.status} /></div>
|
||||
{site.status !== '시공완료' && (
|
||||
<button onClick={() => {
|
||||
const next = { '미착수': '자재대기', '자재대기': '시공중', '시공중': '시공완료' };
|
||||
if (next[site.status]) onUpdate(site.id, 'status', next[site.status]);
|
||||
}} className="px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600">
|
||||
다음 단계
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div><p className="text-xs text-gray-500">발주 상태</p><Badge text={site.orderStatus} /></div>
|
||||
{site.orderStatus === '발주대기' && (
|
||||
<button onClick={() => onUpdate(site.id, 'orderStatus', '발주완료')}
|
||||
className="px-3 py-1 bg-emerald-500 text-white rounded text-xs hover:bg-emerald-600">발주처리</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div><p className="text-xs text-gray-500">기성청구</p><Badge text={site.billingStatus} /></div>
|
||||
{site.billingStatus === '청구대기' && site.status === '시공완료' && (
|
||||
<button onClick={() => onUpdate(site.id, 'billingStatus', '청구완료')}
|
||||
className="px-3 py-1 bg-orange-500 text-white rounded text-xs hover:bg-orange-600">청구처리</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div><p className="text-xs text-gray-500">노임지급</p><Badge text={site.laborStatus} /></div>
|
||||
{site.laborStatus === '미지급' && (
|
||||
<button onClick={() => onUpdate(site.id, 'laborStatus', '지급완료')}
|
||||
className="px-3 py-1 bg-purple-500 text-white rounded text-xs hover:bg-purple-600">지급처리</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t flex justify-end">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 프로젝트 상세 뷰 ---
|
||||
function ProjectDetail({ project, onBack, onUpdateSite, toast: showToast }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [selectedSite, setSelectedSite] = useState(null);
|
||||
|
||||
const sites = project.sites;
|
||||
const filtered = filter === 'all' ? sites
|
||||
: filter === 'pending_order' ? sites.filter(s => s.orderStatus === '발주대기')
|
||||
: filter === 'pending_billing' ? sites.filter(s => s.billingStatus === '청구대기')
|
||||
: filter === 'pending_labor' ? sites.filter(s => s.laborStatus === '미지급')
|
||||
: filter === 'in_progress' ? sites.filter(s => s.status === '시공중')
|
||||
: filter === 'completed' ? sites.filter(s => s.status === '시공완료')
|
||||
: sites;
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
completed: sites.filter(s => s.status === '시공완료').length,
|
||||
inProgress: sites.filter(s => s.status === '시공중').length,
|
||||
waiting: sites.filter(s => s.status === '자재대기').length,
|
||||
notStarted: sites.filter(s => s.status === '미착수').length,
|
||||
pendingOrder: sites.filter(s => s.orderStatus === '발주대기').length,
|
||||
pendingBilling: sites.filter(s => s.billingStatus === '청구대기').length,
|
||||
pendingLabor: sites.filter(s => s.laborStatus === '미지급').length,
|
||||
}), [sites]);
|
||||
|
||||
const handleUpdate = (siteId, field, value) => {
|
||||
onUpdateSite(project.id, siteId, field, value);
|
||||
setSelectedSite(prev => prev ? { ...prev, [field]: value } : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="px-3 py-2 bg-white border rounded-lg text-sm hover:bg-gray-50 flex items-center gap-1">
|
||||
← 목록으로
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{project.name}</h2>
|
||||
<p className="text-sm text-gray-500">{project.client} | {project.siteCount}개소</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 요약 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">계약금액</p>
|
||||
<p className="text-xl font-bold text-blue-600">{fmtBillion(project.contractAmount)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">기성청구</p>
|
||||
<p className="text-xl font-bold text-green-600">{fmtBillion(project.billedAmount)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">노임지급</p>
|
||||
<p className="text-xl font-bold text-purple-600">{fmtBillion(project.laborPaid)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">공정 진행률</p>
|
||||
<p className="text-xl font-bold text-orange-600">{project.progress}%</p>
|
||||
<div className="mt-2 h-2 bg-gray-200 rounded-full"><div className="h-full bg-orange-500 rounded-full" style={{width:`${project.progress}%`}}></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시공 현황 바 */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">시공 현황</h3>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<button onClick={() => setFilter('completed')} className="flex items-center gap-1 px-3 py-1.5 bg-green-50 border border-green-200 rounded-lg text-sm hover:bg-green-100">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span> 시공완료 <span className="font-bold">{stats.completed}</span>
|
||||
</button>
|
||||
<button onClick={() => setFilter('in_progress')} className="flex items-center gap-1 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-lg text-sm hover:bg-blue-100">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span> 시공중 <span className="font-bold">{stats.inProgress}</span>
|
||||
</button>
|
||||
<span className="flex items-center gap-1 px-3 py-1.5 bg-yellow-50 border border-yellow-200 rounded-lg text-sm">
|
||||
<span className="w-2 h-2 bg-yellow-500 rounded-full"></span> 자재대기 <span className="font-bold">{stats.waiting}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full"></span> 미착수 <span className="font-bold">{stats.notStarted}</span>
|
||||
</span>
|
||||
<div className="border-l mx-1"></div>
|
||||
<button onClick={() => setFilter('pending_order')} className="flex items-center gap-1 px-3 py-1.5 bg-red-50 border border-red-200 rounded-lg text-sm hover:bg-red-100">
|
||||
발주대기 <span className="font-bold text-red-600">{stats.pendingOrder}</span>
|
||||
</button>
|
||||
<button onClick={() => setFilter('pending_billing')} className="flex items-center gap-1 px-3 py-1.5 bg-orange-50 border border-orange-200 rounded-lg text-sm hover:bg-orange-100">
|
||||
기성청구대기 <span className="font-bold text-orange-600">{stats.pendingBilling}</span>
|
||||
</button>
|
||||
<button onClick={() => setFilter('pending_labor')} className="flex items-center gap-1 px-3 py-1.5 bg-purple-50 border border-purple-200 rounded-lg text-sm hover:bg-purple-100">
|
||||
노임미지급 <span className="font-bold text-purple-600">{stats.pendingLabor}</span>
|
||||
</button>
|
||||
{filter !== 'all' && (
|
||||
<button onClick={() => setFilter('all')} className="px-3 py-1.5 bg-slate-700 text-white rounded-lg text-sm hover:bg-slate-800">
|
||||
전체보기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개소별 테이블 */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-5 py-4 border-b flex justify-between items-center">
|
||||
<h3 className="font-semibold">개소별 현황 ({filtered.length}건)</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">개소</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">설비</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">시공상태</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">발주</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">기성청구</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">노임</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filtered.map(site => (
|
||||
<tr key={site.id} className="hover:bg-blue-50 cursor-pointer" onClick={() => setSelectedSite(site)}>
|
||||
<td className="px-4 py-3 text-sm font-medium text-blue-600">{site.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{site.type}</td>
|
||||
<td className="px-4 py-3 text-center"><Badge text={site.status} /></td>
|
||||
<td className="px-4 py-3 text-center"><Badge text={site.orderStatus} /></td>
|
||||
<td className="px-4 py-3 text-center"><Badge text={site.billingStatus} /></td>
|
||||
<td className="px-4 py-3 text-center"><Badge text={site.laborStatus} /></td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">{fmt(site.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSite && <SiteDetailModal site={selectedSite} onClose={() => setSelectedSite(null)} onUpdate={handleUpdate} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 대시보드 (메인) ---
|
||||
function Dashboard({ projects, onSelectProject }) {
|
||||
const totals = useMemo(() => ({
|
||||
contract: projects.reduce((s, p) => s + p.contractAmount, 0),
|
||||
billed: projects.reduce((s, p) => s + p.billedAmount, 0),
|
||||
labor: projects.reduce((s, p) => s + p.laborPaid, 0),
|
||||
pending: projects.reduce((s, p) => s + p.pendingOrders + p.pendingBilling + p.pendingLabor, 0),
|
||||
}), [projects]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">전체 프로젝트 현황</h2>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">총 계약금액</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{fmtBillion(totals.contract)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">기성청구 누계</p>
|
||||
<p className="text-2xl font-bold text-green-600">{fmtBillion(totals.billed)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">노임지급 누계</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{fmtBillion(totals.labor)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<p className="text-sm text-gray-500">처리 필요</p>
|
||||
<p className="text-2xl font-bold text-red-600">{totals.pending}건</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{projects.map(pj => (
|
||||
<div key={pj.id} onClick={() => onSelectProject(pj.id)}
|
||||
className="bg-white rounded-lg shadow hover:shadow-lg cursor-pointer transition-shadow p-5">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{pj.name}</h3>
|
||||
<p className="text-sm text-gray-500">{pj.client}</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">{pj.siteCount}개소</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div><p className="text-xs text-gray-500">계약금액</p><p className="font-bold text-blue-600">{fmtBillion(pj.contractAmount)}</p></div>
|
||||
<div><p className="text-xs text-gray-500">기성청구</p><p className="font-bold text-green-600">{fmtBillion(pj.billedAmount)}</p></div>
|
||||
<div><p className="text-xs text-gray-500">노임지급</p><p className="font-bold text-purple-600">{fmtBillion(pj.laborPaid)}</p></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-orange-500" style={{width: `${pj.progress}%`}}></div>
|
||||
</div>
|
||||
<span className="text-xs font-medium w-10 text-right">{pj.progress}%</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t flex gap-2 flex-wrap">
|
||||
{pj.pendingOrders > 0 && <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">발주대기 {pj.pendingOrders}</span>}
|
||||
{pj.pendingBilling > 0 && <span className="px-2 py-1 bg-orange-100 text-orange-700 rounded text-xs">기성청구대기 {pj.pendingBilling}</span>}
|
||||
{pj.pendingLabor > 0 && <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">노임미지급 {pj.pendingLabor}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 앱 ---
|
||||
function App() {
|
||||
const [projects, setProjects] = useState(initialProjects);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (msg) => setToast(msg);
|
||||
|
||||
const handleUpdateSite = (projectId, siteId, field, value) => {
|
||||
setProjects(prev => prev.map(pj => {
|
||||
if (pj.id !== projectId) return pj;
|
||||
const newSites = pj.sites.map(s => s.id === siteId ? { ...s, [field]: value } : s);
|
||||
const pendingOrders = newSites.filter(s => s.orderStatus === '발주대기').length;
|
||||
const pendingBilling = newSites.filter(s => s.billingStatus === '청구대기').length;
|
||||
const pendingLabor = newSites.filter(s => s.laborStatus === '미지급').length;
|
||||
const completed = newSites.filter(s => s.status === '시공완료').length;
|
||||
const progress = Math.round((completed / newSites.length) * 100);
|
||||
const billedAmount = newSites.filter(s => s.billingStatus === '청구완료').reduce((sum, s) => sum + s.amount, 0);
|
||||
const laborPaid = newSites.filter(s => s.laborStatus === '지급완료').length * 650000;
|
||||
|
||||
return { ...pj, sites: newSites, pendingOrders, pendingBilling, pendingLabor, progress, billedAmount, laborPaid };
|
||||
}));
|
||||
|
||||
const labels = { status: '시공상태', orderStatus: '발주', billingStatus: '기성청구', laborStatus: '노임' };
|
||||
showToast(`${labels[field] || field}: ${value} 처리 완료`);
|
||||
};
|
||||
|
||||
const selectedProject = projects.find(p => p.id === selectedProjectId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-gradient-to-r from-slate-800 to-slate-900 text-white shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-bold cursor-pointer" onClick={() => setSelectedProjectId(null)}>주일기업</h1>
|
||||
<span className="text-slate-400">|</span>
|
||||
<span className="text-slate-300">자동방화셔터 시공 관리 시스템</span>
|
||||
</div>
|
||||
{selectedProject && (
|
||||
<span className="text-sm text-slate-300">{selectedProject.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
{selectedProject ? (
|
||||
<ProjectDetail
|
||||
project={selectedProject}
|
||||
onBack={() => setSelectedProjectId(null)}
|
||||
onUpdateSite={handleUpdateSite}
|
||||
toast={showToast}
|
||||
/>
|
||||
) : (
|
||||
<Dashboard projects={projects} onSelectProject={setSelectedProjectId} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{toast && <InlineToast message={toast} onClose={() => setToast(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
@endverbatim
|
||||
</script>
|
||||
@endpush
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user