Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-02-03 11:23:58 +09:00
52 changed files with 2982 additions and 534 deletions

View File

@@ -24,6 +24,8 @@ public function __construct(
/**
* 회원사 조회 및 비밀번호 검증 헬퍼
*
* 회원사의 서버 모드에 따라 BarobillService의 서버 모드도 자동 전환합니다.
*
* @return BarobillMember|JsonResponse 회원사 객체 또는 에러 응답
*/
private function validateMemberForUrlApi(int $id): BarobillMember|JsonResponse
@@ -45,6 +47,9 @@ private function validateMemberForUrlApi(int $id): BarobillMember|JsonResponse
], 422);
}
// 회원사의 서버 모드로 BarobillService 전환
$this->barobillService->setServerMode($member->server_mode ?? 'test');
return $member;
}
@@ -856,4 +861,91 @@ public function getServiceCodes(): JsonResponse
],
]);
}
/**
* 회원사별 서버 모드 변경
*
* 특정 회원사의 바로빌 서버 모드(테스트/운영)를 변경합니다.
* 주의: 운영 서버로 전환 시 요금이 부과됩니다.
*/
public function updateServerMode(Request $request, int $id): JsonResponse
{
$member = BarobillMember::find($id);
if (!$member) {
return response()->json([
'success' => false,
'message' => '회원사를 찾을 수 없습니다.',
], 404);
}
$validated = $request->validate([
'server_mode' => 'required|in:test,production',
'confirmed' => 'required|boolean',
], [
'server_mode.required' => '서버 모드를 선택해주세요.',
'server_mode.in' => '서버 모드는 test 또는 production 이어야 합니다.',
'confirmed.required' => '경고 확인이 필요합니다.',
]);
// 확인 체크
if (!$validated['confirmed']) {
return response()->json([
'success' => false,
'message' => '서버 변경 경고를 확인해주세요.',
], 422);
}
$oldMode = $member->server_mode ?? 'test';
$newMode = $validated['server_mode'];
// 변경 없으면 바로 반환
if ($oldMode === $newMode) {
return response()->json([
'success' => true,
'message' => '서버 모드가 이미 ' . ($newMode === 'test' ? '테스트' : '운영') . ' 서버입니다.',
'data' => $member,
]);
}
$member->update(['server_mode' => $newMode]);
Log::info('바로빌 회원사 서버 모드 변경', [
'member_id' => $id,
'biz_no' => $member->biz_no,
'corp_name' => $member->corp_name,
'old_mode' => $oldMode,
'new_mode' => $newMode,
'user_id' => auth()->id(),
]);
return response()->json([
'success' => true,
'message' => ($newMode === 'test' ? '테스트' : '운영') . ' 서버로 변경되었습니다.',
'data' => $member->fresh(),
]);
}
/**
* 회원사별 서버 모드 조회
*/
public function getServerMode(int $id): JsonResponse
{
$member = BarobillMember::find($id);
if (!$member) {
return response()->json([
'success' => false,
'message' => '회원사를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => [
'server_mode' => $member->server_mode ?? 'test',
'server_mode_label' => $member->server_mode_label,
],
]);
}
}

View File

@@ -102,6 +102,53 @@ public function store(Request $request): JsonResponse
}
}
/**
* 서비스 설정 개별 저장 (체크박스 변경 시 즉시 저장)
*/
public function updateService(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$validated = $request->validate([
'field' => 'required|in:use_tax_invoice,use_bank_account,use_card_usage,use_hometax',
'value' => 'required|boolean',
]);
try {
$setting = BarobillSetting::updateOrCreate(
['tenant_id' => $tenantId],
[
$validated['field'] => $validated['value'],
'corp_name' => '미설정',
'ceo_name' => '미설정',
'corp_num' => '0000000000',
]
);
$serviceNames = [
'use_tax_invoice' => '전자세금계산서',
'use_bank_account' => '계좌조회',
'use_card_usage' => '카드사용내역',
'use_hometax' => '홈텍스매입/매출',
];
$serviceName = $serviceNames[$validated['field']] ?? $validated['field'];
$status = $validated['value'] ? '활성화' : '비활성화';
return response()->json([
'success' => true,
'message' => "{$serviceName} 서비스가 {$status}되었습니다.",
]);
} catch (\Exception $e) {
Log::error('바로빌 서비스 설정 저장 실패', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => '설정 저장에 실패했습니다.',
], 500);
}
}
/**
* 서비스 이용 여부 확인 (다른 메뉴에서 참조용)
*/

View File

