feat:자금계획일정 추가

This commit is contained in:
김보곤
2026-01-20 20:21:06 +09:00
parent 75a4a2b766
commit acad251eec
25 changed files with 6455 additions and 35 deletions

View File

@@ -0,0 +1,372 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Finance\BankAccount;
use App\Services\BankAccountService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class BankAccountController extends Controller
{
public function __construct(
private BankAccountService $bankAccountService
) {}
/**
* 계좌 목록 조회
*/
public function index(Request $request): JsonResponse|Response
{
$accounts = $this->bankAccountService->getAccounts(
$request->all(),
$request->integer('per_page', 15)
);
// HTMX 요청인 경우 HTML 반환
if ($request->header('HX-Request')) {
return response(view('finance.accounts.partials.table', compact('accounts')));
}
return response()->json([
'success' => true,
'data' => $accounts->items(),
'meta' => [
'current_page' => $accounts->currentPage(),
'last_page' => $accounts->lastPage(),
'per_page' => $accounts->perPage(),
'total' => $accounts->total(),
],
]);
}
/**
* 계좌 상세 조회
*/
public function show(int $id): JsonResponse
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $account,
]);
}
/**
* 계좌 생성
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'bank_code' => 'nullable|string|max:10',
'bank_name' => 'required|string|max:50',
'account_number' => 'required|string|max:30',
'account_holder' => 'required|string|max:50',
'account_name' => 'nullable|string|max:100',
'account_type' => 'nullable|string|max:30',
'balance' => 'nullable|numeric|min:0',
'currency' => 'nullable|string|max:3',
'opened_at' => 'nullable|date',
'branch_name' => 'nullable|string|max:100',
'memo' => 'nullable|string',
'status' => 'nullable|string|in:active,inactive',
'is_primary' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
]);
// 기본값 설정
$validated['status'] = $validated['status'] ?? 'active';
$validated['account_name'] = $validated['account_name'] ?? $validated['bank_name'] . ' 계좌';
$account = $this->bankAccountService->createAccount($validated);
return response()->json([
'success' => true,
'message' => '계좌가 등록되었습니다.',
'data' => $account,
], 201);
}
/**
* 계좌 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
$validated = $request->validate([
'bank_code' => 'nullable|string|max:10',
'bank_name' => 'sometimes|required|string|max:50',
'account_number' => 'sometimes|required|string|max:30',
'account_holder' => 'nullable|string|max:50',
'account_name' => 'nullable|string|max:100',
'account_type' => 'nullable|string|max:30',
'balance' => 'nullable|numeric|min:0',
'currency' => 'nullable|string|max:3',
'opened_at' => 'nullable|date',
'branch_name' => 'nullable|string|max:100',
'memo' => 'nullable|string',
'status' => 'nullable|string|in:active,inactive',
'is_primary' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
]);
$account = $this->bankAccountService->updateAccount($account, $validated);
return response()->json([
'success' => true,
'message' => '계좌가 수정되었습니다.',
'data' => $account,
]);
}
/**
* 계좌 삭제 (Soft Delete)
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
$this->bankAccountService->deleteAccount($account);
// HTMX 요청인 경우 갱신된 테이블 반환
if ($request->header('HX-Request')) {
$accounts = $this->bankAccountService->getAccounts($request->all(), $request->integer('per_page', 15));
return response(view('finance.accounts.partials.table', compact('accounts')));
}
return response()->json([
'success' => true,
'message' => '계좌가 삭제되었습니다.',
]);
}
/**
* 계좌 복원
*/
public function restore(Request $request, int $id): JsonResponse|Response
{
$account = $this->bankAccountService->getAccountById($id, withTrashed: true);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
$this->bankAccountService->restoreAccount($account);
// HTMX 요청인 경우 갱신된 테이블 반환
if ($request->header('HX-Request')) {
$accounts = $this->bankAccountService->getAccounts($request->all(), $request->integer('per_page', 15));
return response(view('finance.accounts.partials.table', compact('accounts')));
}
return response()->json([
'success' => true,
'message' => '계좌가 복원되었습니다.',
]);
}
/**
* 계좌 영구 삭제
*/
public function forceDelete(Request $request, int $id): JsonResponse|Response
{
$account = $this->bankAccountService->getAccountById($id, withTrashed: true);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
$this->bankAccountService->forceDeleteAccount($account);
// HTMX 요청인 경우 갱신된 테이블 반환
if ($request->header('HX-Request')) {
$accounts = $this->bankAccountService->getAccounts($request->all(), $request->integer('per_page', 15));
return response(view('finance.accounts.partials.table', compact('accounts')));
}
return response()->json([
'success' => true,
'message' => '계좌가 영구 삭제되었습니다.',
]);
}
/**
* 활성/비활성 토글
*/
public function toggleActive(Request $request, int $id): JsonResponse|Response
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
$account = $this->bankAccountService->toggleActive($account);
// HTMX 요청인 경우 갱신된 테이블 반환
if ($request->header('HX-Request')) {
$accounts = $this->bankAccountService->getAccounts($request->all(), $request->integer('per_page', 15));
return response(view('finance.accounts.partials.table', compact('accounts')));
}
return response()->json([
'success' => true,
'message' => $account->status === 'active' ? '계좌가 활성화되었습니다.' : '계좌가 비활성화되었습니다.',
'data' => $account,
]);
}
/**
* 일괄 삭제
*/
public function bulkDelete(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|exists:bank_accounts,id',
]);
$count = $this->bankAccountService->bulkDelete($validated['ids']);
return response()->json([
'success' => true,
'message' => "{$count}개의 계좌가 삭제되었습니다.",
]);
}
/**
* 일괄 복원
*/
public function bulkRestore(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer',
]);
$count = $this->bankAccountService->bulkRestore($validated['ids']);
return response()->json([
'success' => true,
'message' => "{$count}개의 계좌가 복원되었습니다.",
]);
}
/**
* 일괄 영구 삭제
*/
public function bulkForceDelete(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer',
]);
$count = $this->bankAccountService->bulkForceDelete($validated['ids']);
return response()->json([
'success' => true,
'message' => "{$count}개의 계좌가 영구 삭제되었습니다.",
]);
}
/**
* 모든 계좌 목록 (드롭다운용)
*/
public function all(): JsonResponse
{
$accounts = $this->bankAccountService->getAllAccounts();
return response()->json([
'success' => true,
'data' => $accounts,
]);
}
/**
* 요약 통계
*/
public function summary(): JsonResponse
{
$summary = $this->bankAccountService->getSummary();
return response()->json([
'success' => true,
'data' => $summary,
]);
}
/**
* 계좌의 거래내역 조회
*/
public function transactions(Request $request, int $id): JsonResponse|Response
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
return response()->json([
'success' => false,
'message' => '계좌를 찾을 수 없습니다.',
], 404);
}
$transactions = $this->bankAccountService->getTransactions(
$id,
$request->all(),
$request->integer('per_page', 20)
);
// HTMX 요청인 경우 HTML 반환
if ($request->header('HX-Request')) {
return response(view('finance.accounts.partials.transactions-table', compact('account', 'transactions')));
}
return response()->json([
'success' => true,
'data' => [
'account' => $account,
'transactions' => $transactions->items(),
],
'meta' => [
'current_page' => $transactions->currentPage(),
'last_page' => $transactions->lastPage(),
'per_page' => $transactions->perPage(),
'total' => $transactions->total(),
],
]);
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Finance\FundSchedule;
use App\Services\FundScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class FundScheduleController extends Controller
{
public function __construct(
private FundScheduleService $fundScheduleService
) {}
/**
* 일정 목록 조회
*/
public function index(Request $request): JsonResponse|Response
{
$schedules = $this->fundScheduleService->getSchedules(
$request->all(),
$request->integer('per_page', 15)
);
// HTMX 요청인 경우 HTML 반환
if ($request->header('HX-Request')) {
return response(view('finance.fund-schedules.partials.table', compact('schedules')));
}
return response()->json([
'success' => true,
'data' => $schedules->items(),
'meta' => [
'current_page' => $schedules->currentPage(),
'last_page' => $schedules->lastPage(),
'per_page' => $schedules->perPage(),
'total' => $schedules->total(),
],
]);
}
/**
* 캘린더용 월별 일정 조회
*/
public function calendar(Request $request): JsonResponse|Response
{
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
$calendarData = $this->fundScheduleService->getCalendarData($year, $month);
$summary = $this->fundScheduleService->getMonthlySummary($year, $month);
// HTMX 요청인 경우 캘린더 HTML 반환
if ($request->header('HX-Request')) {
return response(view('finance.fund-schedules.partials.calendar', compact('year', 'month', 'calendarData', 'summary')));
}
return response()->json([
'success' => true,
'data' => [
'year' => $year,
'month' => $month,
'schedules' => $calendarData,
'summary' => $summary,
],
]);
}
/**
* 일정 상세 조회
*/
public function show(int $id): JsonResponse
{
$schedule = $this->fundScheduleService->getScheduleById($id);
if (! $schedule) {
return response()->json([
'success' => false,
'message' => '일정을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $schedule,
]);
}
/**
* 일정 생성
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string',
'schedule_type' => 'required|in:income,expense',
'scheduled_date' => 'required|date',
'amount' => 'required|numeric|min:0',
'currency' => 'nullable|string|max:3',
'related_bank_account_id' => 'nullable|integer|exists:bank_accounts,id',
'counterparty' => 'nullable|string|max:200',
'category' => 'nullable|string|max:50',
'status' => 'nullable|in:pending,completed,cancelled',
'is_recurring' => 'nullable|boolean',
'recurrence_rule' => 'nullable|in:daily,weekly,monthly,yearly',
'recurrence_end_date' => 'nullable|date|after:scheduled_date',
'memo' => 'nullable|string',
]);
$schedule = $this->fundScheduleService->createSchedule($validated);
return response()->json([
'success' => true,
'message' => '일정이 등록되었습니다.',
'data' => $schedule,
], 201);
}
/**
* 일정 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$schedule = $this->fundScheduleService->getScheduleById($id);
if (! $schedule) {
return response()->json([
'success' => false,
'message' => '일정을 찾을 수 없습니다.',
], 404);
}
$validated = $request->validate([
'title' => 'sometimes|required|string|max:200',
'description' => 'nullable|string',
'schedule_type' => 'sometimes|required|in:income,expense',
'scheduled_date' => 'sometimes|required|date',
'amount' => 'sometimes|required|numeric|min:0',
'currency' => 'nullable|string|max:3',
'related_bank_account_id' => 'nullable|integer|exists:bank_accounts,id',
'counterparty' => 'nullable|string|max:200',
'category' => 'nullable|string|max:50',
'status' => 'nullable|in:pending,completed,cancelled',
'is_recurring' => 'nullable|boolean',
'recurrence_rule' => 'nullable|in:daily,weekly,monthly,yearly',
'recurrence_end_date' => 'nullable|date',
'memo' => 'nullable|string',
]);
$schedule = $this->fundScheduleService->updateSchedule($schedule, $validated);
return response()->json([
'success' => true,
'message' => '일정이 수정되었습니다.',
'data' => $schedule,
]);
}
/**
* 일정 삭제 (Soft Delete)
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
$schedule = $this->fundScheduleService->getScheduleById($id);
if (! $schedule) {
return response()->json([
'success' => false,
'message' => '일정을 찾을 수 없습니다.',
], 404);
}
$this->fundScheduleService->deleteSchedule($schedule);
// HTMX 요청인 경우 갱신된 캘린더 반환
if ($request->header('HX-Request')) {
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
$calendarData = $this->fundScheduleService->getCalendarData($year, $month);
$summary = $this->fundScheduleService->getMonthlySummary($year, $month);
return response(view('finance.fund-schedules.partials.calendar', compact('year', 'month', 'calendarData', 'summary')));
}
return response()->json([
'success' => true,
'message' => '일정이 삭제되었습니다.',
]);
}
/**
* 상태 변경
*/
public function updateStatus(Request $request, int $id): JsonResponse|Response
{
$schedule = $this->fundScheduleService->getScheduleById($id);
if (! $schedule) {
return response()->json([
'success' => false,
'message' => '일정을 찾을 수 없습니다.',
], 404);
}
$validated = $request->validate([
'status' => 'required|in:pending,completed,cancelled',
'completed_amount' => 'nullable|numeric|min:0',
'completed_date' => 'nullable|date',
]);
if ($validated['status'] === FundSchedule::STATUS_COMPLETED) {
$schedule = $this->fundScheduleService->markAsCompleted(
$schedule,
$validated['completed_amount'] ?? null,
$validated['completed_date'] ?? null
);
} elseif ($validated['status'] === FundSchedule::STATUS_CANCELLED) {
$schedule = $this->fundScheduleService->markAsCancelled($schedule);
} else {
$schedule = $this->fundScheduleService->updateStatus($schedule, $validated['status']);
}
// HTMX 요청인 경우 갱신된 캘린더 반환
if ($request->header('HX-Request')) {
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
$calendarData = $this->fundScheduleService->getCalendarData($year, $month);
$summary = $this->fundScheduleService->getMonthlySummary($year, $month);
return response(view('finance.fund-schedules.partials.calendar', compact('year', 'month', 'calendarData', 'summary')));
}
return response()->json([
'success' => true,
'message' => '상태가 변경되었습니다.',
'data' => $schedule,
]);
}
/**
* 월별 요약 통계
*/
public function summary(Request $request): JsonResponse
{
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
$summary = $this->fundScheduleService->getMonthlySummary($year, $month);
return response()->json([
'success' => true,
'data' => $summary,
]);
}
/**
* 예정 일정 조회
*/
public function upcoming(Request $request): JsonResponse
{
$days = $request->integer('days', 30);
$schedules = $this->fundScheduleService->getUpcomingSchedules($days);
return response()->json([
'success' => true,
'data' => $schedules,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Services\BankAccountService;
use Illuminate\Contracts\View\View;
class BankAccountController extends Controller
{
public function __construct(
private BankAccountService $bankAccountService
) {}
/**
* 계좌 목록 페이지
*/
public function index(): View
{
$summary = $this->bankAccountService->getSummary();
return view('finance.accounts.index', [
'summary' => $summary,
]);
}
/**
* 계좌 등록 페이지
*/
public function create(): View
{
return view('finance.accounts.create');
}
/**
* 계좌 수정 페이지
*/
public function edit(int $id): View
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
abort(404, '계좌를 찾을 수 없습니다.');
}
return view('finance.accounts.edit', [
'account' => $account,
]);
}
/**
* 계좌 상세/거래내역 페이지
*/
public function show(int $id): View
{
$account = $this->bankAccountService->getAccountById($id);
if (! $account) {
abort(404, '계좌를 찾을 수 없습니다.');
}
return view('finance.accounts.show', [
'account' => $account,
]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\BankAccount;
use App\Models\Finance\FundSchedule;
use App\Services\FundScheduleService;
use Illuminate\Contracts\View\View;
class FundScheduleController extends Controller
{
public function __construct(
private FundScheduleService $fundScheduleService
) {}
/**
* 자금계획일정 목록 (캘린더 뷰)
*/
public function index(): View
{
// 현재 연월
$year = (int) request('year', now()->year);
$month = (int) request('month', now()->month);
// 월별 일정 데이터
$calendarData = $this->fundScheduleService->getCalendarData($year, $month);
// 월별 요약
$summary = $this->fundScheduleService->getMonthlySummary($year, $month);
// 계좌 목록 (필터용)
$accounts = BankAccount::active()->ordered()->get(['id', 'bank_name', 'account_number']);
return view('finance.fund-schedules.index', compact(
'year',
'month',
'calendarData',
'summary',
'accounts'
));
}
/**
* 자금계획일정 등록 폼
*/
public function create(): View
{
$accounts = BankAccount::active()->ordered()->get(['id', 'bank_name', 'account_number', 'account_name']);
$types = FundSchedule::getTypeOptions();
$statuses = FundSchedule::getStatusOptions();
$recurrenceOptions = FundSchedule::getRecurrenceOptions();
// 기본 날짜 (쿼리스트링에서)
$defaultDate = request('date', now()->toDateString());
return view('finance.fund-schedules.create', compact(
'accounts',
'types',
'statuses',
'recurrenceOptions',
'defaultDate'
));
}
/**
* 자금계획일정 수정 폼
*/
public function edit(int $id): View
{
$schedule = $this->fundScheduleService->getScheduleById($id);
if (! $schedule) {
abort(404, '일정을 찾을 수 없습니다.');
}
$accounts = BankAccount::active()->ordered()->get(['id', 'bank_name', 'account_number', 'account_name']);
$types = FundSchedule::getTypeOptions();
$statuses = FundSchedule::getStatusOptions();
$recurrenceOptions = FundSchedule::getRecurrenceOptions();
return view('finance.fund-schedules.edit', compact(
'schedule',
'accounts',
'types',
'statuses',
'recurrenceOptions'
));
}
/**
* 자금계획일정 상세
*/
public function show(int $id): View
{
$schedule = $this->fundScheduleService->getScheduleById($id);
if (! $schedule) {
abort(404, '일정을 찾을 수 없습니다.');
}
return view('finance.fund-schedules.show', compact('schedule'));
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Models\Finance;
use App\Models\Tenants\Tenant;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 은행 계좌 모델
*/
class BankAccount extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',
'bank_code',
'bank_name',
'account_number',
'account_holder',
'account_name',
'account_type',
'balance',
'currency',
'opened_at',
'last_transaction_at',
'branch_name',
'memo',
'status',
'assigned_user_id',
'is_primary',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
'deleted_at',
];
protected $casts = [
'balance' => 'decimal:2',
'is_primary' => 'boolean',
'opened_at' => 'date',
'last_transaction_at' => 'datetime',
];
// ============================================================
// 관계 정의
// ============================================================
/**
* 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* 거래내역
*/
public function transactions(): HasMany
{
return $this->hasMany(BankTransaction::class, 'bank_account_id');
}
// ============================================================
// Accessors
// ============================================================
/**
* 포맷된 잔액
*/
public function getFormattedBalanceAttribute(): string
{
$amount = abs($this->balance);
if ($amount >= 100000000) {
return number_format($amount / 100000000, 1) . '억원';
} elseif ($amount >= 10000000) {
return number_format($amount / 10000000, 0) . '천만원';
} elseif ($amount >= 10000) {
return number_format($amount / 10000, 0) . '만원';
}
return number_format($amount) . '원';
}
/**
* 포맷된 계좌번호 (마스킹)
*/
public function getMaskedAccountNumberAttribute(): string
{
$number = $this->account_number;
if (strlen($number) <= 6) {
return $number;
}
return substr($number, 0, 3) . '-***-' . substr($number, -4);
}
// ============================================================
// Scopes
// ============================================================
/**
* 활성 계좌만 (status = 'active')
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
/**
* 대표 계좌만
*/
public function scopePrimary($query)
{
return $query->where('is_primary', true);
}
/**
* 은행별 필터
*/
public function scopeByBank($query, string $bankName)
{
return $query->where('bank_name', $bankName);
}
/**
* 예금종류별 필터
*/
public function scopeByType($query, string $accountType)
{
return $query->where('account_type', $accountType);
}
/**
* 정렬 순서
*/
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('bank_name');
}
// ============================================================
// 메서드
// ============================================================
/**
* 잔액 업데이트
*/
public function updateBalance(float $newBalance): void
{
$this->update([
'balance' => $newBalance,
'last_transaction_at' => now(),
]);
}
/**
* 활성 상태 여부
*/
public function isActive(): bool
{
return $this->status === 'active';
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Models\Finance;
use App\Models\Tenants\Tenant;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 은행 거래내역 모델
*/
class BankTransaction extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
// 거래 유형 상수
public const TYPE_DEPOSIT = 'deposit'; // 입금
public const TYPE_WITHDRAWAL = 'withdrawal'; // 출금
public const TYPE_TRANSFER = 'transfer'; // 이체
protected $fillable = [
'tenant_id',
'bank_account_id',
'transaction_type',
'amount',
'balance_after',
'transaction_date',
'transaction_time',
'description',
'counterparty',
'reference_number',
'category',
'related_order_id',
'related_payment_id',
'is_reconciled',
'reconciled_at',
'memo',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
'deleted_at',
];
protected $casts = [
'amount' => 'decimal:2',
'balance_after' => 'decimal:2',
'transaction_date' => 'date',
'is_reconciled' => 'boolean',
'reconciled_at' => 'datetime',
'options' => 'array',
];
// ============================================================
// 관계 정의
// ============================================================
/**
* 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* 계좌
*/
public function bankAccount(): BelongsTo
{
return $this->belongsTo(BankAccount::class, 'bank_account_id');
}
// ============================================================
// Accessors
// ============================================================
/**
* 입금 여부
*/
public function getIsDepositAttribute(): bool
{
return $this->transaction_type === self::TYPE_DEPOSIT;
}
/**
* 출금 여부
*/
public function getIsWithdrawalAttribute(): bool
{
return $this->transaction_type === self::TYPE_WITHDRAWAL;
}
/**
* 포맷된 금액 (부호 포함)
*/
public function getFormattedAmountAttribute(): string
{
$prefix = $this->is_deposit ? '+' : '-';
return $prefix . number_format(abs($this->amount)) . '원';
}
/**
* 포맷된 잔액
*/
public function getFormattedBalanceAfterAttribute(): string
{
return number_format($this->balance_after) . '원';
}
/**
* 거래 유형 라벨
*/
public function getTypeLabel(): string
{
return match ($this->transaction_type) {
self::TYPE_DEPOSIT => '입금',
self::TYPE_WITHDRAWAL => '출금',
self::TYPE_TRANSFER => '이체',
default => '기타',
};
}
/**
* 거래 유형 색상 클래스
*/
public function getTypeColorClass(): string
{
return match ($this->transaction_type) {
self::TYPE_DEPOSIT => 'text-green-600',
self::TYPE_WITHDRAWAL => 'text-red-600',
self::TYPE_TRANSFER => 'text-blue-600',
default => 'text-gray-600',
};
}
// ============================================================
// Scopes
// ============================================================
/**
* 계좌별 필터
*/
public function scopeForAccount($query, int $accountId)
{
return $query->where('bank_account_id', $accountId);
}
/**
* 거래유형별 필터
*/
public function scopeOfType($query, string $type)
{
return $query->where('transaction_type', $type);
}
/**
* 입금만
*/
public function scopeDeposits($query)
{
return $query->where('transaction_type', self::TYPE_DEPOSIT);
}
/**
* 출금만
*/
public function scopeWithdrawals($query)
{
return $query->where('transaction_type', self::TYPE_WITHDRAWAL);
}
/**
* 날짜 범위
*/
public function scopeDateBetween($query, $startDate, $endDate)
{
return $query->whereBetween('transaction_date', [$startDate, $endDate]);
}
/**
* 대사 완료 여부
*/
public function scopeReconciled($query, bool $isReconciled = true)
{
return $query->where('is_reconciled', $isReconciled);
}
/**
* 최신순 정렬
*/
public function scopeLatest($query)
{
return $query->orderBy('transaction_date', 'desc')
->orderBy('transaction_time', 'desc')
->orderBy('id', 'desc');
}
// ============================================================
// 메서드
// ============================================================
/**
* 대사 완료 처리
*/
public function markAsReconciled(): void
{
$this->update([
'is_reconciled' => true,
'reconciled_at' => now(),
]);
}
/**
* 대사 취소
*/
public function unmarkReconciled(): void
{
$this->update([
'is_reconciled' => false,
'reconciled_at' => null,
]);
}
/**
* options에서 특정 키 값 조회
*/
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
/**
* options에 특정 키 값 설정
*/
public function setOption(string $key, mixed $value): static
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
}

View File

@@ -0,0 +1,321 @@
<?php
namespace App\Models\Finance;
use App\Models\Tenants\Tenant;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 자금계획일정 모델
*/
class FundSchedule extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
// 일정 유형 상수
public const TYPE_INCOME = 'income';
public const TYPE_EXPENSE = 'expense';
// 상태 상수
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
// 반복 규칙 상수
public const RECURRENCE_DAILY = 'daily';
public const RECURRENCE_WEEKLY = 'weekly';
public const RECURRENCE_MONTHLY = 'monthly';
public const RECURRENCE_YEARLY = 'yearly';
protected $fillable = [
'tenant_id',
'title',
'description',
'schedule_type',
'scheduled_date',
'amount',
'currency',
'related_bank_account_id',
'counterparty',
'category',
'status',
'is_recurring',
'recurrence_rule',
'recurrence_end_date',
'completed_date',
'completed_amount',
'memo',
'created_by',
'updated_by',
'deleted_by',
];
protected $hidden = [
'created_by',
'updated_by',
'deleted_by',
'deleted_at',
];
protected $casts = [
'amount' => 'decimal:2',
'completed_amount' => 'decimal:2',
'scheduled_date' => 'date',
'completed_date' => 'date',
'recurrence_end_date' => 'date',
'is_recurring' => 'boolean',
];
// ============================================================
// 관계 정의
// ============================================================
/**
* 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* 관련 은행 계좌
*/
public function bankAccount(): BelongsTo
{
return $this->belongsTo(BankAccount::class, 'related_bank_account_id');
}
// ============================================================
// Accessors
// ============================================================
/**
* 포맷된 금액
*/
public function getFormattedAmountAttribute(): string
{
$amount = abs($this->amount);
if ($amount >= 100000000) {
return number_format($amount / 100000000, 1) . '억원';
} elseif ($amount >= 10000000) {
return number_format($amount / 10000000, 1) . '천만원';
} elseif ($amount >= 10000) {
return number_format($amount / 10000, 0) . '만원';
}
return number_format($amount) . '원';
}
/**
* 일정 유형 라벨
*/
public function getTypeLabelAttribute(): string
{
return match ($this->schedule_type) {
self::TYPE_INCOME => '입금 예정',
self::TYPE_EXPENSE => '지급 예정',
default => '기타',
};
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => '예정',
self::STATUS_COMPLETED => '완료',
self::STATUS_CANCELLED => '취소',
default => '미정',
};
}
/**
* 일정 유형별 색상 클래스
*/
public function getTypeColorClassAttribute(): string
{
return match ($this->schedule_type) {
self::TYPE_INCOME => 'bg-green-100 text-green-800 border-green-200',
self::TYPE_EXPENSE => 'bg-red-100 text-red-800 border-red-200',
default => 'bg-gray-100 text-gray-800 border-gray-200',
};
}
/**
* 상태별 색상 클래스
*/
public function getStatusColorClassAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => 'bg-yellow-100 text-yellow-800',
self::STATUS_COMPLETED => 'bg-green-100 text-green-800',
self::STATUS_CANCELLED => 'bg-gray-100 text-gray-500',
default => 'bg-gray-100 text-gray-800',
};
}
// ============================================================
// Scopes
// ============================================================
/**
* 입금 예정만
*/
public function scopeIncome($query)
{
return $query->where('schedule_type', self::TYPE_INCOME);
}
/**
* 지급 예정만
*/
public function scopeExpense($query)
{
return $query->where('schedule_type', self::TYPE_EXPENSE);
}
/**
* 예정 상태만
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 완료 상태만
*/
public function scopeCompleted($query)
{
return $query->where('status', self::STATUS_COMPLETED);
}
/**
* 특정 월의 일정
*/
public function scopeForMonth($query, int $year, int $month)
{
return $query->whereYear('scheduled_date', $year)
->whereMonth('scheduled_date', $month);
}
/**
* 날짜 범위 필터
*/
public function scopeDateBetween($query, string $startDate, string $endDate)
{
return $query->whereBetween('scheduled_date', [$startDate, $endDate]);
}
/**
* 정렬 (예정일 기준)
*/
public function scopeOrdered($query)
{
return $query->orderBy('scheduled_date')->orderBy('id');
}
// ============================================================
// 메서드
// ============================================================
/**
* 입금 예정인지 확인
*/
public function isIncome(): bool
{
return $this->schedule_type === self::TYPE_INCOME;
}
/**
* 지급 예정인지 확인
*/
public function isExpense(): bool
{
return $this->schedule_type === self::TYPE_EXPENSE;
}
/**
* 예정 상태인지 확인
*/
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 완료 상태인지 확인
*/
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
/**
* 완료 처리
*/
public function markAsCompleted(?float $actualAmount = null, ?string $completedDate = null): void
{
$this->update([
'status' => self::STATUS_COMPLETED,
'completed_date' => $completedDate ?? now()->toDateString(),
'completed_amount' => $actualAmount ?? $this->amount,
'updated_by' => auth()->id(),
]);
}
/**
* 취소 처리
*/
public function markAsCancelled(): void
{
$this->update([
'status' => self::STATUS_CANCELLED,
'updated_by' => auth()->id(),
]);
}
/**
* 일정 유형 목록
*/
public static function getTypeOptions(): array
{
return [
self::TYPE_INCOME => '입금 예정',
self::TYPE_EXPENSE => '지급 예정',
];
}
/**
* 상태 목록
*/
public static function getStatusOptions(): array
{
return [
self::STATUS_PENDING => '예정',
self::STATUS_COMPLETED => '완료',
self::STATUS_CANCELLED => '취소',
];
}
/**
* 반복 규칙 목록
*/
public static function getRecurrenceOptions(): array
{
return [
self::RECURRENCE_DAILY => '매일',
self::RECURRENCE_WEEKLY => '매주',
self::RECURRENCE_MONTHLY => '매월',
self::RECURRENCE_YEARLY => '매년',
];
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace App\Services;
use App\Models\Finance\BankAccount;
use App\Models\Finance\BankTransaction;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class BankAccountService
{
// =========================================================================
// 계좌 목록 조회
// =========================================================================
/**
* 계좌 목록 조회 (페이지네이션)
*/
public function getAccounts(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = BankAccount::query();
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 검색 필터
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('bank_name', 'like', "%{$search}%")
->orWhere('account_number', 'like', "%{$search}%")
->orWhere('account_holder', 'like', "%{$search}%");
});
}
// 은행 필터
if (! empty($filters['bank_name'])) {
$query->where('bank_name', $filters['bank_name']);
}
// 예금종류 필터
if (! empty($filters['account_type'])) {
$query->where('account_type', $filters['account_type']);
}
// 활성 상태 필터 (status 기반)
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
return $query
->with(['transactions' => fn($q) => $q->latest()->limit(1)])
->orderBy('sort_order')
->orderBy('bank_name')
->paginate($perPage);
}
/**
* 모든 계좌 목록 (드롭다운용)
*/
public function getAllAccounts(): Collection
{
return BankAccount::active()
->ordered()
->get(['id', 'bank_name', 'account_number', 'account_type', 'balance']);
}
/**
* 은행별 통계
*/
public function getStatsByBank(): Collection
{
return BankAccount::active()
->select('bank_name')
->selectRaw('COUNT(*) as count')
->selectRaw('SUM(balance) as total_balance')
->groupBy('bank_name')
->orderBy('total_balance', 'desc')
->get();
}
// =========================================================================
// 계좌 CRUD
// =========================================================================
/**
* 계좌 상세 조회
*/
public function getAccountById(int $id, bool $withTrashed = false): ?BankAccount
{
$query = BankAccount::query();
if ($withTrashed) {
$query->withTrashed();
}
return $query->find($id);
}
/**
* 계좌 생성
*/
public function createAccount(array $data): BankAccount
{
$data['created_by'] = auth()->id();
$data['tenant_id'] = $data['tenant_id'] ?? session('selected_tenant_id') ?? auth()->user()?->tenant_id;
return BankAccount::create($data);
}
/**
* 계좌 수정
*/
public function updateAccount(BankAccount $account, array $data): BankAccount
{
$data['updated_by'] = auth()->id();
$account->update($data);
return $account->fresh();
}
/**
* 계좌 삭제 (Soft Delete)
*/
public function deleteAccount(BankAccount $account): bool
{
$account->deleted_by = auth()->id();
$account->save();
return $account->delete();
}
/**
* 계좌 복원
*/
public function restoreAccount(BankAccount $account): bool
{
$account->deleted_by = null;
return $account->restore();
}
/**
* 계좌 영구 삭제
*/
public function forceDeleteAccount(BankAccount $account): bool
{
return $account->forceDelete();
}
// =========================================================================
// 계좌 상태 토글
// =========================================================================
/**
* 활성/비활성 토글 (status 기반)
*/
public function toggleActive(BankAccount $account): BankAccount
{
$newStatus = $account->status === 'active' ? 'inactive' : 'active';
$account->update([
'status' => $newStatus,
'updated_by' => auth()->id(),
]);
return $account->fresh();
}
// =========================================================================
// 거래내역 관련
// =========================================================================
/**
* 계좌의 거래내역 조회
*/
public function getTransactions(int $accountId, array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$query = BankTransaction::forAccount($accountId);
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 검색 필터
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('counterparty', 'like', "%{$search}%")
->orWhere('reference_number', 'like', "%{$search}%");
});
}
// 거래유형 필터
if (! empty($filters['transaction_type'])) {
$query->ofType($filters['transaction_type']);
}
// 날짜 범위 필터
if (! empty($filters['start_date']) && ! empty($filters['end_date'])) {
$query->dateBetween($filters['start_date'], $filters['end_date']);
} elseif (! empty($filters['start_date'])) {
$query->where('transaction_date', '>=', $filters['start_date']);
} elseif (! empty($filters['end_date'])) {
$query->where('transaction_date', '<=', $filters['end_date']);
}
// 대사 상태 필터
if (isset($filters['is_reconciled'])) {
$query->reconciled((bool) $filters['is_reconciled']);
}
return $query
->with('bankAccount:id,bank_name,account_number')
->latest()
->paginate($perPage);
}
/**
* 거래내역 생성
*/
public function createTransaction(array $data): BankTransaction
{
return DB::transaction(function () use ($data) {
$account = BankAccount::findOrFail($data['bank_account_id']);
// 거래 후 잔액 계산
$newBalance = match ($data['transaction_type']) {
BankTransaction::TYPE_DEPOSIT => $account->balance + $data['amount'],
BankTransaction::TYPE_WITHDRAWAL, BankTransaction::TYPE_TRANSFER => $account->balance - $data['amount'],
default => $account->balance,
};
$data['balance_after'] = $newBalance;
$data['tenant_id'] = $account->tenant_id;
$data['created_by'] = auth()->id();
$transaction = BankTransaction::create($data);
// 계좌 잔액 업데이트
$account->updateBalance($newBalance);
return $transaction;
});
}
/**
* 거래내역 수정
*/
public function updateTransaction(BankTransaction $transaction, array $data): BankTransaction
{
$data['updated_by'] = auth()->id();
$transaction->update($data);
return $transaction->fresh();
}
/**
* 거래내역 삭제
*/
public function deleteTransaction(BankTransaction $transaction): bool
{
$transaction->deleted_by = auth()->id();
$transaction->save();
return $transaction->delete();
}
// =========================================================================
// 일괄 작업
// =========================================================================
/**
* 일괄 삭제
*/
public function bulkDelete(array $ids): int
{
return BankAccount::whereIn('id', $ids)
->update([
'deleted_by' => auth()->id(),
'deleted_at' => now(),
]);
}
/**
* 일괄 복원
*/
public function bulkRestore(array $ids): int
{
return BankAccount::onlyTrashed()
->whereIn('id', $ids)
->update([
'deleted_by' => null,
'deleted_at' => null,
]);
}
/**
* 일괄 영구 삭제
*/
public function bulkForceDelete(array $ids): int
{
return BankAccount::onlyTrashed()
->whereIn('id', $ids)
->forceDelete();
}
// =========================================================================
// 요약 및 통계
// =========================================================================
/**
* 전체 요약 통계
*/
public function getSummary(): array
{
$accounts = BankAccount::active()->get();
return [
'total_accounts' => $accounts->count(),
'total_balance' => $accounts->sum('balance'),
'formatted_total_balance' => number_format($accounts->sum('balance')) . '원',
'by_bank' => $accounts->groupBy('bank_name')->map(fn($group) => [
'count' => $group->count(),
'total' => $group->sum('balance'),
]),
];
}
}

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Services;
use App\Models\Finance\FundSchedule;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class FundScheduleService
{
// =========================================================================
// 일정 목록 조회
// =========================================================================
/**
* 일정 목록 조회 (페이지네이션)
*/
public function getSchedules(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = FundSchedule::query();
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 검색 필터
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('counterparty', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// 일정 유형 필터
if (! empty($filters['schedule_type'])) {
$query->where('schedule_type', $filters['schedule_type']);
}
// 상태 필터
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
// 날짜 범위 필터
if (! empty($filters['start_date']) && ! empty($filters['end_date'])) {
$query->dateBetween($filters['start_date'], $filters['end_date']);
} elseif (! empty($filters['start_date'])) {
$query->where('scheduled_date', '>=', $filters['start_date']);
} elseif (! empty($filters['end_date'])) {
$query->where('scheduled_date', '<=', $filters['end_date']);
}
// 월별 필터
if (! empty($filters['year']) && ! empty($filters['month'])) {
$query->forMonth((int) $filters['year'], (int) $filters['month']);
}
// 카테고리 필터
if (! empty($filters['category'])) {
$query->where('category', $filters['category']);
}
return $query
->with('bankAccount:id,bank_name,account_number')
->orderBy('scheduled_date')
->orderBy('id')
->paginate($perPage);
}
/**
* 월별 일정 조회 (캘린더용)
*/
public function getSchedulesForMonth(int $year, int $month): Collection
{
return FundSchedule::forMonth($year, $month)
->with('bankAccount:id,bank_name,account_number')
->ordered()
->get();
}
/**
* 특정 날짜의 일정 조회
*/
public function getSchedulesForDate(string $date): Collection
{
return FundSchedule::where('scheduled_date', $date)
->with('bankAccount:id,bank_name,account_number')
->ordered()
->get();
}
/**
* 예정된 일정 조회 (향후 N일)
*/
public function getUpcomingSchedules(int $days = 30): Collection
{
$startDate = now()->toDateString();
$endDate = now()->addDays($days)->toDateString();
return FundSchedule::pending()
->dateBetween($startDate, $endDate)
->with('bankAccount:id,bank_name,account_number')
->ordered()
->get();
}
// =========================================================================
// 일정 CRUD
// =========================================================================
/**
* 일정 상세 조회
*/
public function getScheduleById(int $id, bool $withTrashed = false): ?FundSchedule
{
$query = FundSchedule::query();
if ($withTrashed) {
$query->withTrashed();
}
return $query->with('bankAccount')->find($id);
}
/**
* 일정 생성
*/
public function createSchedule(array $data): FundSchedule
{
$data['created_by'] = auth()->id();
$data['tenant_id'] = $data['tenant_id'] ?? session('selected_tenant_id') ?? auth()->user()?->tenant_id;
return FundSchedule::create($data);
}
/**
* 일정 수정
*/
public function updateSchedule(FundSchedule $schedule, array $data): FundSchedule
{
$data['updated_by'] = auth()->id();
$schedule->update($data);
return $schedule->fresh();
}
/**
* 일정 삭제 (Soft Delete)
*/
public function deleteSchedule(FundSchedule $schedule): bool
{
$schedule->deleted_by = auth()->id();
$schedule->save();
return $schedule->delete();
}
/**
* 일정 복원
*/
public function restoreSchedule(FundSchedule $schedule): bool
{
$schedule->deleted_by = null;
return $schedule->restore();
}
/**
* 일정 영구 삭제
*/
public function forceDeleteSchedule(FundSchedule $schedule): bool
{
return $schedule->forceDelete();
}
// =========================================================================
// 상태 변경
// =========================================================================
/**
* 완료 처리
*/
public function markAsCompleted(FundSchedule $schedule, ?float $actualAmount = null, ?string $completedDate = null): FundSchedule
{
$schedule->markAsCompleted($actualAmount, $completedDate);
return $schedule->fresh();
}
/**
* 취소 처리
*/
public function markAsCancelled(FundSchedule $schedule): FundSchedule
{
$schedule->markAsCancelled();
return $schedule->fresh();
}
/**
* 상태 변경
*/
public function updateStatus(FundSchedule $schedule, string $status): FundSchedule
{
$schedule->update([
'status' => $status,
'updated_by' => auth()->id(),
]);
return $schedule->fresh();
}
// =========================================================================
// 일괄 작업
// =========================================================================
/**
* 일괄 삭제
*/
public function bulkDelete(array $ids): int
{
return FundSchedule::whereIn('id', $ids)
->update([
'deleted_by' => auth()->id(),
'deleted_at' => now(),
]);
}
/**
* 일괄 상태 변경
*/
public function bulkUpdateStatus(array $ids, string $status): int
{
return FundSchedule::whereIn('id', $ids)
->update([
'status' => $status,
'updated_by' => auth()->id(),
]);
}
// =========================================================================
// 요약 및 통계
// =========================================================================
/**
* 월별 요약 통계
*/
public function getMonthlySummary(int $year, int $month): array
{
$schedules = FundSchedule::forMonth($year, $month)->get();
$incomeSchedules = $schedules->where('schedule_type', FundSchedule::TYPE_INCOME);
$expenseSchedules = $schedules->where('schedule_type', FundSchedule::TYPE_EXPENSE);
return [
'year' => $year,
'month' => $month,
'total_count' => $schedules->count(),
'income' => [
'count' => $incomeSchedules->count(),
'total' => $incomeSchedules->sum('amount'),
'pending' => $incomeSchedules->where('status', FundSchedule::STATUS_PENDING)->sum('amount'),
'completed' => $incomeSchedules->where('status', FundSchedule::STATUS_COMPLETED)->sum('completed_amount'),
],
'expense' => [
'count' => $expenseSchedules->count(),
'total' => $expenseSchedules->sum('amount'),
'pending' => $expenseSchedules->where('status', FundSchedule::STATUS_PENDING)->sum('amount'),
'completed' => $expenseSchedules->where('status', FundSchedule::STATUS_COMPLETED)->sum('completed_amount'),
],
'net' => $incomeSchedules->sum('amount') - $expenseSchedules->sum('amount'),
];
}
/**
* 캘린더용 데이터 구조화
*/
public function getCalendarData(int $year, int $month): array
{
$schedules = $this->getSchedulesForMonth($year, $month);
// 날짜별로 그룹화
$groupedByDate = $schedules->groupBy(function ($schedule) {
return $schedule->scheduled_date->format('Y-m-d');
});
return $groupedByDate->toArray();
}
/**
* 전체 요약 통계
*/
public function getSummary(): array
{
$pending = FundSchedule::pending()->get();
return [
'pending_count' => $pending->count(),
'pending_income' => $pending->where('schedule_type', FundSchedule::TYPE_INCOME)->sum('amount'),
'pending_expense' => $pending->where('schedule_type', FundSchedule::TYPE_EXPENSE)->sum('amount'),
'upcoming_7days' => FundSchedule::pending()
->dateBetween(now()->toDateString(), now()->addDays(7)->toDateString())
->count(),
];
}
}

2423
public/재무관리.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,42 +7,79 @@
$children = $menu->menuChildren ?? collect(); $children = $menu->menuChildren ?? collect();
$hasChildren = $children->isNotEmpty(); $hasChildren = $children->isNotEmpty();
$paddingLeft = $depth > 0 ? ($depth * 0.75 + 0.75) . 'rem' : '0.75rem'; $paddingLeft = $depth > 0 ? ($depth * 0.75 + 0.75) . 'rem' : '0.75rem';
// depth에 따른 스타일 분기
$isTopLevel = $depth === 0;
@endphp @endphp
<li class="pt-4 pb-1 border-t border-gray-200 mt-2"> @if($isTopLevel)
{{-- 그룹 헤더 (접기/펼치기 버튼) --}} {{-- depth0: 최상위 그룹 헤더 스타일 --}}
<button <li class="pt-4 pb-1 border-t border-gray-200 mt-2">
onclick="toggleMenuGroup('{{ $groupId }}')" <button
class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-600 uppercase tracking-wider hover:bg-gray-50 rounded" onclick="toggleMenuGroup('{{ $groupId }}')"
style="padding-left: {{ $paddingLeft }}" class="sidebar-group-header w-full flex items-center justify-between px-3 py-2 text-xs font-bold text-gray-600 uppercase tracking-wider hover:bg-gray-50 rounded"
> style="padding-left: {{ $paddingLeft }}"
<span class="flex items-center gap-2">
@if($menu->icon)
<x-sidebar.menu-icon :icon="$menu->icon" class="w-4 h-4" />
@endif
<span class="sidebar-text">{{ $menu->name }}</span>
</span>
<svg
id="{{ $groupId }}-icon"
class="w-3 h-3 transition-transform sidebar-text {{ $hasChildren ? 'rotate-180' : '' }}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <span class="flex items-center gap-2">
</svg> @if($menu->icon)
</button> <x-sidebar.menu-icon :icon="$menu->icon" class="w-4 h-4" />
@endif
<span class="sidebar-text">{{ $menu->name }}</span>
</span>
<svg
id="{{ $groupId }}-icon"
class="w-3 h-3 transition-transform sidebar-text {{ $hasChildren ? 'rotate-180' : '' }}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{{-- 하위 메뉴 (자식이 있으면 기본 표시, localStorage에서 상태 복원) --}} <ul id="{{ $groupId }}" class="space-y-1 mt-1" style="display: {{ $hasChildren ? 'block' : 'none' }};">
<ul id="{{ $groupId }}" class="space-y-1 mt-1" style="display: {{ $hasChildren ? 'block' : 'none' }};"> @foreach($children as $child)
@foreach($children as $child) @if($child->menuChildren && $child->menuChildren->isNotEmpty())
@if($child->menuChildren && $child->menuChildren->isNotEmpty()) <x-sidebar.menu-group :menu="$child" :depth="$depth + 1" />
{{-- 하위에 그룹이 있는 경우 (중첩 그룹) --}} @else
<x-sidebar.menu-group :menu="$child" :depth="$depth + 1" /> <x-sidebar.menu-item :menu="$child" :depth="$depth + 1" />
@else @endif
{{-- 일반 메뉴 아이템 --}} @endforeach
<x-sidebar.menu-item :menu="$child" :depth="$depth + 1" /> </ul>
@endif </li>
@endforeach @else
</ul> {{-- depth1+: 서브그룹 스타일 (일반 텍스트, 접기/펼치기) --}}
</li> <li>
<button
onclick="toggleMenuGroup('{{ $groupId }}')"
class="sidebar-subgroup-header w-full flex items-center justify-between px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg"
style="padding-left: {{ $paddingLeft }}"
>
<span class="flex items-center gap-2">
@if($menu->icon)
<x-sidebar.menu-icon :icon="$menu->icon" class="w-4 h-4" />
@endif
<span class="font-medium sidebar-text">{{ $menu->name }}</span>
</span>
<svg
id="{{ $groupId }}-icon"
class="w-3 h-3 transition-transform sidebar-text {{ $isExpanded ? 'rotate-180' : '' }}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<ul id="{{ $groupId }}" class="space-y-1 mt-1" style="display: {{ $isExpanded ? 'block' : 'none' }};">
@foreach($children as $child)
@if($child->menuChildren && $child->menuChildren->isNotEmpty())
<x-sidebar.menu-group :menu="$child" :depth="$depth + 1" />
@else
<x-sidebar.menu-item :menu="$child" :depth="$depth + 1" />
@endif
@endforeach
</ul>
</li>
@endif

View File

@@ -0,0 +1,187 @@
@extends('layouts.app')
@section('title', '계좌 등록')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('finance.accounts.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7"/>
</svg>
계좌 목록으로
</a>
<h1 class="text-2xl font-bold text-gray-800">계좌 등록</h1>
</div>
{{-- 등록 --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="accountForm"
hx-post="{{ route('api.admin.bank-accounts.store') }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
hx-target="#form-message"
hx-swap="innerHTML"
class="space-y-6">
{{-- 메시지 영역 --}}
<div id="form-message"></div>
{{-- 은행명 --}}
@php
$banks = [
// 시중은행
'KB국민은행', '신한은행', '우리은행', '하나은행', 'SC제일은행', '한국씨티은행',
// 지방은행
'경남은행', '광주은행', '대구은행', '부산은행', '전북은행', '제주은행',
// 특수은행
'NH농협은행', 'IBK기업은행', 'KDB산업은행', '수협은행',
// 인터넷은행
'카카오뱅크', '케이뱅크', '토스뱅크',
// 외국계
'BNP파리바은행', 'BOA은행', 'HSBC은행', 'JP모간은행', '도이치은행',
// 기타
'새마을금고', '신협', '우체국', '저축은행', '산림조합', '기타',
];
@endphp
<div>
<label for="bank_name" class="block text-sm font-medium text-gray-700 mb-1">
은행명 <span class="text-red-500">*</span>
</label>
<select name="bank_name" id="bank_name" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="">선택하세요</option>
@foreach($banks as $bank)
<option value="{{ $bank }}">{{ $bank }}</option>
@endforeach
</select>
</div>
{{-- 계좌번호 --}}
<div>
<label for="account_number" class="block text-sm font-medium text-gray-700 mb-1">
계좌번호 <span class="text-red-500">*</span>
</label>
<input type="text" name="account_number" id="account_number" required
placeholder="123-456-789012"
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="account_holder" class="block text-sm font-medium text-gray-700 mb-1">
예금주 <span class="text-red-500">*</span>
</label>
<input type="text" name="account_holder" id="account_holder" required
placeholder="예금주명"
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="account_name" class="block text-sm font-medium text-gray-700 mb-1">
계좌별칭
</label>
<input type="text" name="account_name" id="account_name"
placeholder="예: 주거래 계좌, 급여계좌"
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="account_type" class="block text-sm font-medium text-gray-700 mb-1">
예금종류
</label>
<select name="account_type" id="account_type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="보통예금">보통예금</option>
<option value="정기예금">정기예금</option>
<option value="적금">적금</option>
<option value="법인카드 출금">법인카드 출금</option>
<option value="외화예금">외화예금</option>
<option value="기타">기타</option>
</select>
</div>
{{-- 현재 잔액 --}}
<div>
<label for="balance" class="block text-sm font-medium text-gray-700 mb-1">
현재 잔액
</label>
<input type="number" name="balance" id="balance"
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">
</div>
{{-- 개설일자 --}}
<div>
<label for="opened_at" class="block text-sm font-medium text-gray-700 mb-1">
개설일자
</label>
<input type="date" name="opened_at" id="opened_at"
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="branch_name" class="block text-sm font-medium text-gray-700 mb-1">
지점명
</label>
<input type="text" name="branch_name" id="branch_name"
placeholder="지점명"
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="memo" class="block text-sm font-medium text-gray-700 mb-1">
메모
</label>
<textarea name="memo" id="memo" rows="3"
placeholder="메모를 입력하세요"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"></textarea>
</div>
{{-- 상태 --}}
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">
상태
</label>
<select name="status" id="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="active" selected>활성</option>
<option value="inactive">비활성</option>
</select>
</div>
{{-- 버튼 --}}
<div class="flex justify-end gap-3 pt-4 border-t">
<a href="{{ route('finance.accounts.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
등록
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
// 폼 제출 성공 시 리다이렉트
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
window.location.href = '{{ route('finance.accounts.index') }}';
}
} catch (e) {}
}
});
</script>
@endpush

