Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 이용 여부 확인 (다른 메뉴에서 참조용)
|
||||
*/
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 인보이스 조회
|
||||
|
||||
@@ -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' => '매출/매입 탭을 클릭하면 데이터가 조회되고 수집 시간이 기록됩니다.'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
166
app/Http/Controllers/Finance/CorporateVehicleController.php
Normal file
166
app/Http/Controllers/Finance/CorporateVehicleController.php
Normal 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' => '차량이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업파트너 가이드북 도움말 모달
|
||||
*/
|
||||
|
||||
@@ -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']]} 역할이 제거되었습니다.");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Models/CorporateVehicle.php
Normal file
51
app/Models/CorporateVehicle.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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' => '홈텍스 매입/매출',
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => '장',
|
||||
|
||||
31
database/seeders/PartnerMenuRenameSeeder.php
Normal file
31
database/seeders/PartnerMenuRenameSeeder.php
Normal 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('영업파트너 관리 메뉴를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 문의
|
||||
|
||||
이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 진행 현황 (완료) -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }} }))"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user