@@ -107,16 +107,58 @@ public function index(Request $request): View|Response
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
// 테넌트별 서버 모드 적용 (회원사 설정 우선)
$isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
// 서버 모드에 따라 SOAP 설정 재초기화
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
return view('barobill.eaccount.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
'isTestMode' => $this->isTestMode,
'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
/**
* 회원사 서버 모드에 따라 SOAP 설정 적용
*/
private function applyMemberServerMode(BarobillMember $member): void
{
$memberTestMode = $member->isTestMode();
$targetEnv = $memberTestMode ? 'test' : 'production';
// 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
$config = BarobillConfig::where('environment', $targetEnv)->first();
if ($config) {
$this->isTestMode = $memberTestMode;
$this->certKey = $config->cert_key;
$this->corpNum = $config->corp_num;
$baseUrl = $config->base_url ?: ($memberTestMode
? 'https://testws.baroservice.com'
: 'https://ws.baroservice.com');
$this->soapUrl = $baseUrl . '/BANKACCOUNT.asmx?WSDL';
// SOAP 클라이언트 재초기화
$this->initSoapClient();
Log::info('[Eaccount] 서버 모드 적용', [
'targetEnv' => $targetEnv,
'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
'corpNum' => $this->corpNum,
'soapUrl' => $this->soapUrl,
]);
} else {
Log::warning('[Eaccount] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
}
}
/**
* 등록된 계좌 목록 조회 (GetBankAccountEx)
*/
@@ -129,6 +171,11 @@ public function accounts(Request $request): JsonResponse
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
// 테넌트별 서버 모드 적용
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
// 바로빌 사용자 ID 결정
$userId = $barobillMember?->barobill_id ?? '';
@@ -210,6 +257,11 @@ public function transactions(Request $request): JsonResponse
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
$userId = $barobillMember?->barobill_id ?? '';
// 테넌트별 서버 모드 적용
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
// DB에서 저장된 계정과목 데이터 조회
$savedData = BankTransaction::getByDateRange($tenantId, $startDate, $endDate, $bankAccountNum ?: null);
@@ -532,12 +584,11 @@ private function getBankName(string $code): string
}
/**
* 계정과목 목록 조회
* 계정과목 목록 조회 (글로벌 데이터)
*/
public function accountCodes(): JsonResponse
{
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$codes = AccountCode::getActiveByTenant($tenantId);
$codes = AccountCode::getActive();
return response()->json([
'success' => true,
@@ -551,15 +602,11 @@ public function accountCodes(): JsonResponse
}
/**
* 전체 계정과목 목록 조회 (설정용, 비활성 포함)
* 전체 계정과목 목록 조회 (설정용, 비활성 포함, 글로벌 데이터)
*/
public function accountCodesAll(): JsonResponse
{
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$codes = AccountCode::where('tenant_id', $tenantId)
->orderBy('sort_order')
->orderBy('code')
->get();
$codes = AccountCode::getAll();
return response()->json([
'success' => true,
@@ -568,23 +615,19 @@ public function accountCodesAll(): JsonResponse
}
/**
* 계정과목 추가
* 계정과목 추가 (글로벌 데이터)
*/
public function accountCodeStore(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$validated = $request->validate([
'code' => 'required|string|max:10',
'name' => 'required|string|max:100',
'category' => 'nullable|string|max:50',
]);
// 중복 체크
$exists = AccountCode::where('tenant_id', $tenantId)
->where('code', $validated['code'])
->exists();
// 중복 체크 (글로벌)
$exists = AccountCode::where('code', $validated['code'])->exists();
if ($exists) {
return response()->json([
@@ -593,10 +636,10 @@ public function accountCodeStore(Request $request): JsonResponse
], 422);
}
$maxSort = AccountCode::where('tenant_id', $tenantId)->max('sort_order') ?? 0;
$maxSort = AccountCode::max('sort_order') ?? 0;
$accountCode = AccountCode::create([
'tenant_id' => $tenantId,
'tenant_id' => self::HEADQUARTERS_TENANT_ID, // 글로벌 데이터는 기본 테넌트에 저장
'code' => $validated['code'],
'name' => $validated['name'],
'category' => $validated['category'] ?? null,
@@ -618,16 +661,12 @@ public function accountCodeStore(Request $request): JsonResponse
}
/**
* 계정과목 수정
* 계정과목 수정 (글로벌 데이터)
*/
public function accountCodeUpdate(Request $request, int $id): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$accountCode = AccountCode::where('tenant_id', $tenantId)
->where('id', $id)
->first();
$accountCode = AccountCode::find($id);
if (!$accountCode) {
return response()->json([
@@ -643,10 +682,9 @@ public function accountCodeUpdate(Request $request, int $id): JsonResponse
'is_active' => 'sometimes|boolean',
]);
// 코드 변경 시 중복 체크
// 코드 변경 시 중복 체크 (글로벌)
if (isset($validated['code']) && $validated['code'] !== $accountCode->code) {
$exists = AccountCode::where('tenant_id', $tenantId)
->where('code', $validated['code'])
$exists = AccountCode::where('code', $validated['code'])
->where('id', '!=', $id)
->exists();
@@ -674,16 +712,12 @@ public function accountCodeUpdate(Request $request, int $id): JsonResponse
}
/**
* 계정과목 삭제
* 계정과목 삭제 (글로벌 데이터)
*/
public function accountCodeDestroy(int $id): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$accountCode = AccountCode::where('tenant_id', $tenantId)
->where('id', $id)
->first();
$accountCode = AccountCode::find($id);
if (!$accountCode) {
return response()->json([

View File

@@ -107,16 +107,58 @@ public function index(Request $request): View|Response
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
// 테넌트별 서버 모드 적용 (회원사 설정 우선)
$isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
// 서버 모드에 따라 SOAP 설정 재초기화
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
return view('barobill.ecard.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
'isTestMode' => $this->isTestMode,
'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
/**
* 회원사 서버 모드에 따라 SOAP 설정 적용
*/
private function applyMemberServerMode(BarobillMember $member): void
{
$memberTestMode = $member->isTestMode();
$targetEnv = $memberTestMode ? 'test' : 'production';
// 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
$config = BarobillConfig::where('environment', $targetEnv)->first();
if ($config) {
$this->isTestMode = $memberTestMode;
$this->certKey = $config->cert_key;
$this->corpNum = $config->corp_num;
$baseUrl = $config->base_url ?: ($memberTestMode
? 'https://testws.baroservice.com'
: 'https://ws.baroservice.com');
$this->soapUrl = $baseUrl . '/CARD.asmx?WSDL';
// SOAP 클라이언트 재초기화
$this->initSoapClient();
Log::info('[Ecard] 서버 모드 적용', [
'targetEnv' => $targetEnv,
'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
'corpNum' => $this->corpNum,
'soapUrl' => $this->soapUrl,
]);
} else {
Log::warning('[Ecard] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
}
}
/**
* 등록된 카드 목록 조회 (GetCardEx2)
* 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2
@@ -124,6 +166,13 @@ public function index(Request $request): View|Response
public function cards(Request $request): JsonResponse
{
try {
// 테넌트별 서버 모드 적용
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
$availOnly = $request->input('availOnly', 0);
$result = $this->callSoap('GetCardEx2', [
@@ -220,6 +269,11 @@ public function transactions(Request $request): JsonResponse
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
$userId = $barobillMember?->barobill_id ?? '';
// 테넌트별 서버 모드 적용
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
// 디버그 로그
Log::info('[ECard] 조회 요청', [
'tenantId' => $tenantId,

View File

@@ -99,16 +99,58 @@ public function index(Request $request): View|Response
? BarobillMember::where('tenant_id', $tenantId)->first()
: null;
// 테넌트별 서버 모드 적용 (회원사 설정 우선)
$isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
// 서버 모드에 따라 SOAP 설정 재초기화
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
return view('barobill.etax.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
'isTestMode' => $this->isTestMode,
'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'currentTenant' => $currentTenant,
'barobillMember' => $barobillMember,
]);
}
/**
* 회원사 서버 모드에 따라 SOAP 설정 적용
*/
private function applyMemberServerMode(BarobillMember $member): void
{
$memberTestMode = $member->isTestMode();
$targetEnv = $memberTestMode ? 'test' : 'production';
// 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
$config = BarobillConfig::where('environment', $targetEnv)->first();
if ($config) {
$this->isTestMode = $memberTestMode;
$this->certKey = $config->cert_key;
$this->corpNum = $config->corp_num;
$baseUrl = $config->base_url ?: ($memberTestMode
? 'https://testws.baroservice.com'
: 'https://ws.baroservice.com');
$this->soapUrl = $baseUrl . '/TI.asmx?WSDL';
// SOAP 클라이언트 재초기화
$this->initSoapClient();
Log::info('[Etax] 서버 모드 적용', [
'targetEnv' => $targetEnv,
'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
'corpNum' => $this->corpNum,
'soapUrl' => $this->soapUrl,
]);
} else {
Log::warning('[Etax] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
}
}
// 바로빌 파트너사 (본사) 테넌트 ID
private const HEADQUARTERS_TENANT_ID = 1;
@@ -153,6 +195,13 @@ public function getInvoices(): JsonResponse
*/
public function issue(Request $request): JsonResponse
{
// 테넌트별 서버 모드 적용
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
$input = $request->all();
$useRealAPI = $this->soapClient !== null && ($this->isTestMode || !empty($this->certKey));
@@ -221,6 +270,13 @@ public function issue(Request $request): JsonResponse
*/
public function sendToNts(Request $request): JsonResponse
{
// 테넌트별 서버 모드 적용
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
$invoiceId = $request->input('invoiceId');
// 인보이스 조회

View File

@@ -107,10 +107,18 @@ public function index(Request $request): View|Response
// 해당 테넌트의 바로빌 회원사 정보
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
// 테넌트별 서버 모드 적용 (회원사 설정 우선)
$isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode;
// 서버 모드에 따라 SOAP 설정 재초기화
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
return view('barobill.hometax.index', [
'certKey' => $this->certKey,
'corpNum' => $this->corpNum,
'isTestMode' => $this->isTestMode,
'isTestMode' => $isTestMode,
'hasSoapClient' => $this->soapClient !== null,
'tenantId' => $tenantId,
'currentTenant' => $currentTenant,
@@ -118,6 +126,39 @@ public function index(Request $request): View|Response
]);
}
/**
* 회원사 서버 모드에 따라 SOAP 설정 적용
*/
private function applyMemberServerMode(BarobillMember $member): void
{
$memberTestMode = $member->isTestMode();
$targetEnv = $memberTestMode ? 'test' : 'production';
// 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용)
$config = BarobillConfig::where('environment', $targetEnv)->first();
if ($config) {
$this->isTestMode = $memberTestMode;
$this->certKey = $config->cert_key;
$this->corpNum = $config->corp_num;
$this->baseUrl = $config->base_url ?: ($memberTestMode
? 'https://testws.baroservice.com'
: 'https://ws.baroservice.com');
// SOAP 클라이언트 재초기화
$this->initSoapClient();
Log::info('[Hometax] 서버 모드 적용', [
'targetEnv' => $targetEnv,
'certKey' => substr($this->certKey ?? '', 0, 10) . '...',
'corpNum' => $this->corpNum,
'baseUrl' => $this->baseUrl,
]);
} else {
Log::warning('[Hometax] BarobillConfig 없음', ['targetEnv' => $targetEnv]);
}
}
/**
* 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList)
*
@@ -146,6 +187,11 @@ public function sales(Request $request): JsonResponse
]);
}
// 테넌트별 서버 모드 적용
if ($barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
$userId = $barobillMember->barobill_id ?? '';
if (empty($userId)) {
@@ -221,12 +267,16 @@ public function sales(Request $request): JsonResponse
// 작성일 기준으로 정렬 (최신순)
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
// 마지막 매출 수집 시간 업데이트
$barobillMember->update(['last_sales_fetch_at' => now()]);
return response()->json([
'success' => true,
'data' => [
'invoices' => $allInvoices,
'pagination' => $lastPagination,
'summary' => $totalSummary
'summary' => $totalSummary,
'lastFetchAt' => now()->format('Y-m-d H:i:s')
]
]);
} catch (\Throwable $e) {
@@ -266,6 +316,11 @@ public function purchases(Request $request): JsonResponse
]);
}
// 테넌트별 서버 모드 적용
if ($barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
$userId = $barobillMember->barobill_id ?? '';
if (empty($userId)) {
@@ -341,12 +396,16 @@ public function purchases(Request $request): JsonResponse
// 작성일 기준으로 정렬 (최신순)
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
// 마지막 매입 수집 시간 업데이트
$barobillMember->update(['last_purchases_fetch_at' => now()]);
return response()->json([
'success' => true,
'data' => [
'invoices' => $allInvoices,
'pagination' => $lastPagination,
'summary' => $totalSummary
'summary' => $totalSummary,
'lastFetchAt' => now()->format('Y-m-d H:i:s')
]
]);
} catch (\Throwable $e) {
@@ -491,6 +550,12 @@ public function diagnose(Request $request): JsonResponse
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
// 테넌트별 서버 모드 적용
if ($barobillMember && $barobillMember->server_mode) {
$this->applyMemberServerMode($barobillMember);
}
$userId = $barobillMember?->barobill_id ?? '';
$memberCorpNum = $barobillMember?->biz_no ?? '';
@@ -577,18 +642,24 @@ public function requestCollect(Request $request): JsonResponse
}
/**
* 수집 상태 확인 (미지원 안내)
* 수집 상태 확인 (마지막 수집 시간 조회)
*/
public function collectStatus(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
$salesLastFetch = $barobillMember?->last_sales_fetch_at;
$purchasesLastFetch = $barobillMember?->last_purchases_fetch_at;
return response()->json([
'success' => true,
'data' => [
'salesLastCollectDate' => '',
'purchaseLastCollectDate' => '',
'salesLastCollectDate' => $salesLastFetch ? $salesLastFetch->format('Y-m-d H:i') : '',
'purchaseLastCollectDate' => $purchasesLastFetch ? $purchasesLastFetch->format('Y-m-d H:i') : '',
'isCollecting' => false,
'collectStateText' => '확인 필요',
'message' => '서비스 상태 진단 기능을 사용하여 홈택스 연동 상태를 확인해주세요.'
'collectStateText' => ($salesLastFetch || $purchasesLastFetch) ? '조회 완료' : '조회 전',
'message' => '매출/매입 탭을 클릭하면 데이터가 조회되고 수집 시간이 기록됩니다.'
]
]);
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\CorporateVehicle;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class CorporateVehicleController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.corporate-vehicles'));
}
return view('finance.corporate-vehicles');
}
public function list(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$query = CorporateVehicle::where('tenant_id', $tenantId);
// 필터링
if ($request->filled('ownership_type') && $request->ownership_type !== 'all') {
$query->where('ownership_type', $request->ownership_type);
}
if ($request->filled('vehicle_type') && $request->vehicle_type !== 'all') {
$query->where('vehicle_type', $request->vehicle_type);
}
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('plate_number', 'like', "%{$search}%")
->orWhere('model', 'like', "%{$search}%")
->orWhere('driver', 'like', "%{$search}%");
});
}
$vehicles = $query->orderBy('created_at', 'desc')->get();
return response()->json([
'success' => true,
'data' => $vehicles,
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'plate_number' => 'required|string|max:20',
'model' => 'required|string|max:100',
'vehicle_type' => 'required|string|max:20',
'ownership_type' => 'required|in:corporate,rent,lease',
]);
$tenantId = session('tenant_id', 1);
$vehicle = CorporateVehicle::create([
'tenant_id' => $tenantId,
'plate_number' => $request->plate_number,
'model' => $request->model,
'vehicle_type' => $request->vehicle_type,
'ownership_type' => $request->ownership_type,
'year' => $request->year,
'driver' => $request->driver,
'status' => $request->status ?? 'active',
'mileage' => $request->mileage ?? 0,
'memo' => $request->memo,
// 법인차량 전용
'purchase_date' => $request->purchase_date,
'purchase_price' => $request->purchase_price ?? 0,
// 렌트/리스 전용
'contract_date' => $request->contract_date,
'rent_company' => $request->rent_company,
'rent_company_tel' => $request->rent_company_tel,
'rent_period' => $request->rent_period,
'agreed_mileage' => $request->agreed_mileage,
'vehicle_price' => $request->vehicle_price ?? 0,
'residual_value' => $request->residual_value ?? 0,
'deposit' => $request->deposit ?? 0,
'monthly_rent' => $request->monthly_rent ?? 0,
'monthly_rent_tax' => $request->monthly_rent_tax ?? 0,
'insurance_company' => $request->insurance_company,
'insurance_company_tel' => $request->insurance_company_tel,
]);
return response()->json([
'success' => true,
'message' => '차량이 등록되었습니다.',
'data' => $vehicle,
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('tenant_id', 1);
$vehicle = CorporateVehicle::where('tenant_id', $tenantId)->findOrFail($id);
$request->validate([
'plate_number' => 'required|string|max:20',
'model' => 'required|string|max:100',
'vehicle_type' => 'required|string|max:20',
'ownership_type' => 'required|in:corporate,rent,lease',
]);
$vehicle->update([
'plate_number' => $request->plate_number,
'model' => $request->model,
'vehicle_type' => $request->vehicle_type,
'ownership_type' => $request->ownership_type,
'year' => $request->year,
'driver' => $request->driver,
'status' => $request->status ?? 'active',
'mileage' => $request->mileage ?? 0,
'memo' => $request->memo,
// 법인차량 전용
'purchase_date' => $request->purchase_date,
'purchase_price' => $request->purchase_price ?? 0,
// 렌트/리스 전용
'contract_date' => $request->contract_date,
'rent_company' => $request->rent_company,
'rent_company_tel' => $request->rent_company_tel,
'rent_period' => $request->rent_period,
'agreed_mileage' => $request->agreed_mileage,
'vehicle_price' => $request->vehicle_price ?? 0,
'residual_value' => $request->residual_value ?? 0,
'deposit' => $request->deposit ?? 0,
'monthly_rent' => $request->monthly_rent ?? 0,
'monthly_rent_tax' => $request->monthly_rent_tax ?? 0,
'insurance_company' => $request->insurance_company,
'insurance_company_tel' => $request->insurance_company_tel,
]);
return response()->json([
'success' => true,
'message' => '차량 정보가 수정되었습니다.',
'data' => $vehicle,
]);
}
public function destroy(int $id): JsonResponse
{
$tenantId = session('tenant_id', 1);
$vehicle = CorporateVehicle::where('tenant_id', $tenantId)->findOrFail($id);
$vehicle->delete();
return response()->json([
'success' => true,
'message' => '차량이 삭제되었습니다.',
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Sales\SalesCommission;
use App\Models\Sales\SalesScenarioChecklist;
use App\Models\Sales\SalesTenantManagement;
use App\Models\Sales\TenantProspect;
@@ -82,7 +83,7 @@ private function getIndexData(Request $request): array
// 영업 역할을 가진 사용자 목록 (영업파트너)
$salesPartners = User::whereHas('userRoles', function ($q) {
$q->whereHas('role', function ($rq) {
$rq->whereIn('name', ['sales', 'manager', 'recruiter']);
$rq->whereIn('name', ['sales', 'manager']);
});
})->orderBy('name')->get();
@@ -136,6 +137,14 @@ private function getIndexData(Request $request): array
$prospect->hq_status = $management?->hq_status ?? 'pending';
$prospect->hq_status_label = $management?->hq_status_label ?? '대기';
$prospect->manager_user = $management?->manager;
// 수당 정보 (management가 있는 경우)
if ($management) {
$commission = SalesCommission::where('management_id', $management->id)->first();
$prospect->commission = $commission;
} else {
$prospect->commission = null;
}
}
// 전체 통계
@@ -185,4 +194,87 @@ public function updateHqStatus(int $id, Request $request)
'hq_status_label' => $management->hq_status_label,
]);
}
/**
* 수당 날짜 기록/수정
*/
public function updateCommissionDate(int $id, Request $request)
{
$this->checkAdminAccess();
$request->validate([
'field' => 'required|in:first_payment_at,first_partner_paid_at,second_payment_at,second_partner_paid_at,first_subscription_at,manager_paid_at',
'date' => 'nullable|date',
]);
$prospect = TenantProspect::findOrFail($id);
$management = SalesTenantManagement::findOrCreateByProspect($prospect->id);
// Commission 레코드 조회 또는 생성
$commission = SalesCommission::firstOrCreate(
['management_id' => $management->id],
[
'tenant_id' => $prospect->tenant_id ?? 1,
'payment_type' => 'deposit',
'payment_amount' => 0,
'payment_date' => now(),
'base_amount' => 0,
'partner_rate' => 0,
'manager_rate' => 0,
'partner_commission' => 0,
'manager_commission' => 0,
'scheduled_payment_date' => now()->addMonth()->day(10),
'status' => SalesCommission::STATUS_PENDING,
'partner_id' => $management->sales_partner_id ?? 0,
'manager_user_id' => $management->manager_user_id,
]
);
$field = $request->input('field');
$date = $request->input('date') ?: now()->format('Y-m-d');
$commission->update([
$field => $date,
]);
return response()->json([
'success' => true,
'field' => $field,
'date' => $commission->$field?->format('Y-m-d'),
'date_display' => $commission->$field?->format('m/d'),
]);
}
/**
* 수당 날짜 삭제 (초기화)
*/
public function clearCommissionDate(int $id, Request $request)
{
$this->checkAdminAccess();
$request->validate([
'field' => 'required|in:first_payment_at,first_partner_paid_at,second_payment_at,second_partner_paid_at,first_subscription_at,manager_paid_at',
]);
$prospect = TenantProspect::findOrFail($id);
$management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first();
if (!$management) {
return response()->json(['success' => false, 'message' => '관리 정보가 없습니다.']);
}
$commission = SalesCommission::where('management_id', $management->id)->first();
if (!$commission) {
return response()->json(['success' => false, 'message' => '수당 정보가 없습니다.']);
}
$field = $request->input('field');
$commission->update([$field => null]);
return response()->json([
'success' => true,
'field' => $field,
]);
}
}

View File

@@ -251,10 +251,8 @@ private function getDashboardData(Request $request): array
->get()
->keyBy('tenant_id');
// 내가 유치한 영업파트너 목록 (드롭다운용)
$allManagers = auth()->user()->children()
->where('is_active', true)
->get(['id', 'name', 'email']);
// 상담매니저 역할을 가진 모든 사용자 (드롭다운용)
$allManagers = $this->getAllManagerUsers();
// 내가 매니저로만 참여하는 건 (다른 사람이 등록, 내가 매니저)
$managerOnlyProspects = $this->getManagerOnlyProspects($currentUserId);
@@ -412,10 +410,8 @@ public function refreshTenantList(Request $request): View
->get()
->keyBy('tenant_id');
// 내가 유치한 영업파트너 목록 (드롭다운용)
$allManagers = auth()->user()->children()
->where('is_active', true)
->get(['id', 'name', 'email']);
// 상담매니저 역할을 가진 모든 사용자 (드롭다운용)
$allManagers = $this->getAllManagerUsers();
return view('sales.dashboard.partials.tenant-list', compact(
'tenants',
@@ -768,6 +764,58 @@ private function getCommissionData(): array
return compact('commissionSummary', 'recentCommissions', 'partner');
}
/**
* 상담매니저 역할을 가진 모든 사용자 조회
* (tenant_id 조건 없이 - 파트너 관리 페이지와 동일한 방식)
*/
private function getAllManagerUsers()
{
return User::whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
->where('is_active', true)
->where('id', '!=', auth()->id()) // 본인 제외
->get(['id', 'name', 'email']);
}
/**
* 매니저 검색 API (AJAX)
* (tenant_id 조건 없이 - 모든 상담매니저 검색 가능)
*/
public function searchManagers(Request $request): JsonResponse
{
$query = $request->input('q', '');
$authId = auth()->id();
// 디버깅: SQL 쿼리 로깅
\DB::enableQueryLog();
$managers = User::whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
->where('is_active', true)
->where('id', '!=', $authId)
->when($query, function ($q) use ($query) {
$q->where(function ($subQ) use ($query) {
$subQ->where('name', 'like', "%{$query}%")
->orWhere('email', 'like', "%{$query}%");
});
})
->limit(10)
->get(['id', 'name', 'email']);
$sqlLog = \DB::getQueryLog();
\Log::info('searchManagers 디버그', [
'query' => $query,
'auth_id' => $authId,
'result_count' => $managers->count(),
'results' => $managers->pluck('name')->toArray(),
'sql' => $sqlLog,
]);
return response()->json([
'success' => true,
'managers' => $managers,
]);
}
/**
* 영업파트너 가이드북 도움말 모달
*/

View File

@@ -261,7 +261,7 @@ public function delegateRole(Request $request, int $id)
{
$validated = $request->validate([
'to_user_id' => 'required|exists:users,id',
'role_name' => 'required|string|in:manager,recruiter',
'role_name' => 'required|string|in:manager',
]);
$fromUser = User::findOrFail($id);
@@ -270,7 +270,7 @@ public function delegateRole(Request $request, int $id)
try {
$this->service->delegateRole($fromUser, $toUser, $validated['role_name']);
$roleLabel = $validated['role_name'] === 'manager' ? '매니저' : '유치담당';
$roleLabel = '상담매니저';
return redirect()->back()
->with('success', "{$roleLabel} 역할이 {$toUser->name}님에게 위임되었습니다.");
} catch (\InvalidArgumentException $e) {
@@ -285,13 +285,13 @@ public function delegateRole(Request $request, int $id)
public function assignRole(Request $request, int $id)
{
$validated = $request->validate([
'role_name' => 'required|string|in:sales,manager,recruiter',
'role_name' => 'required|string|in:sales,manager',
]);
$partner = User::findOrFail($id);
$this->service->assignRole($partner, $validated['role_name']);
$roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
$roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
return redirect()->back()
->with('success', "{$roleLabels[$validated['role_name']]} 역할이 부여되었습니다.");
}
@@ -302,13 +302,13 @@ public function assignRole(Request $request, int $id)
public function removeRole(Request $request, int $id)
{
$validated = $request->validate([
'role_name' => 'required|string|in:sales,manager,recruiter',
'role_name' => 'required|string|in:sales,manager',
]);
$partner = User::findOrFail($id);
$this->service->removeRole($partner, $validated['role_name']);
$roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
$roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
return redirect()->back()
->with('success', "{$roleLabels[$validated['role_name']]} 역할이 제거되었습니다.");
}

View File

@@ -228,6 +228,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View
$steps = config('sales_scenario.manager_steps');
$currentStep = (int) $request->input('step', 1);
$icons = config('sales_scenario.icons');
$readonly = $request->boolean('readonly', false);
// 가망고객 영업 관리 정보 조회 또는 생성
$management = SalesTenantManagement::findOrCreateByProspect($prospectId);
@@ -250,6 +251,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View
'icons' => $icons,
'management' => $management,
'isProspect' => true,
'readonly' => $readonly,
]);
}
@@ -262,6 +264,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View
'icons' => $icons,
'management' => $management,
'isProspect' => true,
'readonly' => $readonly,
]);
}

View File

@@ -171,9 +171,10 @@ public function destroy(int $id)
->with('error', '이미 테넌트로 전환된 영업권은 삭제할 수 없습니다.');
}
// 본인 또는 관리자만 삭제 가능
if ($prospect->registered_by !== auth()->id()) {
// TODO: 관리자 권한 체크 추가
// 관리자만 삭제 가능
if (!auth()->user()->isAdmin()) {
return redirect()->route('sales.prospects.index')
->with('error', '삭제 권한이 없습니다. 본사 운영팀에 문의하세요.');
}
$prospect->delete();

View File

@@ -36,14 +36,31 @@ public function tenant(): BelongsTo
}
/**
* 테넌트별 활성 계정과목 조회
* 테넌트별 활성 계정과목 조회 (하위 호환용)
*/
public static function getActiveByTenant(int $tenantId)
{
return self::where('tenant_id', $tenantId)
->where('is_active', true)
return self::getActive();
}
/**
* 전체 활성 계정과목 조회 (글로벌)
*/
public static function getActive()
{
return self::where('is_active', true)
->orderBy('sort_order')
->orderBy('code')
->get();
}
/**
* 전체 계정과목 조회 (글로벌)
*/
public static function getAll()
{
return self::orderBy('sort_order')
->orderBy('code')
->get();
}
}

View File

@@ -40,27 +40,25 @@ class BarobillConfig extends Model
];
/**
* 활성화된 테스트 서버 설정 조회
* 테스트 서버 설정 조회 (환경 기준, is_active 무관)
*/
public static function getActiveTest(): ?self
{
return self::where('environment', 'test')
->where('is_active', true)
->first();
return self::where('environment', 'test')->first();
}
/**
* 활성화된 운영 서버 설정 조회
* 운영 서버 설정 조회 (환경 기준, is_active 무관)
*/
public static function getActiveProduction(): ?self
{
return self::where('environment', 'production')
->where('is_active', true)
->first();
return self::where('environment', 'production')->first();
}
/**
* 현재 환경에 맞는 활성 설정 조회
* 현재 환경에 맞는 설정 조회
*
* 테넌트별 서버 모드 지원을 위해 is_active 조건 제거
*/
public static function getActive(bool $isTestMode = false): ?self
{

View File

@@ -27,6 +27,9 @@ class BarobillMember extends Model
'manager_email',
'manager_hp',
'status',
'server_mode',
'last_sales_fetch_at',
'last_purchases_fetch_at',
];
protected $casts = [
@@ -34,6 +37,8 @@ class BarobillMember extends Model
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
'barobill_pwd' => 'encrypted', // 복호화 가능한 암호화 (바로빌 API 호출 시 필요)
'last_sales_fetch_at' => 'datetime',
'last_purchases_fetch_at' => 'datetime',
];
protected $hidden = [
@@ -85,4 +90,36 @@ public function getStatusColorAttribute(): string
default => 'bg-gray-100 text-gray-800',
};
}
/**
* 서버 모드 라벨
*/
public function getServerModeLabelAttribute(): string
{
return match ($this->server_mode) {
'test' => '테스트',
'production' => '운영',
default => '테스트',
};
}
/**
* 서버 모드별 색상 클래스
*/
public function getServerModeColorAttribute(): string
{
return match ($this->server_mode) {
'test' => 'bg-amber-100 text-amber-800',
'production' => 'bg-green-100 text-green-800',
default => 'bg-amber-100 text-amber-800',
};
}
/**
* 테스트 모드 여부
*/
public function isTestMode(): bool
{
return $this->server_mode !== 'production';
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CorporateVehicle extends Model
{
use SoftDeletes;
protected $fillable = [
'tenant_id',
'plate_number',
'model',
'vehicle_type',
'ownership_type',
'year',
'driver',
'status',
'mileage',
'memo',
// 법인차량 전용
'purchase_date',
'purchase_price',
// 렌트/리스 전용
'contract_date',
'rent_company',
'rent_company_tel',
'rent_period',
'agreed_mileage',
'vehicle_price',
'residual_value',
'deposit',
'monthly_rent',
'monthly_rent_tax',
'insurance_company',
'insurance_company_tel',
];
protected $casts = [
'year' => 'integer',
'mileage' => 'integer',
'purchase_price' => 'integer',
'vehicle_price' => 'integer',
'residual_value' => 'integer',
'deposit' => 'integer',
'monthly_rent' => 'integer',
'monthly_rent_tax' => 'integer',
];
}

View File

@@ -20,6 +20,12 @@
* @property string $payment_type
* @property float $payment_amount
* @property string $payment_date
* @property string|null $first_payment_at 1차 납입완료일
* @property string|null $first_partner_paid_at 1차 파트너 수당지급일
* @property string|null $second_payment_at 2차 납입완료일
* @property string|null $second_partner_paid_at 2차 파트너 수당지급일
* @property string|null $first_subscription_at 첫 구독료 입금일 (매니저 수당 기준)
* @property string|null $manager_paid_at 매니저 수당지급일
* @property float $base_amount
* @property float $partner_rate
* @property float $manager_rate
@@ -79,6 +85,12 @@ class SalesCommission extends Model
'payment_type',
'payment_amount',
'payment_date',
'first_payment_at',
'first_partner_paid_at',
'second_payment_at',
'second_partner_paid_at',
'first_subscription_at',
'manager_paid_at',
'base_amount',
'partner_rate',
'manager_rate',
@@ -103,6 +115,12 @@ class SalesCommission extends Model
'partner_commission' => 'decimal:2',
'manager_commission' => 'decimal:2',
'payment_date' => 'date',
'first_payment_at' => 'date',
'first_partner_paid_at' => 'date',
'second_payment_at' => 'date',
'second_partner_paid_at' => 'date',
'first_subscription_at' => 'date',
'manager_paid_at' => 'date',
'scheduled_payment_date' => 'date',
'actual_payment_date' => 'date',
'approved_at' => 'datetime',
@@ -290,4 +308,147 @@ public function scopePaymentDateBetween(Builder $query, string $startDate, strin
{
return $query->whereBetween('payment_date', [$startDate, $endDate]);
}
// ========================================
// 2단계 수당 지급 관련 메서드
// ========================================
/**
* 1차 납입완료 처리
*/
public function recordFirstPayment(?Carbon $paymentDate = null): bool
{
return $this->update([
'first_payment_at' => $paymentDate ?? now(),
]);
}
/**
* 1차 파트너 수당 지급 처리
*/
public function recordFirstPartnerPaid(?Carbon $paidDate = null): bool
{
if (!$this->first_payment_at) {
return false; // 1차 납입이 먼저 완료되어야 함
}
return $this->update([
'first_partner_paid_at' => $paidDate ?? now(),
]);
}
/**
* 2차 납입완료 처리
*/
public function recordSecondPayment(?Carbon $paymentDate = null): bool
{
return $this->update([
'second_payment_at' => $paymentDate ?? now(),
]);
}
/**
* 2차 파트너 수당 지급 처리
*/
public function recordSecondPartnerPaid(?Carbon $paidDate = null): bool
{
if (!$this->second_payment_at) {
return false; // 2차 납입이 먼저 완료되어야 함
}
return $this->update([
'second_partner_paid_at' => $paidDate ?? now(),
]);
}
/**
* 첫 구독료 입금 기록 (매니저 수당 기준)
*/
public function recordFirstSubscription(?Carbon $subscriptionDate = null): bool
{
return $this->update([
'first_subscription_at' => $subscriptionDate ?? now(),
]);
}
/**
* 매니저 수당 지급 처리
*/
public function recordManagerPaid(?Carbon $paidDate = null): bool
{
if (!$this->first_subscription_at) {
return false; // 첫 구독료 입금이 먼저 완료되어야 함
}
return $this->update([
'manager_paid_at' => $paidDate ?? now(),
]);
}
/**
* 1차 파트너 수당 지급예정일 (1차 납입일 익월 10일)
*/
public function getFirstPartnerScheduledDateAttribute(): ?Carbon
{
if (!$this->first_payment_at) {
return null;
}
return Carbon::parse($this->first_payment_at)->addMonth()->day(10);
}
/**
* 2차 파트너 수당 지급예정일 (2차 납입일 익월 10일)
*/
public function getSecondPartnerScheduledDateAttribute(): ?Carbon
{
if (!$this->second_payment_at) {
return null;
}
return Carbon::parse($this->second_payment_at)->addMonth()->day(10);
}
/**
* 매니저 수당 지급예정일 (첫 구독료 입금일 익월 10일)
*/
public function getManagerScheduledDateAttribute(): ?Carbon
{
if (!$this->first_subscription_at) {
return null;
}
return Carbon::parse($this->first_subscription_at)->addMonth()->day(10);
}
/**
* 파트너 수당 1차 지급 완료 여부
*/
public function isFirstPartnerPaid(): bool
{
return $this->first_partner_paid_at !== null;
}
/**
* 파트너 수당 2차 지급 완료 여부
*/
public function isSecondPartnerPaid(): bool
{
return $this->second_partner_paid_at !== null;
}
/**
* 매니저 수당 지급 완료 여부
*/
public function isManagerPaid(): bool
{
return $this->manager_paid_at !== null;
}
/**
* 전체 수당 지급 완료 여부 (파트너 1차/2차 + 매니저)
*/
public function isFullyPaid(): bool
{
return $this->isFirstPartnerPaid()
&& $this->isSecondPartnerPaid()
&& $this->isManagerPaid();
}
}

View File

@@ -96,9 +96,55 @@ class BarobillService
public function __construct()
{
// .env에서 테스트 모드 설정 가져오기 (기본값: true = 테스트 모드)
// 기본값: .env 설정 사용
$this->isTestMode = config('services.barobill.test_mode', true);
$this->initializeConfig();
}
/**
* 서버 모드 전환 (회원사별 설정 적용)
*
* @param bool $isTestMode true: 테스트서버, false: 운영서버
*/
public function switchServerMode(bool $isTestMode): self
{
if ($this->isTestMode !== $isTestMode) {
$this->isTestMode = $isTestMode;
// SOAP 클라이언트 초기화 (새 서버로 재연결)
$this->corpStateClient = null;
$this->tiClient = null;
$this->bankAccountClient = null;
$this->cardClient = null;
// 설정 재로드
$this->initializeConfig();
}
return $this;
}
/**
* 서버 모드 문자열로 전환
*
* @param string $mode 'test' 또는 'production'
*/
public function setServerMode(string $mode): self
{
return $this->switchServerMode($mode === 'test');
}
/**
* 현재 서버 모드 조회
*/
public function getServerMode(): string
{
return $this->isTestMode ? 'test' : 'production';
}
/**
* 설정 초기화 (서버 모드에 따른 설정 로드)
*/
protected function initializeConfig(): void
{
// DB에서 활성화된 설정 가져오기 (우선순위)
$dbConfig = $this->loadConfigFromDatabase();

View File

@@ -16,7 +16,7 @@
* 과금 정책 (DB 관리):
* - 전자세금계산서: 기본 100건 무료, 추가 50건 단위 5,000원
* - 계좌조회: 기본 1계좌 무료, 추가 1계좌당 10,000원
* - 카드등록: 기본 3장 무료, 추가 1장당 5,000원
* - 카드등록: 기본 5장 무료, 추가 1장당 5,000원
*/
class BarobillUsageService
{
@@ -270,12 +270,12 @@ public static function getPriceInfo(): array
],
'card' => [
'name' => '법인카드 등록',
'free_quota' => 3,
'free_quota' => 5,
'free_quota_unit' => '장',
'additional_unit' => 1,
'additional_unit_label' => '장',
'additional_price' => 5000,
'description' => '기본 3장 무료, 추가 1장당 5,000원',
'description' => '기본 5장 무료, 추가 1장당 5,000원',
],
'hometax' => [
'name' => '홈텍스 매입/매출',

View File

@@ -19,7 +19,7 @@ class SalesManagerService
/**
* 영업파트너 역할 이름 목록
*/
public const SALES_ROLES = ['sales', 'manager', 'recruiter'];
public const SALES_ROLES = ['sales', 'manager'];
/**
* 영업파트너 생성
@@ -510,9 +510,6 @@ public function getStats(?int $parentId = null): array
'manager' => (clone $baseQuery)
->whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
->count(),
'recruiter' => (clone $baseQuery)
->whereHas('userRoles.role', fn($q) => $q->where('name', 'recruiter'))
->count(),
];
}

View File

@@ -16,8 +16,8 @@ public function run(): void
[
'service_type' => 'card',
'name' => '법인카드 등록',
'description' => '법인카드 등록 기본 3장 제공, 추가 시 장당 과금',
'free_quota' => 3,
'description' => '법인카드 등록 기본 5장 제공, 추가 시 장당 과금',
'free_quota' => 5,
'free_quota_unit' => '장',
'additional_unit' => 1,
'additional_unit_label' => '장',

View File

@@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\Commons\Menu;
use Illuminate\Database\Seeder;
/**
* 영업파트너 관리 메뉴 이름 변경 시더
* - "영업파트너 관리" → "파트너 관리"
*/
class PartnerMenuRenameSeeder extends Seeder
{
public function run(): void
{
$tenantId = 1;
// "영업파트너 관리" 메뉴 찾아서 이름 변경
$menu = Menu::where('tenant_id', $tenantId)
->where('name', '영업파트너 관리')
->first();
if ($menu) {
$menu->name = '파트너 관리';
$menu->save();
$this->command->info('메뉴 이름 변경: 영업파트너 관리 → 파트너 관리');
} else {
$this->command->warn('영업파트너 관리 메뉴를 찾을 수 없습니다.');
}
}
}

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Role;
use App\Models\UserRole;
use Illuminate\Database\Seeder;
class SalesRoleSeeder extends Seeder
@@ -20,15 +21,11 @@ public function run(): void
$roles = [
[
'name' => 'sales',
'description' => '영업 - 가망고객 발굴, 상담, 계약 체결',
'description' => '영업파트너 - 고객 발굴, 계약 체결',
],
[
'name' => 'manager',
'description' => '매니저 - 하위 파트너 관리, 실적 취합, 승인 처리',
],
[
'name' => 'recruiter',
'description' => '유치담당 - 새로운 영업파트너 유치 활동',
'description' => '상담매니저 - 고객 상담, 인터뷰 정리',
],
];
@@ -45,6 +42,23 @@ public function run(): void
);
}
$this->command->info('영업파트너 역할이 생성되었습니다: sales, manager, recruiter');
// recruiter 역할 삭제 (더 이상 사용하지 않음)
$recruiterRole = Role::where('tenant_id', $tenantId)
->where('name', 'recruiter')
->first();
if ($recruiterRole) {
// 해당 역할을 가진 user_roles 강제 삭제 (soft delete 포함)
UserRole::withTrashed()
->where('role_id', $recruiterRole->id)
->forceDelete();
$this->command->info('recruiter 역할 할당이 삭제되었습니다.');
// 역할 강제 삭제
$recruiterRole->forceDelete();
$this->command->info('recruiter 역할이 삭제되었습니다.');
}
$this->command->info('영업파트너 역할이 생성되었습니다: sales, manager');
}
}

View File

@@ -656,14 +656,147 @@ #### `resources/views/products/partials/table.blade.php`
---
## 10. 문서 이력
## 10. React 테이블 (Blade + Babel)
> **참조 파일**: `resources/views/barobill/etax/index.blade.php`
> **작성일**: 2026-02-03
Blade 템플릿 내에서 React(Babel)를 사용하는 경우, 테이블 컬럼 너비 설정 시 주의해야 할 사항이 있습니다.
### 10.1 colgroup을 사용한 컬럼 너비 지정
React에서 `table-fixed` 레이아웃과 함께 컬럼 너비를 정확하게 지정하려면 `colgroup`을 사용해야 합니다. Tailwind의 `w-[]` 클래스만으로는 정확한 너비 적용이 어려울 수 있습니다.
#### 잘못된 예시 (Tailwind 클래스만 사용)
```jsx
// ❌ 테이블 셀에 Tailwind 클래스만 적용 - 비율이 의도대로 안 될 수 있음
<table className="w-full text-sm table-fixed">
<thead>
<tr>
<th className="w-[30%]">품목명</th>
<th className="w-[60px]">수량</th>
<th className="w-[100px]">단가</th>
</tr>
</thead>
</table>
```
#### 올바른 예시 (colgroup 사용)
```jsx
// ✅ colgroup으로 명시적 너비 지정
<table className="w-full text-sm" style=@{{tableLayout: 'fixed'}}>
<colgroup>
<col style=@{{width: '30%'}} /> {/* 품목명 - 가장 넓게 */}
<col style=@{{width: '60px'}} /> {/* 수량 - 작게 고정 */}
<col style=@{{width: '100px'}} /> {/* 단가 - 수량보다 넓게 */}
<col style=@{{width: '12%'}} /> {/* 공급가액 */}
<col style=@{{width: '10%'}} /> {/* 세액 */}
<col style=@{{width: '12%'}} /> {/* 금액 */}
<col style=@{{width: '70px'}} /> {/* 과세 (select) */}
<col style=@{{width: '40px'}} /> {/* 삭제 버튼 */}
</colgroup>
<thead className="bg-stone-100 border-b border-stone-200">
<tr>
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">수량</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">단가</th>
{/* ... */}
</tr>
</thead>
</table>
```
### 10.2 Blade 템플릿에서 React 스타일 객체 이스케이프
**중요**: Blade 템플릿(`.blade.php`)에서 React의 스타일 객체 `{{ }}`를 사용하면 Blade가 이를 PHP echo 구문으로 해석하여 에러가 발생합니다.
#### 에러 발생 코드
```jsx
// ❌ Blade가 {{ }}를 PHP 변수로 해석 → 에러 발생
<table style={{tableLayout: 'fixed'}}>
// Error: Unknown named parameter $tableLayout
```
#### 해결 방법: `@{{ }}` 사용
```jsx
// ✅ @를 붙여 Blade 이스케이프 처리
<table style=@{{tableLayout: 'fixed'}}>
<col style=@{{width: '30%'}} />
```
`@{{ }}`를 사용하면 Blade가 해당 구문을 처리하지 않고 그대로 `{{ }}`로 출력하여 React/Babel이 정상적으로 해석합니다.
### 10.3 입력 테이블 컬럼 비율 가이드
품목 입력 테이블의 권장 컬럼 비율:
| 컬럼 | 너비 | 설명 |
|------|------|------|
| 품목명 | **30%** | 텍스트 입력, 가장 넓게 |
| 수량 | **60px** | 작은 숫자 입력, 고정 너비 |
| 단가 | **100px** | 금액 입력, 수량보다 넓게 |
| 공급가액 | **12%** | 계산된 금액 표시 |
| 세액 | **10%** | 계산된 금액 표시 |
| 금액 | **12%** | 합계 금액 표시 |
| 과세유형 | **70px** | select 박스 |
| 삭제 | **40px** | 버튼 |
#### 비율 설정 원칙
1. **입력 필드**는 내용에 맞는 적절한 너비 확보
2. **수량**은 보통 작은 숫자이므로 좁게 (60px)
3. **단가/금액**은 큰 숫자를 표시하므로 넉넉하게
4. **품목명**은 텍스트 입력이므로 가장 넓게 (%)
5. **버튼/아이콘**은 고정 픽셀 (px)
### 10.4 전체 예제 코드
```jsx
// Blade 템플릿 내 React 코드 (@push('scripts') 내부)
<table className="w-full text-sm" style=@{{tableLayout: 'fixed'}}>
<colgroup>
<col style=@{{width: '30%'}} />
<col style=@{{width: '60px'}} />
<col style=@{{width: '100px'}} />
<col style=@{{width: '12%'}} />
<col style=@{{width: '10%'}} />
<col style=@{{width: '12%'}} />
<col style=@{{width: '70px'}} />
<col style=@{{width: '40px'}} />
</colgroup>
<thead className="bg-stone-100 border-b border-stone-200">
<tr>
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">수량</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">단가</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">공급가액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">세액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">금액</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700">과세</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{/* 데이터 행들 */}
</tbody>
</table>
```
---
## 11. 문서 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------|
| 1.0 | 2025-11-25 | Claude | 초안 작성 (권한 관리 페이지 기반) |
| 1.1 | 2026-02-03 | Claude | React 테이블 섹션 추가 (colgroup, Blade 이스케이프) |
---
## 11. 문의
## 12. 문의
이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.

View File

@@ -80,6 +80,14 @@
const CSRF_TOKEN = '{{ csrf_token() }}';
// 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
@@ -88,8 +96,8 @@
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
from: firstDay.toISOString().split('T')[0],
to: lastDay.toISOString().split('T')[0]
from: formatLocalDate(firstDay),
to: formatLocalDate(lastDay)
};
};
@@ -102,53 +110,46 @@
}
};
// StatCard Component
const StatCard = ({ title, value, subtext, icon, color = 'blue' }) => {
// CompactStat Component (큰 크기 통계 표시)
const CompactStat = ({ label, value, color = 'stone' }) => {
const colorClasses = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
stone: 'bg-stone-50 text-stone-600'
blue: 'text-blue-600',
green: 'text-green-600',
red: 'text-red-600',
stone: 'text-stone-700'
};
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<h3 className="text-sm font-medium text-stone-500">{title}</h3>
<div className={`p-2 rounded-lg ${colorClasses[color] || colorClasses.blue}`}>
{icon}
</div>
</div>
<div className="text-2xl font-bold text-stone-900 mb-1">{value}</div>
{subtext && <div className="text-xs text-stone-400">{subtext}</div>}
<div className="flex items-center gap-3 px-6 py-4 bg-white rounded-xl border border-stone-200 shadow-sm">
<span className="text-base text-stone-500 font-medium">{label}</span>
<span className={`text-xl font-bold ${colorClasses[color]}`}>{value}</span>
</div>
);
};
// AccountSelector Component
// AccountSelector Component (컴팩트 버전)
const AccountSelector = ({ accounts, selectedAccount, onSelect }) => (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => onSelect('')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
selectedAccount === ''
? 'bg-emerald-600 text-white'
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
}`}
>
전체 계좌
전체
</button>
{accounts.map(acc => (
<button
key={acc.bankAccountNum}
onClick={() => onSelect(acc.bankAccountNum)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
selectedAccount === acc.bankAccountNum
? 'bg-emerald-600 text-white'
: 'bg-white border border-stone-200 text-stone-700 hover:bg-stone-50'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
}`}
>
{acc.bankName} {acc.bankAccountNum ? '****' + acc.bankAccountNum.slice(-4) : ''}
{acc.accountName && ` (${acc.accountName})`}
{acc.bankName} ****{acc.bankAccountNum ? acc.bankAccountNum.slice(-4) : ''}
</button>
))}
</div>
@@ -158,7 +159,9 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
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);
@@ -172,12 +175,18 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
code.name.toLowerCase().includes(searchLower);
});
// 검색어 변경 시 하이라이트 초기화
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);
@@ -189,12 +198,48 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
onChange(code.code, selected?.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(() => {
if (listRef.current && listRef.current.children[newIndex]) {
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(() => {
if (listRef.current && listRef.current.children[newIndex]) {
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
}
}, 0);
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
e.preventDefault();
const selectIndex = highlightIndex >= 0 ? highlightIndex : 0;
handleSelect(filteredCodes[selectIndex]);
} else if (e.key === 'Escape') {
setIsOpen(false);
setSearch('');
setHighlightIndex(-1);
}
};
return (
@@ -235,27 +280,32 @@ className="text-stone-400 hover:text-stone-600"
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-emerald-500 outline-none"
autoFocus
/>
</div>
{/* 옵션 목록 */}
<div className="max-h-48 overflow-y-auto">
<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 => (
filteredCodes.slice(0, 50).map((code, index) => (
<div
key={code.code}
onClick={() => handleSelect(code)}
className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-emerald-50 ${
value === code.code ? 'bg-emerald-100 text-emerald-700' : 'text-stone-700'
className={`px-3 py-1.5 text-xs cursor-pointer ${
index === highlightIndex
? 'bg-emerald-600 text-white font-semibold'
: value === code.code
? 'bg-emerald-100 text-emerald-700'
: 'text-stone-700 hover:bg-emerald-50'
}`}
>
<span className="font-mono text-emerald-600">{code.code}</span>
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-emerald-600'}`}>{code.code}</span>
<span className="ml-1">{code.name}</span>
</div>
))
@@ -576,7 +626,7 @@ className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font-
}
return (
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
<div className="bg-white rounded-xl shadow-sm border border-stone-100 min-h-[calc(100vh-200px)]">
<div className="p-6 border-b border-stone-100">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<h2 className="text-lg font-bold text-stone-900">입출금 내역</h2>
@@ -665,7 +715,7 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded
</button>
</div>
</div>
<div className="overflow-x-auto" style={ {maxHeight: '500px', overflowY: 'auto'} }>
<div className="overflow-x-auto" style={ {minHeight: '500px', overflowY: 'auto'} }>
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
<tr>
@@ -929,6 +979,13 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
<div className="flex items-center gap-2">
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
</svg>
운영 모드
</span>
@endif
@if($hasSoapClient)
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
@@ -938,49 +995,31 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
</div>
</div>
{/* Dashboard */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="총 입금액"
value={formatCurrency(summary.totalDeposit)}
subtext="조회기간 합계"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4"/></svg>}
color="blue"
/>
<StatCard
title="총 출금액"
value={formatCurrency(summary.totalWithdraw)}
subtext="조회기간 합계"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 12H4"/></svg>}
color="red"
/>
<StatCard
title="등록된 계좌"
value={`${accounts.length}개`}
subtext="사용 가능한 계좌"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>}
color="green"
/>
<StatCard
title="거래건수"
value={`${(summary.count || 0).toLocaleString()}건`}
subtext="전체 입출금 건수"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>}
color="stone"
/>
</div>
{/* 통계 + 계좌 선택 (한 줄) */}
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-4">
<div className="flex flex-wrap items-center gap-3">
{/* 통계 배지들 */}
<CompactStat label="입금" value={formatCurrency(summary.totalDeposit)} color="blue" />
<CompactStat label="출금" value={formatCurrency(summary.totalWithdraw)} color="red" />
<CompactStat label="계좌" value={`${accounts.length}개`} color="green" />
<CompactStat label="거래" value={`${(summary.count || 0).toLocaleString()}건`} color="stone" />
{/* Account Filter */}
{accounts.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-stone-100 p-6">
<h2 className="text-sm font-medium text-stone-700 mb-3">계좌 선택</h2>
<AccountSelector
accounts={accounts}
selectedAccount={selectedAccount}
onSelect={setSelectedAccount}
/>
{/* 구분선 */}
{accounts.length > 0 && <div className="w-px h-6 bg-stone-200 mx-1"></div>}
{/* 계좌 선택 버튼들 */}
{accounts.length > 0 && (
<>
<span className="text-xs text-stone-500">계좌:</span>
<AccountSelector
accounts={accounts}
selectedAccount={selectedAccount}
onSelect={setSelectedAccount}
/>
</>
)}
</div>
)}
</div>
{/* Error Display */}
{error && (

View File

@@ -79,21 +79,29 @@
const CSRF_TOKEN = '{{ csrf_token() }}';
// 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const today = formatLocalDate(now);
const year = now.getFullYear();
const month = now.getMonth() + offset;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// 종료일: 이번달이면 오늘, 지난달이면 그 달의 마지막 날
const lastDayStr = lastDay.toISOString().split('T')[0];
const lastDayStr = formatLocalDate(lastDay);
const endDate = offset >= 0 && lastDayStr > today ? today : lastDayStr;
return {
from: firstDay.toISOString().split('T')[0],
from: formatLocalDate(firstDay),
to: endDate
};
};
@@ -163,7 +171,9 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
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);
@@ -177,12 +187,18 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
code.name.toLowerCase().includes(searchLower);
});
// 검색어 변경 시 하이라이트 초기화
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);
@@ -194,12 +210,47 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
onChange(code.code, selected?.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(() => {
if (listRef.current && listRef.current.children[newIndex]) {
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(() => {
if (listRef.current && listRef.current.children[newIndex]) {
listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' });
}
}, 0);
} else if (e.key === 'Enter' && filteredCodes.length > 0) {
e.preventDefault();
const selectIndex = highlightIndex >= 0 ? highlightIndex : 0;
handleSelect(filteredCodes[selectIndex]);
} else if (e.key === 'Escape') {
setIsOpen(false);
setSearch('');
setHighlightIndex(-1);
}
};
return (
@@ -233,34 +284,39 @@ className="text-stone-400 hover:text-stone-600"
{/* 드롭다운 */}
{isOpen && (
<div className="absolute z-50 mt-1 w-48 bg-white border border-stone-200 rounded-lg shadow-lg">
<div className="absolute z-[9999] mt-1 w-48 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-purple-500 outline-none"
autoFocus
/>
</div>
{/* 옵션 목록 */}
<div className="max-h-48 overflow-y-auto">
<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 => (
filteredCodes.slice(0, 50).map((code, index) => (
<div
key={code.code}
onClick={() => handleSelect(code)}
className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-purple-50 ${
value === code.code ? 'bg-purple-100 text-purple-700' : 'text-stone-700'
className={`px-3 py-1.5 text-xs cursor-pointer ${
index === highlightIndex
? 'bg-purple-600 text-white font-semibold'
: value === code.code
? 'bg-purple-100 text-purple-700'
: 'text-stone-700 hover:bg-purple-50'
}`}
>
<span className="font-mono text-purple-600">{code.code}</span>
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-purple-600'}`}>{code.code}</span>
<span className="ml-1">{code.name}</span>
</div>
))
@@ -575,7 +631,7 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t
}
return (
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden">
<div className="bg-white rounded-xl shadow-sm border border-stone-100 overflow-hidden min-h-[calc(100vh-200px)]">
<div className="p-6 border-b border-stone-100">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<h2 className="text-lg font-bold text-stone-900">카드 사용내역</h2>
@@ -663,7 +719,7 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
</button>
</div>
</div>
<div className="overflow-x-auto" style={ {maxHeight: '500px', overflowY: 'auto'} }>
<div className="overflow-x-auto" style={ {minHeight: '500px', overflowY: 'auto'} }>
<table className="w-full text-left text-sm text-stone-600">
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
<tr>
@@ -1199,6 +1255,13 @@ className="text-xs text-amber-600 hover:text-amber-700 underline"
<div className="flex items-center gap-2">
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
</svg>
운영 모드
</span>
@endif
@if($hasSoapClient)
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>

View File

@@ -101,6 +101,17 @@
isHeadquarters: {{ ($currentTenant?->id ?? 0) == 1 ? 'true' : 'false' }}
};
// 서버 모드 정보
const IS_TEST_MODE = {{ $isTestMode ? 'true' : 'false' }};
// 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// StatCard Component
const StatCard = ({ title, value, subtext, icon }) => (
<div className="bg-white rounded-xl p-6 shadow-sm border border-stone-100 hover:shadow-md transition-shadow">
@@ -145,7 +156,7 @@
recipientAddr: randomRecipient.addr,
recipientContact: randomRecipient.contact || '홍길동',
recipientEmail: randomRecipient.email,
supplyDate: supplyDate.toISOString().split('T')[0],
supplyDate: formatLocalDate(supplyDate),
items,
memo: ''
};
@@ -259,17 +270,27 @@
</button>
</div>
<div className="border border-stone-200 rounded-lg overflow-hidden">
<table className="w-full text-sm table-fixed">
<table className="w-full text-sm" style=@{{tableLayout: 'fixed'}}>
<colgroup>
<col style=@{{width: '30%'}} />
<col style=@{{width: '60px'}} />
<col style=@{{width: '100px'}} />
<col style=@{{width: '12%'}} />
<col style=@{{width: '10%'}} />
<col style=@{{width: '12%'}} />
<col style=@{{width: '70px'}} />
<col style=@{{width: '40px'}} />
</colgroup>
<thead className="bg-stone-100 border-b border-stone-200">
<tr>
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[45px]">수량</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[70px]">단가</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[75px]">공급가액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[65px]">세액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700 w-[75px]">금액</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700 w-[50px]">과세</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700 w-[28px]"></th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">수량</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">단가</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">공급가액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">세액</th>
<th className="px-2 py-2.5 text-right font-medium text-stone-700">금액</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700">과세</th>
<th className="px-1 py-2.5 text-center font-medium text-stone-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
@@ -352,15 +373,33 @@
<textarea className="w-full rounded-lg border-stone-200 border p-3 focus:ring-2 focus:ring-blue-500 outline-none" rows="2" value={formData.memo} onChange={(e) => setFormData({ ...formData, memo: e.target.value })} placeholder="추가 메모사항" />
</div>
{/* 운영 모드 경고 */}
{!IS_TEST_MODE && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg mb-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p className="text-sm font-semibold text-red-800">운영 서버 - 실제 국세청 전송</p>
<p className="text-xs text-red-600 mt-1">발행된 세금계산서는 실제 국세청으로 전송됩니다. 입력 정보를 신중히 확인하시기 바랍니다.</p>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex gap-2">
<button type="button" onClick={onCancel} className="px-4 py-2 text-stone-600 hover:text-stone-800">취소</button>
<button type="button" onClick={regenerateData} className="px-4 py-2 text-blue-600 hover:text-blue-700 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
랜덤 데이터 재생성
</button>
{/* 랜덤 데이터 재생성 버튼은 테스트 모드에서만 표시 */}
{IS_TEST_MODE && (
<button type="button" onClick={regenerateData} className="px-4 py-2 text-blue-600 hover:text-blue-700 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
랜덤 데이터 재생성
</button>
)}
</div>
<button type="submit" disabled={isSubmitting} className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 flex items-center gap-2">
<button type="submit" disabled={isSubmitting} className={`px-6 py-3 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${IS_TEST_MODE ? 'bg-blue-600 hover:bg-blue-700' : 'bg-red-600 hover:bg-red-700'}`}>
{isSubmitting ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> 발행 ...</>
) : (
@@ -654,8 +693,8 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
from: firstDay.toISOString().split('T')[0],
to: lastDay.toISOString().split('T')[0]
from: formatLocalDate(firstDay),
to: formatLocalDate(lastDay)
};
};
@@ -841,6 +880,13 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
<div className="flex items-center gap-2">
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
운영 모드
</span>
@endif
@if($hasSoapClient)
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>
@@ -866,9 +912,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
전자세금계산서 발행
</h2>
{!showIssueForm && (
<button onClick={() => setShowIssueForm(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors flex items-center gap-2">
<button onClick={() => setShowIssueForm(true)} className={`px-4 py-2 text-white rounded-lg font-medium transition-colors flex items-center gap-2 ${IS_TEST_MODE ? 'bg-blue-600 hover:bg-blue-700' : 'bg-red-600 hover:bg-red-700'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
새로 발행 (랜덤 데이터)
{IS_TEST_MODE ? '새로 발행 (랜덤 데이터)' : '새로 발행'}
</button>
)}
</div>

View File

@@ -77,6 +77,14 @@
const CSRF_TOKEN = '{{ csrf_token() }}';
// 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 날짜 유틸리티 함수
const getMonthDates = (offset = 0) => {
const now = new Date();
@@ -85,8 +93,8 @@
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
from: firstDay.toISOString().split('T')[0],
to: lastDay.toISOString().split('T')[0]
from: formatLocalDate(firstDay),
to: formatLocalDate(lastDay)
};
};
@@ -353,6 +361,8 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
pagination: json.data?.pagination || {},
loaded: true
});
// 마지막 수집 시간 갱신
loadCollectStatus();
} else {
setError(json.error || '매출 조회 실패');
}
@@ -385,6 +395,8 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
pagination: json.data?.pagination || {},
loaded: true
});
// 마지막 수집 시간 갱신
loadCollectStatus();
} else {
setError(json.error || '매입 조회 실패');
}
@@ -562,6 +574,13 @@ className="px-3 py-1.5 bg-stone-100 text-stone-700 rounded-lg text-xs font-mediu
</button>
@if($isTestMode)
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-medium">테스트 모드</span>
@else
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
</svg>
운영 모드
</span>
@endif
@if($hasSoapClient)
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">SOAP 연결됨</span>

View File

@@ -123,6 +123,78 @@ class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg
</div>
</div>
</div>
<!-- 서버 모드 변경 확인 모달 -->
<div id="serverModeConfirmModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="ServerModeManager.closeModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg" onclick="event.stopPropagation()">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-800">바로빌 서버 변경</h3>
<p class="text-sm text-gray-500">
<span id="serverModeModalMemberName" class="font-medium text-gray-700"></span> 회원사
</p>
</div>
<button type="button" onclick="ServerModeManager.closeModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-6 space-y-4">
<!-- 변경 정보 -->
<div class="flex items-center justify-center gap-4 text-center">
<div>
<p class="text-xs text-gray-500 mb-1">현재</p>
<p id="serverModeModalCurrentMode" class="font-semibold"></p>
</div>
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
<div>
<p class="text-xs text-gray-500 mb-1">변경</p>
<p id="serverModeModalNewMode" class="font-semibold"></p>
</div>
</div>
<!-- 경고 메시지 -->
<div id="serverModeWarning">
<!-- 동적으로 채워짐 -->
</div>
<!-- 확인 체크박스 -->
<label class="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition">
<input type="checkbox"
id="serverModeConfirmCheckbox"
onchange="ServerModeManager.onConfirmCheckChange(this.checked)"
class="mt-0.5 w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">
내용을 확인하였으며, <strong>서버 변경에 따른 요금 부과</strong> 동의합니다.
</span>
</label>
</div>
<div class="px-6 py-4 border-t border-gray-100 flex gap-3">
<button
type="button"
onclick="ServerModeManager.closeModal()"
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
취소
</button>
<button
type="button"
id="serverModeConfirmBtn"
onclick="ServerModeManager.confirmChange()"
disabled
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
서버 변경
</button>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
@@ -621,6 +693,132 @@ function closeBarobillDropdown() {
}
});
// 회원사별 서버 모드 변경 관리
const ServerModeManager = {
pendingMemberId: null,
pendingMode: null,
// 서버 모드 변경 요청 (경고 모달 표시)
requestChange(memberId, memberName, currentMode) {
this.pendingMemberId = memberId;
const newMode = currentMode === 'test' ? 'production' : 'test';
this.pendingMode = newMode;
const modal = document.getElementById('serverModeConfirmModal');
const memberNameEl = document.getElementById('serverModeModalMemberName');
const currentModeEl = document.getElementById('serverModeModalCurrentMode');
const newModeEl = document.getElementById('serverModeModalNewMode');
const warningEl = document.getElementById('serverModeWarning');
const confirmCheckbox = document.getElementById('serverModeConfirmCheckbox');
memberNameEl.textContent = memberName;
currentModeEl.textContent = currentMode === 'test' ? '테스트 서버' : '운영 서버';
currentModeEl.className = currentMode === 'test'
? 'font-semibold text-amber-600'
: 'font-semibold text-green-600';
newModeEl.textContent = newMode === 'test' ? '테스트 서버' : '운영 서버';
newModeEl.className = newMode === 'test'
? 'font-semibold text-amber-600'
: 'font-semibold text-green-600';
// 운영 서버로 전환 시 추가 경고
if (newMode === 'production') {
warningEl.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-semibold">⚠️ 요금 부과 안내</p>
<ul class="mt-2 text-sm space-y-1">
<li>• 운영 서버 사용 시 <strong>실제 요금이 부과</strong>됩니다.</li>
<li>• 회원사 등록, 세금계산서 발행 등 모든 API 호출에 과금됩니다.</li>
<li>• 테스트 목적이라면 테스트 서버를 사용해 주세요.</li>
</ul>
</div>
</div>
</div>
`;
} else {
warningEl.innerHTML = `
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 text-amber-700">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="font-semibold">테스트 서버 안내</p>
<ul class="mt-2 text-sm space-y-1">
<li>• 테스트 서버는 개발/테스트 용도로만 사용됩니다.</li>
<li>• 테스트 데이터는 실제 국세청에 전송되지 않습니다.</li>
<li>• 운영 환경에서는 반드시 운영 서버로 전환해 주세요.</li>
</ul>
</div>
</div>
</div>
`;
}
confirmCheckbox.checked = false;
document.getElementById('serverModeConfirmBtn').disabled = true;
modal.classList.remove('hidden');
},
// 확인 체크박스 상태 변경
onConfirmCheckChange(checked) {
document.getElementById('serverModeConfirmBtn').disabled = !checked;
},
// 서버 모드 변경 실행
async confirmChange() {
if (!this.pendingMemberId || !this.pendingMode) return;
const confirmBtn = document.getElementById('serverModeConfirmBtn');
const originalText = confirmBtn.textContent;
confirmBtn.disabled = true;
confirmBtn.textContent = '변경 중...';
try {
const res = await fetch(`/api/admin/barobill/members/${this.pendingMemberId}/server-mode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify({
server_mode: this.pendingMode,
confirmed: true
})
});
const data = await res.json();
if (data.success) {
showToast(data.message, 'success');
this.closeModal();
htmx.trigger(document.body, 'memberUpdated');
} else {
showToast(data.message || '서버 모드 변경 실패', 'error');
}
} catch (error) {
console.error('서버 모드 변경 실패:', error);
showToast('서버 모드 변경 중 오류가 발생했습니다.', 'error');
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
},
// 모달 닫기
closeModal() {
document.getElementById('serverModeConfirmModal').classList.add('hidden');
this.pendingMemberId = null;
this.pendingMode = null;
}
};
// 초기화
document.addEventListener('DOMContentLoaded', function() {
MemberModal.init();

View File

@@ -46,6 +46,9 @@ class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm te
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상태
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
서버
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
바로빌 서비스
</th>
@@ -88,6 +91,25 @@ class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm te
{{ $member->status_label }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<button
type="button"
onclick="ServerModeManager.requestChange({{ $member->id }}, '{{ addslashes($member->corp_name) }}', '{{ $member->server_mode ?? 'test' }}')"
class="px-2.5 py-1 inline-flex items-center text-xs leading-5 font-semibold rounded-full cursor-pointer hover:opacity-80 transition {{ $member->server_mode_color }}"
title="클릭하여 서버 변경"
>
@if(($member->server_mode ?? 'test') === 'test')
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
@else
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
@endif
{{ $member->server_mode_label }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="relative inline-block text-left barobill-dropdown">
<button

View File

@@ -83,7 +83,7 @@
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- 전자세금계산서 -->
<label class="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition border-2 border-transparent has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
<input type="checkbox" name="use_tax_invoice" id="use_tax_invoice" class="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500">
<input type="checkbox" name="use_tax_invoice" id="use_tax_invoice" class="service-checkbox mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" onchange="saveServiceSetting('use_tax_invoice', this.checked)">
<div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -97,7 +97,7 @@
<!-- 계좌조회 -->
<label class="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition border-2 border-transparent has-[:checked]:border-green-500 has-[:checked]:bg-green-50">
<input type="checkbox" name="use_bank_account" id="use_bank_account" class="mt-1 w-4 h-4 text-green-600 bg-gray-100 border-gray-300 rounded focus:ring-green-500">
<input type="checkbox" name="use_bank_account" id="use_bank_account" class="service-checkbox mt-1 w-4 h-4 text-green-600 bg-gray-100 border-gray-300 rounded focus:ring-green-500" onchange="saveServiceSetting('use_bank_account', this.checked)">
<div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -111,7 +111,7 @@
<!-- 카드사용내역 -->
<label class="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition border-2 border-transparent has-[:checked]:border-purple-500 has-[:checked]:bg-purple-50">
<input type="checkbox" name="use_card_usage" id="use_card_usage" class="mt-1 w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 rounded focus:ring-purple-500">
<input type="checkbox" name="use_card_usage" id="use_card_usage" class="service-checkbox mt-1 w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 rounded focus:ring-purple-500" onchange="saveServiceSetting('use_card_usage', this.checked)">
<div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -125,7 +125,7 @@
<!-- 홈텍스매입/매출 -->
<label class="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition border-2 border-transparent has-[:checked]:border-orange-500 has-[:checked]:bg-orange-50">
<input type="checkbox" name="use_hometax" id="use_hometax" class="mt-1 w-4 h-4 text-orange-600 bg-gray-100 border-gray-300 rounded focus:ring-orange-500">
<input type="checkbox" name="use_hometax" id="use_hometax" class="service-checkbox mt-1 w-4 h-4 text-orange-600 bg-gray-100 border-gray-300 rounded focus:ring-orange-500" onchange="saveServiceSetting('use_hometax', this.checked)">
<div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -224,29 +224,60 @@
@push('scripts')
<script>
// 서비스 설정 개별 저장 (체크박스 변경 시 즉시 저장)
async function saveServiceSetting(field, value) {
const toast = document.getElementById('toast');
try {
const response = await fetch('/api/admin/barobill/settings/service', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ field: field, value: value }),
});
const result = await response.json();
if (result.success) {
showToast(result.message || '설정이 저장되었습니다.', 'success');
} else {
showToast(result.message || '설정 저장에 실패했습니다.', 'error');
// 실패 시 체크박스 원상복구
document.getElementById(field).checked = !value;
}
} catch (error) {
console.error('설정 저장 실패:', error);
showToast('설정 저장 중 오류가 발생했습니다.', 'error');
// 실패 시 체크박스 원상복구
document.getElementById(field).checked = !value;
}
}
// 토스트 표시 (전역 함수)
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
if (type === 'success') {
toast.classList.add('bg-green-600', 'text-white');
} else {
toast.classList.add('bg-red-600', 'text-white');
}
toast.classList.remove('translate-y-full', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-y-full', 'opacity-0');
}, 3000);
}
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('settings-form');
const toast = document.getElementById('toast');
const saveSpinner = document.getElementById('save-spinner');
const btnSave = document.getElementById('btn-save');
const btnReset = document.getElementById('btn-reset');
// 토스트 표시
function showToast(message, type = 'success') {
toast.textContent = message;
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
if (type === 'success') {
toast.classList.add('bg-green-600', 'text-white');
} else {
toast.classList.add('bg-red-600', 'text-white');
}
toast.classList.remove('translate-y-full', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-y-full', 'opacity-0');
}, 3000);
}
// 설정 로드
async function loadSettings() {
try {

View File

@@ -496,7 +496,9 @@ className={`grid grid-cols-12 gap-4 px-4 py-3 border-b border-gray-100 cursor-po
{/* 사용현황 */}
<div className="col-span-3 flex items-center gap-2">
{card.cardType === 'credit' && card.creditLimit > 0 ? (
{card.cardType === 'debit' ? (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs font-medium rounded">체크카드</span>
) : card.creditLimit > 0 ? (
<div className="flex-1">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500">{formatCurrency(card.currentUsage)}</span>
@@ -510,7 +512,7 @@ className={`h-1.5 rounded-full ${getUsageColor(getUsagePercent(card.currentUsage
</div>
</div>
) : (
<span className="text-xs text-gray-400">체크카드</span>
<span className="px-2 py-0.5 bg-violet-100 text-violet-700 text-xs font-medium rounded">신용카드</span>
)}
</div>

View File

@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', '법인차량 등록')
@section('title', '법인차량관리')
@push('styles')
<style>
@@ -39,47 +39,72 @@
const Search = createIcon('search');
const Download = createIcon('download');
const X = createIcon('x');
const Edit = createIcon('edit');
const Trash2 = createIcon('trash-2');
const Calendar = createIcon('calendar');
const Fuel = createIcon('fuel');
const Gauge = createIcon('gauge');
const Loader = createIcon('loader-2');
function CorporateVehiclesManagement() {
const [vehicles, setVehicles] = useState([
{ id: 1, plateNumber: '12가 3456', model: '제네시스 G80', type: '승용차', year: 2024, purchaseDate: '2024-03-15', purchasePrice: 75000000, driver: '김대표', status: 'active', mileage: 15000, insuranceExpiry: '2025-03-14', inspectionExpiry: '2026-03-14', memo: '대표이사 차량' },
{ id: 2, plateNumber: '34나 5678', model: '현대 스타렉스', type: '승합차', year: 2023, purchaseDate: '2023-06-20', purchasePrice: 45000000, driver: '박기사', status: 'active', mileage: 48000, insuranceExpiry: '2024-06-19', inspectionExpiry: '2025-06-19', memo: '직원 출퇴근용' },
{ id: 3, plateNumber: '56다 7890', model: '기아 레이', type: '승용차', year: 2022, purchaseDate: '2022-01-10', purchasePrice: 15000000, driver: '이영업', status: 'active', mileage: 62000, insuranceExpiry: '2025-01-09', inspectionExpiry: '2026-01-09', memo: '영업용' },
{ id: 4, plateNumber: '78라 1234', model: '포터2', type: '화물차', year: 2021, purchaseDate: '2021-08-05', purchasePrice: 25000000, driver: '최배송', status: 'maintenance', mileage: 95000, insuranceExpiry: '2024-08-04', inspectionExpiry: '2025-08-04', memo: '배송용' },
]);
const [vehicles, setVehicles] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterOwnership, setFilterOwnership] = useState('all');
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const [showDetail, setShowDetail] = useState(false);
const types = ['승용차', '승합차', '화물차', 'SUV'];
const ownershipTypes = [
{ value: 'corporate', label: '법인차량' },
{ value: 'rent', label: '렌트차량' },
{ value: 'lease', label: '리스차량' }
];
const getOwnershipLabel = (type) => {
const found = ownershipTypes.find(t => t.value === type);
return found ? found.label : type;
};
const getOwnershipColor = (type) => {
switch(type) {
case 'corporate': return 'bg-blue-100 text-blue-700';
case 'rent': return 'bg-purple-100 text-purple-700';
case 'lease': return 'bg-orange-100 text-orange-700';
default: return 'bg-gray-100 text-gray-700';
}
};
const initialFormState = {
plateNumber: '',
plate_number: '',
model: '',
type: '승용차',
vehicle_type: '승용차',
ownership_type: 'corporate',
year: new Date().getFullYear(),
purchaseDate: '',
purchasePrice: '',
driver: '',
status: 'active',
mileage: '',
insuranceExpiry: '',
inspectionExpiry: '',
memo: ''
memo: '',
purchase_date: '',
purchase_price: '',
contract_date: '',
rent_company: '',
rent_company_tel: '',
rent_period: '',
agreed_mileage: '',
vehicle_price: '',
residual_value: '',
deposit: '',
monthly_rent: '',
monthly_rent_tax: '',
insurance_company: '',
insurance_company_tel: ''
};
const [formData, setFormData] = useState(initialFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatCurrency = (num) => num ? Number(num).toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
@@ -87,45 +112,162 @@ function CorporateVehiclesManagement() {
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const loadVehicles = async () => {
try {
setLoading(true);
const response = await fetch('/finance/corporate-vehicles/list');
const result = await response.json();
if (result.success) {
setVehicles(result.data);
}
} catch (error) {
console.error('Failed to load vehicles:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadVehicles();
}, []);
const filteredVehicles = vehicles.filter(item => {
const matchesSearch = item.model.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.plateNumber.includes(searchTerm) ||
item.driver.toLowerCase().includes(searchTerm.toLowerCase());
item.plate_number.includes(searchTerm) ||
(item.driver && item.driver.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesType = filterType === 'all' || item.type === filterType;
return matchesSearch && matchesStatus && matchesType;
const matchesOwnership = filterOwnership === 'all' || item.ownership_type === filterOwnership;
return matchesSearch && matchesStatus && matchesOwnership;
});
const totalVehicles = vehicles.length;
const activeVehicles = vehicles.filter(v => v.status === 'active').length;
const totalValue = vehicles.reduce((sum, v) => sum + v.purchasePrice, 0);
const totalMileage = vehicles.reduce((sum, v) => sum + v.mileage, 0);
const corporateVehicles = vehicles.filter(v => v.ownership_type === 'corporate');
const totalPurchaseValue = corporateVehicles.reduce((sum, v) => sum + (v.purchase_price || 0), 0);
const rentLeaseVehicles = vehicles.filter(v => v.ownership_type === 'rent' || v.ownership_type === 'lease');
const totalMonthlyRent = rentLeaseVehicles.reduce((sum, v) => sum + (v.monthly_rent || 0) + (v.monthly_rent_tax || 0), 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.plateNumber || !formData.model) { alert('필수 항목을 입력해주세요.'); return; }
const purchasePrice = parseInt(formData.purchasePrice) || 0;
const mileage = parseInt(formData.mileage) || 0;
if (modalMode === 'add') {
setVehicles(prev => [{ id: Date.now(), ...formData, purchasePrice, mileage }, ...prev]);
} else {
setVehicles(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, purchasePrice, mileage } : item));
}
setShowModal(false); setEditingItem(null);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowDetail(false); setShowModal(true); };
const handleEdit = (item) => {
setModalMode('edit');
setEditingItem(item);
setShowDetail(false);
setFormData({
plate_number: item.plate_number || '',
model: item.model || '',
vehicle_type: item.vehicle_type || '승용차',
ownership_type: item.ownership_type || 'corporate',
year: item.year || new Date().getFullYear(),
driver: item.driver || '',
status: item.status || 'active',
mileage: item.mileage || '',
memo: item.memo || '',
purchase_date: item.purchase_date ? item.purchase_date.split('T')[0] : '',
purchase_price: item.purchase_price || '',
contract_date: item.contract_date ? item.contract_date.split('T')[0] : '',
rent_company: item.rent_company || '',
rent_company_tel: item.rent_company_tel || '',
rent_period: item.rent_period || '',
agreed_mileage: item.agreed_mileage || '',
vehicle_price: item.vehicle_price || '',
residual_value: item.residual_value || '',
deposit: item.deposit || '',
monthly_rent: item.monthly_rent || '',
monthly_rent_tax: item.monthly_rent_tax || '',
insurance_company: item.insurance_company || '',
insurance_company_tel: item.insurance_company_tel || ''
});
setShowModal(true);
};
const handleSave = async () => {
if (!formData.plate_number || !formData.model) {
alert('차량번호와 모델은 필수입니다.');
return;
}
setSaving(true);
try {
const payload = {
...formData,
mileage: parseInt(formData.mileage) || 0,
purchase_price: parseInt(formData.purchase_price) || 0,
vehicle_price: parseInt(formData.vehicle_price) || 0,
residual_value: parseInt(formData.residual_value) || 0,
deposit: parseInt(formData.deposit) || 0,
monthly_rent: parseInt(formData.monthly_rent) || 0,
monthly_rent_tax: parseInt(formData.monthly_rent_tax) || 0,
};
const url = modalMode === 'add'
? '/finance/corporate-vehicles'
: `/finance/corporate-vehicles/${editingItem.id}`;
const method = modalMode === 'add' ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
await loadVehicles();
setShowModal(false);
setEditingItem(null);
} else {
alert(result.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('Save error:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
const handleDelete = async (id) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/finance/corporate-vehicles/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
});
const result = await response.json();
if (result.success) {
await loadVehicles();
setShowModal(false);
} else {
alert(result.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('Delete error:', error);
alert('삭제 중 오류가 발생했습니다.');
}
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setVehicles(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleDownload = () => {
const rows = [['법인차량 등록'], [], ['차량번호', '모델', '종류', '연식', '취득일', '취득가', '운전자', '상태', '주행거리'],
...filteredVehicles.map(item => [item.plateNumber, item.model, item.type, item.year, item.purchaseDate, item.purchasePrice, item.driver, item.status, item.mileage])];
const rows = [['법인차량관리'], [], ['차량번호', '모델', '종류', '구분', '연식', '운전자', '취득가/월렌트료', '상태', '주행거리'],
...filteredVehicles.map(item => [
item.plate_number, item.model, item.vehicle_type, getOwnershipLabel(item.ownership_type), item.year,
item.driver,
item.ownership_type === 'corporate' ? item.purchase_price : ((item.monthly_rent || 0) + (item.monthly_rent_tax || 0)),
getStatusLabel(item.status), item.mileage
])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '법인차량목록.csv'; link.click();
};
const getStatusColor = (status) => {
const colors = { active: 'bg-emerald-100 text-emerald-700', maintenance: 'bg-amber-100 text-amber-700', disposed: 'bg-gray-100 text-gray-700' };
const colors = { active: 'bg-emerald-100 text-emerald-700', maintenance: 'bg-amber-100 text-amber-700', disposed: 'bg-gray-100 text-gray-500' };
return colors[status] || 'bg-gray-100 text-gray-700';
};
const getStatusLabel = (status) => {
@@ -135,11 +277,12 @@ function CorporateVehiclesManagement() {
return (
<div className="bg-gray-50 min-h-screen">
{/* 헤더 */}
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-100 rounded-xl"><Car className="w-6 h-6 text-slate-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">법인차량 등록</h1><p className="text-sm text-gray-500">Corporate Vehicles</p></div>
<div className="p-2 bg-blue-100 rounded-xl"><Car className="w-6 h-6 text-blue-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">법인차량관리</h1><p className="text-sm text-gray-500">Corporate Vehicles</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
@@ -148,36 +291,46 @@ function CorporateVehiclesManagement() {
</div>
</header>
{/* 요약 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 차량</span><Car className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalVehicles}</p>
<p className="text-xs text-gray-400 mt-1">운행중 {activeVehicles}</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 bg-slate-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-slate-700"> 취득가</span></div>
<p className="text-2xl font-bold text-slate-600">{formatCurrency(totalValue)}</p>
<div className="bg-white rounded-xl border border-blue-200 p-6 bg-blue-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">법인차량 취득가</span></div>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(totalPurchaseValue)}</p>
<p className="text-xs text-blue-400 mt-1">{corporateVehicles.length}</p>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6 bg-purple-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-purple-700"> 렌트/리스료</span></div>
<p className="text-2xl font-bold text-purple-600">{formatCurrency(totalMonthlyRent)}</p>
<p className="text-xs text-purple-400 mt-1">{rentLeaseVehicles.length}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 주행거리</span><Gauge className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalMileage)}km</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">평균 주행</span></div>
<p className="text-2xl font-bold text-gray-900">{totalVehicles > 0 ? formatCurrency(Math.round(totalMileage / totalVehicles)) : 0}km</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(vehicles.reduce((sum, v) => sum + (v.mileage || 0), 0))}km</p>
</div>
</div>
{/* 검색 및 필터 */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2 relative">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="차량번호, 모델, 운전자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-slate-500" />
<input type="text" placeholder="차량번호, 모델, 운전자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" />
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 종류</option>{types.map(t => <option key={t} value={t}>{t}</option>)}</select>
<div className="flex gap-1">
<div className="flex gap-2">
{ownershipTypes.map(t => (
<button key={t.value} onClick={() => setFilterOwnership(filterOwnership === t.value ? 'all' : t.value)} className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${filterOwnership === t.value ? getOwnershipColor(t.value) : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{t.label}
</button>
))}
</div>
<div className="flex gap-2">
{['all', 'active', 'maintenance'].map(status => (
<button key={status} onClick={() => setFilterStatus(status)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterStatus === status ? (status === 'active' ? 'bg-green-600 text-white' : status === 'maintenance' ? 'bg-yellow-500 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-700'}`}>
<button key={status} onClick={() => setFilterStatus(status)} className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${filterStatus === status ? (status === 'active' ? 'bg-emerald-600 text-white' : status === 'maintenance' ? 'bg-amber-500 text-white' : 'bg-blue-600 text-white') : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{status === 'all' ? '전체' : getStatusLabel(status)}
</button>
))}
@@ -185,33 +338,82 @@ function CorporateVehiclesManagement() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredVehicles.map(item => (
<div key={item.id} onClick={() => handleEdit(item)} className="bg-white rounded-xl border border-gray-200 p-6 cursor-pointer hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-lg"><Car className="w-5 h-5 text-slate-600" /></div>
<div>
<h3 className="font-bold text-gray-900">{item.model}</h3>
<p className="text-sm text-gray-500">{item.plateNumber}</p>
{/* 차량 목록 - 한줄 형태 */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{/* 헤더 */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-600">
<div className="col-span-3">차량</div>
<div className="col-span-2">차량번호</div>
<div className="col-span-2">구분</div>
<div className="col-span-2">운전자</div>
<div className="col-span-2 text-right">취득가/월렌트료</div>
<div className="col-span-1 text-center">상태</div>
</div>
{/* 차량 목록 */}
{loading ? (
<div className="text-center py-12 text-gray-400">
<div className="animate-spin w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p>차량 목록을 불러오는 ...</p>
</div>
) : filteredVehicles.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<Car className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>등록된 차량이 없습니다.</p>
<button onClick={handleAdd} className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">차량 등록하기</button>
</div>
) : (
filteredVehicles.map(item => (
<div key={item.id} onClick={() => handleEdit(item)} className={`grid grid-cols-12 gap-4 px-4 py-3 border-b border-gray-100 cursor-pointer transition-colors hover:bg-blue-50 ${item.status !== 'active' ? 'opacity-60 bg-gray-50' : ''}`}>
{/* 차량 (모델 + 종류/연식) */}
<div className="col-span-3 flex items-center gap-2">
<div className={`p-1.5 rounded-lg ${item.status === 'active' ? 'bg-blue-100' : 'bg-gray-100'}`}>
<Car className={`w-4 h-4 ${item.status === 'active' ? 'text-blue-600' : 'text-gray-400'}`} />
</div>
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">{item.model}</p>
<p className="text-xs text-gray-500">{item.vehicle_type} · {item.year}</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>{getStatusLabel(item.status)}</span>
{/* 차량번호 */}
<div className="col-span-2 flex items-center">
<p className="font-mono text-sm text-gray-700">{item.plate_number}</p>
</div>
{/* 구분 */}
<div className="col-span-2 flex items-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getOwnershipColor(item.ownership_type)}`}>
{getOwnershipLabel(item.ownership_type)}
</span>
</div>
{/* 운전자 */}
<div className="col-span-2 flex items-center">
<p className="text-sm text-gray-900">{item.driver || '-'}</p>
</div>
{/* 취득가/월렌트료 */}
<div className="col-span-2 flex items-center justify-end">
{item.ownership_type === 'corporate' ? (
<p className="text-sm font-medium text-gray-900">{formatCurrency(item.purchase_price)}</p>
) : (
<p className="text-sm font-medium text-purple-600">{formatCurrency((item.monthly_rent || 0) + (item.monthly_rent_tax || 0))}/</p>
)}
</div>
{/* 상태 */}
<div className="col-span-1 flex items-center justify-center">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>
{getStatusLabel(item.status)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><p className="text-gray-500">종류</p><p className="font-medium">{item.type}</p></div>
<div><p className="text-gray-500">연식</p><p className="font-medium">{item.year}</p></div>
<div><p className="text-gray-500">운전자</p><p className="font-medium">{item.driver}</p></div>
<div><p className="text-gray-500">주행거리</p><p className="font-medium">{formatCurrency(item.mileage)}km</p></div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center">
<span className="text-sm text-gray-500">취득가</span>
<span className="font-bold text-slate-600">{formatCurrency(item.purchasePrice)}</span>
</div>
</div>
))}
))
)}
</div>
{/* 등록/수정 모달 */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
@@ -220,33 +422,88 @@ function CorporateVehiclesManagement() {
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량번호 *</label><input type="text" value={formData.plateNumber} onChange={(e) => setFormData(prev => ({ ...prev, plateNumber: e.target.value }))} placeholder="12가 3456" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">종류</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{types.map(t => <option key={t} value={t}>{t}</option>)}</select></div>
{/* 기본 정보 */}
<div className="grid grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량번호 *</label><input type="text" value={formData.plate_number} onChange={(e) => setFormData(prev => ({ ...prev, plate_number: e.target.value }))} placeholder="12가 3456" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">종류</label><select value={formData.vehicle_type} onChange={(e) => setFormData(prev => ({ ...prev, vehicle_type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{types.map(t => <option key={t} value={t}>{t}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">구분 *</label><select value={formData.ownership_type} onChange={(e) => setFormData(prev => ({ ...prev, ownership_type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{ownershipTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">모델 *</label><input type="text" value={formData.model} onChange={(e) => setFormData(prev => ({ ...prev, model: e.target.value }))} placeholder="차량 모델명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">연식</label><input type="number" value={formData.year} onChange={(e) => setFormData(prev => ({ ...prev, year: parseInt(e.target.value) }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">취득일</label><input type="date" value={formData.purchaseDate} onChange={(e) => setFormData(prev => ({ ...prev, purchaseDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">취득가</label><input type="text" value={formatInputCurrency(formData.purchasePrice)} onChange={(e) => setFormData(prev => ({ ...prev, purchasePrice: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주행거리(km)</label><input type="text" value={formatInputCurrency(formData.mileage)} onChange={(e) => setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
{formData.ownership_type === 'corporate' && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">취득일</label><input type="date" value={formData.purchase_date} onChange={(e) => setFormData(prev => ({ ...prev, purchase_date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
)}
{(formData.ownership_type === 'rent' || formData.ownership_type === 'lease') && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">계약일자</label><input type="date" value={formData.contract_date} onChange={(e) => setFormData(prev => ({ ...prev, contract_date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
)}
</div>
{/* 렌트/리스 전용 필드 */}
{(formData.ownership_type === 'rent' || formData.ownership_type === 'lease') && (
<div className="border-t border-gray-200 pt-4 mt-2">
{/* 핵심 필드 - 항상 표시 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">{formData.ownership_type === 'rent' ? '렌트회사명' : '리스회사명'}</label><input type="text" value={formData.rent_company} onChange={(e) => setFormData(prev => ({ ...prev, rent_company: e.target.value }))} placeholder="회사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">{formData.ownership_type === 'rent' ? '렌트' : '리스'}기간</label><input type="text" value={formData.rent_period} onChange={(e) => setFormData(prev => ({ ...prev, rent_period: e.target.value }))} placeholder="예: 36개월" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1"> {formData.ownership_type === 'rent' ? '렌트료' : '리스료'} (공급가)</label><input type="text" value={formatInputCurrency(formData.monthly_rent)} onChange={(e) => setFormData(prev => ({ ...prev, monthly_rent: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">세액</label><input type="text" value={formatInputCurrency(formData.monthly_rent_tax)} onChange={(e) => setFormData(prev => ({ ...prev, monthly_rent_tax: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
{/* 상세 정보 토글 */}
<button type="button" onClick={() => setShowDetail(!showDetail)} className="mt-4 text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1">
{showDetail ? '▼ 상세 정보 접기' : '▶ 상세 정보 보기'}
</button>
{/* 상세 필드 - 토글로 표시 */}
{showDetail && (
<div className="mt-4 pt-4 border-t border-gray-100 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">회사 연락처</label><input type="text" value={formData.rent_company_tel} onChange={(e) => setFormData(prev => ({ ...prev, rent_company_tel: e.target.value }))} placeholder="연락처" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">약정운행거리</label><input type="text" value={formData.agreed_mileage} onChange={(e) => setFormData(prev => ({ ...prev, agreed_mileage: e.target.value }))} placeholder="km" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량가격</label><input type="text" value={formatInputCurrency(formData.vehicle_price)} onChange={(e) => setFormData(prev => ({ ...prev, vehicle_price: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">추정잔존가액</label><input type="text" value={formatInputCurrency(formData.residual_value)} onChange={(e) => setFormData(prev => ({ ...prev, residual_value: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">보증금</label><input type="text" value={formatInputCurrency(formData.deposit)} onChange={(e) => setFormData(prev => ({ ...prev, deposit: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">보험사명</label><input type="text" value={formData.insurance_company} onChange={(e) => setFormData(prev => ({ ...prev, insurance_company: e.target.value }))} placeholder="보험사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">보험사 연락처</label><input type="text" value={formData.insurance_company_tel} onChange={(e) => setFormData(prev => ({ ...prev, insurance_company_tel: e.target.value }))} placeholder="연락처" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
)}
</div>
)}
{/* 법인차량 전용 필드 */}
{formData.ownership_type === 'corporate' && (
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">취득가</label><input type="text" value={formatInputCurrency(formData.purchase_price)} onChange={(e) => setFormData(prev => ({ ...prev, purchase_price: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">주행거리(km)</label><input type="text" value={formatInputCurrency(formData.mileage)} onChange={(e) => setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
)}
{/* 렌트/리스 차량 주행거리 */}
{(formData.ownership_type === 'rent' || formData.ownership_type === 'lease') && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">현재 주행거리(km)</label><input type="text" value={formatInputCurrency(formData.mileage)} onChange={(e) => setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
)}
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">운전자</label><input type="text" value={formData.driver} onChange={(e) => setFormData(prev => ({ ...prev, driver: e.target.value }))} placeholder="운전자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="active">운행중</option><option value="maintenance">정비중</option><option value="disposed">처분</option></select></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">보험 만료일</label><input type="date" value={formData.insuranceExpiry} onChange={(e) => setFormData(prev => ({ ...prev, insuranceExpiry: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">검사 만료일</label><input type="date" value={formData.inspectionExpiry} onChange={(e) => setFormData(prev => ({ ...prev, inspectionExpiry: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg" disabled={saving}>삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" disabled={saving}>취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center justify-center gap-2" disabled={saving}>
{saving && <Loader className="w-4 h-4 animate-spin" />}
{modalMode === 'add' ? '등록' : '저장'}
</button>
</div>
</div>
</div>

View File

@@ -74,12 +74,15 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
</div>
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 mb-1">
<label for="amount_display" class="block text-sm font-medium text-gray-700 mb-1">
금액 <span class="text-red-500">*</span>
</label>
<input type="number" name="amount" id="amount" required
value="0" min="0" step="1"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<input type="text" id="amount_display" required
value="0" inputmode="numeric"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-right"
oninput="formatAmountInput(this)"
onblur="formatAmountInput(this)">
<input type="hidden" name="amount" id="amount" value="0">
</div>
</div>
@@ -93,10 +96,10 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
</div>
{{-- 관련 계좌 --}}
{{-- 출금 계좌 --}}
<div>
<label for="related_bank_account_id" class="block text-sm font-medium text-gray-700 mb-1">
관련 계좌
출금 계좌
</label>
<select name="related_bank_account_id" id="related_bank_account_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@@ -165,6 +168,30 @@ class="px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg trans
@push('scripts')
<script>
// 금액 입력 포맷팅 (콤마 추가, 소수점 제거)
function formatAmountInput(input) {
// 숫자만 추출
let value = input.value.replace(/[^\d]/g, '');
// 빈 값이면 0
if (!value) value = '0';
// 숫자로 변환 후 콤마 포맷
const numValue = parseInt(value, 10);
input.value = numValue.toLocaleString('ko-KR');
// hidden input에 실제 값 저장
document.getElementById('amount').value = numValue;
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
const amountDisplay = document.getElementById('amount_display');
if (amountDisplay) {
formatAmountInput(amountDisplay);
}
});
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {

View File

@@ -75,12 +75,15 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
</div>
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 mb-1">
<label for="amount_display" class="block text-sm font-medium text-gray-700 mb-1">
금액 <span class="text-red-500">*</span>
</label>
<input type="number" name="amount" id="amount" required
value="{{ $schedule->amount }}" min="0" step="1"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<input type="text" id="amount_display" required
value="{{ number_format((int)$schedule->amount) }}" inputmode="numeric"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-right"
oninput="formatAmountInput(this)"
onblur="formatAmountInput(this)">
<input type="hidden" name="amount" id="amount" value="{{ (int)$schedule->amount }}">
</div>
</div>
@@ -94,10 +97,10 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
</div>
{{-- 관련 계좌 --}}
{{-- 출금 계좌 --}}
<div>
<label for="related_bank_account_id" class="block text-sm font-medium text-gray-700 mb-1">
관련 계좌
출금 계좌
</label>
<select name="related_bank_account_id" id="related_bank_account_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@@ -185,6 +188,22 @@ class="px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg trans
@push('scripts')
<script>
// 금액 입력 포맷팅 (콤마 추가, 소수점 제거)
function formatAmountInput(input) {
// 숫자만 추출
let value = input.value.replace(/[^\d]/g, '');
// 빈 값이면 0
if (!value) value = '0';
// 숫자로 변환 후 콤마 포맷
const numValue = parseInt(value, 10);
input.value = numValue.toLocaleString('ko-KR');
// hidden input에 실제 값 저장
document.getElementById('amount').value = numValue;
}
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {

View File

@@ -71,6 +71,7 @@ function PartnersManagement() {
type: 'vendor',
category: '기타',
bizNo: '',
bankAccount: '',
contact: '',
email: '',
manager: '',
@@ -209,7 +210,10 @@ function PartnersManagement() {
<div><label className="block text-sm font-medium text-gray-700 mb-1">유형</label><select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{types.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">분류</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">계좌번호</label><input type="text" value={formData.bankAccount} onChange={(e) => setFormData(prev => ({ ...prev, bankAccount: e.target.value }))} placeholder="000-000000-00000" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">연락처</label><input type="text" value={formData.contact} onChange={(e) => setFormData(prev => ({ ...prev, contact: e.target.value }))} placeholder="02-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">이메일</label><input type="email" value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="email@company.com" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>

View File

@@ -80,7 +80,8 @@ function PayablesManagement() {
paidAmount: 0,
status: 'unpaid',
category: '사무용품',
description: ''
description: '',
memo: ''
};
const [formData, setFormData] = useState(initialFormState);
@@ -286,6 +287,7 @@ function PayablesManagement() {
<div><label className="block text-sm font-medium text-gray-700 mb-1">청구금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">적요</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="적요 입력" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">메모</label><textarea value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="부분지급, 연체 사유 등 메모" rows="2" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
{modalMode === 'edit' && (
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="unpaid">미지급</option><option value="partial">부분지급</option><option value="paid">지급완료</option><option value="overdue">연체</option></select></div>
)}

View File

@@ -293,10 +293,16 @@ function RefundsManagement() {
<div><label className="block text-sm font-medium text-gray-700 mb-1">사유</label><select value={formData.reason} onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{reasons.map(r => <option key={r} value={r}>{r}</option>)}</select></div>
</div>
{modalMode === 'edit' && (
<>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">환불금액</label><input type="text" value={formatInputCurrency(formData.refundAmount)} onChange={(e) => setFormData(prev => ({ ...prev, refundAmount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">상태</label><select value={formData.status} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg"><option value="pending">대기</option><option value="approved">승인</option><option value="completed">완료</option><option value="rejected">거절</option></select></div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모 {formData.status === 'rejected' && <span className="text-red-500">(거절 사유)</span>}</label>
<textarea value={formData.note} onChange={(e) => setFormData(prev => ({ ...prev, note: e.target.value }))} placeholder={formData.status === 'rejected' ? '거절 사유를 입력하세요' : '처리 관련 메모'} rows="2" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
</div>
</>
)}
</div>
<div className="flex gap-3 mt-6">

View File

@@ -1,6 +1,6 @@
@extends('layouts.app')
@section('title', '차량 유지비')
@section('title', '법인차량 관리')
@push('styles')
<style>
@@ -46,13 +46,24 @@
const Car = createIcon('car');
function VehicleMaintenanceManagement() {
// 탭 상태
const [activeTab, setActiveTab] = useState('maintenance');
// 차량 목록 (동적 관리)
const [vehicles, setVehicles] = useState([
{ id: 1, plateNumber: '12가 3456', model: '제네시스 G80', ownershipType: 'corporate', purchaseDate: '2023-05-15', purchasePrice: '', currentMileage: 15000 },
{ id: 2, plateNumber: '34나 5678', model: '현대 스타렉스', ownershipType: 'corporate', purchaseDate: '2022-03-10', purchasePrice: '', currentMileage: 48000 },
{ id: 3, plateNumber: '56다 7890', model: '기아 레이', ownershipType: 'rent', contractDate: '2024-01-01', rentCompany: '롯데렌터카', rentCompanyTel: '1588-1234', rentPeriod: '36개월', agreedMileage: '30000', vehiclePrice: '25000000', residualValue: '15000000', deposit: '3000000', monthlyRent: '450000', monthlyRentTax: '45000', insuranceCompany: '삼성화재', insuranceCompanyTel: '1588-5114', currentMileage: 62000 },
{ id: 4, plateNumber: '78라 1234', model: '포터2', ownershipType: 'lease', contractDate: '2023-06-01', rentCompany: '현대캐피탈', rentCompanyTel: '1588-1234', rentPeriod: '48개월', agreedMileage: '60000', vehiclePrice: '32000000', residualValue: '12000000', deposit: '5000000', monthlyRent: '520000', monthlyRentTax: '52000', insuranceCompany: 'DB손해보험', insuranceCompanyTel: '1588-0100', currentMileage: 95000 },
]);
const [maintenances, setMaintenances] = useState([
{ id: 1, date: '2026-01-20', vehicle: '12가 3456 (제네시스 G80)', category: '주유', description: '휘발유', amount: 120000, mileage: 15000, vendor: 'GS칼텍스', memo: '' },
{ id: 2, date: '2026-01-18', vehicle: '34나 5678 (현대 스타렉스)', category: '주유', description: '경유', amount: 95000, mileage: 48000, vendor: 'SK에너지', memo: '' },
{ id: 3, date: '2026-01-15', vehicle: '78라 1234 (포터2)', category: '정비', description: '엔진오일 교환', amount: 150000, mileage: 95000, vendor: '현대오토', memo: '정기 점검' },
{ id: 4, date: '2026-01-10', vehicle: '56다 7890 (기아 레이)', category: '보험', description: '자동차보험 연납', amount: 850000, mileage: 62000, vendor: '삼성화재', memo: '2025.01~2026.01' },
{ id: 5, date: '2026-01-05', vehicle: '12가 3456 (제네시스 G80)', category: '세차', description: '세차 및 실내크리닝', amount: 50000, mileage: 14500, vendor: '카와시', memo: '' },
{ id: 6, date: '2025-12-20', vehicle: '34나 5678 (현대 스타렉스)', category: '정비', description: '타이어 교체', amount: 480000, mileage: 45000, vendor: '한국타이어', memo: '4개 교체' },
{ id: 1, date: '2026-01-20', vehicleId: 1, category: '주유', description: '휘발유', amount: 120000, mileage: 15000, vendor: 'GS칼텍스', memo: '' },
{ id: 2, date: '2026-01-18', vehicleId: 2, category: '주유', description: '경유', amount: 95000, mileage: 48000, vendor: 'SK에너지', memo: '' },
{ id: 3, date: '2026-01-15', vehicleId: 4, category: '정비', description: '엔진오일 교환', amount: 150000, mileage: 95000, vendor: '현대오토', memo: '정기 점검' },
{ id: 4, date: '2026-01-10', vehicleId: 3, category: '보험', description: '자동차보험 연납', amount: 850000, mileage: 62000, vendor: '삼성화재', memo: '2025.01~2026.01' },
{ id: 5, date: '2026-01-05', vehicleId: 1, category: '세차', description: '세차 및 실내크리닝', amount: 50000, mileage: 14500, vendor: '카와시', memo: '' },
{ id: 6, date: '2025-12-20', vehicleId: 2, category: '정비', description: '타이어 교체', amount: 480000, mileage: 45000, vendor: '한국타이어', memo: '4개 교체' },
]);
const [searchTerm, setSearchTerm] = useState('');
@@ -67,12 +78,41 @@ function VehicleMaintenanceManagement() {
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
// 차량 등록 모달
const [showVehicleModal, setShowVehicleModal] = useState(false);
const [vehicleModalMode, setVehicleModalMode] = useState('add');
const [editingVehicle, setEditingVehicle] = useState(null);
const categories = ['주유', '정비', '보험', '세차', '주차', '통행료', '검사', '기타'];
const vehicles = ['12가 3456 (제네시스 G80)', '34나 5678 (현대 스타렉스)', '56다 7890 (기아 레이)', '78라 1234 (포터2)'];
const ownershipTypes = [
{ value: 'corporate', label: '법인차량' },
{ value: 'rent', label: '렌트차량' },
{ value: 'lease', label: '리스차량' }
];
// 차량 표시용 헬퍼
const getVehicleDisplay = (vehicleId) => {
const v = vehicles.find(v => v.id === vehicleId);
return v ? `${v.plateNumber} (${v.model})` : '-';
};
const getOwnershipLabel = (type) => {
const found = ownershipTypes.find(t => t.value === type);
return found ? found.label : type;
};
const getOwnershipColor = (type) => {
switch(type) {
case 'corporate': return 'bg-blue-100 text-blue-700';
case 'rent': return 'bg-purple-100 text-purple-700';
case 'lease': return 'bg-orange-100 text-orange-700';
default: return 'bg-gray-100 text-gray-700';
}
};
const initialFormState = {
date: new Date().toISOString().split('T')[0],
vehicle: vehicles[0],
vehicleId: vehicles[0]?.id || '',
category: '주유',
description: '',
amount: '',
@@ -82,6 +122,30 @@ function VehicleMaintenanceManagement() {
};
const [formData, setFormData] = useState(initialFormState);
// 차량 등록 초기 상태
const initialVehicleFormState = {
plateNumber: '',
model: '',
ownershipType: 'corporate',
purchaseDate: '',
purchasePrice: '',
currentMileage: '',
// 렌트/리스 전용 필드
contractDate: '',
rentCompany: '',
rentCompanyTel: '',
rentPeriod: '',
agreedMileage: '',
vehiclePrice: '',
residualValue: '',
deposit: '',
monthlyRent: '',
monthlyRentTax: '',
insuranceCompany: '',
insuranceCompanyTel: ''
};
const [vehicleFormData, setVehicleFormData] = useState(initialVehicleFormState);
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
@@ -94,7 +158,7 @@ function VehicleMaintenanceManagement() {
const matchesSearch = item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.vendor.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
const matchesVehicle = filterVehicle === 'all' || item.vehicle === filterVehicle;
const matchesVehicle = filterVehicle === 'all' || item.vehicleId === parseInt(filterVehicle);
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
return matchesSearch && matchesCategory && matchesVehicle && matchesDate;
});
@@ -104,24 +168,45 @@ function VehicleMaintenanceManagement() {
const maintenanceAmount = filteredMaintenances.filter(m => m.category === '정비').reduce((sum, item) => sum + item.amount, 0);
const otherAmount = totalAmount - fuelAmount - maintenanceAmount;
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
// 유지비 등록/수정
const handleAdd = () => { setModalMode('add'); setFormData({...initialFormState, vehicleId: vehicles[0]?.id || ''}); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.description || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
const amount = parseInt(formData.amount) || 0;
const mileage = parseInt(formData.mileage) || 0;
if (modalMode === 'add') {
setMaintenances(prev => [{ id: Date.now(), ...formData, amount, mileage }, ...prev]);
setMaintenances(prev => [{ id: Date.now(), ...formData, vehicleId: parseInt(formData.vehicleId), amount, mileage }, ...prev]);
} else {
setMaintenances(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount, mileage } : item));
setMaintenances(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, vehicleId: parseInt(formData.vehicleId), amount, mileage } : item));
}
setShowModal(false); setEditingItem(null);
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setMaintenances(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
// 차량 등록/수정
const handleAddVehicle = () => { setVehicleModalMode('add'); setVehicleFormData(initialVehicleFormState); setShowVehicleModal(true); };
const handleEditVehicle = (vehicle) => { setVehicleModalMode('edit'); setEditingVehicle(vehicle); setVehicleFormData({ ...vehicle }); setShowVehicleModal(true); };
const handleSaveVehicle = () => {
if (!vehicleFormData.plateNumber || !vehicleFormData.model) { alert('차량번호와 모델명은 필수입니다.'); return; }
if (vehicleModalMode === 'add') {
setVehicles(prev => [{ id: Date.now(), ...vehicleFormData }, ...prev]);
} else {
setVehicles(prev => prev.map(v => v.id === editingVehicle.id ? { ...v, ...vehicleFormData } : v));
}
setShowVehicleModal(false); setEditingVehicle(null);
};
const handleDeleteVehicle = (id) => {
if (confirm('차량을 삭제하시겠습니까? 관련 유지비 기록도 함께 삭제됩니다.')) {
setVehicles(prev => prev.filter(v => v.id !== id));
setMaintenances(prev => prev.filter(m => m.vehicleId !== id));
setShowVehicleModal(false);
}
};
const handleDownload = () => {
const rows = [['차량 유지비', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '차량', '구분', '내용', '금액', '주행거리', '업체'],
...filteredMaintenances.map(item => [item.date, item.vehicle, item.category, item.description, item.amount, item.mileage, item.vendor])];
...filteredMaintenances.map(item => [item.date, getVehicleDisplay(item.vehicleId), item.category, item.description, item.amount, item.mileage, item.vendor])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `차량유지비_${dateRange.start}_${dateRange.end}.csv`; link.click();
@@ -137,86 +222,172 @@ function VehicleMaintenanceManagement() {
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-amber-100 rounded-xl"><Wrench className="w-6 h-6 text-amber-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">차량 유지비</h1><p className="text-sm text-gray-500">Vehicle Maintenance</p></div>
<div className="p-2 bg-amber-100 rounded-xl"><Car className="w-6 h-6 text-amber-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">법인차량 관리</h1><p className="text-sm text-gray-500">Corporate Vehicle Management</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">비용 등록</span></button>
{activeTab === 'maintenance' ? (
<>
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handleAdd} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">비용 등록</span></button>
</>
) : (
<button onClick={handleAddVehicle} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"><Plus className="w-4 h-4" /><span className="text-sm font-medium">차량 등록</span></button>
)}
</div>
</div>
{/* 탭 */}
<div className="px-6 flex gap-1">
<button onClick={() => setActiveTab('maintenance')} className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'maintenance' ? 'bg-gray-50 text-amber-600 border-b-2 border-amber-500' : 'text-gray-500 hover:text-gray-700'}`}>
유지비 관리
</button>
<button onClick={() => setActiveTab('vehicles')} className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'vehicles' ? 'bg-gray-50 text-amber-600 border-b-2 border-amber-500' : 'text-gray-500 hover:text-gray-700'}`}>
차량 등록
</button>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 유지비</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{filteredMaintenances.length}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">주유비</span><Fuel className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(fuelAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">정비비</span><Wrench className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(maintenanceAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">기타</span></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(otherAmount)}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="내용, 업체 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500" />
{/* 유지비 관리 탭 */}
{activeTab === 'maintenance' && (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 유지비</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{filteredMaintenances.length}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">주유비</span><Fuel className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(fuelAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">정비비</span><Wrench className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(maintenanceAmount)}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">기타</span></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(otherAmount)}</p>
</div>
</div>
<select value={filterVehicle} onChange={(e) => setFilterVehicle(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 차량</option>{vehicles.map(v => <option key={v} value={v}>{v}</option>)}</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 구분</option>{categories.map(c => <option key={c} value={c}>{c}</option>)}</select>
<div className="flex items-center gap-2 md:col-span-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="내용, 업체 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500" />
</div>
<select value={filterVehicle} onChange={(e) => setFilterVehicle(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg">
<option value="all">전체 차량</option>
{vehicles.map(v => <option key={v.id} value={v.id}>{v.plateNumber} ({v.model})</option>)}
</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 구분</option>{categories.map(c => <option key={c} value={c}>{c}</option>)}</select>
<div className="flex items-center gap-2 md:col-span-2">
<input type="date" value={dateRange.start} onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
<span>~</span>
<input type="date" value={dateRange.end} onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))} className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">차량</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">내용</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">주행거리</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredMaintenances.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredMaintenances.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.vehicle.split(' (')[0]}</p><p className="text-xs text-gray-400">{item.vehicle.split(' (')[1]?.replace(')', '')}</p></td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getCategoryColor(item.category)}`}>{item.category}</span></td>
<td className="px-6 py-4"><p className="text-sm text-gray-900">{item.description}</p>{item.vendor && <p className="text-xs text-gray-400">{item.vendor}</p>}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-amber-600">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm text-right text-gray-600">{formatCurrency(item.mileage)}km</td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">날짜</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">차량</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">내용</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">주행거리</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredMaintenances.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredMaintenances.map(item => {
const vehicle = vehicles.find(v => v.id === item.vehicleId);
return (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
<td className="px-6 py-4 text-sm text-gray-600">{item.date}</td>
<td className="px-6 py-4">
<p className="text-sm font-medium text-gray-900">{vehicle?.plateNumber || '-'}</p>
<p className="text-xs text-gray-400">{vehicle?.model || ''}</p>
</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getCategoryColor(item.category)}`}>{item.category}</span></td>
<td className="px-6 py-4"><p className="text-sm text-gray-900">{item.description}</p>{item.vendor && <p className="text-xs text-gray-400">{item.vendor}</p>}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-amber-600">{formatCurrency(item.amount)}</td>
<td className="px-6 py-4 text-sm text-right text-gray-600">{formatCurrency(item.mileage)}km</td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEdit(item)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
{/* 차량 등록 탭 */}
{activeTab === 'vehicles' && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl border border-blue-200 p-6 bg-blue-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">법인차량</span><Car className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{vehicles.filter(v => v.ownershipType === 'corporate').length}</p>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6 bg-purple-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-purple-700">렌트차량</span><Car className="w-5 h-5 text-purple-500" /></div>
<p className="text-2xl font-bold text-purple-600">{vehicles.filter(v => v.ownershipType === 'rent').length}</p>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6 bg-orange-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-orange-700">리스차량</span><Car className="w-5 h-5 text-orange-500" /></div>
<p className="text-2xl font-bold text-orange-600">{vehicles.filter(v => v.ownershipType === 'lease').length}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">차량번호</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">모델명</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">구분</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">계약/구입일</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">렌트/리스사</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600"> 렌트료</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600">현재 주행거리</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vehicles.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">등록된 차량이 없습니다.</td></tr>
) : vehicles.map(vehicle => (
<tr key={vehicle.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEditVehicle(vehicle)}>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{vehicle.plateNumber}</td>
<td className="px-6 py-4 text-sm text-gray-600">{vehicle.model}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded text-xs font-medium ${getOwnershipColor(vehicle.ownershipType)}`}>{getOwnershipLabel(vehicle.ownershipType)}</span></td>
<td className="px-6 py-4 text-sm text-gray-600">{vehicle.contractDate || vehicle.purchaseDate || '-'}</td>
<td className="px-6 py-4 text-sm text-gray-600">{vehicle.rentCompany || '-'}</td>
<td className="px-6 py-4 text-sm font-bold text-right text-purple-600">{vehicle.monthlyRent ? `${formatCurrency(parseInt(vehicle.monthlyRent))}원` : '-'}</td>
<td className="px-6 py-4 text-sm text-right text-gray-600">{formatCurrency(vehicle.currentMileage)}km</td>
<td className="px-6 py-4 text-center" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleEditVehicle(vehicle)} className="p-1 text-gray-400 hover:text-blue-500"><Edit className="w-4 h-4" /></button>
<button onClick={() => handleDeleteVehicle(vehicle.id)} className="p-1 text-gray-400 hover:text-rose-500"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* 유지비 등록/수정 모달 */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
@@ -229,7 +400,11 @@ function VehicleMaintenanceManagement() {
<div><label className="block text-sm font-medium text-gray-700 mb-1">날짜 *</label><input type="date" value={formData.date} onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">구분</label><select value={formData.category} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{categories.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량</label><select value={formData.vehicle} onChange={(e) => setFormData(prev => ({ ...prev, vehicle: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">{vehicles.map(v => <option key={v} value={v}>{v}</option>)}</select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량</label>
<select value={formData.vehicleId} onChange={(e) => setFormData(prev => ({ ...prev, vehicleId: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
{vehicles.map(v => <option key={v.id} value={v.id}>{v.plateNumber} ({v.model})</option>)}
</select>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">내용 *</label><input type="text" value={formData.description} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder="내용" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label><input type="text" value={formatInputCurrency(formData.amount)} onChange={(e) => setFormData(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
@@ -241,13 +416,88 @@ function VehicleMaintenanceManagement() {
</div>
</div>
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
{/* 차량 등록/수정 모달 */}
{showVehicleModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{vehicleModalMode === 'add' ? '차량 등록' : '차량 수정'}</h3>
<button onClick={() => setShowVehicleModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
{/* 기본 정보 */}
<div className="grid grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량번호 *</label><input type="text" value={vehicleFormData.plateNumber} onChange={(e) => setVehicleFormData(prev => ({ ...prev, plateNumber: e.target.value }))} placeholder="12가 3456" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">모델명 *</label><input type="text" value={vehicleFormData.model} onChange={(e) => setVehicleFormData(prev => ({ ...prev, model: e.target.value }))} placeholder="제네시스 G80" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">구분 *</label>
<select value={vehicleFormData.ownershipType} onChange={(e) => setVehicleFormData(prev => ({ ...prev, ownershipType: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
{ownershipTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">현재 주행거리 (km)</label><input type="text" value={formatInputCurrency(vehicleFormData.currentMileage)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, currentMileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
{vehicleFormData.ownershipType === 'corporate' && (
<>
<div><label className="block text-sm font-medium text-gray-700 mb-1">구입일</label><input type="date" value={vehicleFormData.purchaseDate} onChange={(e) => setVehicleFormData(prev => ({ ...prev, purchaseDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</>
)}
</div>
{/* 렌트/리스 전용 필드 */}
{(vehicleFormData.ownershipType === 'rent' || vehicleFormData.ownershipType === 'lease') && (
<>
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-semibold text-gray-900 mb-3">{vehicleFormData.ownershipType === 'rent' ? '렌트' : '리스'} 계약 정보</h4>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">계약일자</label><input type="date" value={vehicleFormData.contractDate} onChange={(e) => setVehicleFormData(prev => ({ ...prev, contractDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">{vehicleFormData.ownershipType === 'rent' ? '렌트' : '리스'}회사명</label><input type="text" value={vehicleFormData.rentCompany} onChange={(e) => setVehicleFormData(prev => ({ ...prev, rentCompany: e.target.value }))} placeholder="렌터카/리스사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div><label className="block text-sm font-medium text-gray-700 mb-1">{vehicleFormData.ownershipType === 'rent' ? '렌트' : '리스'}회사 연락처</label><input type="text" value={vehicleFormData.rentCompanyTel} onChange={(e) => setVehicleFormData(prev => ({ ...prev, rentCompanyTel: e.target.value }))} placeholder="02-1234-5678" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">{vehicleFormData.ownershipType === 'rent' ? '렌트' : '리스'}기간</label><input type="text" value={vehicleFormData.rentPeriod} onChange={(e) => setVehicleFormData(prev => ({ ...prev, rentPeriod: e.target.value }))} placeholder="36개월" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div><label className="block text-sm font-medium text-gray-700 mb-1">약정운행거리 (km)</label><input type="text" value={formatInputCurrency(vehicleFormData.agreedMileage)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, agreedMileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">차량가격 ()</label><input type="text" value={formatInputCurrency(vehicleFormData.vehiclePrice)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, vehiclePrice: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div><label className="block text-sm font-medium text-gray-700 mb-1">추정잔존가액 ()</label><input type="text" value={formatInputCurrency(vehicleFormData.residualValue)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, residualValue: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">보증금 ()</label><input type="text" value={formatInputCurrency(vehicleFormData.deposit)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, deposit: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div><label className="block text-sm font-medium text-gray-700 mb-1"> {vehicleFormData.ownershipType === 'rent' ? '렌트' : '리스'} - 공급가액 ()</label><input type="text" value={formatInputCurrency(vehicleFormData.monthlyRent)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, monthlyRent: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1"> {vehicleFormData.ownershipType === 'rent' ? '렌트' : '리스'} - 세액 ()</label><input type="text" value={formatInputCurrency(vehicleFormData.monthlyRentTax)} onChange={(e) => setVehicleFormData(prev => ({ ...prev, monthlyRentTax: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
</div>
</div>
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-semibold text-gray-900 mb-3">보험 정보</h4>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">보험사명</label><input type="text" value={vehicleFormData.insuranceCompany} onChange={(e) => setVehicleFormData(prev => ({ ...prev, insuranceCompany: e.target.value }))} placeholder="삼성화재" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">보험사 연락처</label><input type="text" value={vehicleFormData.insuranceCompanyTel} onChange={(e) => setVehicleFormData(prev => ({ ...prev, insuranceCompanyTel: e.target.value }))} placeholder="1588-0000" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
</div>
</>
)}
</div>
<div className="flex gap-3 mt-6">
{vehicleModalMode === 'edit' && <button onClick={() => handleDeleteVehicle(editingVehicle.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowVehicleModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSaveVehicle} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{vehicleModalMode === 'add' ? '등록' : '저장'}</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -164,5 +164,90 @@ function closeDetailModal() {
closeDetailModal();
}
});
// 수당 날짜 저장 (date input에서 호출)
function saveCommissionDate(prospectId, field, date) {
const input = document.querySelector(`input[data-prospect-id="${prospectId}"][data-field="${field}"]`);
// 날짜가 비어있으면 삭제 처리
if (!date) {
clearCommissionDate(prospectId, field, input);
return;
}
fetch(`/sales/admin-prospects/${prospectId}/commission-date`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({ field: field, date: date })
})
.then(response => response.json())
.then(result => {
if (result.success && input) {
// 입력 스타일 업데이트
updateInputStyle(input, field, true);
} else {
alert(result.message || '날짜 저장에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('날짜 저장 중 오류가 발생했습니다.');
});
}
// 수당 날짜 삭제
function clearCommissionDate(prospectId, field, input) {
fetch(`/sales/admin-prospects/${prospectId}/commission-date`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({ field: field })
})
.then(response => response.json())
.then(result => {
if (result.success && input) {
// 입력 스타일 업데이트
updateInputStyle(input, field, false);
} else if (!result.success) {
alert(result.message || '날짜 삭제에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('날짜 삭제 중 오류가 발생했습니다.');
});
}
// 입력 스타일 업데이트
function updateInputStyle(input, field, hasValue) {
// 기본 클래스
input.className = 'commission-date-input w-24 text-xs px-1 py-1 border border-gray-200 rounded text-center focus:outline-none focus:ring-1';
if (hasValue) {
if (field === 'first_payment_at' || field === 'second_payment_at') {
input.className += ' text-emerald-600 font-medium bg-emerald-50 focus:ring-emerald-500 focus:border-emerald-500';
} else if (field === 'first_partner_paid_at' || field === 'second_partner_paid_at') {
input.className += ' text-blue-600 font-medium bg-blue-50 focus:ring-blue-500 focus:border-blue-500';
} else if (field === 'manager_paid_at') {
input.className += ' text-purple-600 font-medium bg-purple-50 focus:ring-purple-500 focus:border-purple-500';
}
} else {
input.className += ' text-gray-400 bg-white';
if (field === 'first_payment_at' || field === 'second_payment_at') {
input.className += ' focus:ring-emerald-500 focus:border-emerald-500';
} else if (field === 'first_partner_paid_at' || field === 'second_partner_paid_at') {
input.className += ' focus:ring-blue-500 focus:border-blue-500';
} else if (field === 'manager_paid_at') {
input.className += ' focus:ring-purple-500 focus:border-purple-500';
}
}
}
</script>
@endpush

View File

@@ -84,6 +84,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">담당 매니저</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">영업 진행률</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">매니저 진행률</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">1 납입</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">1 수당</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">2 납입</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">2 수당</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">매니저 수당</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">개발 상태</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">등록일</th>
@@ -127,6 +132,51 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<span class="text-xs text-gray-600">{{ $prospect->manager_progress }}%</span>
</div>
</td>
{{-- 1 납입완료 --}}
<td class="px-1 py-2 whitespace-nowrap text-center">
<input type="date"
class="w-28 h-7 text-xs px-1 border-2 border-gray-300 rounded cursor-pointer hover:border-emerald-400 focus:outline-none focus:border-emerald-500 {{ $prospect->commission?->first_payment_at ? 'text-emerald-600 font-medium bg-emerald-50 border-emerald-400' : 'text-gray-500 bg-gray-50' }}"
value="{{ $prospect->commission?->first_payment_at?->format('Y-m-d') }}"
data-prospect-id="{{ $prospect->id }}"
data-field="first_payment_at"
onchange="saveCommissionDate({{ $prospect->id }}, 'first_payment_at', this.value)">
</td>
{{-- 1 파트너 수당지급 --}}
<td class="px-1 py-2 whitespace-nowrap text-center">
<input type="date"
class="w-28 h-7 text-xs px-1 border-2 border-gray-300 rounded cursor-pointer hover:border-blue-400 focus:outline-none focus:border-blue-500 {{ $prospect->commission?->first_partner_paid_at ? 'text-blue-600 font-medium bg-blue-50 border-blue-400' : 'text-gray-500 bg-gray-50' }}"
value="{{ $prospect->commission?->first_partner_paid_at?->format('Y-m-d') }}"
data-prospect-id="{{ $prospect->id }}"
data-field="first_partner_paid_at"
onchange="saveCommissionDate({{ $prospect->id }}, 'first_partner_paid_at', this.value)">
</td>
{{-- 2 납입완료 --}}
<td class="px-1 py-2 whitespace-nowrap text-center">
<input type="date"
class="w-28 h-7 text-xs px-1 border-2 border-gray-300 rounded cursor-pointer hover:border-emerald-400 focus:outline-none focus:border-emerald-500 {{ $prospect->commission?->second_payment_at ? 'text-emerald-600 font-medium bg-emerald-50 border-emerald-400' : 'text-gray-500 bg-gray-50' }}"
value="{{ $prospect->commission?->second_payment_at?->format('Y-m-d') }}"
data-prospect-id="{{ $prospect->id }}"
data-field="second_payment_at"
onchange="saveCommissionDate({{ $prospect->id }}, 'second_payment_at', this.value)">
</td>
{{-- 2 파트너 수당지급 --}}
<td class="px-1 py-2 whitespace-nowrap text-center">
<input type="date"
class="w-28 h-7 text-xs px-1 border-2 border-gray-300 rounded cursor-pointer hover:border-blue-400 focus:outline-none focus:border-blue-500 {{ $prospect->commission?->second_partner_paid_at ? 'text-blue-600 font-medium bg-blue-50 border-blue-400' : 'text-gray-500 bg-gray-50' }}"
value="{{ $prospect->commission?->second_partner_paid_at?->format('Y-m-d') }}"
data-prospect-id="{{ $prospect->id }}"
data-field="second_partner_paid_at"
onchange="saveCommissionDate({{ $prospect->id }}, 'second_partner_paid_at', this.value)">
</td>
{{-- 매니저 수당지급 --}}
<td class="px-1 py-2 whitespace-nowrap text-center">
<input type="date"
class="w-28 h-7 text-xs px-1 border-2 border-gray-300 rounded cursor-pointer hover:border-purple-400 focus:outline-none focus:border-purple-500 {{ $prospect->commission?->manager_paid_at ? 'text-purple-600 font-medium bg-purple-50 border-purple-400' : 'text-gray-500 bg-gray-50' }}"
value="{{ $prospect->commission?->manager_paid_at?->format('Y-m-d') }}"
data-prospect-id="{{ $prospect->id }}"
data-field="manager_paid_at"
onchange="saveCommissionDate({{ $prospect->id }}, 'manager_paid_at', this.value)">
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<select
onchange="updateHqStatus({{ $prospect->id }}, this.value)"
@@ -155,7 +205,7 @@ class="text-xs font-medium rounded-lg px-2 py-1 border cursor-pointer
</tr>
@empty
<tr>
<td colspan="9" class="px-6 py-12 text-center text-gray-500">
<td colspan="14" class="px-6 py-12 text-center text-gray-500">
등록된 고객이 없습니다.
</td>
</tr>

View File

@@ -1,4 +1,4 @@
{{-- 매니저 드롭다운 컴포넌트 (테넌트 또는 가망고객용) --}}
{{-- 매니저 검색 컴포넌트 (테넌트 또는 가망고객용) --}}
@once
<style>[x-cloak] { display: none !important; }</style>
@endonce
@@ -16,26 +16,83 @@
$assignedManager = $management?->manager;
$isSelf = !$assignedManager || $assignedManager->id === auth()->id();
$managerName = $assignedManager?->name ?? '본인';
$managersJson = $allManagers->map(fn($m) => ['id' => $m->id, 'name' => $m->name, 'email' => $m->email])->values()->toJson();
$currentManagerJson = json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'is_self' => $isSelf] : null);
// API 엔드포인트 결정
$apiEndpoint = $isProspect ? '/sales/prospects/' : '/sales/tenants/';
$currentManagerJson = json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'email' => $assignedManager->email ?? '', 'is_self' => $isSelf] : null);
@endphp
<div x-data="{
entityId: {{ $entityId }},
isProspect: {{ $isProspect ? 'true' : 'false' }},
isOpen: false,
managers: {{ $managersJson }},
searchQuery: '',
searchResults: [],
isLoading: false,
currentManager: {{ $currentManagerJson }},
searchTimeout: null,
allManagers: [],
hasLoadedAll: false,
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.$nextTick(() => {
this.$refs.searchInput?.focus();
});
this.searchQuery = '';
// 처음 열릴 때 전체 목록 로드
if (!this.hasLoadedAll) {
this.loadAllManagers();
} else {
this.searchResults = this.allManagers;
}
}
},
close() {
this.isOpen = false;
this.searchQuery = '';
},
selectManager(managerId, managerName) {
loadAllManagers() {
this.isLoading = true;
fetch('/sales/managers/search?q=', {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
})
.then(response => response.json())
.then(result => {
this.isLoading = false;
if (result.success) {
this.allManagers = result.managers;
this.searchResults = result.managers;
this.hasLoadedAll = true;
}
})
.catch(error => {
this.isLoading = false;
console.error('매니저 목록 로드 실패:', error);
});
},
search() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 200);
},
performSearch() {
if (this.searchQuery.length < 1) {
// 검색어 없으면 전체 목록 표시
this.searchResults = this.allManagers;
return;
}
// 로컬에서 필터링 (이미 로드된 목록에서)
const query = this.searchQuery.toLowerCase();
this.searchResults = this.allManagers.filter(m =>
m.name.toLowerCase().includes(query) ||
(m.email && m.email.toLowerCase().includes(query))
);
},
selectManager(managerId, managerName, managerEmail) {
const endpoint = this.isProspect ? '/sales/prospects/' : '/sales/tenants/';
fetch(endpoint + this.entityId + '/assign-manager', {
method: 'POST',
@@ -52,6 +109,7 @@
this.currentManager = {
id: result.manager.id,
name: result.manager.name,
email: managerEmail || '',
is_self: managerId === 0 || result.manager.id === {{ auth()->id() }},
};
} else {
@@ -92,59 +150,93 @@ class="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium tr
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute z-50 mt-1 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
class="absolute z-50 mt-1 w-72 bg-white rounded-lg shadow-lg border border-gray-200"
x-on:click.stop
>
{{-- 본인 옵션 --}}
<button
type="button"
x-on:click="selectManager(0, '{{ auth()->user()->name }}')"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors"
:class="(currentManager?.is_self || !currentManager) && 'bg-blue-50'">
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
{{-- 검색 입력 --}}
<div class="p-3 border-b border-gray-100">
<div class="relative">
<input
x-ref="searchInput"
type="text"
x-model="searchQuery"
x-on:input="search()"
placeholder="매니저 이름 또는 이메일 검색..."
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900">본인</div>
<div class="text-xs text-gray-500">{{ auth()->user()->name }}</div>
</div>
<svg x-show="currentManager?.is_self || !currentManager" class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
{{-- 구분선 (다른 매니저가 있을 때만) --}}
<template x-if="managers.length > 0">
<div class="border-t border-gray-100 my-1"></div>
</template>
{{-- 다른 매니저 목록 --}}
<template x-for="manager in managers" :key="manager.id">
<div class="max-h-64 overflow-y-auto">
{{-- 본인 옵션 (항상 표시) --}}
<button
type="button"
x-on:click="selectManager(manager.id, manager.name)"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors"
:class="currentManager?.id === manager.id && !currentManager?.is_self && 'bg-blue-50'">
<div class="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-gray-600" x-text="manager.name.charAt(0)"></span>
x-on:click="selectManager(0, '{{ auth()->user()->name }}', '')"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 transition-colors border-b border-gray-100"
:class="(currentManager?.is_self || !currentManager) && 'bg-blue-50'">
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900" x-text="manager.name"></div>
<div class="text-xs text-gray-500" x-text="manager.email"></div>
<div class="font-medium text-gray-900">본인</div>
<div class="text-xs text-gray-500">{{ auth()->user()->name }} ({{ auth()->user()->email }})</div>
</div>
<svg x-show="currentManager?.id === manager.id && !currentManager?.is_self" class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg x-show="currentManager?.is_self || !currentManager" class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</template>
{{-- 매니저가 없을 --}}
<template x-if="managers.length === 0">
<div class="px-4 py-3 text-sm text-gray-500 text-center border-t border-gray-100">
등록된 매니저가 없습니다.
{{-- 로딩 표시 --}}
<div x-show="isLoading" class="px-4 py-6 text-center">
<svg class="animate-spin h-5 w-5 text-blue-600 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="text-xs text-gray-500 mt-2">검색 ...</div>
</div>
</template>
{{-- 검색 결과 (매니저 목록) --}}
<template x-if="!isLoading && searchResults.length > 0">
<div>
<div class="px-3 py-1.5 text-xs text-gray-500 bg-gray-50">
<span x-text="searchQuery.length > 0 ? '검색 결과' : '상담매니저 목록'"></span>
<span class="text-gray-400" x-text="'(' + searchResults.length + '명)'"></span>
</div>
<template x-for="manager in searchResults" :key="manager.id">
<button
type="button"
x-on:click="selectManager(manager.id, manager.name, manager.email)"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 transition-colors"
:class="currentManager?.id === manager.id && !currentManager?.is_self && 'bg-blue-50'">
<div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-green-700" x-text="manager.name.charAt(0)"></span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate" x-text="manager.name"></div>
<div class="text-xs text-gray-500 truncate" x-text="manager.email"></div>
</div>
<svg x-show="currentManager?.id === manager.id && !currentManager?.is_self" class="w-4 h-4 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</template>
</div>
</template>
{{-- 검색 결과 없음 --}}
<template x-if="!isLoading && searchResults.length === 0 && hasLoadedAll">
<div class="px-4 py-6 text-center">
<svg class="w-10 h-10 text-blue-200 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<div class="text-sm font-medium text-gray-700" x-text="searchQuery.length > 0 ? '검색 결과가 없습니다' : '상담매니저를 검색하세요'"></div>
<div class="text-xs text-gray-400 mt-1">이름 또는 이메일로 검색할 있습니다.</div>
</div>
</template>
</div>
</div>
</div>

View File

@@ -9,10 +9,11 @@
swap: 'innerHTML'
});
},
openProspectScenarioModal(prospectId, type) {
openProspectScenarioModal(prospectId, type, readonly = false) {
const readonlyParam = readonly ? '?readonly=1' : '';
const url = type === 'sales'
? `/sales/scenarios/prospect/${prospectId}/sales`
: `/sales/scenarios/prospect/${prospectId}/manager`;
? `/sales/scenarios/prospect/${prospectId}/sales${readonlyParam}`
: `/sales/scenarios/prospect/${prospectId}/manager${readonlyParam}`;
htmx.ajax('GET', url, {
target: '#scenario-modal-container',
swap: 'innerHTML'
@@ -329,6 +330,29 @@ class="inline-flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium bg
<span>{{ $prospect->business_number ?? '-' }}</span>
</div>
</div>
{{-- 영업/매니저 기록 조회 버튼 (읽기 전용) --}}
<div class="flex-shrink-0 flex items-center gap-1">
<button
x-on:click="openProspectScenarioModal({{ $prospect->id }}, 'sales', true)"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors"
title="영업 기록 조회">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
영업
</button>
<button
x-on:click="openProspectScenarioModal({{ $prospect->id }}, 'manager', true)"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
title="매니저 기록 조회">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
매니저
</button>
</div>
</div>
<!-- 진행 현황 (완료) -->

View File

@@ -80,13 +80,11 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
$roleColor = match($userRole->role->name ?? '') {
'sales' => 'bg-blue-100 text-blue-800',
'manager' => 'bg-purple-100 text-purple-800',
'recruiter' => 'bg-green-100 text-green-800',
default => 'bg-gray-100 text-gray-800',
};
$roleLabel = match($userRole->role->name ?? '') {
'sales' => '영업',
'manager' => '매니저',
'recruiter' => '유치담당',
'sales' => '영업파트너',
'manager' => '상담매니저',
default => $userRole->role->name ?? '-',
};
@endphp
@@ -177,13 +175,11 @@ class="px-2 py-1 bg-gray-400 hover:bg-gray-500 text-white text-xs font-medium ro
$roleColor = match($userRole->role->name ?? '') {
'sales' => 'bg-blue-100 text-blue-800',
'manager' => 'bg-purple-100 text-purple-800',
'recruiter' => 'bg-green-100 text-green-800',
default => 'bg-gray-100 text-gray-800',
};
$roleLabel = match($userRole->role->name ?? '') {
'sales' => '영업',
'manager' => '매니저',
'recruiter' => '유치담당',
'sales' => '영업파트너',
'manager' => '상담매니저',
default => $userRole->role->name ?? '-',
};
@endphp

View File

@@ -220,11 +220,27 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">파일</label>
<div class="doc-drop-zone" data-index="0">
<input type="file" name="documents[0][file]" class="hidden" accept="image/*,.pdf,.doc,.docx">
<input type="file" name="documents[0][file]" class="hidden doc-file-input" accept="image/*,.pdf,.doc,.docx">
<input type="file" class="hidden doc-camera-input" accept="image/*" capture="environment">
<svg class="doc-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-xs text-gray-500 mt-1">클릭 또는 드래그하여 업로드</p>
<div class="doc-buttons flex gap-2 mt-2">
<button type="button" class="doc-file-btn inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
파일 선택
</button>
<button type="button" class="doc-camera-btn inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-blue-600 border border-blue-600 rounded-md hover:bg-blue-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
카메라 촬영
</button>
</div>
</div>
</div>
<div class="w-40 flex-shrink-0">
@@ -267,10 +283,32 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
// 드래그 앤 드롭 초기화
function initDropZone(dropZone) {
const fileInput = dropZone.querySelector('input[type="file"]');
const fileInput = dropZone.querySelector('.doc-file-input');
const cameraInput = dropZone.querySelector('.doc-camera-input');
const fileBtn = dropZone.querySelector('.doc-file-btn');
const cameraBtn = dropZone.querySelector('.doc-camera-btn');
// 파일 선택 버튼 클릭
if (fileBtn) {
fileBtn.addEventListener('click', (e) => {
e.stopPropagation();
fileInput.click();
});
}
// 카메라 촬영 버튼 클릭
if (cameraBtn) {
cameraBtn.addEventListener('click', (e) => {
e.stopPropagation();
cameraInput.click();
});
}
// 드롭존 영역 클릭 (버튼 외 영역)
dropZone.addEventListener('click', (e) => {
if (!e.target.closest('.doc-preview-remove')) {
if (!e.target.closest('.doc-preview-remove') &&
!e.target.closest('.doc-file-btn') &&
!e.target.closest('.doc-camera-btn')) {
fileInput.click();
}
});
@@ -292,17 +330,29 @@ function initDropZone(dropZone) {
}
});
// 파일 input change 이벤트
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) {
handleFile(dropZone, e.target.files[0]);
}
});
// 카메라 input change 이벤트 (카메라로 촬영한 이미지 처리)
if (cameraInput) {
cameraInput.addEventListener('change', (e) => {
if (e.target.files.length) {
handleFile(dropZone, e.target.files[0]);
// 카메라 input 초기화 (같은 이미지 다시 촬영 가능하도록)
cameraInput.value = '';
}
});
}
}
function handleFile(dropZone, file) {
const fileInput = dropZone.querySelector('input[type="file"]');
const fileInput = dropZone.querySelector('.doc-file-input');
// DataTransfer로 파일 설정
// DataTransfer로 파일 설정 (메인 file input에 저장)
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
@@ -314,11 +364,13 @@ function handleFile(dropZone, file) {
const existingPreview = dropZone.querySelector('.doc-preview');
if (existingPreview) existingPreview.remove();
// 아이콘 텍스트 숨기기
// 아이콘, 텍스트, 버튼 숨기기
const icon = dropZone.querySelector('.doc-drop-zone-icon');
const text = dropZone.querySelector('p');
const buttons = dropZone.querySelector('.doc-buttons');
if (icon) icon.style.display = 'none';
if (text) text.style.display = 'none';
if (buttons) buttons.style.display = 'none';
// 미리보기 생성
const preview = document.createElement('div');
@@ -365,10 +417,11 @@ function handleFile(dropZone, file) {
function removeFile(btn) {
const dropZone = btn.closest('.doc-drop-zone');
const fileInput = dropZone.querySelector('input[type="file"]');
const fileInput = dropZone.querySelector('.doc-file-input');
const preview = dropZone.querySelector('.doc-preview');
const icon = dropZone.querySelector('.doc-drop-zone-icon');
const text = dropZone.querySelector('p');
const buttons = dropZone.querySelector('.doc-buttons');
// 파일 input 초기화
fileInput.value = '';
@@ -376,9 +429,10 @@ function removeFile(btn) {
// 미리보기 제거
if (preview) preview.remove();
// 아이콘 텍스트 다시 표시
// 아이콘, 텍스트, 버튼 다시 표시
if (icon) icon.style.display = '';
if (text) text.style.display = '';
if (buttons) buttons.style.display = '';
dropZone.classList.remove('has-file');
}
@@ -413,11 +467,27 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">파일</label>
<div class="doc-drop-zone" data-index="${documentIndex}">
<input type="file" name="documents[${documentIndex}][file]" class="hidden" accept="image/*,.pdf,.doc,.docx">
<input type="file" name="documents[${documentIndex}][file]" class="hidden doc-file-input" accept="image/*,.pdf,.doc,.docx">
<input type="file" class="hidden doc-camera-input" accept="image/*" capture="environment">
<svg class="doc-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-xs text-gray-500 mt-1">클릭 또는 드래그하여 업로드</p>
<div class="doc-buttons flex gap-2 mt-2">
<button type="button" class="doc-file-btn inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
파일 선택
</button>
<button type="button" class="doc-camera-btn inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-blue-600 border border-blue-600 rounded-md hover:bg-blue-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
카메라 촬영
</button>
</div>
</div>
</div>
<div class="w-40 flex-shrink-0">

View File

@@ -20,7 +20,7 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-4 flex-shrink-0">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-4 flex-shrink-0">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-800">{{ number_format($stats['total']) }}</div>
@@ -34,17 +34,13 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
<div class="text-2xl font-bold text-emerald-800">{{ number_format($stats['approved']) }}</div>
</div>
<div class="bg-blue-50 rounded-lg shadow-sm p-4">
<div class="text-sm text-blue-600">영업</div>
<div class="text-sm text-blue-600">영업파트너</div>
<div class="text-2xl font-bold text-blue-800">{{ number_format($stats['sales']) }}</div>
</div>
<div class="bg-purple-50 rounded-lg shadow-sm p-4">
<div class="text-sm text-purple-600">매니저</div>
<div class="text-sm text-purple-600">상담매니저</div>
<div class="text-2xl font-bold text-purple-800">{{ number_format($stats['manager']) }}</div>
</div>
<div class="bg-green-50 rounded-lg shadow-sm p-4">
<div class="text-sm text-green-600">유치담당</div>
<div class="text-2xl font-bold text-green-800">{{ number_format($stats['recruiter']) }}</div>
</div>
</div>
<!-- 필터 영역 ( ) -->
@@ -87,9 +83,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<div class="w-32 flex-shrink-0">
<select name="role" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
<option value="">전체 역할</option>
<option value="sales" {{ request('role') === 'sales' ? 'selected' : '' }}>영업</option>
<option value="manager" {{ request('role') === 'manager' ? 'selected' : '' }}>매니저</option>
<option value="recruiter" {{ request('role') === 'recruiter' ? 'selected' : '' }}>유치담당</option>
<option value="sales" {{ request('role') === 'sales' ? 'selected' : '' }}>영업파트너</option>
<option value="manager" {{ request('role') === 'manager' ? 'selected' : '' }}>상담매니저</option>
</select>
</div>
@@ -135,13 +130,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc
$roleColor = match($userRole->role->name ?? '') {
'sales' => 'bg-blue-100 text-blue-800',
'manager' => 'bg-purple-100 text-purple-800',
'recruiter' => 'bg-green-100 text-green-800',
default => 'bg-gray-100 text-gray-800',
};
$roleLabel = match($userRole->role->name ?? '') {
'sales' => '영업',
'manager' => '매니저',
'recruiter' => '유치담당',
'sales' => '영업파트너',
'manager' => '상담매니저',
default => $userRole->role->name ?? '-',
};
@endphp

View File

@@ -151,11 +151,10 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
<h3 class="text-sm font-semibold text-gray-800 mb-3">역할 관리</h3>
@php
$currentRoles = $partner->userRoles->pluck('role.name')->toArray();
$roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
$roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
$roleColors = [
'sales' => 'bg-blue-100 text-blue-800 border-blue-200',
'manager' => 'bg-purple-100 text-purple-800 border-purple-200',
'recruiter' => 'bg-green-100 text-green-800 border-green-200',
];
@endphp
<div class="flex flex-wrap gap-2 mb-3">
@@ -180,7 +179,7 @@ class="ml-1 text-gray-400 hover:text-red-500">
@endforelse
</div>
<div class="flex flex-wrap gap-2">
@foreach(['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'] as $roleName => $label)
@foreach(['sales' => '영업파트너', 'manager' => '상담매니저'] as $roleName => $label)
@if(!in_array($roleName, $currentRoles))
<form action="{{ route('sales.managers.assign-role', $partner->id) }}" method="POST" class="inline">
@csrf
@@ -229,13 +228,11 @@ class="text-xs text-blue-600 hover:underline">다운로드</a>
$roleColor = match($userRole->role->name ?? '') {
'sales' => 'bg-blue-100 text-blue-800',
'manager' => 'bg-purple-100 text-purple-800',
'recruiter' => 'bg-green-100 text-green-800',
default => 'bg-gray-100 text-gray-800',
};
$roleLabel = match($userRole->role->name ?? '') {
'sales' => '영업',
'manager' => '매니저',
'recruiter' => '유치담당',
'sales' => '영업파트너',
'manager' => '상담매니저',
default => $userRole->role->name ?? '-',
};
@endphp

View File

@@ -21,13 +21,11 @@
$roleColor = match($userRole->role->name ?? '') {
'sales' => 'bg-blue-100 text-blue-800',
'manager' => 'bg-purple-100 text-purple-800',
'recruiter' => 'bg-green-100 text-green-800',
default => 'bg-gray-100 text-gray-800',
};
$roleLabel = match($userRole->role->name ?? '') {
'sales' => '영업',
'manager' => '매니저',
'recruiter' => '유치담당',
'sales' => '영업파트너',
'manager' => '상담매니저',
default => $userRole->role->name ?? '-',
};
@endphp
@@ -169,11 +167,10 @@ class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition te
<div class="flex flex-wrap gap-2">
@php
$currentRoles = $partner->userRoles->pluck('role.name')->toArray();
$roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'];
$roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
$roleColors = [
'sales' => 'bg-blue-100 text-blue-800 border-blue-200',
'manager' => 'bg-purple-100 text-purple-800 border-purple-200',
'recruiter' => 'bg-green-100 text-green-800 border-green-200',
];
@endphp
@forelse($currentRoles as $roleName)
@@ -202,7 +199,7 @@ class="ml-1 text-gray-400 hover:text-red-500">
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-700 mb-2">역할 부여</h3>
<div class="flex flex-wrap gap-2">
@foreach(['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'] as $roleName => $label)
@foreach(['sales' => '영업파트너', 'manager' => '상담매니저'] as $roleName => $label)
@if(!in_array($roleName, $currentRoles))
<form action="{{ route('sales.managers.assign-role', $partner->id) }}" method="POST" class="inline">
@csrf
@@ -217,8 +214,8 @@ class="px-3 py-1 text-sm border border-gray-300 rounded-full hover:bg-gray-50 tr
</div>
</div>
<!-- 역할 위임 (manager 또는 recruiter 역할이 있고 위임 가능한 하위 파트너가 있을 ) -->
@if((in_array('manager', $currentRoles) || in_array('recruiter', $currentRoles)) && $delegationCandidates->isNotEmpty())
<!-- 역할 위임 (manager 역할이 있고 위임 가능한 하위 파트너가 있을 ) -->
@if(in_array('manager', $currentRoles) && $delegationCandidates->isNotEmpty())
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-medium text-gray-700 mb-3">역할 위임</h3>
<p class="text-xs text-gray-500 mb-3">보유 중인 역할을 하위 파트너에게 위임할 있습니다. 위임하면 해당 역할이 제거됩니다.</p>
@@ -230,10 +227,7 @@ class="px-3 py-1 text-sm border border-gray-300 rounded-full hover:bg-gray-50 tr
<label class="block text-sm text-gray-600 mb-1">위임할 역할</label>
<select name="role_name" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
@if(in_array('manager', $currentRoles))
<option value="manager">매니저</option>
@endif
@if(in_array('recruiter', $currentRoles))
<option value="recruiter">유치담당</option>
<option value="manager">상담매니저</option>
@endif
</select>
</div>
@@ -322,13 +316,11 @@ class="text-sm text-blue-600 hover:underline">다운로드</a>
$roleColor = match($userRole->role->name ?? '') {
'sales' => 'bg-blue-100 text-blue-800',
'manager' => 'bg-purple-100 text-purple-800',
'recruiter' => 'bg-green-100 text-green-800',
default => 'bg-gray-100 text-gray-800',
};
$roleLabel = match($userRole->role->name ?? '') {
'sales' => '영업',
'manager' => '매니저',
'recruiter' => '유치담당',
'sales' => '영업파트너',
'manager' => '상담매니저',
default => $userRole->role->name ?? '-',
};
@endphp

View File

@@ -202,12 +202,15 @@ class="border-t border-gray-100">
$routeName = $isProspectMode
? 'sales.scenarios.prospect.' . $scenarioType
: 'sales.scenarios.' . $scenarioType;
// readonly 파라미터
$readonlyParam = $isReadonly ? '&readonly=1' : '';
@endphp
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
{{-- 이전 단계 버튼 --}}
@if($currentStepId > 1)
<button type="button"
hx-get="{{ route($routeName, $entityId) }}?step={{ $prevStepId }}"
hx-get="{{ route($routeName, $entityId) }}?step={{ $prevStepId }}{{ $readonlyParam }}"
hx-target="#scenario-step-content"
hx-swap="innerHTML"
x-on:click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $prevStepId }} }))"
@@ -229,11 +232,11 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
완료
{{ $isReadonly ? '닫기' : '완료' }}
</button>
@else
<button type="button"
hx-get="{{ route($routeName, $entityId) }}?step={{ $nextStepId }}"
hx-get="{{ route($routeName, $entityId) }}?step={{ $nextStepId }}{{ $readonlyParam }}"
hx-target="#scenario-step-content"
hx-swap="innerHTML"
x-on:click="window.dispatchEvent(new CustomEvent('step-changed', { detail: {{ $nextStepId }} }))"

View File

@@ -117,6 +117,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<button type="button" onclick="openProspectShowModal({{ $prospect->id }})" class="text-blue-600 hover:text-blue-900 mr-3">상세</button>
@if(!$prospect->isConverted())
<button type="button" onclick="openProspectEditModal({{ $prospect->id }})" class="text-indigo-600 hover:text-indigo-900 mr-3">수정</button>
@if(auth()->user()->isAdmin())
<form action="{{ route('sales.prospects.destroy', $prospect->id) }}" method="POST" class="inline"
onsubmit="return confirm('정말 삭제하시겠습니까?')">
@csrf
@@ -124,6 +125,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<button type="submit" class="text-red-600 hover:text-red-900">삭제</button>
</form>
@endif
@endif
</td>
</tr>
@empty

View File

@@ -108,6 +108,7 @@
Route::prefix('barobill/settings')->name('barobill.settings.')->group(function () {
Route::get('/', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSettingController::class, 'show'])->name('show');
Route::post('/', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSettingController::class, 'store'])->name('store');
Route::post('/service', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSettingController::class, 'updateService'])->name('update-service');
Route::get('/check/{service}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSettingController::class, 'checkService'])->name('check');
});
@@ -154,6 +155,12 @@
Route::post('/{id}/cash-charge-url', [\App\Http\Controllers\Api\Admin\Barobill\BarobillMemberController::class, 'getCashChargeUrl'])->name('cash-charge-url');
Route::get('/{id}/certificate-status', [\App\Http\Controllers\Api\Admin\Barobill\BarobillMemberController::class, 'getCertificateStatus'])->name('certificate-status');
Route::get('/{id}/balance', [\App\Http\Controllers\Api\Admin\Barobill\BarobillMemberController::class, 'getBalance'])->name('balance');
// ==========================================
// 서버 모드 관리 (회원사별)
// ==========================================
Route::get('/{id}/server-mode', [\App\Http\Controllers\Api\Admin\Barobill\BarobillMemberController::class, 'getServerMode'])->name('server-mode.get');
Route::post('/{id}/server-mode', [\App\Http\Controllers\Api\Admin\Barobill\BarobillMemberController::class, 'updateServerMode'])->name('server-mode.update');
});
// 바로빌 사용량조회 API

View File

@@ -810,13 +810,11 @@
})->name('subscription');
// 차량관리
Route::get('/corporate-vehicles', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.corporate-vehicles'));
}
return view('finance.corporate-vehicles');
})->name('corporate-vehicles');
Route::get('/corporate-vehicles', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'index'])->name('corporate-vehicles');
Route::get('/corporate-vehicles/list', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'list'])->name('corporate-vehicles.list');
Route::post('/corporate-vehicles', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'store'])->name('corporate-vehicles.store');
Route::put('/corporate-vehicles/{id}', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'update'])->name('corporate-vehicles.update');
Route::delete('/corporate-vehicles/{id}', [\App\Http\Controllers\Finance\CorporateVehicleController::class, 'destroy'])->name('corporate-vehicles.destroy');
Route::get('/vehicle-maintenance', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.vehicle-maintenance'));
@@ -908,6 +906,10 @@
->name('approvals.detail');
});
// 매니저 검색 (리소스 라우트보다 먼저 정의해야 함!)
Route::get('managers/list', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'getManagers'])->name('managers.list');
Route::get('managers/search', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'searchManagers'])->name('managers.search');
// 영업 담당자 관리
Route::resource('managers', \App\Http\Controllers\Sales\SalesManagerController::class);
Route::get('managers/{id}/modal-show', [\App\Http\Controllers\Sales\SalesManagerController::class, 'modalShow'])->name('managers.modal-show');
@@ -933,6 +935,8 @@
Route::get('admin-prospects/refresh', [\App\Http\Controllers\Sales\AdminProspectController::class, 'refresh'])->name('admin-prospects.refresh');
Route::get('admin-prospects/{id}/modal-show', [\App\Http\Controllers\Sales\AdminProspectController::class, 'modalShow'])->name('admin-prospects.modal-show');
Route::post('admin-prospects/{id}/hq-status', [\App\Http\Controllers\Sales\AdminProspectController::class, 'updateHqStatus'])->name('admin-prospects.update-hq-status');
Route::post('admin-prospects/{id}/commission-date', [\App\Http\Controllers\Sales\AdminProspectController::class, 'updateCommissionDate'])->name('admin-prospects.update-commission-date');
Route::delete('admin-prospects/{id}/commission-date', [\App\Http\Controllers\Sales\AdminProspectController::class, 'clearCommissionDate'])->name('admin-prospects.clear-commission-date');
// 영업 시나리오 관리
Route::prefix('scenarios')->name('scenarios.')->group(function () {
@@ -975,8 +979,7 @@
Route::post('/tenants/{tenant}/assign-manager', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'assignManager'])->name('tenants.assign-manager');
Route::post('/prospects/{prospect}/assign-manager', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'assignProspectManager'])->name('prospects.assign-manager');
// 매니저 목록 조회 (드롭다운용)
Route::get('/managers/list', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'getManagers'])->name('managers.list');
// 매니저 목록/검색은 리소스 라우트 앞에 정의됨 (912줄 위치)
// 상품관리 (HQ 전용)
Route::prefix('products')->name('products.')->group(function () {