View File

@@ -0,0 +1,191 @@
@extends('layouts.app')
@section('title', '계좌 수정')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('finance.accounts.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7"/>
</svg>
계좌 목록으로
</a>
<h1 class="text-2xl font-bold text-gray-800">계좌 수정</h1>
<p class="text-sm text-gray-500 mt-1">{{ $account->bank_name }} {{ $account->account_number }}</p>
</div>
{{-- 수정 --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="accountForm"
hx-put="{{ route('api.admin.bank-accounts.update', $account->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
hx-target="#form-message"
hx-swap="innerHTML"
class="space-y-6">
{{-- 메시지 영역 --}}
<div id="form-message"></div>
{{-- 은행명 --}}
@php
$banks = [
// 시중은행
'KB국민은행', '신한은행', '우리은행', '하나은행', 'SC제일은행', '한국씨티은행',
// 지방은행
'경남은행', '광주은행', '대구은행', '부산은행', '전북은행', '제주은행',
// 특수은행
'NH농협은행', 'IBK기업은행', 'KDB산업은행', '수협은행',
// 인터넷은행
'카카오뱅크', '케이뱅크', '토스뱅크',
// 외국계
'BNP파리바은행', 'BOA은행', 'HSBC은행', 'JP모간은행', '도이치은행',
// 기타
'새마을금고', '신협', '우체국', '저축은행', '산림조합', '기타',
];
// 기존 값이 목록에 없으면 맨 앞에 추가
$currentBank = $account->bank_name;
$bankInList = in_array($currentBank, $banks);
@endphp
<div>
<label for="bank_name" class="block text-sm font-medium text-gray-700 mb-1">
은행명 <span class="text-red-500">*</span>
</label>
<select name="bank_name" id="bank_name" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="">선택하세요</option>
@if($currentBank && !$bankInList)
<option value="{{ $currentBank }}" selected>{{ $currentBank }}</option>
@endif
@foreach($banks as $bank)
<option value="{{ $bank }}" {{ $currentBank === $bank ? 'selected' : '' }}>{{ $bank }}</option>
@endforeach
</select>
</div>
{{-- 계좌번호 --}}
<div>
<label for="account_number" class="block text-sm font-medium text-gray-700 mb-1">
계좌번호 <span class="text-red-500">*</span>
</label>
<input type="text" name="account_number" id="account_number" required
value="{{ $account->account_number }}"
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="account_holder" class="block text-sm font-medium text-gray-700 mb-1">
예금주 <span class="text-red-500">*</span>
</label>
<input type="text" name="account_holder" id="account_holder" required
value="{{ $account->account_holder }}"
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="account_name" class="block text-sm font-medium text-gray-700 mb-1">
계좌별칭
</label>
<input type="text" name="account_name" id="account_name"
value="{{ $account->account_name }}"
placeholder="예: 주거래 계좌, 급여계좌"
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="account_type" class="block text-sm font-medium text-gray-700 mb-1">
예금종류
</label>
<select name="account_type" id="account_type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@foreach(['보통예금', '정기예금', '적금', '법인카드 출금', '외화예금', '기타'] as $type)
<option value="{{ $type }}" {{ $account->account_type === $type ? 'selected' : '' }}>{{ $type }}</option>
@endforeach
</select>
</div>
{{-- 현재 잔액 --}}
<div>
<label for="balance" class="block text-sm font-medium text-gray-700 mb-1">
현재 잔액
</label>
<input type="number" name="balance" id="balance"
value="{{ $account->balance }}" 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">
</div>
{{-- 개설일자 --}}
<div>
<label for="opened_at" class="block text-sm font-medium text-gray-700 mb-1">
개설일자
</label>
<input type="date" name="opened_at" id="opened_at"
value="{{ $account->opened_at?->format('Y-m-d') }}"
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="branch_name" class="block text-sm font-medium text-gray-700 mb-1">
지점명
</label>
<input type="text" name="branch_name" id="branch_name"
value="{{ $account->branch_name }}"
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="memo" class="block text-sm font-medium text-gray-700 mb-1">
메모
</label>
<textarea name="memo" id="memo" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">{{ $account->memo }}</textarea>
</div>
{{-- 상태 --}}
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">
상태
</label>
<select name="status" id="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="active" {{ $account->status === 'active' ? 'selected' : '' }}>활성</option>
<option value="inactive" {{ $account->status === 'inactive' ? 'selected' : '' }}>비활성</option>
</select>
</div>
{{-- 버튼 --}}
<div class="flex justify-end gap-3 pt-4 border-t">
<a href="{{ route('finance.accounts.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
저장
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
window.location.href = '{{ route('finance.accounts.index') }}';
}
} catch (e) {}
}
});
</script>
@endpush

View File

@@ -0,0 +1,91 @@
@extends('layouts.app')
@section('title', '계좌관리')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- 페이지 헤더 --}}
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">계좌관리</h1>
<p class="text-sm text-gray-500 mt-1">{{ now()->format('Y년 n월 j일') }} 현재</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<a href="{{ route('finance.accounts.create') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium rounded-lg transition-colors">
<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="M12 4v16m8-8H4"/>
</svg>
계좌 등록
</a>
</div>
</div>
{{-- 요약 카드 (선택사항) --}}
@if(isset($summary) && $summary['total_accounts'] > 0)
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<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">{{ $summary['total_accounts'] }}</div>
</div>
<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-emerald-600">{{ $summary['formatted_total_balance'] }}</div>
</div>
<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">{{ count($summary['by_bank']) }}</div>
</div>
</div>
@endif
{{-- 테이블 컨테이너 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
{{-- 테이블 헤더 --}}
<div class="px-6 py-4 border-b border-gray-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-800">보유 계좌 목록</h2>
{{-- 필터/검색 --}}
<form id="filterForm" class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<input type="text" name="search" placeholder="검색..."
value="{{ request('search') }}"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<select name="is_active" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="">전체 상태</option>
<option value="1" {{ request('is_active') === '1' ? 'selected' : '' }}>활성</option>
<option value="0" {{ request('is_active') === '0' ? 'selected' : '' }}>비활성</option>
</select>
<button type="submit"
hx-get="{{ route('api.admin.bank-accounts.index') }}"
hx-target="#accounts-table"
hx-include="#filterForm"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
검색
</button>
</form>
</div>
{{-- HTMX 테이블 영역 --}}
<div id="accounts-table"
hx-get="{{ route('api.admin.bank-accounts.index') }}"
hx-trigger="load"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="min-h-[200px]">
{{-- 로딩 스피너 --}}
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// 필터 폼 제출 시 HTMX 트리거
document.getElementById('filterForm')?.addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#accounts-table', 'htmx:trigger');
});
</script>
@endpush

View File

@@ -0,0 +1,144 @@
{{-- 계좌 목록 테이블 (HTMX로 로드) --}}
<x-table-swipe>
<table class="min-w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">은행</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">계좌번호</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">예금종류</th>
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">잔액</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">개설일자</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">최종처리일시</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
@forelse($accounts as $account)
<tr class="hover:bg-gray-50 transition-colors {{ $account->trashed() ? 'opacity-50 bg-red-50' : '' }}">
{{-- 은행 --}}
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ route('finance.accounts.show', $account->id) }}"
class="text-blue-600 hover:text-blue-800 hover:underline font-medium">
{{ $account->bank_name }}
</a>
</td>
{{-- 계좌번호 --}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $account->account_number }}
</td>
{{-- 예금종류 --}}
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ $account->account_type === '보통예금' ? 'bg-blue-100 text-blue-700' : '' }}
{{ $account->account_type === '정기예금' ? 'bg-purple-100 text-purple-700' : '' }}
{{ $account->account_type === '적금' ? 'bg-green-100 text-green-700' : '' }}
{{ !in_array($account->account_type, ['보통예금', '정기예금', '적금']) ? 'bg-gray-100 text-gray-700' : '' }}
">
{{ $account->account_type }}
</span>
</td>
{{-- 잔액 --}}
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-semibold {{ $account->balance >= 100000000 ? 'text-emerald-600' : 'text-gray-900' }}">
{{ $account->formatted_balance }}
</span>
</td>
{{-- 개설일자 --}}
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
{{ $account->opened_at?->format('Y-m-d') ?? '-' }}
</td>
{{-- 최종처리일시 --}}
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
{{ $account->last_transaction_at?->format('Y-m-d H:i') ?? '-' }}
</td>
{{-- 작업 버튼 --}}
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
@if($account->trashed())
{{-- 삭제된 항목: 복원/영구삭제 --}}
<button type="button"
hx-post="{{ route('api.admin.bank-accounts.restore', $account->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-target="#accounts-table"
hx-swap="innerHTML"
hx-confirm="이 계좌를 복원하시겠습니까?"
class="text-green-600 hover:text-green-800 text-sm font-medium">
복원
</button>
<button type="button"
hx-delete="{{ route('api.admin.bank-accounts.force-delete', $account->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-target="#accounts-table"
hx-swap="innerHTML"
hx-confirm="이 계좌를 영구 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
class="text-red-600 hover:text-red-800 text-sm font-medium">
영구삭제
</button>
@else
{{-- 거래내역 보기 --}}
<a href="{{ route('finance.accounts.show', $account->id) }}"
class="text-gray-600 hover:text-gray-800"
title="거래내역">
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 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>
</a>
{{-- 수정 --}}
<a href="{{ route('finance.accounts.edit', $account->id) }}"
class="text-blue-600 hover:text-blue-800"
title="수정">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
{{-- 삭제 --}}
<button type="button"
hx-delete="{{ route('api.admin.bank-accounts.destroy', $account->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-target="#accounts-table"
hx-swap="innerHTML"
hx-confirm="이 계좌를 삭제하시겠습니까?"
class="text-red-600 hover:text-red-800"
title="삭제">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex flex-col items-center gap-2">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-gray-500">등록된 계좌가 없습니다.</p>
<a href="{{ route('finance.accounts.create') }}"
class="mt-2 text-sm text-emerald-600 hover:text-emerald-700 font-medium">
번째 계좌 등록하기
</a>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
{{-- 페이지네이션 --}}
@if($accounts->hasPages())
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
{{ $accounts->links() }}
</div>
@endif

View File

@@ -0,0 +1,97 @@
{{-- 거래내역 테이블 (HTMX로 로드) --}}
<x-table-swipe>
<table class="min-w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">거래일</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">유형</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">적요</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">거래상대방</th>
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">금액</th>
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">잔액</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">대사</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
@forelse($transactions as $tx)
<tr class="hover:bg-gray-50 transition-colors">
{{-- 거래일 --}}
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-900">
{{ $tx->transaction_date->format('Y-m-d') }}
@if($tx->transaction_time)
<span class="text-gray-500 text-xs block">{{ $tx->transaction_time }}</span>
@endif
</td>
{{-- 유형 --}}
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ $tx->transaction_type === 'deposit' ? 'bg-green-100 text-green-700' : '' }}
{{ $tx->transaction_type === 'withdrawal' ? 'bg-red-100 text-red-700' : '' }}
{{ $tx->transaction_type === 'transfer' ? 'bg-blue-100 text-blue-700' : '' }}
">
{{ $tx->getTypeLabel() }}
</span>
</td>
{{-- 적요 --}}
<td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="{{ $tx->description }}">
{{ $tx->description ?? '-' }}
</td>
{{-- 거래상대방 --}}
<td class="px-6 py-4 text-sm text-gray-700">
{{ $tx->counterparty ?? '-' }}
</td>
{{-- 금액 --}}
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-semibold {{ $tx->getTypeColorClass() }}">
{{ $tx->formatted_amount }}
</span>
</td>
{{-- 잔액 --}}
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-900">
{{ $tx->formatted_balance_after }}
</td>
{{-- 대사 상태 --}}
<td class="px-6 py-4 whitespace-nowrap text-center">
@if($tx->is_reconciled)
<span class="inline-flex items-center text-green-600" title="대사완료 {{ $tx->reconciled_at?->format('Y-m-d H:i') }}">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
</span>
@else
<span class="inline-flex items-center text-gray-300">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex flex-col items-center gap-2">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 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>
<p class="text-gray-500">거래내역이 없습니다.</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
{{-- 페이지네이션 --}}
@if($transactions->hasPages())
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
{{ $transactions->links() }}
</div>
@endif

View File

@@ -0,0 +1,93 @@
@extends('layouts.app')
@section('title', '계좌 상세 - ' . $account->bank_name)
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- 페이지 헤더 --}}
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-start gap-4 mb-6">
<div>
<a href="{{ route('finance.accounts.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7"/>
</svg>
계좌 목록으로
</a>
<h1 class="text-2xl font-bold text-gray-800">{{ $account->bank_name }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ $account->account_number }} · {{ $account->account_type }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('finance.accounts.edit', $account->id) }}"
class="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
수정
</a>
</div>
</div>
{{-- 계좌 정보 카드 --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div class="text-sm text-gray-500">현재 잔액</div>
<div class="text-3xl font-bold text-emerald-600">{{ $account->formatted_balance }}</div>
</div>
<div>
<div class="text-sm text-gray-500">예금주</div>
<div class="text-lg font-medium text-gray-900">{{ $account->account_holder ?? '-' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">개설일자</div>
<div class="text-lg font-medium text-gray-900">{{ $account->opened_at?->format('Y-m-d') ?? '-' }}</div>
</div>
</div>
@if($account->memo)
<div class="mt-4 pt-4 border-t">
<div class="text-sm text-gray-500">메모</div>
<div class="text-gray-700">{{ $account->memo }}</div>
</div>
@endif
</div>
{{-- 거래내역 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-800">거래내역</h2>
{{-- 필터 --}}
<form id="transactionFilterForm" class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<input type="date" name="start_date" placeholder="시작일"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<input type="date" name="end_date" placeholder="종료일"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<select name="transaction_type" class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">전체</option>
<option value="deposit">입금</option>
<option value="withdrawal">출금</option>
<option value="transfer">이체</option>
</select>
<button type="submit"
hx-get="{{ route('api.admin.bank-accounts.transactions', $account->id) }}"
hx-target="#transactions-table"
hx-include="#transactionFilterForm"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg">
조회
</button>
</form>
</div>
{{-- 거래내역 테이블 --}}
<div id="transactions-table"
hx-get="{{ route('api.admin.bank-accounts.transactions', $account->id) }}"
hx-trigger="load"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="min-h-[200px]">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,179 @@
@extends('layouts.app')
@section('title', '일정 등록')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('finance.fund-schedules.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7"/>
</svg>
자금계획일정으로
</a>
<h1 class="text-2xl font-bold text-gray-800">일정 등록</h1>
</div>
{{-- 등록 --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="scheduleForm"
hx-post="{{ route('api.admin.fund-schedules.store') }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
hx-target="#form-message"
hx-swap="innerHTML"
class="space-y-6">
{{-- 메시지 영역 --}}
<div id="form-message"></div>
{{-- 일정 유형 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
일정 유형 <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-2 gap-4">
@foreach($types as $value => $label)
<label class="relative flex cursor-pointer rounded-lg border p-4 focus:outline-none
{{ $value === 'income' ? 'border-green-300 bg-green-50 has-[:checked]:border-green-500 has-[:checked]:ring-2 has-[:checked]:ring-green-500' : 'border-red-300 bg-red-50 has-[:checked]:border-red-500 has-[:checked]:ring-2 has-[:checked]:ring-red-500' }}">
<input type="radio" name="schedule_type" value="{{ $value }}"
class="sr-only" {{ $value === 'expense' ? 'checked' : '' }}>
<span class="flex flex-1">
<span class="flex flex-col">
<span class="block text-sm font-medium {{ $value === 'income' ? 'text-green-900' : 'text-red-900' }}">
{{ $label }}
</span>
</span>
</span>
<svg class="h-5 w-5 {{ $value === 'income' ? 'text-green-600' : 'text-red-600' }} hidden [input:checked~&]:block" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
@endforeach
</div>
</div>
{{-- 일정명 --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">
일정명 <span class="text-red-500">*</span>
</label>
<input type="text" name="title" id="title" required
placeholder="예: 프로젝트 대금 입금, 사무실 임대료 지급"
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 class="grid grid-cols-2 gap-4">
<div>
<label for="scheduled_date" class="block text-sm font-medium text-gray-700 mb-1">
예정일 <span class="text-red-500">*</span>
</label>
<input type="date" name="scheduled_date" id="scheduled_date" required
value="{{ $defaultDate }}"
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">
금액 <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">
</div>
</div>
{{-- 거래상대방 --}}
<div>
<label for="counterparty" class="block text-sm font-medium text-gray-700 mb-1">
거래상대방
</label>
<input type="text" name="counterparty" id="counterparty"
placeholder="거래 상대방 이름"
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">
<option value="">선택안함</option>
@foreach($accounts as $account)
<option value="{{ $account->id }}">{{ $account->bank_name }} - {{ $account->account_number }}</option>
@endforeach
</select>
</div>
{{-- 분류 --}}
<div>
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">
분류
</label>
<select name="category" id="category"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="">선택안함</option>
<option value="매출">매출</option>
<option value="매입">매입</option>
<option value="급여">급여</option>
<option value="임대료">임대료</option>
<option value="운영비">운영비</option>
<option value="세금">세금</option>
<option value="대출">대출</option>
<option value="투자">투자</option>
<option value="기타">기타</option>
</select>
</div>
{{-- 설명 --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
설명
</label>
<textarea name="description" id="description" rows="2"
placeholder="상세 설명"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"></textarea>
</div>
{{-- 메모 --}}
<div>
<label for="memo" class="block text-sm font-medium text-gray-700 mb-1">
메모
</label>
<textarea name="memo" id="memo" rows="2"
placeholder="내부 메모"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"></textarea>
</div>
{{-- 버튼 --}}
<div class="flex justify-end gap-3 pt-4 border-t">
<a href="{{ route('finance.fund-schedules.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
등록
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
window.location.href = '{{ route('finance.fund-schedules.index') }}';
}
} catch (e) {}
}
});
</script>
@endpush

View File

@@ -0,0 +1,202 @@
@extends('layouts.app')
@section('title', '일정 수정')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('finance.fund-schedules.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7"/>
</svg>
자금계획일정으로
</a>
<h1 class="text-2xl font-bold text-gray-800">일정 수정</h1>
<p class="text-sm text-gray-500 mt-1">{{ $schedule->title }}</p>
</div>
{{-- 수정 --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="scheduleForm"
hx-put="{{ route('api.admin.fund-schedules.update', $schedule->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
hx-target="#form-message"
hx-swap="innerHTML"
class="space-y-6">
{{-- 메시지 영역 --}}
<div id="form-message"></div>
{{-- 일정 유형 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
일정 유형 <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-2 gap-4">
@foreach($types as $value => $label)
<label class="relative flex cursor-pointer rounded-lg border p-4 focus:outline-none
{{ $value === 'income' ? 'border-green-300 bg-green-50 has-[:checked]:border-green-500 has-[:checked]:ring-2 has-[:checked]:ring-green-500' : 'border-red-300 bg-red-50 has-[:checked]:border-red-500 has-[:checked]:ring-2 has-[:checked]:ring-red-500' }}">
<input type="radio" name="schedule_type" value="{{ $value }}"
class="sr-only" {{ $schedule->schedule_type === $value ? 'checked' : '' }}>
<span class="flex flex-1">
<span class="flex flex-col">
<span class="block text-sm font-medium {{ $value === 'income' ? 'text-green-900' : 'text-red-900' }}">
{{ $label }}
</span>
</span>
</span>
<svg class="h-5 w-5 {{ $value === 'income' ? 'text-green-600' : 'text-red-600' }} hidden [input:checked~&]:block" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</label>
@endforeach
</div>
</div>
{{-- 일정명 --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">
일정명 <span class="text-red-500">*</span>
</label>
<input type="text" name="title" id="title" required
value="{{ $schedule->title }}"
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 class="grid grid-cols-2 gap-4">
<div>
<label for="scheduled_date" class="block text-sm font-medium text-gray-700 mb-1">
예정일 <span class="text-red-500">*</span>
</label>
<input type="date" name="scheduled_date" id="scheduled_date" required
value="{{ $schedule->scheduled_date->format('Y-m-d') }}"
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">
금액 <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">
</div>
</div>
{{-- 거래상대방 --}}
<div>
<label for="counterparty" class="block text-sm font-medium text-gray-700 mb-1">
거래상대방
</label>
<input type="text" name="counterparty" id="counterparty"
value="{{ $schedule->counterparty }}"
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">
<option value="">선택안함</option>
@foreach($accounts as $account)
<option value="{{ $account->id }}" {{ $schedule->related_bank_account_id == $account->id ? 'selected' : '' }}>
{{ $account->bank_name }} - {{ $account->account_number }}
</option>
@endforeach
</select>
</div>
{{-- 분류 --}}
<div>
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">
분류
</label>
@php
$categories = ['매출', '매입', '급여', '임대료', '운영비', '세금', '대출', '투자', '기타'];
@endphp
<select name="category" id="category"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="">선택안함</option>
@foreach($categories as $cat)
<option value="{{ $cat }}" {{ $schedule->category === $cat ? 'selected' : '' }}>{{ $cat }}</option>
@endforeach
</select>
</div>
{{-- 상태 --}}
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">
상태
</label>
<select name="status" id="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@foreach($statuses as $value => $label)
<option value="{{ $value }}" {{ $schedule->status === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
{{-- 설명 --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
설명
</label>
<textarea name="description" id="description" rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">{{ $schedule->description }}</textarea>
</div>
{{-- 메모 --}}
<div>
<label for="memo" class="block text-sm font-medium text-gray-700 mb-1">
메모
</label>
<textarea name="memo" id="memo" rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">{{ $schedule->memo }}</textarea>
</div>
{{-- 버튼 --}}
<div class="flex justify-between pt-4 border-t">
<button type="button"
hx-delete="{{ route('api.admin.fund-schedules.destroy', $schedule->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-confirm="이 일정을 삭제하시겠습니까?"
class="px-4 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors">
삭제
</button>
<div class="flex gap-3">
<a href="{{ route('finance.fund-schedules.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
저장
</button>
</div>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
window.location.href = '{{ route('finance.fund-schedules.index') }}';
}
} catch (e) {
// HTMX HTML 응답인 경우 (삭제 후 리다이렉트)
window.location.href = '{{ route('finance.fund-schedules.index') }}';
}
}
});
</script>
@endpush

View File

@@ -0,0 +1,146 @@
@extends('layouts.app')
@section('title', '자금계획일정')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- 페이지 헤더 --}}
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">자금계획일정</h1>
<p class="text-sm text-gray-500 mt-1">{{ $year }} {{ $month }} 현재</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('finance.fund-schedules.create') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors">
<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="M12 4v16m8-8H4"/>
</svg>
일정 등록
</a>
</div>
</div>
{{-- 월별 요약 카드 --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{{-- 입금 예정 --}}
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-green-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">입금 예정</p>
<p class="text-xl font-bold text-green-600">{{ number_format($summary['income']['total']) }}</p>
</div>
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ $summary['income']['count'] }}</p>
</div>
{{-- 지급 예정 --}}
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-red-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">지급 예정</p>
<p class="text-xl font-bold text-red-600">{{ number_format($summary['expense']['total']) }}</p>
</div>
<div class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4"/>
</svg>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ $summary['expense']['count'] }}</p>
</div>
{{-- 자금 흐름 --}}
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-blue-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500"> 자금 흐름</p>
<p class="text-xl font-bold {{ $summary['net'] >= 0 ? 'text-blue-600' : 'text-orange-600' }}">
{{ $summary['net'] >= 0 ? '+' : '' }}{{ number_format($summary['net']) }}
</p>
</div>
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
</svg>
</div>
</div>
</div>
{{-- 건수 --}}
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-purple-500">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500"> 일정</p>
<p class="text-xl font-bold text-purple-600">{{ $summary['total_count'] }}</p>
</div>
<div class="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
</div>
</div>
</div>
{{-- 캘린더 섹션 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">자금 일정 달력</h2>
{{-- 네비게이션 --}}
<div class="flex items-center gap-2">
@php
$prevMonth = $month - 1;
$prevYear = $year;
if ($prevMonth < 1) {
$prevMonth = 12;
$prevYear--;
}
$nextMonth = $month + 1;
$nextYear = $year;
if ($nextMonth > 12) {
$nextMonth = 1;
$nextYear++;
}
@endphp
<a href="{{ route('finance.fund-schedules.index', ['year' => $prevYear, 'month' => $prevMonth]) }}"
class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="px-4 py-2 font-medium text-gray-700">{{ $year }} {{ str_pad($month, 2, '0', STR_PAD_LEFT) }}</span>
<a href="{{ route('finance.fund-schedules.index', ['year' => $nextYear, 'month' => $nextMonth]) }}"
class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
{{-- 캘린더 그리드 --}}
<div id="calendar-container">
@include('finance.fund-schedules.partials.calendar', ['year' => $year, 'month' => $month, 'calendarData' => $calendarData])
</div>
</div>
{{-- 범례 --}}
<div class="mt-4 flex items-center gap-6 text-sm text-gray-600">
<div class="flex items-center gap-2">
<span class="w-4 h-4 bg-green-100 border border-green-300 rounded"></span>
<span>입금 예정</span>
</div>
<div class="flex items-center gap-2">
<span class="w-4 h-4 bg-red-100 border border-red-300 rounded"></span>
<span>지급 예정</span>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,112 @@
{{-- 캘린더 그리드 (HTMX로 로드) --}}
@php
use Carbon\Carbon;
$firstDay = Carbon::create($year, $month, 1);
$lastDay = $firstDay->copy()->endOfMonth();
$startOfWeek = $firstDay->copy()->startOfWeek(Carbon::SUNDAY);
$endOfWeek = $lastDay->copy()->endOfWeek(Carbon::SATURDAY);
$today = Carbon::today();
@endphp
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="bg-gray-50">
<th class="px-2 py-3 text-center text-sm font-semibold text-red-500 border-b w-[14.28%]"></th>
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]"></th>
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]"></th>
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]"></th>
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]"></th>
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]"></th>
<th class="px-2 py-3 text-center text-sm font-semibold text-blue-500 border-b w-[14.28%]"></th>
</tr>
</thead>
<tbody>
@php
$currentDate = $startOfWeek->copy();
@endphp
@while($currentDate <= $endOfWeek)
<tr>
@for($i = 0; $i < 7; $i++)
@php
$dateKey = $currentDate->format('Y-m-d');
$isCurrentMonth = $currentDate->month === $month;
$isToday = $currentDate->isSameDay($today);
$isSunday = $currentDate->dayOfWeek === Carbon::SUNDAY;
$isSaturday = $currentDate->dayOfWeek === Carbon::SATURDAY;
$daySchedules = $calendarData[$dateKey] ?? [];
@endphp
<td class="border border-gray-100 align-top h-28 {{ !$isCurrentMonth ? 'bg-gray-50' : '' }}">
<div class="p-1 h-full">
{{-- 날짜 --}}
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium
{{ !$isCurrentMonth ? 'text-gray-300' : '' }}
{{ $isCurrentMonth && $isSunday ? 'text-red-500' : '' }}
{{ $isCurrentMonth && $isSaturday ? 'text-blue-500' : '' }}
{{ $isCurrentMonth && !$isSunday && !$isSaturday ? 'text-gray-700' : '' }}
{{ $isToday ? 'bg-emerald-500 text-white rounded-full w-6 h-6 flex items-center justify-center' : '' }}
">
{{ $currentDate->day }}
</span>
@if($isCurrentMonth && count($daySchedules) === 0)
<a href="{{ route('finance.fund-schedules.create', ['date' => $dateKey]) }}"
class="opacity-0 hover:opacity-100 text-gray-400 hover:text-emerald-600 transition-opacity"
title="일정 추가">
<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="M12 4v16m8-8H4"/>
</svg>
</a>
@endif
</div>
{{-- 일정 목록 --}}
<div class="space-y-1 overflow-y-auto max-h-20">
@foreach($daySchedules as $schedule)
<a href="{{ route('finance.fund-schedules.edit', $schedule['id']) }}"
class="block px-1.5 py-0.5 rounded text-xs truncate border
{{ $schedule['schedule_type'] === 'income' ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100' : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100' }}
{{ $schedule['status'] === 'completed' ? 'opacity-60 line-through' : '' }}
{{ $schedule['status'] === 'cancelled' ? 'opacity-40 line-through' : '' }}
"
title="{{ $schedule['title'] }} - {{ number_format($schedule['amount']) }}원">
<div class="font-medium truncate">{{ $schedule['title'] }}</div>
<div class="text-[10px]">
@if($schedule['amount'] >= 100000000)
{{ number_format($schedule['amount'] / 100000000, 1) }}억원
@elseif($schedule['amount'] >= 10000)
{{ number_format($schedule['amount'] / 10000, 0) }}만원
@else
{{ number_format($schedule['amount']) }}
@endif
</div>
</a>
@endforeach
@if($isCurrentMonth && count($daySchedules) > 0)
<a href="{{ route('finance.fund-schedules.create', ['date' => $dateKey]) }}"
class="block text-center text-gray-400 hover:text-emerald-600 py-0.5"
title="일정 추가">
<svg class="w-3 h-3 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</a>
@endif
</div>
</div>
</td>
@php
$currentDate->addDay();
@endphp
@endfor
</tr>
@endwhile
</tbody>
</table>
</div>

View File

@@ -0,0 +1,126 @@
@extends('layouts.app')
@section('title', '일정 상세')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="flex justify-between items-start mb-6">
<div>
<a href="{{ route('finance.fund-schedules.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7"/>
</svg>
자금계획일정으로
</a>
<h1 class="text-2xl font-bold text-gray-800">{{ $schedule->title }}</h1>
<div class="flex items-center gap-2 mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $schedule->type_color_class }}">
{{ $schedule->type_label }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $schedule->status_color_class }}">
{{ $schedule->status_label }}
</span>
</div>
</div>
<a href="{{ route('finance.fund-schedules.edit', $schedule->id) }}"
class="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
수정
</a>
</div>
{{-- 상세 정보 카드 --}}
<div class="bg-white rounded-lg shadow-sm p-6 space-y-6">
{{-- 금액 --}}
<div class="text-center py-4 border-b">
<div class="text-sm text-gray-500">예정 금액</div>
<div class="text-3xl font-bold {{ $schedule->schedule_type === 'income' ? 'text-green-600' : 'text-red-600' }}">
{{ $schedule->schedule_type === 'expense' ? '-' : '+' }}{{ number_format($schedule->amount) }}
</div>
@if($schedule->isCompleted() && $schedule->completed_amount)
<div class="text-sm text-gray-500 mt-1">
실제 완료: {{ number_format($schedule->completed_amount) }}
</div>
@endif
</div>
{{-- 기본 정보 --}}
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500">예정일</div>
<div class="font-medium">{{ $schedule->scheduled_date->format('Y년 m월 d일') }}</div>
</div>
@if($schedule->completed_date)
<div>
<div class="text-sm text-gray-500">완료일</div>
<div class="font-medium">{{ $schedule->completed_date->format('Y년 m월 d일') }}</div>
</div>
@endif
<div>
<div class="text-sm text-gray-500">거래상대방</div>
<div class="font-medium">{{ $schedule->counterparty ?? '-' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">분류</div>
<div class="font-medium">{{ $schedule->category ?? '-' }}</div>
</div>
</div>
@if($schedule->bankAccount)
<div>
<div class="text-sm text-gray-500">관련 계좌</div>
<div class="font-medium">{{ $schedule->bankAccount->bank_name }} - {{ $schedule->bankAccount->account_number }}</div>
</div>
@endif
@if($schedule->description)
<div>
<div class="text-sm text-gray-500">설명</div>
<div class="text-gray-700 whitespace-pre-line">{{ $schedule->description }}</div>
</div>
@endif
@if($schedule->memo)
<div>
<div class="text-sm text-gray-500">메모</div>
<div class="text-gray-700 whitespace-pre-line">{{ $schedule->memo }}</div>
</div>
@endif
{{-- 상태 변경 버튼 --}}
@if($schedule->isPending())
<div class="flex gap-3 pt-4 border-t">
<button type="button"
hx-patch="{{ route('api.admin.fund-schedules.status', $schedule->id) }}"
hx-vals='{"status": "completed"}'
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-confirm="이 일정을 완료 처리하시겠습니까?"
class="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-center">
완료 처리
</button>
<button type="button"
hx-patch="{{ route('api.admin.fund-schedules.status', $schedule->id) }}"
hx-vals='{"status": "cancelled"}'
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-confirm="이 일정을 취소하시겠습니까?"
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors text-center">
취소
</button>
</div>
@endif
</div>
</div>
@endsection
@push('scripts')
<script>
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
window.location.reload();
}
});
</script>
@endpush

View File

@@ -0,0 +1,44 @@
@extends('layouts.app')
@section('title', $title ?? '재무관리')
@section('content')
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<x-sidebar.menu-icon icon="wallet" class="w-6 h-6" />
{{ $title ?? '재무관리' }}
</h1>
<p class="text-sm text-gray-500 mt-1">재무 관리 시스템</p>
</div>
<!-- 준비 카드 -->
<div class="bg-white rounded-lg shadow-sm p-12 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 bg-blue-50 rounded-full mb-6">
<x-sidebar.menu-icon icon="settings" class="w-10 h-10 text-blue-500" />
</div>
<h2 class="text-xl font-semibold text-gray-800 mb-2">페이지 준비 </h2>
<p class="text-gray-500 mb-6">
<strong>{{ $title }}</strong> 기능이 제공될 예정입니다.
</p>
<div class="flex justify-center gap-4">
<a href="{{ url('/재무관리') }}"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
재무관리 데모 보기
</a>
<a href="{{ route('dashboard') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
대시보드로 이동
</a>
</div>
</div>
<!-- 현재 경로 정보 -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500">
<strong>현재 경로:</strong> {{ request()->path() }}
</p>
</div>
</div>
@endsection

View File

@@ -35,6 +35,55 @@
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin')->name('api.admin.')->group(function () { Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin')->name('api.admin.')->group(function () {
// 계좌 관리 API
Route::prefix('bank-accounts')->name('bank-accounts.')->group(function () {
// 고정 경로
Route::get('/all', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'all'])->name('all');
Route::get('/summary', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'summary'])->name('summary');
Route::post('/bulk-delete', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'bulkDelete'])->name('bulkDelete');
Route::post('/bulk-restore', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'bulkRestore'])->name('bulkRestore');
Route::middleware('super.admin')->post('/bulk-force-delete', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'bulkForceDelete'])->name('bulkForceDelete');
// 기본 CRUD
Route::get('/', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'store'])->name('store');
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'show'])->name('show');
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'destroy'])->name('destroy');
// 복원 (일반관리자 가능)
Route::post('/{id}/restore', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'restore'])->name('restore');
// 슈퍼관리자 전용 액션 (영구삭제)
Route::middleware('super.admin')->group(function () {
Route::delete('/{id}/force', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'forceDelete'])->name('force-delete');
});
// 추가 액션
Route::post('/{id}/toggle-active', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'toggleActive'])->name('toggleActive');
// 거래내역
Route::get('/{id}/transactions', [\App\Http\Controllers\Api\Admin\BankAccountController::class, 'transactions'])->name('transactions');
});
// 자금계획일정 API
Route::prefix('fund-schedules')->name('fund-schedules.')->group(function () {
// 고정 경로
Route::get('/calendar', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'calendar'])->name('calendar');
Route::get('/summary', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'summary'])->name('summary');
Route::get('/upcoming', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'upcoming'])->name('upcoming');
// 기본 CRUD
Route::get('/', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'store'])->name('store');
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'show'])->name('show');
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'destroy'])->name('destroy');
// 상태 변경
Route::patch('/{id}/status', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'updateStatus'])->name('status');
});
// 테넌트 관리 API // 테넌트 관리 API
Route::prefix('tenants')->name('tenants.')->group(function () { Route::prefix('tenants')->name('tenants.')->group(function () {
// 고정 경로는 먼저 정의 // 고정 경로는 먼저 정의

View File

@@ -451,3 +451,79 @@
}); });
}); });
}); });
/*
|--------------------------------------------------------------------------
| Demo Pages (데모 페이지)
|--------------------------------------------------------------------------
*/
Route::get('/재무관리', function () {
return response()->file(public_path('재무관리.html'));
});
/*
|--------------------------------------------------------------------------
| Finance Routes (재무 관리)
|--------------------------------------------------------------------------
*/
Route::middleware('auth')->prefix('finance')->name('finance.')->group(function () {
// 대시보드
Route::get('/dashboard', fn() => view('finance.placeholder', ['title' => '재무 대시보드']))->name('dashboard');
// 계좌관리 (실제 구현)
Route::prefix('accounts')->name('accounts.')->group(function () {
Route::get('/', [\App\Http\Controllers\Finance\BankAccountController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Finance\BankAccountController::class, 'create'])->name('create');
Route::get('/{id}', [\App\Http\Controllers\Finance\BankAccountController::class, 'show'])->name('show');
Route::get('/{id}/edit', [\App\Http\Controllers\Finance\BankAccountController::class, 'edit'])->name('edit');
});
// 자금관리 (계좌거래내역은 accounts로 리다이렉트)
Route::get('/account-transactions', fn() => redirect()->route('finance.accounts.index'))->name('account-transactions');
// 자금계획일정 (실제 구현)
Route::prefix('fund-schedules')->name('fund-schedules.')->group(function () {
Route::get('/', [\App\Http\Controllers\Finance\FundScheduleController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Finance\FundScheduleController::class, 'create'])->name('create');
Route::get('/{id}', [\App\Http\Controllers\Finance\FundScheduleController::class, 'show'])->name('show');
Route::get('/{id}/edit', [\App\Http\Controllers\Finance\FundScheduleController::class, 'edit'])->name('edit');
});
// 기존 fund-schedule URL 리다이렉트 (호환성)
Route::get('/fund-schedule', fn() => redirect()->route('finance.fund-schedules.index'))->name('fund-schedule');
Route::get('/daily-fund', fn() => view('finance.placeholder', ['title' => '일일자금일보']))->name('daily-fund');
// 카드관리
Route::get('/corporate-cards', fn() => view('finance.placeholder', ['title' => '법인카드 등록/조회']))->name('corporate-cards');
Route::get('/card-transactions', fn() => view('finance.placeholder', ['title' => '법인카드 거래내역']))->name('card-transactions');
// 수입/지출
Route::get('/income', fn() => view('finance.placeholder', ['title' => '수입관리']))->name('income');
Route::get('/expense', fn() => view('finance.placeholder', ['title' => '지출관리']))->name('expense');
// 매출/매입
Route::get('/sales', fn() => view('finance.placeholder', ['title' => '매출관리']))->name('sales');
Route::get('/purchase', fn() => view('finance.placeholder', ['title' => '매입관리']))->name('purchase');
// 정산관리
Route::get('/sales-commission', fn() => view('finance.placeholder', ['title' => '영업수수료']))->name('sales-commission');
Route::get('/consulting-fee', fn() => view('finance.placeholder', ['title' => '상담수수료']))->name('consulting-fee');
Route::get('/customer-settlement', fn() => view('finance.placeholder', ['title' => '고객사별 정산']))->name('customer-settlement');
Route::get('/subscription', fn() => view('finance.placeholder', ['title' => '구독관리']))->name('subscription');
// 차량관리
Route::get('/corporate-vehicles', fn() => view('finance.placeholder', ['title' => '법인차량 등록']))->name('corporate-vehicles');
Route::get('/vehicle-maintenance', fn() => view('finance.placeholder', ['title' => '차량 유지비']))->name('vehicle-maintenance');
// 거래처관리
Route::get('/customers', fn() => view('finance.placeholder', ['title' => '고객사 관리']))->name('customers');
Route::get('/partners', fn() => view('finance.placeholder', ['title' => '일반 거래처']))->name('partners');
// 채권/채무
Route::get('/receivables', fn() => view('finance.placeholder', ['title' => '미수금']))->name('receivables');
Route::get('/payables', fn() => view('finance.placeholder', ['title' => '미지급금']))->name('payables');
// 기타
Route::get('/refunds', fn() => view('finance.placeholder', ['title' => '환불/해지']))->name('refunds');
Route::get('/vat', fn() => view('finance.placeholder', ['title' => '부가세']))->name('vat');
});