Initial financial project import
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$accountType = $_POST['account_type'] ?? '';
|
||||
$institutionName = trim($_POST['institution_name'] ?? '');
|
||||
$accountName = trim($_POST['account_name'] ?? '');
|
||||
$openingBalance = (float)str_replace(',', '', (string)($_POST['opening_balance'] ?? 0));
|
||||
|
||||
$cardKind = $_POST['card_kind'] ?? null;
|
||||
$billingDay = !empty($_POST['billing_day']) ? (int)$_POST['billing_day'] : null;
|
||||
$paymentDay = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
|
||||
$useCreditGracePeriod = !empty($_POST['use_credit_grace_period']) ? 1 : 0;
|
||||
$billingCycleMemo = trim($_POST['billing_cycle_memo'] ?? '');
|
||||
|
||||
$statementStartMonthOffset = isset($_POST['statement_start_month_offset']) && $_POST['statement_start_month_offset'] !== ''
|
||||
? (int)$_POST['statement_start_month_offset']
|
||||
: null;
|
||||
$statementStartDay = !empty($_POST['statement_start_day']) ? (int)$_POST['statement_start_day'] : null;
|
||||
$statementEndMonthOffset = isset($_POST['statement_end_month_offset']) && $_POST['statement_end_month_offset'] !== ''
|
||||
? (int)$_POST['statement_end_month_offset']
|
||||
: null;
|
||||
$statementEndDay = !empty($_POST['statement_end_day']) ? (int)$_POST['statement_end_day'] : null;
|
||||
|
||||
if (!in_array($accountType, ['bank', 'card', 'cash', 'other'], true)) {
|
||||
throw new RuntimeException('계정 유형이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($institutionName === '' || $accountName === '') {
|
||||
throw new RuntimeException('기관명과 계좌명을 입력하세요.');
|
||||
}
|
||||
|
||||
if ($accountType !== 'card') {
|
||||
$cardKind = null;
|
||||
$billingDay = null;
|
||||
$paymentDay = null;
|
||||
$useCreditGracePeriod = 0;
|
||||
$billingCycleMemo = null;
|
||||
$statementStartMonthOffset = null;
|
||||
$statementStartDay = null;
|
||||
$statementEndMonthOffset = null;
|
||||
$statementEndDay = null;
|
||||
} else {
|
||||
if (!in_array($cardKind, ['credit', 'check'], true)) {
|
||||
throw new RuntimeException('카드 종류를 선택하세요.');
|
||||
}
|
||||
|
||||
if ($cardKind === 'check') {
|
||||
$billingDay = null;
|
||||
$paymentDay = null;
|
||||
$useCreditGracePeriod = 0;
|
||||
$billingCycleMemo = '즉시출금';
|
||||
$statementStartMonthOffset = null;
|
||||
$statementStartDay = null;
|
||||
$statementEndMonthOffset = null;
|
||||
$statementEndDay = null;
|
||||
}
|
||||
|
||||
if ($cardKind === 'credit') {
|
||||
if ($paymentDay === null || $paymentDay < 1 || $paymentDay > 31) {
|
||||
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
|
||||
if ($useCreditGracePeriod) {
|
||||
if ($statementStartMonthOffset === null || $statementEndMonthOffset === null) {
|
||||
throw new RuntimeException('사용기간 기준 월을 선택하세요.');
|
||||
}
|
||||
|
||||
if ($statementStartDay === null || $statementStartDay < 1 || $statementStartDay > 31) {
|
||||
throw new RuntimeException('사용기간 시작일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
|
||||
if ($statementEndDay === null || $statementEndDay < 1 || $statementEndDay > 31) {
|
||||
throw new RuntimeException('사용기간 종료일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
} else {
|
||||
$statementStartMonthOffset = null;
|
||||
$statementStartDay = null;
|
||||
$statementEndMonthOffset = null;
|
||||
$statementEndDay = null;
|
||||
}
|
||||
|
||||
if ($billingCycleMemo === '') {
|
||||
$billingCycleMemo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO accounts
|
||||
(
|
||||
user_id,
|
||||
account_type,
|
||||
institution_name,
|
||||
account_name,
|
||||
opening_balance,
|
||||
current_balance,
|
||||
is_active,
|
||||
card_kind,
|
||||
billing_day,
|
||||
payment_day,
|
||||
use_credit_grace_period,
|
||||
statement_start_month_offset,
|
||||
statement_start_day,
|
||||
statement_end_month_offset,
|
||||
statement_end_day,
|
||||
billing_cycle_memo
|
||||
)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$uid,
|
||||
$accountType,
|
||||
$institutionName,
|
||||
$accountName,
|
||||
$openingBalance,
|
||||
$openingBalance,
|
||||
$cardKind,
|
||||
$billingDay,
|
||||
$paymentDay,
|
||||
$useCreditGracePeriod,
|
||||
$statementStartMonthOffset,
|
||||
$statementStartDay,
|
||||
$statementEndMonthOffset,
|
||||
$statementEndDay,
|
||||
$billingCycleMemo
|
||||
]);
|
||||
|
||||
redirect('/accounts.php');
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>계좌 / 카드 추가</h2>
|
||||
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3" id="accountCreateForm">
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">유형</label>
|
||||
<select name="account_type" id="account_type" class="form-select" required>
|
||||
<option value="bank">은행계좌</option>
|
||||
<option value="card">카드</option>
|
||||
<option value="cash">현금</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">기관명</label>
|
||||
<input type="text" name="institution_name" class="form-control" placeholder="예: IBK, 우리, 농협" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">계좌명</label>
|
||||
<input type="text" name="account_name" class="form-control" placeholder="예: IBK 신용카드 / IBK 체크카드" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">시작 잔액</label>
|
||||
<input type="text" name="opening_balance" id="opening_balance" class="form-control" value="0" inputmode="numeric" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="card_setting_wrap" style="display:none;">
|
||||
<div class="loan-create-highlight">
|
||||
<div class="title">카드 설정</div>
|
||||
<div class="desc">
|
||||
카드사마다 신용공여기간이 다르므로 직접 설정 가능합니다.
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">카드 종류</label>
|
||||
<select name="card_kind" id="card_kind" class="form-select">
|
||||
<option value="">선택하세요</option>
|
||||
<option value="credit">신용카드</option>
|
||||
<option value="check">체크카드</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-only">
|
||||
<label class="form-label">납부일</label>
|
||||
<input type="number" name="payment_day" class="form-control" min="1" max="31" placeholder="예: 25">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-only d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="use_credit_grace_period" id="use_credit_grace_period" value="1" checked>
|
||||
<label class="form-check-label">신용공여기간 계산 사용</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 credit-only">
|
||||
<hr>
|
||||
<div class="fw-bold mb-2">청구월 기준 사용기간</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">시작 월 기준</label>
|
||||
<select name="statement_start_month_offset" class="form-select">
|
||||
<option value="-2">전전월</option>
|
||||
<option value="-1" selected>전월</option>
|
||||
<option value="0">당월</option>
|
||||
<option value="1">익월</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="number" name="statement_start_day" class="form-control" min="1" max="31" placeholder="예: 11">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">종료 월 기준</label>
|
||||
<select name="statement_end_month_offset" class="form-select">
|
||||
<option value="-2">전전월</option>
|
||||
<option value="-1">전월</option>
|
||||
<option value="0" selected>당월</option>
|
||||
<option value="1">익월</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">종료일</label>
|
||||
<input type="number" name="statement_end_day" class="form-control" min="1" max="31" placeholder="예: 10">
|
||||
</div>
|
||||
|
||||
<div class="col-12 credit-only">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="billing_cycle_memo" class="form-control" placeholder="예: 전월 11일 ~ 당월 10일 사용분">
|
||||
</div>
|
||||
|
||||
<div class="col-12 check-only" style="display:none;">
|
||||
<div class="alert alert-secondary mb-0">
|
||||
체크카드는 즉시출금 기준으로 처리됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary">추가</button>
|
||||
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const accountTypeEl = document.getElementById('account_type');
|
||||
const cardWrapEl = document.getElementById('card_setting_wrap');
|
||||
const cardKindEl = document.getElementById('card_kind');
|
||||
const useGraceEl = document.getElementById('use_credit_grace_period');
|
||||
|
||||
const creditOnlyEls = document.querySelectorAll('.credit-only');
|
||||
const creditPeriodOnlyEls = document.querySelectorAll('.credit-period-only');
|
||||
const checkOnlyEls = document.querySelectorAll('.check-only');
|
||||
|
||||
const openingBalanceEl = document.getElementById('opening_balance');
|
||||
|
||||
function comma(v) {
|
||||
v = String(v).replace(/,/g, '').replace(/[^\d.-]/g, '');
|
||||
if (!v) return '';
|
||||
return Number(v).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function show(nodes, on) {
|
||||
nodes.forEach(el => el.style.display = on ? '' : 'none');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const isCard = accountTypeEl.value === 'card';
|
||||
const isCredit = cardKindEl.value === 'credit';
|
||||
const isCheck = cardKindEl.value === 'check';
|
||||
const useGrace = useGraceEl.checked;
|
||||
|
||||
cardWrapEl.style.display = isCard ? '' : 'none';
|
||||
|
||||
show(creditOnlyEls, isCredit);
|
||||
show(checkOnlyEls, isCheck);
|
||||
show(creditPeriodOnlyEls, isCredit && useGrace);
|
||||
}
|
||||
|
||||
openingBalanceEl.addEventListener('input', function () {
|
||||
this.value = comma(this.value);
|
||||
});
|
||||
|
||||
document.getElementById('accountCreateForm').addEventListener('submit', function () {
|
||||
openingBalanceEl.value = openingBalanceEl.value.replace(/,/g, '');
|
||||
});
|
||||
|
||||
accountTypeEl.addEventListener('change', refresh);
|
||||
cardKindEl.addEventListener('change', refresh);
|
||||
useGraceEl.addEventListener('change', refresh);
|
||||
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/account_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$error = '';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$id, $uid]);
|
||||
$account = $stmt->fetch();
|
||||
|
||||
if (!$account) {
|
||||
exit('계좌를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$accountType = $_POST['account_type'] ?? '';
|
||||
$institutionName = trim($_POST['institution_name'] ?? '');
|
||||
$accountName = trim($_POST['account_name'] ?? '');
|
||||
$openingBalance = (float)str_replace(',', '', (string)($_POST['opening_balance'] ?? 0));
|
||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||
|
||||
$cardKind = $_POST['card_kind'] ?? null;
|
||||
$billingDay = !empty($_POST['billing_day']) ? (int)$_POST['billing_day'] : null;
|
||||
$paymentDay = !empty($_POST['payment_day']) ? (int)$_POST['payment_day'] : null;
|
||||
$useCreditGracePeriod = !empty($_POST['use_credit_grace_period']) ? 1 : 0;
|
||||
$billingCycleMemo = trim($_POST['billing_cycle_memo'] ?? '');
|
||||
|
||||
$statementStartMonthOffset = isset($_POST['statement_start_month_offset']) && $_POST['statement_start_month_offset'] !== ''
|
||||
? (int)$_POST['statement_start_month_offset']
|
||||
: null;
|
||||
$statementStartDay = !empty($_POST['statement_start_day']) ? (int)$_POST['statement_start_day'] : null;
|
||||
$statementEndMonthOffset = isset($_POST['statement_end_month_offset']) && $_POST['statement_end_month_offset'] !== ''
|
||||
? (int)$_POST['statement_end_month_offset']
|
||||
: null;
|
||||
$statementEndDay = !empty($_POST['statement_end_day']) ? (int)$_POST['statement_end_day'] : null;
|
||||
|
||||
if (!in_array($accountType, ['bank', 'card', 'cash', 'other'], true)) {
|
||||
throw new RuntimeException('계정 유형이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($institutionName === '' || $accountName === '') {
|
||||
throw new RuntimeException('기관명과 계좌명을 입력하세요.');
|
||||
}
|
||||
|
||||
if ($accountType !== 'card') {
|
||||
$cardKind = null;
|
||||
$billingDay = null;
|
||||
$paymentDay = null;
|
||||
$useCreditGracePeriod = 0;
|
||||
$billingCycleMemo = null;
|
||||
$statementStartMonthOffset = null;
|
||||
$statementStartDay = null;
|
||||
$statementEndMonthOffset = null;
|
||||
$statementEndDay = null;
|
||||
} else {
|
||||
if (!in_array($cardKind, ['credit', 'check'], true)) {
|
||||
throw new RuntimeException('카드 종류를 선택하세요.');
|
||||
}
|
||||
|
||||
if ($cardKind === 'check') {
|
||||
$billingDay = null;
|
||||
$paymentDay = null;
|
||||
$useCreditGracePeriod = 0;
|
||||
$billingCycleMemo = '즉시출금';
|
||||
$statementStartMonthOffset = null;
|
||||
$statementStartDay = null;
|
||||
$statementEndMonthOffset = null;
|
||||
$statementEndDay = null;
|
||||
}
|
||||
|
||||
if ($cardKind === 'credit') {
|
||||
if ($paymentDay === null || $paymentDay < 1 || $paymentDay > 31) {
|
||||
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
|
||||
if ($useCreditGracePeriod) {
|
||||
if ($statementStartMonthOffset === null || $statementEndMonthOffset === null) {
|
||||
throw new RuntimeException('사용기간의 시작/종료 월 기준을 선택하세요.');
|
||||
}
|
||||
|
||||
if ($statementStartDay === null || $statementStartDay < 1 || $statementStartDay > 31) {
|
||||
throw new RuntimeException('사용기간 시작일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
|
||||
if ($statementEndDay === null || $statementEndDay < 1 || $statementEndDay > 31) {
|
||||
throw new RuntimeException('사용기간 종료일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
} else {
|
||||
$statementStartMonthOffset = null;
|
||||
$statementStartDay = null;
|
||||
$statementEndMonthOffset = null;
|
||||
$statementEndDay = null;
|
||||
}
|
||||
|
||||
if ($billingCycleMemo === '') {
|
||||
$billingCycleMemo = null;
|
||||
}
|
||||
|
||||
if ($billingDay !== null && ($billingDay < 1 || $billingDay > 31)) {
|
||||
throw new RuntimeException('구형 결제기준일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE accounts
|
||||
SET
|
||||
account_type = ?,
|
||||
institution_name = ?,
|
||||
account_name = ?,
|
||||
opening_balance = ?,
|
||||
is_active = ?,
|
||||
card_kind = ?,
|
||||
billing_day = ?,
|
||||
payment_day = ?,
|
||||
use_credit_grace_period = ?,
|
||||
statement_start_month_offset = ?,
|
||||
statement_start_day = ?,
|
||||
statement_end_month_offset = ?,
|
||||
statement_end_day = ?,
|
||||
billing_cycle_memo = ?
|
||||
WHERE id = ?
|
||||
AND user_id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$accountType,
|
||||
$institutionName,
|
||||
$accountName,
|
||||
$openingBalance,
|
||||
$isActive,
|
||||
$cardKind,
|
||||
$billingDay,
|
||||
$paymentDay,
|
||||
$useCreditGracePeriod,
|
||||
$statementStartMonthOffset,
|
||||
$statementStartDay,
|
||||
$statementEndMonthOffset,
|
||||
$statementEndDay,
|
||||
$billingCycleMemo,
|
||||
$id,
|
||||
$uid
|
||||
]);
|
||||
|
||||
recalculate_account_balance($id);
|
||||
|
||||
redirect('/accounts.php');
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>계좌 / 카드 수정</h2>
|
||||
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3" id="accountEditForm">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">유형</label>
|
||||
<select name="account_type" id="account_type" class="form-select" required>
|
||||
<?php foreach (['bank'=>'은행계좌','card'=>'카드','cash'=>'현금','other'=>'기타'] as $k => $v): ?>
|
||||
<option value="<?= $k ?>" <?= $account['account_type'] === $k ? 'selected' : '' ?>><?= $v ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">기관명</label>
|
||||
<input type="text" name="institution_name" class="form-control" value="<?= h($account['institution_name']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">계좌명</label>
|
||||
<input type="text" name="account_name" class="form-control" value="<?= h($account['account_name']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">시작 잔액</label>
|
||||
<input type="text" name="opening_balance" id="opening_balance" class="form-control" inputmode="numeric" value="<?= h(money_plain($account['opening_balance'])) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" <?= $account['is_active'] ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="is_active">활성</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12" id="card_setting_wrap" style="display:none;">
|
||||
<div class="loan-create-highlight">
|
||||
<div class="title">카드 설정</div>
|
||||
<div class="desc">
|
||||
카드사/결제일마다 신용공여기간이 다르므로 사용기간을 직접 설정합니다.
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">카드 종류</label>
|
||||
<select name="card_kind" id="card_kind" class="form-select">
|
||||
<option value="">선택하세요</option>
|
||||
<option value="credit" <?= ($account['card_kind'] ?? '') === 'credit' ? 'selected' : '' ?>>신용카드</option>
|
||||
<option value="check" <?= ($account['card_kind'] ?? '') === 'check' ? 'selected' : '' ?>>체크카드</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-only">
|
||||
<label class="form-label">납부일</label>
|
||||
<input type="number" name="payment_day" id="payment_day" class="form-control" min="1" max="31" value="<?= h((string)($account['payment_day'] ?? '')) ?>" placeholder="예: 25">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-only d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="use_credit_grace_period" id="use_credit_grace_period" value="1" <?= !empty($account['use_credit_grace_period']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="use_credit_grace_period">신용공여기간 계산 사용</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 credit-only">
|
||||
<hr>
|
||||
<div class="fw-bold mb-2">청구월 기준 사용기간</div>
|
||||
<div class="form-text mb-3">
|
||||
예: 결제일 25일 기준 IBK는 “전월 11일 ~ 당월 10일 사용분” → 시작 월 기준 전월 / 시작일 11 / 종료 월 기준 당월 / 종료일 10
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">시작 월 기준</label>
|
||||
<select name="statement_start_month_offset" class="form-select">
|
||||
<?php foreach ([-2=>'전전월', -1=>'전월', 0=>'당월', 1=>'익월'] as $k => $v): ?>
|
||||
<option value="<?= $k ?>" <?= (string)($account['statement_start_month_offset'] ?? '') === (string)$k ? 'selected' : '' ?>><?= $v ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="number" name="statement_start_day" class="form-control" min="1" max="31" value="<?= h((string)($account['statement_start_day'] ?? '')) ?>" placeholder="예: 11">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">종료 월 기준</label>
|
||||
<select name="statement_end_month_offset" class="form-select">
|
||||
<?php foreach ([-2=>'전전월', -1=>'전월', 0=>'당월', 1=>'익월'] as $k => $v): ?>
|
||||
<option value="<?= $k ?>" <?= (string)($account['statement_end_month_offset'] ?? '') === (string)$k ? 'selected' : '' ?>><?= $v ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 credit-period-only">
|
||||
<label class="form-label">종료일</label>
|
||||
<input type="number" name="statement_end_day" class="form-control" min="1" max="31" value="<?= h((string)($account['statement_end_day'] ?? '')) ?>" placeholder="예: 10">
|
||||
</div>
|
||||
|
||||
<div class="col-12 credit-only">
|
||||
<label class="form-label">신용공여기간 메모</label>
|
||||
<input type="text" name="billing_cycle_memo" class="form-control" value="<?= h($account['billing_cycle_memo'] ?? '') ?>" placeholder="예: 전월 11일 ~ 당월 10일 사용분">
|
||||
</div>
|
||||
|
||||
<div class="col-12 credit-only">
|
||||
<div class="alert alert-info mb-0">
|
||||
결제일 25일 기준 예시:
|
||||
IBK 신용카드 = 전월 11일 ~ 당월 10일,
|
||||
우리카드 = 전월 12일 ~ 당월 11일.
|
||||
신규 카드사는 카드사 안내표를 보고 직접 입력하면 됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 check-only" style="display:none;">
|
||||
<div class="alert alert-secondary mb-0">
|
||||
체크카드는 신용공여기간 없이 거래월 기준으로 반영됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary">저장</button>
|
||||
<a href="/accounts.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const accountTypeEl = document.getElementById('account_type');
|
||||
const cardWrapEl = document.getElementById('card_setting_wrap');
|
||||
const cardKindEl = document.getElementById('card_kind');
|
||||
const creditOnlyEls = document.querySelectorAll('.credit-only');
|
||||
const creditPeriodOnlyEls = document.querySelectorAll('.credit-period-only');
|
||||
const checkOnlyEls = document.querySelectorAll('.check-only');
|
||||
const useGraceEl = document.getElementById('use_credit_grace_period');
|
||||
const openingBalanceEl = document.getElementById('opening_balance');
|
||||
|
||||
function formatWithComma(value) {
|
||||
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.-]/g, '');
|
||||
if (normalized === '' || normalized === '-') return normalized;
|
||||
const negative = normalized.startsWith('-');
|
||||
const raw = negative ? normalized.slice(1) : normalized;
|
||||
const parts = raw.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return (negative ? '-' : '') + parts.join('.');
|
||||
}
|
||||
|
||||
function setDisplay(nodes, visible) {
|
||||
nodes.forEach(el => el.style.display = visible ? '' : 'none');
|
||||
}
|
||||
|
||||
function toggleCardSettings() {
|
||||
const isCard = accountTypeEl.value === 'card';
|
||||
const isCredit = cardKindEl.value === 'credit';
|
||||
const isCheck = cardKindEl.value === 'check';
|
||||
const useGrace = useGraceEl && useGraceEl.checked;
|
||||
|
||||
cardWrapEl.style.display = isCard ? '' : 'none';
|
||||
|
||||
if (!isCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplay(creditOnlyEls, isCredit);
|
||||
setDisplay(checkOnlyEls, isCheck);
|
||||
setDisplay(creditPeriodOnlyEls, isCredit && useGrace);
|
||||
}
|
||||
|
||||
openingBalanceEl.addEventListener('input', function () {
|
||||
this.value = formatWithComma(this.value);
|
||||
});
|
||||
|
||||
document.getElementById('accountEditForm').addEventListener('submit', function () {
|
||||
openingBalanceEl.value = openingBalanceEl.value.replace(/,/g, '');
|
||||
});
|
||||
|
||||
accountTypeEl.addEventListener('change', toggleCardSettings);
|
||||
cardKindEl.addEventListener('change', toggleCardSettings);
|
||||
if (useGraceEl) {
|
||||
useGraceEl.addEventListener('change', toggleCardSettings);
|
||||
}
|
||||
|
||||
toggleCardSettings();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/card_billing_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
|
||||
$ym = $_GET['ym'] ?? date('Y-m');
|
||||
$nextYm = date('Y-m', strtotime($ym . '-01 +1 month'));
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
ORDER BY FIELD(account_type, 'bank', 'card', 'cash', 'other'), id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$cardDueMap = [];
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
t.account_id,
|
||||
t.billing_year_month,
|
||||
COALESCE(SUM(t.amount),0) total_amount
|
||||
FROM transactions t
|
||||
JOIN accounts a ON a.id=t.account_id
|
||||
WHERE t.user_id=?
|
||||
AND t.transaction_type='expense'
|
||||
AND a.account_type='card'
|
||||
AND a.card_kind='credit'
|
||||
AND COALESCE(t.is_installment,0)=0
|
||||
AND t.billing_year_month IN (?,?)
|
||||
GROUP BY t.account_id,t.billing_year_month
|
||||
");
|
||||
$stmt->execute([$uid,$ym,$nextYm]);
|
||||
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$aid = (int)$row['account_id'];
|
||||
$billYm = $row['billing_year_month'];
|
||||
|
||||
$cardDueMap[$aid][$billYm] =
|
||||
($cardDueMap[$aid][$billYm] ?? 0) + (float)$row['total_amount'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
i.account_id AS account_id,
|
||||
s.bill_year_month,
|
||||
COALESCE(SUM(s.total_amount),0) total_amount
|
||||
FROM installment_schedules s
|
||||
JOIN installments i ON i.id=s.installment_id
|
||||
WHERE i.user_id=?
|
||||
AND s.is_billed=0
|
||||
AND s.total_amount>0
|
||||
AND s.bill_year_month IN (?,?)
|
||||
GROUP BY i.account_id,s.bill_year_month
|
||||
");
|
||||
$stmt->execute([$uid,$ym,$nextYm]);
|
||||
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$aid = (int)$row['account_id'];
|
||||
$billYm = $row['bill_year_month'];
|
||||
|
||||
$cardDueMap[$aid][$billYm] =
|
||||
($cardDueMap[$aid][$billYm] ?? 0) + (float)$row['total_amount'];
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>계좌 / 카드 현황</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="get" class="d-flex gap-2">
|
||||
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
|
||||
<button class="btn btn-outline-primary">조회</button>
|
||||
</form>
|
||||
<a href="/account_create.php" class="btn btn-primary">추가</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<?php foreach ($accounts as $account): ?>
|
||||
<?php
|
||||
$isCard = $account['account_type'] === 'card';
|
||||
$cardKind = $account['card_kind'] ?? null;
|
||||
$thisMonthDue = $cardDueMap[(int)$account['id']][$ym] ?? 0;
|
||||
$nextMonthDue = $cardDueMap[(int)$account['id']][$nextYm] ?? 0;
|
||||
?>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<div>
|
||||
<div class="eyebrow">
|
||||
<?= h(blank_to_dash($account['institution_name'])) ?> / <?= h($account['account_type']) ?>
|
||||
</div>
|
||||
<div class="card-title-lg"><?= h($account['account_name']) ?></div>
|
||||
</div>
|
||||
|
||||
<span class="badge <?= $account['is_active'] ? 'text-bg-success' : 'text-bg-secondary' ?>">
|
||||
<?= $account['is_active'] ? 'active' : 'inactive' ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if ($isCard): ?>
|
||||
<div class="mb-3">
|
||||
<span class="badge <?= $cardKind === 'credit' ? 'text-bg-warning' : 'text-bg-info' ?>">
|
||||
<?= $cardKind === 'credit' ? '신용카드' : ($cardKind === 'check' ? '체크카드' : '카드') ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if ($cardKind === 'credit'): ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="stat-label"><?= h($ym) ?> 청구예정</div>
|
||||
<div class="stat-value text-danger"><?= won($thisMonthDue) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label"><?= h($nextYm) ?> 이월예정</div>
|
||||
<div class="stat-value"><?= won($nextMonthDue) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<div class="stat-label">결제기준일</div>
|
||||
<div class="stat-value">
|
||||
<?= !empty($account['billing_day']) ? intvalf($account['billing_day']) . '일' : '-' ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">납부일</div>
|
||||
<div class="stat-value">
|
||||
<?= !empty($account['payment_day']) ? intvalf($account['payment_day']) . '일' : '-' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-secondary mt-3">
|
||||
<?= h(get_card_billing_label($account)) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="stat-label">체크카드 잔액 기준</div>
|
||||
<div class="stat-value text-primary"><?= won($account['current_balance']) ?></div>
|
||||
<div class="small text-secondary mt-2">체크카드는 신용공여기간 없이 거래월 기준으로 반영됩니다.</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="stat-label">현재 잔액</div>
|
||||
<div class="stat-value text-primary"><?= won($account['current_balance']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<a href="/account_edit.php?id=<?= $account['id'] ?>" class="btn btn-sm btn-outline-primary">수정</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$accounts): ?>
|
||||
<div class="col-12">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body empty-state">등록된 계좌/카드가 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../../app/lib/merchant_pattern_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$merchant = trim((string)($_GET['merchant_name'] ?? ''));
|
||||
$transactionType = trim((string)($_GET['transaction_type'] ?? ''));
|
||||
|
||||
$allowedTypes = ['income', 'expense', 'transfer'];
|
||||
|
||||
if ($merchant === '' || $transactionType === '' || !in_array($transactionType, $allowedTypes, true)) {
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'found' => false,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (mb_strlen($merchant, 'UTF-8') < 2) {
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'found' => false,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$suggested = suggest_category_from_merchant(user_id(), $merchant, $transactionType);
|
||||
|
||||
if (!$suggested) {
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'found' => false,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'found' => true,
|
||||
'category_id' => (int)$suggested['category_id'],
|
||||
'category_name' => (string)$suggested['category_name'],
|
||||
'category_type' => (string)$suggested['category_type'],
|
||||
'pattern_text' => (string)($suggested['pattern_text'] ?? ''),
|
||||
'keyword' => (string)($suggested['keyword'] ?? $suggested['pattern_text'] ?? ''),
|
||||
'match_type' => (string)($suggested['match_type'] ?? ''),
|
||||
'priority' => (int)($suggested['priority'] ?? 0),
|
||||
'confidence' => (float)($suggested['confidence'] ?? 0),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'found' => false,
|
||||
'message' => '자동추천 조회 실패',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
:root {
|
||||
--app-bg: #f3f6fb;
|
||||
--card-bg: #ffffff;
|
||||
--text-main: #07152b;
|
||||
--text-sub: #51627a;
|
||||
--border: #dbe4f0;
|
||||
--line: #e7edf5;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warning: #f59e0b;
|
||||
--shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--app-bg);
|
||||
color: var(--text-main);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
main,
|
||||
.container,
|
||||
.container-fluid {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 22px 60px;
|
||||
}
|
||||
|
||||
/* top nav */
|
||||
.navbar,
|
||||
header.navbar {
|
||||
min-height: 52px;
|
||||
background: #fff !important;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: var(--text-main) !important;
|
||||
margin-right: 26px;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #334155 !important;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
padding: 15px 9px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* page head */
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-head h1,
|
||||
.page-head h2 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
/* card */
|
||||
.card,
|
||||
.finance-card,
|
||||
.finance-summary-card {
|
||||
border: 0 !important;
|
||||
border-radius: 16px !important;
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.finance-summary-card .card-body {
|
||||
padding: 26px !important;
|
||||
}
|
||||
|
||||
.finance-summary-income {
|
||||
background: linear-gradient(135deg, #dcfce7, #ffffff);
|
||||
}
|
||||
|
||||
.finance-summary-expense {
|
||||
background: linear-gradient(135deg, #fee2e2, #ffffff);
|
||||
}
|
||||
|
||||
.finance-summary-loan {
|
||||
background: linear-gradient(135deg, #fef3c7, #ffffff);
|
||||
}
|
||||
|
||||
.finance-summary-net {
|
||||
background: linear-gradient(135deg, #dbeafe, #ffffff);
|
||||
}
|
||||
|
||||
/* text */
|
||||
.eyebrow {
|
||||
color: var(--text-sub);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-title-lg {
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1.25;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.card-title-sm {
|
||||
font-size: 20px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-value {
|
||||
font-size: 34px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.small,
|
||||
.form-text,
|
||||
.text-secondary {
|
||||
color: var(--text-sub) !important;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
/* amount colors */
|
||||
.text-primary,
|
||||
.amount-income {
|
||||
color: #0b63ff !important;
|
||||
}
|
||||
|
||||
.text-danger,
|
||||
.amount-expense {
|
||||
color: #c90000 !important;
|
||||
}
|
||||
|
||||
.amount-card {
|
||||
color: #7c3aed !important;
|
||||
}
|
||||
|
||||
.amount-transfer {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* forms */
|
||||
.form-label {
|
||||
color: #26364d;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
min-height: 48px;
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid #c9d5e6 !important;
|
||||
color: var(--text-main);
|
||||
font-weight: 700;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: 0 0 0 0.18rem rgba(37, 99, 235, 0.12) !important;
|
||||
}
|
||||
|
||||
/* buttons */
|
||||
.btn {
|
||||
border-radius: 12px !important;
|
||||
font-weight: 900 !important;
|
||||
min-height: 42px;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
min-height: 34px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--primary) !important;
|
||||
border-color: #adc6ff !important;
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
color: var(--danger) !important;
|
||||
border-color: #f5b5b5 !important;
|
||||
}
|
||||
|
||||
/* badges */
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
padding: 7px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.text-bg-success {
|
||||
background: #dcfce7 !important;
|
||||
color: #047857 !important;
|
||||
}
|
||||
|
||||
.text-bg-warning {
|
||||
background: #fef3c7 !important;
|
||||
color: #92400e !important;
|
||||
}
|
||||
|
||||
.text-bg-secondary {
|
||||
background: #e5e7eb !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
.text-bg-info {
|
||||
background: #dbeafe !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
/* tables */
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 950;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
padding: 14px 10px !important;
|
||||
}
|
||||
|
||||
.table td {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
vertical-align: middle;
|
||||
padding: 14px 10px !important;
|
||||
border-bottom: 1px solid var(--line) !important;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.mobile-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mobile-scroll table {
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
/* progress */
|
||||
.finance-progress,
|
||||
.progress.finance-progress {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #e5edf7;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-progress .progress-bar,
|
||||
.progress.finance-progress .progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #60a5fa);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* highlight */
|
||||
.loan-create-highlight {
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.loan-create-highlight .title {
|
||||
color: #1d4ed8;
|
||||
font-size: 15px;
|
||||
font-weight: 950;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.loan-create-highlight .desc {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* alert */
|
||||
.alert {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
font-weight: 800;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* empty */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-sub);
|
||||
font-weight: 800;
|
||||
padding: 42px 10px;
|
||||
}
|
||||
|
||||
/* charts */
|
||||
canvas {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* card link */
|
||||
a.text-decoration-none .finance-card,
|
||||
a .finance-card {
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
a.text-decoration-none:hover .finance-card,
|
||||
a:hover .finance-card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
/* loan/installment cards */
|
||||
.finance-card .row.g-3 {
|
||||
row-gap: 18px !important;
|
||||
}
|
||||
|
||||
.finance-card .stat-value {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* long text */
|
||||
.text-truncate-cell {
|
||||
max-width: 220px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
td {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* danger border */
|
||||
.border-danger {
|
||||
border: 1px solid rgba(220, 38, 38, 0.75) !important;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.nav-link {
|
||||
font-size: 14px;
|
||||
padding-left: 6px !important;
|
||||
padding-right: 6px !important;
|
||||
}
|
||||
|
||||
.hero-value {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding-bottom: 76px;
|
||||
}
|
||||
|
||||
main,
|
||||
.container,
|
||||
.container-fluid {
|
||||
padding: 20px 14px 44px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.page-head h1,
|
||||
.page-head h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.page-head > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-head .d-flex,
|
||||
.page-head form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-head .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 18px !important;
|
||||
}
|
||||
|
||||
.hero-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.card-title-lg {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-tabbar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1030;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-top: 1px solid var(--line);
|
||||
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-tabbar-item {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-tabbar-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.transaction-list-table {
|
||||
min-width: 0 !important;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 12px;
|
||||
}
|
||||
|
||||
.transaction-list-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.transaction-list-table,
|
||||
.transaction-list-table tbody,
|
||||
.transaction-list-table tr,
|
||||
.transaction-list-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transaction-list-table tr {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.transaction-list-table td {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border-bottom: 0 !important;
|
||||
padding: 6px 0 !important;
|
||||
text-align: right !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.transaction-list-table td::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 78px;
|
||||
color: var(--text-sub);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.transaction-list-table td:last-child {
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px !important;
|
||||
}
|
||||
|
||||
.transaction-list-table td:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
(function () {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function () {});
|
||||
});
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
Vendored
+14
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$type = $_POST['category_type'] ?? '';
|
||||
$sort = (int)($_POST['sort_order'] ?? 0);
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('카테고리명을 입력하세요.');
|
||||
}
|
||||
|
||||
if (!in_array($type, ['income','expense','transfer'], true)) {
|
||||
throw new RuntimeException('카테고리 유형이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO categories (user_id, category_type, name, sort_order, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
");
|
||||
$stmt->execute([$uid, $type, $name, $sort]);
|
||||
|
||||
redirect('/categories.php');
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
ORDER BY category_type, sort_order, id
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$list = $stmt->fetchAll();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>카테고리 관리</h2>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">유형</label>
|
||||
<select name="category_type" class="form-select">
|
||||
<option value="expense">지출</option>
|
||||
<option value="income">수입</option>
|
||||
<option value="transfer">이동</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">카테고리명</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">정렬순서</label>
|
||||
<input type="number" name="sort_order" class="form-control" value="0">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary">추가</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body mobile-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>유형</th>
|
||||
<th>이름</th>
|
||||
<th>정렬</th>
|
||||
<th>활성</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($list as $row): ?>
|
||||
<tr>
|
||||
<td><?= $row['id'] ?></td>
|
||||
<td><?= h($row['category_type']) ?></td>
|
||||
<td><?= h($row['name']) ?></td>
|
||||
<td><?= $row['sort_order'] ?></td>
|
||||
<td><?= $row['is_active'] ? 'Y' : 'N' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/installment_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
|
||||
$ym = $_GET['ym'] ?? date('Y-m');
|
||||
$start = $ym . '-01';
|
||||
$end = date('Y-m-t', strtotime($start));
|
||||
$today = date('Y-m-d');
|
||||
$nextYm = date('Y-m', strtotime($ym . '-01 +1 month'));
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN transaction_type = 'income' THEN amount ELSE 0 END), 0) AS income_total,
|
||||
COALESCE(SUM(CASE WHEN transaction_type = 'expense' THEN amount ELSE 0 END), 0) AS expense_total,
|
||||
COALESCE(SUM(CASE WHEN transaction_type = 'card_payment' THEN amount ELSE 0 END), 0) AS card_payment_total
|
||||
FROM transactions
|
||||
WHERE user_id = ?
|
||||
AND transaction_date BETWEEN ? AND ?
|
||||
");
|
||||
$stmt->execute([$uid, $start, $end]);
|
||||
$summary = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN account_type IN ('bank','cash','other') THEN current_balance ELSE 0 END), 0) AS liquid_assets
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$assetSummary = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(t.amount), 0)
|
||||
FROM transactions t
|
||||
JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.transaction_type = 'expense'
|
||||
AND a.account_type = 'card'
|
||||
AND a.card_kind = 'credit'
|
||||
AND t.billing_year_month = ?
|
||||
AND COALESCE(t.is_installment, 0) = 0
|
||||
");
|
||||
$stmt->execute([$uid, $ym]);
|
||||
$cardDueThisMonth = (float)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(t.amount), 0)
|
||||
FROM transactions t
|
||||
JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.transaction_type = 'expense'
|
||||
AND a.account_type = 'card'
|
||||
AND a.card_kind = 'credit'
|
||||
AND t.billing_year_month = ?
|
||||
AND COALESCE(t.is_installment, 0) = 0
|
||||
");
|
||||
$stmt->execute([$uid, $nextYm]);
|
||||
$cardDueNextMonth = (float)$stmt->fetchColumn();
|
||||
|
||||
$installmentDueNextMonth = get_installment_due_this_month($uid, $nextYm);
|
||||
$cardTotalDueNextMonth = $cardDueNextMonth + $installmentDueNextMonth;
|
||||
|
||||
$installmentDueThisMonth = get_installment_due_this_month($uid, $ym);
|
||||
$installmentRemainingPrincipal = get_installment_remaining_principal($uid);
|
||||
$installmentRemainingInterest = get_installment_remaining_interest($uid);
|
||||
$installmentRemainingTotal = get_installment_remaining_total($uid);
|
||||
|
||||
$cardTotalDueThisMonth = $cardDueThisMonth + $installmentDueThisMonth;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT c.name, SUM(t.amount) AS total_amount
|
||||
FROM transactions t
|
||||
JOIN categories c ON t.category_id = c.id
|
||||
WHERE t.user_id = ?
|
||||
AND t.transaction_type = 'expense'
|
||||
AND t.transaction_date BETWEEN ? AND ?
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY total_amount DESC
|
||||
LIMIT 8
|
||||
");
|
||||
$stmt->execute([$uid, $start, $end]);
|
||||
$expenseByCategory = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
DATE_FORMAT(transaction_date, '%d') AS day_num,
|
||||
COALESCE(SUM(CASE WHEN transaction_type = 'expense' THEN amount ELSE 0 END), 0) AS daily_expense
|
||||
FROM transactions
|
||||
WHERE user_id = ?
|
||||
AND transaction_date BETWEEN ? AND ?
|
||||
GROUP BY DATE_FORMAT(transaction_date, '%Y-%m-%d'), DATE_FORMAT(transaction_date, '%d')
|
||||
ORDER BY DATE_FORMAT(transaction_date, '%Y-%m-%d') ASC
|
||||
");
|
||||
$stmt->execute([$uid, $start, $end]);
|
||||
$dailyRows = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY FIELD(account_type, 'bank', 'card', 'cash', 'other'), id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(scheduled_principal), 0) AS due_principal,
|
||||
COALESCE(SUM(scheduled_interest), 0) AS due_interest,
|
||||
COALESCE(SUM(scheduled_total), 0) AS due_total
|
||||
FROM loan_schedules ls
|
||||
JOIN loans l ON l.id = ls.loan_id
|
||||
WHERE l.user_id = ?
|
||||
AND ls.is_paid = 0
|
||||
AND ls.due_date BETWEEN ? AND ?
|
||||
");
|
||||
$stmt->execute([$uid, $start, $end]);
|
||||
$loanDue = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(scheduled_principal), 0) AS overdue_principal,
|
||||
COALESCE(SUM(scheduled_interest), 0) AS overdue_interest,
|
||||
COALESCE(SUM(scheduled_total), 0) AS overdue_total
|
||||
FROM loan_schedules ls
|
||||
JOIN loans l ON l.id = ls.loan_id
|
||||
WHERE l.user_id = ?
|
||||
AND ls.is_paid = 0
|
||||
AND ls.due_date < ?
|
||||
");
|
||||
$stmt->execute([$uid, $today]);
|
||||
$loanOverdue = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(current_principal_balance), 0) AS remaining_principal
|
||||
FROM loans
|
||||
WHERE user_id = ?
|
||||
AND status = 'active'
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$loanPrincipalRow = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN ls.is_paid = 0 THEN ls.scheduled_interest ELSE 0 END), 0) AS remaining_interest,
|
||||
COALESCE(SUM(CASE WHEN ls.is_paid = 0 THEN ls.scheduled_total ELSE 0 END), 0) AS remaining_total
|
||||
FROM loan_schedules ls
|
||||
JOIN loans l ON l.id = ls.loan_id
|
||||
WHERE l.user_id = ?
|
||||
AND l.status = 'active'
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$loanRemaining = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
DATE_FORMAT(payment_date, '%d') AS day_num,
|
||||
COALESCE(SUM(total_amount), 0) AS daily_loan_payment
|
||||
FROM loan_payments
|
||||
WHERE user_id = ?
|
||||
AND payment_date BETWEEN ? AND ?
|
||||
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d'), DATE_FORMAT(payment_date, '%d')
|
||||
ORDER BY DATE_FORMAT(payment_date, '%Y-%m-%d') ASC
|
||||
");
|
||||
$stmt->execute([$uid, $start, $end]);
|
||||
$loanPaymentRows = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
l.loan_name,
|
||||
l.lender_name,
|
||||
ls.due_date,
|
||||
ls.scheduled_principal,
|
||||
ls.scheduled_interest,
|
||||
ls.scheduled_total
|
||||
FROM loan_schedules ls
|
||||
JOIN loans l ON l.id = ls.loan_id
|
||||
WHERE l.user_id = ?
|
||||
AND ls.is_paid = 0
|
||||
AND ls.due_date BETWEEN ? AND ?
|
||||
ORDER BY ls.due_date ASC, l.id ASC
|
||||
LIMIT 10
|
||||
");
|
||||
$stmt->execute([$uid, $start, $end]);
|
||||
$loanUpcoming = $stmt->fetchAll();
|
||||
|
||||
$liquidAssets = (float)($assetSummary['liquid_assets'] ?? 0);
|
||||
|
||||
$incomeTotal = (float)($summary['income_total'] ?? 0);
|
||||
$expenseTotal = (float)($summary['expense_total'] ?? 0);
|
||||
$cardPaymentTotal = (float)($summary['card_payment_total'] ?? 0);
|
||||
|
||||
$loanDuePrincipal = (float)($loanDue['due_principal'] ?? 0);
|
||||
$loanDueInterest = (float)($loanDue['due_interest'] ?? 0);
|
||||
$loanDueTotal = (float)($loanDue['due_total'] ?? 0);
|
||||
|
||||
$loanOverduePrincipal = (float)($loanOverdue['overdue_principal'] ?? 0);
|
||||
$loanOverdueInterest = (float)($loanOverdue['overdue_interest'] ?? 0);
|
||||
$loanOverdueTotal = (float)($loanOverdue['overdue_total'] ?? 0);
|
||||
|
||||
$loanRemainingPrincipal = (float)($loanPrincipalRow['remaining_principal'] ?? 0);
|
||||
$loanRemainingInterest = (float)($loanRemaining['remaining_interest'] ?? 0);
|
||||
$loanRemainingTotal = (float)($loanRemaining['remaining_total'] ?? 0);
|
||||
|
||||
$netCashFlow = $incomeTotal - $expenseTotal - $loanDueTotal;
|
||||
|
||||
$categoryLabels = array_map(fn($r) => $r['name'], $expenseByCategory);
|
||||
$categoryValues = array_map(fn($r) => (float)$r['total_amount'], $expenseByCategory);
|
||||
|
||||
$dayLabels = array_map(fn($r) => $r['day_num'] . '일', $dailyRows);
|
||||
$dayValues = array_map(fn($r) => (float)$r['daily_expense'], $dailyRows);
|
||||
|
||||
$loanDayLabels = array_map(fn($r) => $r['day_num'] . '일', $loanPaymentRows);
|
||||
$loanDayValues = array_map(fn($r) => (float)$r['daily_loan_payment'], $loanPaymentRows);
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>대시보드</h2>
|
||||
<form method="get" class="d-flex gap-2">
|
||||
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
|
||||
<button class="btn btn-outline-primary">조회</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-summary-card finance-summary-income h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">이번 달 수입</div>
|
||||
<div class="hero-value"><?= won($incomeTotal) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-summary-card finance-summary-expense h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">이번 달 지출</div>
|
||||
<div class="hero-value"><?= won($expenseTotal) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-summary-card finance-summary-loan h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">이번 달 카드 총청구액</div>
|
||||
<div class="hero-value"><?= won($cardTotalDueThisMonth) ?></div>
|
||||
<div class="small text-secondary mt-2">
|
||||
일반 <?= won($cardDueThisMonth) ?> · 할부 <?= won($installmentDueThisMonth) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-summary-card finance-summary-net h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">이번 달 대출 납부 예정액</div>
|
||||
<div class="hero-value"><?= won($loanDueTotal) ?></div>
|
||||
<div class="small text-secondary mt-2">
|
||||
원금 <?= won($loanDuePrincipal) ?> · 이자 <?= won($loanDueInterest) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">유동자산 합계</div>
|
||||
<div class="stat-value text-primary"><?= won($liquidAssets) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">다음달 카드 예정액</div>
|
||||
<div class="stat-value text-danger"><?= won($cardTotalDueNextMonth) ?></div>
|
||||
<div class="small text-secondary mt-2">
|
||||
일반 <?= won($cardDueNextMonth) ?> · 할부 <?= won($installmentDueNextMonth) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">남은 할부 총액</div>
|
||||
<div class="stat-value"><?= won($installmentRemainingTotal) ?></div>
|
||||
<div class="small text-secondary mt-2">
|
||||
원금 <?= won($installmentRemainingPrincipal) ?> · 이자 <?= won($installmentRemainingInterest) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card finance-card h-100 border-danger">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">대출 미납액</div>
|
||||
<div class="stat-value text-danger"><?= won($loanOverdueTotal) ?></div>
|
||||
<div class="small text-secondary mt-2">
|
||||
원금 <?= won($loanOverduePrincipal) ?> · 이자 <?= won($loanOverdueInterest) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">남은 대출 원금</div>
|
||||
<div class="stat-value text-primary"><?= won($loanRemainingPrincipal) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">남은 대출 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($loanRemainingInterest) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">이번 달 순현금흐름</div>
|
||||
<div class="stat-value <?= $netCashFlow >= 0 ? 'text-primary' : 'text-danger' ?>">
|
||||
<?= won($netCashFlow) ?>
|
||||
</div>
|
||||
<div class="small text-secondary mt-2">수입 - 지출 - 대출예정</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ($accounts as $account): ?>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a href="/transactions.php?ym=<?= h($ym) ?>&account_id=<?= (int)$account['id'] ?>" class="text-decoration-none">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="eyebrow"><?= h($account['institution_name']) ?> / <?= h($account['account_type']) ?></div>
|
||||
<div class="card-title-sm"><?= h($account['account_name']) ?></div>
|
||||
<div class="stat-value <?= $account['account_type'] === 'card' ? 'text-danger' : 'text-primary' ?>">
|
||||
<?= won($account['current_balance']) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">카테고리별 지출</div>
|
||||
<div class="card-body pt-0">
|
||||
<canvas id="categoryChart" style="min-height:280px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">일자별 지출</div>
|
||||
<div class="card-body pt-0">
|
||||
<canvas id="dailyChart" style="min-height:280px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">일자별 대출 납부</div>
|
||||
<div class="card-body pt-0">
|
||||
<canvas id="loanPaymentChart" style="min-height:280px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card finance-card">
|
||||
<div class="card-header bg-transparent border-0 fw-bold pt-4 px-4">이번 달 대출 납부 예정</div>
|
||||
<div class="card-body pt-0 mobile-scroll">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>대출명</th>
|
||||
<th>기관</th>
|
||||
<th>납부일</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($loanUpcoming as $row): ?>
|
||||
<tr>
|
||||
<td><?= h($row['loan_name']) ?></td>
|
||||
<td><?= h($row['lender_name'] ?: '-') ?></td>
|
||||
<td><?= ymd($row['due_date']) ?></td>
|
||||
<td class="text-end"><?= won($row['scheduled_principal']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($row['scheduled_interest']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($row['scheduled_total']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$loanUpcoming): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-5">이번 달 대출 납부 예정이 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const categoryLabels = <?= json_encode($categoryLabels, JSON_UNESCAPED_UNICODE) ?>;
|
||||
const categoryValues = <?= json_encode($categoryValues, JSON_UNESCAPED_UNICODE) ?>;
|
||||
const dayLabels = <?= json_encode($dayLabels, JSON_UNESCAPED_UNICODE) ?>;
|
||||
const dayValues = <?= json_encode($dayValues, JSON_UNESCAPED_UNICODE) ?>;
|
||||
const loanDayLabels = <?= json_encode($loanDayLabels, JSON_UNESCAPED_UNICODE) ?>;
|
||||
const loanDayValues = <?= json_encode($loanDayValues, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
new Chart(document.getElementById('categoryChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: categoryLabels,
|
||||
datasets: [{ data: categoryValues }]
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('dailyChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: dayLabels,
|
||||
datasets: [{
|
||||
label: '지출',
|
||||
data: dayValues,
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true
|
||||
}
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('loanPaymentChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: loanDayLabels,
|
||||
datasets: [{
|
||||
label: '대출 납부',
|
||||
data: loanDayValues,
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 477 KiB |
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
header('Location: /dashboard.php');
|
||||
exit;
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
$ym = $_GET['ym'] ?? date('Y-m');
|
||||
$accountId = (int)($_GET['account_id'] ?? 0);
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$mode = $_POST['mode'] ?? '';
|
||||
|
||||
require_once __DIR__ . '/../app/lib/installment_service.php';
|
||||
|
||||
if ($mode === 'mark_billed') {
|
||||
$scheduleId = (int)($_POST['schedule_id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT s.installment_id
|
||||
FROM installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
WHERE s.id = ?
|
||||
AND i.user_id = ?
|
||||
");
|
||||
$stmt->execute([$scheduleId, $uid]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) {
|
||||
throw new RuntimeException('회차를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
SET s.is_billed = 1,
|
||||
s.billed_at = NOW()
|
||||
WHERE s.id = ?
|
||||
AND i.user_id = ?
|
||||
");
|
||||
$stmt->execute([$scheduleId, $uid]);
|
||||
|
||||
recalculate_installment_status((int)$row['installment_id']);
|
||||
$msg = '청구완료 처리되었습니다.';
|
||||
}
|
||||
|
||||
if ($mode === 'mark_unbilled') {
|
||||
$scheduleId = (int)($_POST['schedule_id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT s.installment_id
|
||||
FROM installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
WHERE s.id = ?
|
||||
AND i.user_id = ?
|
||||
");
|
||||
$stmt->execute([$scheduleId, $uid]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) {
|
||||
throw new RuntimeException('회차를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
SET s.is_billed = 0,
|
||||
s.billed_at = NULL
|
||||
WHERE s.id = ?
|
||||
AND i.user_id = ?
|
||||
");
|
||||
$stmt->execute([$scheduleId, $uid]);
|
||||
|
||||
recalculate_installment_status((int)$row['installment_id']);
|
||||
$msg = '청구완료 취소되었습니다.';
|
||||
}
|
||||
|
||||
if ($mode === 'mark_month_all_billed' || $mode === 'mark_month_all_unbilled') {
|
||||
$targetYm = trim($_POST['target_ym'] ?? '');
|
||||
|
||||
if ($targetYm === '') {
|
||||
throw new RuntimeException('대상 월이 없습니다.');
|
||||
}
|
||||
|
||||
$wantBilled = ($mode === 'mark_month_all_billed');
|
||||
$fromState = $wantBilled ? 0 : 1;
|
||||
|
||||
$params = [$uid, $targetYm];
|
||||
$sql = "
|
||||
SELECT s.id, s.installment_id
|
||||
FROM installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
JOIN transactions t ON t.id = i.transaction_id
|
||||
WHERE i.user_id = ?
|
||||
AND s.bill_year_month = ?
|
||||
AND s.is_billed = ?
|
||||
";
|
||||
$params[] = $fromState;
|
||||
|
||||
if ($accountId > 0) {
|
||||
$sql .= " AND i.account_id = ? ";
|
||||
$params[] = $accountId;
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$sql .= " AND (t.merchant_name LIKE ? OR t.description LIKE ?) ";
|
||||
$like = '%' . $q . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
if ($rows) {
|
||||
$scheduleIds = array_column($rows, 'id');
|
||||
$installmentIds = array_values(array_unique(array_column($rows, 'installment_id')));
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($scheduleIds), '?'));
|
||||
|
||||
if ($wantBilled) {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE installment_schedules
|
||||
SET is_billed = 1,
|
||||
billed_at = NOW()
|
||||
WHERE id IN ($placeholders)
|
||||
");
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE installment_schedules
|
||||
SET is_billed = 0,
|
||||
billed_at = NULL
|
||||
WHERE id IN ($placeholders)
|
||||
");
|
||||
}
|
||||
|
||||
$stmt->execute($scheduleIds);
|
||||
|
||||
foreach ($installmentIds as $iid) {
|
||||
recalculate_installment_status((int)$iid);
|
||||
}
|
||||
}
|
||||
|
||||
$msg = $wantBilled
|
||||
? $targetYm . ' 조회건 전체 청구완료 처리되었습니다.'
|
||||
: $targetYm . ' 조회건 전체 취소되었습니다.';
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$params = [$uid, $ym];
|
||||
$where = [
|
||||
"i.user_id = ?",
|
||||
"s.bill_year_month = ?"
|
||||
];
|
||||
|
||||
if ($accountId > 0) {
|
||||
$where[] = "i.account_id = ?";
|
||||
$params[] = $accountId;
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$where[] = "(t.merchant_name LIKE ? OR t.description LIKE ? OR a.account_name LIKE ?)";
|
||||
$like = '%' . $q . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
s.id AS schedule_id,
|
||||
s.installment_id,
|
||||
s.cycle_no,
|
||||
s.bill_year_month,
|
||||
s.principal_amount,
|
||||
s.interest_amount,
|
||||
s.total_amount,
|
||||
s.is_billed,
|
||||
s.billed_at,
|
||||
|
||||
i.installment_months,
|
||||
i.annual_interest_rate,
|
||||
|
||||
a.account_name,
|
||||
a.institution_name,
|
||||
|
||||
t.merchant_name,
|
||||
t.description
|
||||
|
||||
FROM installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
JOIN accounts a ON a.id = i.account_id
|
||||
JOIN transactions t ON t.id = i.transaction_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY s.is_billed ASC, a.account_name ASC, s.cycle_no ASC, s.id ASC
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$list = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, account_name, institution_name
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type = 'card'
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$cardAccounts = $stmt->fetchAll();
|
||||
|
||||
$summaryStmt = $pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN s.is_billed=0 THEN s.principal_amount ELSE 0 END),0) AS unbilled_principal,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed=0 THEN s.interest_amount ELSE 0 END),0) AS unbilled_interest,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed=0 THEN s.total_amount ELSE 0 END),0) AS unbilled_total,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed=1 THEN s.total_amount ELSE 0 END),0) AS billed_total
|
||||
FROM installment_schedules s
|
||||
JOIN installments i ON i.id = s.installment_id
|
||||
JOIN transactions t ON t.id = i.transaction_id
|
||||
JOIN accounts a ON a.id = i.account_id
|
||||
WHERE " . implode(' AND ', $where));
|
||||
|
||||
$summaryStmt->execute($params);
|
||||
$summary = $summaryStmt->fetch();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>할부 청구 관리</h2>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/installments.php" class="btn btn-outline-secondary">할부 목록</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">청구월</label>
|
||||
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">카드</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($cardAccounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>" <?= $accountId === (int)$acc['id'] ? 'selected' : '' ?>>
|
||||
<?= h($acc['institution_name']) ?> / <?= h($acc['account_name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">검색</label>
|
||||
<input type="text" name="q" class="form-control" value="<?= h($q) ?>" placeholder="사용처, 메모, 카드명">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-2 d-flex align-items-end gap-2">
|
||||
<button class="btn btn-primary w-100">조회</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">미청구 원금</div>
|
||||
<div class="stat-value"><?= won($summary['unbilled_principal']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">미청구 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($summary['unbilled_interest']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">미청구 합계</div>
|
||||
<div class="stat-value"><?= won($summary['unbilled_total']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">청구완료 합계</div>
|
||||
<div class="stat-value text-primary"><?= won($summary['billed_total']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<form method="post">
|
||||
<input type="hidden" name="mode" value="mark_month_all_billed">
|
||||
<input type="hidden" name="target_ym" value="<?= h($ym) ?>">
|
||||
<button class="btn btn-primary" onclick="return confirm('조회 조건 전체를 청구완료 처리하시겠습니까?');">
|
||||
전체 청구완료
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="mode" value="mark_month_all_unbilled">
|
||||
<input type="hidden" name="target_ym" value="<?= h($ym) ?>">
|
||||
<button class="btn btn-outline-danger" onclick="return confirm('조회 조건 전체를 취소하시겠습니까?');">
|
||||
전체 취소
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body mobile-scroll">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>카드</th>
|
||||
<th>사용처</th>
|
||||
<th>회차</th>
|
||||
<th>청구월</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
<th>상태</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<?php foreach ($list as $row): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold"><?= h($row['account_name']) ?></div>
|
||||
<div class="small text-secondary"><?= h($row['institution_name']) ?></div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="fw-bold"><?= h($row['merchant_name'] ?: '-') ?></div>
|
||||
<div class="small text-secondary"><?= h($row['description'] ?: '-') ?></div>
|
||||
<div class="small text-secondary">연이자율 <?= numf($row['annual_interest_rate'], 2) ?>%</div>
|
||||
</td>
|
||||
|
||||
<td><?= intvalf($row['cycle_no']) ?> / <?= intvalf($row['installment_months']) ?></td>
|
||||
<td><?= h($row['bill_year_month']) ?></td>
|
||||
|
||||
<td class="text-end"><?= won($row['principal_amount']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($row['interest_amount']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($row['total_amount']) ?></td>
|
||||
|
||||
<td>
|
||||
<?php if ((int)$row['is_billed'] === 1): ?>
|
||||
<span class="badge text-bg-success">청구완료</span>
|
||||
<?php if (!empty($row['billed_at'])): ?>
|
||||
<div class="small text-secondary mt-1">
|
||||
<?= h(date('Y-m-d H:i', strtotime($row['billed_at']))) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-secondary">미청구</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">
|
||||
<?php if ((int)$row['is_billed'] === 0): ?>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="mark_billed">
|
||||
<input type="hidden" name="schedule_id" value="<?= $row['schedule_id'] ?>">
|
||||
<button class="btn btn-sm btn-primary">완료</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="mark_unbilled">
|
||||
<input type="hidden" name="schedule_id" value="<?= $row['schedule_id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger">취소</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$list): ?>
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-secondary py-5">조회 결과가 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/installment_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
$installmentId = (int)($_GET['id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
i.*,
|
||||
a.account_name,
|
||||
a.institution_name,
|
||||
t.transaction_date,
|
||||
t.merchant_name,
|
||||
t.description,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.principal_amount ELSE 0 END), 0) AS remaining_principal,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.interest_amount ELSE 0 END), 0) AS remaining_interest,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.total_amount ELSE 0 END), 0) AS remaining_total,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 1 THEN s.total_amount ELSE 0 END), 0) AS billed_total,
|
||||
COALESCE(MIN(CASE WHEN s.is_billed = 0 THEN s.cycle_no ELSE NULL END), 0) AS next_cycle
|
||||
FROM installments i
|
||||
JOIN accounts a ON a.id = i.account_id
|
||||
JOIN transactions t ON t.id = i.transaction_id
|
||||
LEFT JOIN installment_schedules s ON s.installment_id = i.id
|
||||
WHERE i.id = ?
|
||||
AND i.user_id = ?
|
||||
GROUP BY
|
||||
i.id, i.user_id, i.transaction_id, i.account_id,
|
||||
i.principal_amount, i.interest_total, i.total_billed_amount,
|
||||
i.installment_months, i.annual_interest_rate, i.start_year_month,
|
||||
i.interest_type, i.current_cycle, i.is_completed,
|
||||
i.prepaid_principal_amount, i.prepaid_interest_amount,
|
||||
i.created_at, i.updated_at,
|
||||
a.account_name, a.institution_name,
|
||||
t.transaction_date, t.merchant_name, t.description
|
||||
");
|
||||
$stmt->execute([$installmentId, $uid]);
|
||||
$installment = $stmt->fetch();
|
||||
|
||||
if (!$installment) {
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>할부 선결제 / 중도상환</h2>
|
||||
<a href="/installments.php" class="btn btn-outline-secondary">할부내역</a>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="card-title-sm mb-3">할부 정보를 찾을 수 없습니다.</div>
|
||||
<div class="text-secondary mb-4">
|
||||
이미 초기화되었거나 삭제된 할부 내역일 수 있습니다.
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<a href="/installments.php" class="btn btn-primary">할부내역으로 이동</a>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="history.back();">뒤로가기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require __DIR__ . '/../app/views/footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type IN ('bank','cash','other')
|
||||
ORDER BY FIELD(account_type, 'bank', 'cash', 'other'), id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$paymentAccounts = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$paymentAccountId = (int)($_POST['payment_account_id'] ?? 0);
|
||||
$prepayDate = $_POST['prepay_date'] ?? date('Y-m-d');
|
||||
$prepayPrincipal = (float)str_replace(',', '', (string)($_POST['prepay_principal_amount'] ?? 0));
|
||||
$prepayInterest = (float)str_replace(',', '', (string)($_POST['prepay_interest_amount'] ?? 0));
|
||||
$description = trim($_POST['description'] ?? '') ?: null;
|
||||
|
||||
$targetScheduleId = !empty($_POST['target_schedule_id'])
|
||||
? (int)$_POST['target_schedule_id']
|
||||
: null;
|
||||
|
||||
if ($paymentAccountId <= 0) {
|
||||
throw new RuntimeException('결제 계좌를 선택하세요.');
|
||||
}
|
||||
|
||||
prepay_installment(
|
||||
$uid,
|
||||
$installmentId,
|
||||
$paymentAccountId,
|
||||
$prepayDate,
|
||||
$prepayPrincipal,
|
||||
$prepayInterest,
|
||||
$description,
|
||||
$targetScheduleId
|
||||
);
|
||||
|
||||
$msg = '선결제/중도상환이 처리되었습니다.';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
i.*,
|
||||
a.account_name,
|
||||
a.institution_name,
|
||||
t.transaction_date,
|
||||
t.merchant_name,
|
||||
t.description,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.principal_amount ELSE 0 END), 0) AS remaining_principal,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.interest_amount ELSE 0 END), 0) AS remaining_interest,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.total_amount ELSE 0 END), 0) AS remaining_total,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 1 THEN s.total_amount ELSE 0 END), 0) AS billed_total,
|
||||
COALESCE(MIN(CASE WHEN s.is_billed = 0 THEN s.cycle_no ELSE NULL END), 0) AS next_cycle
|
||||
FROM installments i
|
||||
JOIN accounts a ON a.id = i.account_id
|
||||
JOIN transactions t ON t.id = i.transaction_id
|
||||
LEFT JOIN installment_schedules s ON s.installment_id = i.id
|
||||
WHERE i.id = ?
|
||||
AND i.user_id = ?
|
||||
GROUP BY
|
||||
i.id, i.user_id, i.transaction_id, i.account_id,
|
||||
i.principal_amount, i.interest_total, i.total_billed_amount,
|
||||
i.installment_months, i.annual_interest_rate, i.start_year_month,
|
||||
i.interest_type, i.current_cycle, i.is_completed,
|
||||
i.prepaid_principal_amount, i.prepaid_interest_amount,
|
||||
i.created_at, i.updated_at,
|
||||
a.account_name, a.institution_name,
|
||||
t.transaction_date, t.merchant_name, t.description
|
||||
");
|
||||
$stmt->execute([$installmentId, $uid]);
|
||||
$installment = $stmt->fetch();
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM installment_schedules
|
||||
WHERE installment_id = ?
|
||||
ORDER BY cycle_no ASC
|
||||
");
|
||||
$stmt->execute([$installmentId]);
|
||||
$schedules = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM installment_prepayments
|
||||
WHERE installment_id = ?
|
||||
AND user_id = ?
|
||||
ORDER BY prepay_date DESC, id DESC
|
||||
");
|
||||
$stmt->execute([$installmentId, $uid]);
|
||||
$prepayments = $stmt->fetchAll();
|
||||
|
||||
$remainingPrincipal = (float)($installment['remaining_principal'] ?? 0);
|
||||
$remainingInterest = (float)($installment['remaining_interest'] ?? 0);
|
||||
$remainingTotal = (float)($installment['remaining_total'] ?? 0);
|
||||
$totalBilled = (float)($installment['total_billed_amount'] ?? 0);
|
||||
$billedTotal = (float)($installment['billed_total'] ?? 0);
|
||||
|
||||
$progress = $totalBilled > 0 ? (($totalBilled - $remainingTotal) / $totalBilled) * 100 : 0;
|
||||
$progress = max(0, min(100, $progress));
|
||||
$barWidth = $progress > 0 ? max($progress, 2) : 0;
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>할부 선결제 / 중도상환</h2>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/installments.php" class="btn btn-outline-secondary">할부내역</a>
|
||||
<a href="/installment_billing.php?ym=<?= h($installment['start_year_month']) ?>&account_id=<?= h((string)$installment['account_id']) ?>" class="btn btn-outline-primary">청구관리</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card finance-card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-2">
|
||||
<div>
|
||||
<div class="eyebrow"><?= h($installment['institution_name']) ?> / <?= h($installment['account_name']) ?></div>
|
||||
<div class="card-title-lg"><?= h($installment['merchant_name'] ?: '사용처 없음') ?></div>
|
||||
</div>
|
||||
|
||||
<span class="badge <?= $remainingTotal > 0 ? 'text-bg-warning' : 'text-bg-success' ?>">
|
||||
<?= $remainingTotal > 0 ? '진행중' : '완료' ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="small text-secondary mb-3">
|
||||
결제일 <?= ymd($installment['transaction_date']) ?>
|
||||
· 시작 청구월 <?= h($installment['start_year_month']) ?>
|
||||
· <?= intvalf($installment['installment_months']) ?>개월
|
||||
<?php if (!empty($installment['description'])): ?>
|
||||
<br><?= h($installment['description']) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="stat-label">남은 원금</div>
|
||||
<div class="stat-value"><?= won($remainingPrincipal) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">남은 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($remainingInterest) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">남은 총액</div>
|
||||
<div class="stat-value"><?= won($remainingTotal) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">연이자율</div>
|
||||
<div class="stat-value"><?= numf($installment['annual_interest_rate'], 2) ?>%</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">총 청구금액</div>
|
||||
<div class="stat-value"><?= won($totalBilled) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">청구완료 금액</div>
|
||||
<div class="stat-value text-primary"><?= won($billedTotal) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="finance-progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: <?= number_format($barWidth, 1) ?>%"></div>
|
||||
</div>
|
||||
<div class="small text-secondary mt-2">
|
||||
진행률 <?= percentf($progress) ?>
|
||||
<?php if ((int)$installment['next_cycle'] > 0): ?>
|
||||
· 다음 <?= intvalf($installment['next_cycle']) ?>회차
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="card-title-sm mb-3">선결제 입력</div>
|
||||
|
||||
<?php if ($remainingTotal <= 0): ?>
|
||||
<div class="alert alert-success mb-0">
|
||||
남아있는 할부 금액이 없습니다.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" class="row g-3" id="prepayForm">
|
||||
<input type="hidden" name="target_schedule_id" id="target_schedule_id" value="">
|
||||
<div class="col-12">
|
||||
<label class="form-label">선결제 출금 계좌</label>
|
||||
<select name="payment_account_id" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<?php foreach ($paymentAccounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>">
|
||||
<?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">선결제일</label>
|
||||
<input type="date" name="prepay_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">선결제 원금</label>
|
||||
<input
|
||||
type="text"
|
||||
name="prepay_principal_amount"
|
||||
id="prepay_principal_amount"
|
||||
class="form-control"
|
||||
inputmode="numeric"
|
||||
value="0"
|
||||
required
|
||||
>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary fill-principal">남은 원금</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary clear-principal">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">선결제 이자</label>
|
||||
<input
|
||||
type="text"
|
||||
name="prepay_interest_amount"
|
||||
id="prepay_interest_amount"
|
||||
class="form-control"
|
||||
inputmode="numeric"
|
||||
value="0"
|
||||
>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary fill-interest">남은 이자</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary clear-interest">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="loan-create-highlight">
|
||||
<div class="title">빠른 입력</div>
|
||||
<div class="desc">남은 금액 전체를 선결제하려면 전체상환 버튼을 사용하세요.</div>
|
||||
<div class="mt-3 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-primary" id="fill_all_btn">전체상환 입력</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="clear_all_btn">전체 초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" id="description" class="form-control" placeholder="예: 카드 선결제">
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary" onclick="return confirm('선결제/중도상환 처리하시겠습니까?');">
|
||||
선결제 실행
|
||||
</button>
|
||||
<a href="/installments.php" class="btn btn-outline-secondary">취소</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card finance-card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="card-title-sm mb-3">남은 회차 상세</div>
|
||||
|
||||
<div class="mobile-scroll">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회차</th>
|
||||
<th>청구월</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
<th>상태</th>
|
||||
<th>선납</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($schedules as $s): ?>
|
||||
<?php $isBilled = (int)$s['is_billed'] === 1; ?>
|
||||
<tr class="<?= $isBilled ? 'text-secondary' : '' ?>">
|
||||
<td><?= intvalf($s['cycle_no']) ?>회차</td>
|
||||
<td><?= h($s['bill_year_month']) ?></td>
|
||||
<td class="text-end"><?= won($s['principal_amount']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($s['interest_amount']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($s['total_amount']) ?></td>
|
||||
<td>
|
||||
<?php if ($isBilled): ?>
|
||||
<span class="badge text-bg-success">청구완료</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-secondary">미청구</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<?php if (!$isBilled && (float)$s['total_amount'] > 0): ?>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary cycle-prepay-btn"
|
||||
data-schedule-id="<?= (int)$s['id'] ?>"
|
||||
data-cycle="<?= (int)$s['cycle_no'] ?>"
|
||||
data-principal="<?= (float)$s['principal_amount'] ?>"
|
||||
data-interest="<?= (float)$s['interest_amount'] ?>"
|
||||
>
|
||||
선납
|
||||
</button>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$schedules): ?>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-secondary py-4">회차 정보가 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="card-title-sm mb-3">선결제 이력</div>
|
||||
|
||||
<div class="mobile-scroll">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일자</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
<th>메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($prepayments as $p): ?>
|
||||
<tr>
|
||||
<td><?= ymd($p['prepay_date']) ?></td>
|
||||
<td class="text-end"><?= won($p['principal_amount']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($p['interest_amount']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($p['total_amount']) ?></td>
|
||||
<td><?= h($p['description'] ?: '-') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$prepayments): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-secondary py-4">선결제 이력이 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const remainPrincipal = <?= json_encode($remainingPrincipal) ?>;
|
||||
const remainInterest = <?= json_encode($remainingInterest) ?>;
|
||||
|
||||
const principalEl = document.getElementById('prepay_principal_amount');
|
||||
const interestEl = document.getElementById('prepay_interest_amount');
|
||||
|
||||
if (!principalEl || !interestEl) return;
|
||||
|
||||
function formatWithComma(value) {
|
||||
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.]/g, '');
|
||||
if (normalized === '') return '';
|
||||
const parts = normalized.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
function setMoney(el, value) {
|
||||
el.value = formatWithComma(Math.round(Number(value || 0)));
|
||||
}
|
||||
|
||||
principalEl.addEventListener('input', function () {
|
||||
this.value = formatWithComma(this.value);
|
||||
});
|
||||
|
||||
interestEl.addEventListener('input', function () {
|
||||
this.value = formatWithComma(this.value);
|
||||
});
|
||||
|
||||
document.querySelector('.fill-principal')?.addEventListener('click', function () {
|
||||
setMoney(principalEl, remainPrincipal);
|
||||
});
|
||||
|
||||
document.querySelector('.clear-principal')?.addEventListener('click', function () {
|
||||
principalEl.value = '0';
|
||||
});
|
||||
|
||||
document.querySelector('.fill-interest')?.addEventListener('click', function () {
|
||||
setMoney(interestEl, remainInterest);
|
||||
});
|
||||
|
||||
document.querySelector('.clear-interest')?.addEventListener('click', function () {
|
||||
interestEl.value = '0';
|
||||
});
|
||||
|
||||
document.getElementById('fill_all_btn')?.addEventListener('click', function () {
|
||||
setMoney(principalEl, remainPrincipal);
|
||||
setMoney(interestEl, remainInterest);
|
||||
|
||||
const targetScheduleEl = document.getElementById('target_schedule_id');
|
||||
if (targetScheduleEl) targetScheduleEl.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('clear_all_btn')?.addEventListener('click', function () {
|
||||
principalEl.value = '0';
|
||||
interestEl.value = '0';
|
||||
|
||||
const targetScheduleEl = document.getElementById('target_schedule_id');
|
||||
if (targetScheduleEl) targetScheduleEl.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('prepayForm')?.addEventListener('submit', function () {
|
||||
principalEl.value = principalEl.value.replace(/,/g, '');
|
||||
interestEl.value = interestEl.value.replace(/,/g, '');
|
||||
});
|
||||
|
||||
const descEl = document.getElementById('description');
|
||||
const targetScheduleEl = document.getElementById('target_schedule_id');
|
||||
|
||||
document.querySelectorAll('.cycle-prepay-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const cycle = this.dataset.cycle;
|
||||
const principal = Number(this.dataset.principal || 0);
|
||||
const interest = Number(this.dataset.interest || 0);
|
||||
|
||||
setMoney(principalEl, principal);
|
||||
setMoney(interestEl, interest);
|
||||
|
||||
if (targetScheduleEl) {
|
||||
targetScheduleEl.value = this.dataset.scheduleId || '';
|
||||
}
|
||||
|
||||
if (descEl) {
|
||||
descEl.value = cycle + '회차 선납';
|
||||
}
|
||||
|
||||
principalEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/installment_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
|
||||
$msg = '';
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$mode = $_POST['mode'] ?? '';
|
||||
|
||||
if ($mode === 'rebuild_installments') {
|
||||
$count = rebuild_all_installments_for_user($uid);
|
||||
$msg = '할부 자동반영 재적용 완료 (' . number_format($count) . '건)';
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$ym = $_GET['ym'] ?? '';
|
||||
$accountId = (int)($_GET['account_id'] ?? 0);
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
|
||||
$params = [$uid];
|
||||
$where = ["i.user_id = ?"];
|
||||
|
||||
if ($ym !== '') {
|
||||
$where[] = "i.start_year_month = ?";
|
||||
$params[] = $ym;
|
||||
}
|
||||
|
||||
if ($accountId > 0) {
|
||||
$where[] = "i.account_id = ?";
|
||||
$params[] = $accountId;
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$where[] = "(t.merchant_name LIKE ? OR t.description LIKE ? OR a.account_name LIKE ?)";
|
||||
$like = '%' . $q . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
i.*,
|
||||
a.account_name,
|
||||
a.institution_name,
|
||||
t.transaction_date,
|
||||
t.merchant_name,
|
||||
t.description,
|
||||
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.principal_amount ELSE 0 END), 0) AS remaining_principal,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.interest_amount ELSE 0 END), 0) AS remaining_interest,
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 0 THEN s.total_amount ELSE 0 END), 0) AS remaining_total,
|
||||
|
||||
COALESCE(SUM(CASE WHEN s.is_billed = 1 THEN s.total_amount ELSE 0 END), 0) AS billed_total,
|
||||
COALESCE(MIN(CASE WHEN s.is_billed = 0 THEN s.cycle_no ELSE NULL END), 0) AS next_cycle
|
||||
FROM installments i
|
||||
JOIN accounts a ON a.id = i.account_id
|
||||
JOIN transactions t ON t.id = i.transaction_id
|
||||
LEFT JOIN installment_schedules s ON s.installment_id = i.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
GROUP BY
|
||||
i.id, i.user_id, i.transaction_id, i.account_id,
|
||||
i.principal_amount, i.interest_total, i.total_billed_amount,
|
||||
i.installment_months, i.annual_interest_rate, i.start_year_month,
|
||||
i.interest_type, i.current_cycle, i.is_completed,
|
||||
i.prepaid_principal_amount, i.prepaid_interest_amount,
|
||||
i.created_at, i.updated_at,
|
||||
a.account_name, a.institution_name,
|
||||
t.transaction_date, t.merchant_name, t.description
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$installments = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, account_name, institution_name
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type = 'card'
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$cardAccounts = $stmt->fetchAll();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>할부 내역</h2>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/installment_billing.php" class="btn btn-outline-primary">
|
||||
할부 청구 관리
|
||||
</a>
|
||||
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="rebuild_installments">
|
||||
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
onclick="return confirm('기존 할부 스케줄을 초기화 후 재생성합니다. 진행하시겠습니까?');">
|
||||
자동반영 리셋 후 재적용
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">시작월</label>
|
||||
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">카드</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($cardAccounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>" <?= $accountId === (int)$acc['id'] ? 'selected' : '' ?>>
|
||||
<?= h($acc['institution_name']) ?> / <?= h($acc['account_name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">검색</label>
|
||||
<input type="text" name="q" class="form-control" value="<?= h($q) ?>" placeholder="사용처, 메모, 카드명">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-2 d-flex align-items-end gap-2">
|
||||
<button class="btn btn-primary w-100">조회</button>
|
||||
<a href="/installments.php" class="btn btn-outline-secondary w-100">초기화</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<?php foreach ($installments as $row): ?>
|
||||
<?php
|
||||
$progress = 0;
|
||||
if ((float)$row['total_billed_amount'] > 0) {
|
||||
$progress = (($row['total_billed_amount'] - $row['remaining_total']) / (float)$row['total_billed_amount']) * 100;
|
||||
$progress = max(0, min(100, $progress));
|
||||
}
|
||||
|
||||
$scheduleStmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM installment_schedules
|
||||
WHERE installment_id = ?
|
||||
ORDER BY cycle_no ASC
|
||||
");
|
||||
$scheduleStmt->execute([$row['id']]);
|
||||
$schedules = $scheduleStmt->fetchAll();
|
||||
?>
|
||||
<div class="col-12">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="eyebrow"><?= h($row['institution_name']) ?> / <?= h($row['account_name']) ?></div>
|
||||
<div class="card-title-lg"><?= h($row['merchant_name'] ?: '사용처 없음') ?></div>
|
||||
<div class="text-secondary small">
|
||||
결제일 <?= h($row['transaction_date']) ?>
|
||||
<?php if (!empty($row['description'])): ?>
|
||||
· <?= h($row['description']) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl-end">
|
||||
<span class="badge <?= (float)$row['remaining_total'] > 0 ? 'text-bg-warning' : 'text-bg-success' ?>">
|
||||
<?= (float)$row['remaining_total'] > 0 ? '진행중' : '완료' ?>
|
||||
</span>
|
||||
<div class="small text-secondary mt-2">연이자율 <?= h((string)$row['annual_interest_rate']) ?>%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">원금</div>
|
||||
<div class="stat-value"><?= won($row['principal_amount']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">총 할부이자</div>
|
||||
<div class="stat-value text-danger"><?= won($row['interest_total']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">총 청구금액</div>
|
||||
<div class="stat-value"><?= won($row['total_billed_amount']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">개월 수</div>
|
||||
<div class="stat-value"><?= h((string)$row['installment_months']) ?>개월</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 원금</div>
|
||||
<div class="stat-value"><?= won($row['remaining_principal']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($row['remaining_interest']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 총액</div>
|
||||
<div class="stat-value"><?= won($row['remaining_total']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">다음 회차</div>
|
||||
<div class="stat-value"><?= (int)$row['next_cycle'] > 0 ? ((int)$row['next_cycle'] . '회차') : '-' ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="progress finance-progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: <?= number_format($progress, 1) ?>%"></div>
|
||||
</div>
|
||||
<div class="small text-secondary mt-2">진행률 <?= number_format($progress, 1) ?>%</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex flex-wrap gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#schedule-<?= $row['id'] ?>"
|
||||
>
|
||||
회차별 보기
|
||||
</button>
|
||||
|
||||
<?php if ((float)$row['remaining_total'] > 0): ?>
|
||||
<a href="/installment_prepay.php?id=<?= $row['id'] ?>" class="btn btn-sm btn-outline-danger">선결제 / 중도상환</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="/installment_billing.php?ym=<?= h($row['start_year_month']) ?>&account_id=<?= $row['account_id'] ?>&q=<?= urlencode((string)($row['merchant_name'] ?? '')) ?>" class="btn btn-sm btn-outline-secondary">
|
||||
청구관리로 보기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="collapse mt-3" id="schedule-<?= $row['id'] ?>">
|
||||
<div class="mobile-scroll">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회차</th>
|
||||
<th>청구월</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($schedules as $s): ?>
|
||||
<tr>
|
||||
<td><?= $s['cycle_no'] ?>회차</td>
|
||||
<td><?= h($s['bill_year_month']) ?></td>
|
||||
<td class="text-end"><?= won($s['principal_amount']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($s['interest_amount']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($s['total_amount']) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$s['is_billed'] === 1): ?>
|
||||
<span class="badge text-bg-success">청구완료</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-secondary">미청구</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$schedules): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">회차 정보가 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$installments): ?>
|
||||
<div class="col-12">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body text-center text-secondary py-5">
|
||||
등록된 할부 내역이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/loan_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type IN ('bank','cash','other')
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$loanName = trim($_POST['loan_name'] ?? '');
|
||||
$lenderName = trim($_POST['lender_name'] ?? '');
|
||||
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
|
||||
$annualInterestRate = (float)($_POST['annual_interest_rate'] ?? 0);
|
||||
$startDate = $_POST['start_date'] ?? date('Y-m-d');
|
||||
$graceMonths = (int)($_POST['grace_period_months'] ?? 0);
|
||||
$repaymentMonths = (int)($_POST['repayment_months'] ?? 0);
|
||||
$repaymentMethod = $_POST['repayment_method'] ?? '';
|
||||
$paymentDay = (int)($_POST['payment_day'] ?? 1);
|
||||
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$createFullHistory = !empty($_POST['create_full_history']) ? 1 : 0;
|
||||
|
||||
if ($loanName === '') {
|
||||
throw new RuntimeException('대출명을 입력하세요.');
|
||||
}
|
||||
|
||||
if ($principalAmount <= 0) {
|
||||
throw new RuntimeException('대출원금은 0보다 커야 합니다.');
|
||||
}
|
||||
|
||||
if ($annualInterestRate < 0) {
|
||||
throw new RuntimeException('연이자율이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($repaymentMonths <= 0) {
|
||||
throw new RuntimeException('상환개월 수는 1 이상이어야 합니다.');
|
||||
}
|
||||
|
||||
if ($paymentDay < 1 || $paymentDay > 31) {
|
||||
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
'interest_only_then_equal_payment',
|
||||
'interest_only_then_equal_principal',
|
||||
'interest_only_then_bullet',
|
||||
'equal_payment',
|
||||
'equal_principal',
|
||||
'bullet',
|
||||
];
|
||||
|
||||
if (!in_array($repaymentMethod, $allowed, true)) {
|
||||
throw new RuntimeException('상환방식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($createFullHistory && !$accountId) {
|
||||
throw new RuntimeException('완전 반영 방식을 사용할 때는 입출금 계좌를 선택하세요.');
|
||||
}
|
||||
|
||||
create_loan_with_full_backfill([
|
||||
'user_id' => $uid,
|
||||
'account_id' => $accountId,
|
||||
'loan_name' => $loanName,
|
||||
'lender_name' => $lenderName,
|
||||
'principal_amount' => $principalAmount,
|
||||
'annual_interest_rate' => $annualInterestRate,
|
||||
'start_date' => $startDate,
|
||||
'maturity_date' => null,
|
||||
'grace_period_months' => $graceMonths,
|
||||
'repayment_months' => $repaymentMonths,
|
||||
'repayment_method' => $repaymentMethod,
|
||||
'payment_day' => $paymentDay,
|
||||
'description' => $description,
|
||||
'create_full_history' => $createFullHistory,
|
||||
'today_date' => date('Y-m-d'),
|
||||
]);
|
||||
|
||||
redirect('/loans.php');
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<style>
|
||||
.loan-create-highlight {
|
||||
border: 1px solid rgba(37, 99, 235, 0.18);
|
||||
background: linear-gradient(180deg, rgba(37,99,235,0.06), rgba(37,99,235,0.03));
|
||||
border-radius: 16px;
|
||||
padding: 18px 18px 16px 18px;
|
||||
}
|
||||
.loan-create-highlight .title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.loan-create-highlight .desc {
|
||||
color: #5b6475;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.loan-create-check {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
.loan-create-check .form-check-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
.loan-create-check small {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">대출 등록</h2>
|
||||
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">대출명</label>
|
||||
<input type="text" name="loan_name" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">대출기관</label>
|
||||
<input type="text" name="lender_name" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">대출원금</label>
|
||||
<input type="number" name="principal_amount" class="form-control" min="1" step="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">연이자율(%)</label>
|
||||
<input type="number" name="annual_interest_rate" class="form-control" min="0" step="0.01" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">거치기간(개월)</label>
|
||||
<input type="number" name="grace_period_months" class="form-control" min="0" value="0" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">상환기간(개월)</label>
|
||||
<input type="number" name="repayment_months" class="form-control" min="1" value="12" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">납부일</label>
|
||||
<input type="number" name="payment_day" class="form-control" min="1" max="31" value="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">상환방식</label>
|
||||
<select name="repayment_method" class="form-select" required>
|
||||
<option value="interest_only_then_equal_payment">거치 후 원리금균등</option>
|
||||
<option value="interest_only_then_equal_principal">거치 후 원금균등</option>
|
||||
<option value="interest_only_then_bullet">거치 후 만기일시상환</option>
|
||||
<option value="equal_payment">원리금균등</option>
|
||||
<option value="equal_principal">원금균등</option>
|
||||
<option value="bullet">만기일시상환</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">입출금 계좌</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<option value="">선택안함</option>
|
||||
<?php foreach ($accounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>"><?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">과거 거래까지 자동 생성하려면 계좌를 선택하세요.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="loan-create-highlight">
|
||||
<div class="title">완전 반영 방식</div>
|
||||
<div class="desc">
|
||||
대출 시작일이 과거라면, 대출 실행 거래와 오늘까지의 과거 상환 거래를 자동으로 모두 생성합니다.
|
||||
미래 회차는 예정 스케줄로 남겨둡니다.
|
||||
</div>
|
||||
|
||||
<div class="loan-create-check">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="create_full_history" id="create_full_history" value="1" checked>
|
||||
<label class="form-check-label" for="create_full_history">
|
||||
대출 실행 + 과거 납부분 거래내역까지 전부 자동 생성
|
||||
</label>
|
||||
<small>
|
||||
예: 2024년에 받은 대출을 지금 등록하면<br>
|
||||
대출 실행일의 유입 거래 1건 + 오늘까지 도래한 상환 거래들이 자동 생성됩니다.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary">저장</button>
|
||||
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/loan_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM loans
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
$stmt->execute([$id, $uid]);
|
||||
$loan = $stmt->fetch();
|
||||
|
||||
if (!$loan) {
|
||||
exit('대출 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type IN ('bank','cash','other')
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$scheduleId = (int)($_POST['schedule_id'] ?? 0);
|
||||
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
|
||||
$paymentDate = $_POST['payment_date'] ?? date('Y-m-d');
|
||||
$description = trim($_POST['description'] ?? '') ?: null;
|
||||
|
||||
pay_loan_schedule($uid, $scheduleId, $accountId, $paymentDate, $description);
|
||||
$msg = '상환 처리되었습니다.';
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
ls.*,
|
||||
lp.payment_date,
|
||||
lp.description AS payment_description,
|
||||
lp.is_auto_generated,
|
||||
lp.payment_type
|
||||
FROM loan_schedules ls
|
||||
LEFT JOIN loan_payments lp
|
||||
ON lp.loan_schedule_id = ls.id
|
||||
WHERE ls.loan_id = ?
|
||||
ORDER BY ls.cycle_no ASC
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$schedules = $stmt->fetchAll();
|
||||
|
||||
$summary = get_loan_remaining_summary($id);
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">대출 상세</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<?php if ($loan['status'] === 'active'): ?>
|
||||
<a href="/loan_prepay.php?id=<?= $loan['id'] ?>" class="btn btn-outline-danger">중도상환</a>
|
||||
<?php endif; ?>
|
||||
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="eyebrow"><?= h($loan['lender_name'] ?: '-') ?></div>
|
||||
<div class="card-title-lg"><?= h($loan['loan_name']) ?></div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">원금</div>
|
||||
<div class="stat-value"><?= won($loan['principal_amount']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">연이자율</div>
|
||||
<div class="stat-value"><?= h((string)$loan['annual_interest_rate']) ?>%</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">거치기간</div>
|
||||
<div class="stat-value"><?= h((string)$loan['grace_period_months']) ?>개월</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">상환기간</div>
|
||||
<div class="stat-value"><?= h((string)$loan['repayment_months']) ?>개월</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="stat-label">남은 원금</div>
|
||||
<div class="stat-value"><?= won($summary['remaining_principal']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="stat-label">남은 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($summary['remaining_interest']) ?></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="stat-label">남은 총액</div>
|
||||
<div class="stat-value"><?= won($summary['remaining_total']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body mobile-scroll">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회차</th>
|
||||
<th>납부일</th>
|
||||
<th>구간</th>
|
||||
<th class="text-end">기초원금</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
<th class="text-end">기말원금</th>
|
||||
<th>상태</th>
|
||||
<th>처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($schedules as $s): ?>
|
||||
<tr>
|
||||
<td><?= $s['cycle_no'] ?></td>
|
||||
<td><?= h($s['due_date']) ?></td>
|
||||
<td><?= $s['payment_phase'] === 'grace' ? '거치' : '상환' ?></td>
|
||||
<td class="text-end"><?= won($s['opening_principal']) ?></td>
|
||||
<td class="text-end"><?= won($s['scheduled_principal']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($s['scheduled_interest']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($s['scheduled_total']) ?></td>
|
||||
<td class="text-end"><?= won($s['closing_principal']) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$s['is_paid'] === 1): ?>
|
||||
<?php if ((int)($s['is_auto_generated'] ?? 0) === 1): ?>
|
||||
<span class="badge text-bg-info">자동반영</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-success">납부완료</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($s['payment_date'])): ?>
|
||||
<div class="small text-secondary mt-1"><?= h($s['payment_date']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($s['payment_description'])): ?>
|
||||
<div class="small text-secondary"><?= h($s['payment_description']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-secondary">예정</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ((int)$s['is_paid'] === 0): ?>
|
||||
<form method="post" class="d-flex flex-column gap-2">
|
||||
<input type="hidden" name="schedule_id" value="<?= $s['id'] ?>">
|
||||
<select name="account_id" class="form-select form-select-sm">
|
||||
<option value="">출금계좌</option>
|
||||
<?php foreach ($accounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>"><?= h($acc['account_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input type="date" name="payment_date" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
|
||||
<button class="btn btn-sm btn-primary">상환처리</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$schedules): ?>
|
||||
<tr>
|
||||
<td colspan="10" class="text-center text-secondary py-5">스케줄이 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/loan_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||
$error = '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM loans
|
||||
WHERE id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([$id, $uid]);
|
||||
$loan = $stmt->fetch();
|
||||
|
||||
if (!$loan) {
|
||||
exit('대출 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type IN ('bank','cash','other')
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$loanName = trim($_POST['loan_name'] ?? '');
|
||||
$lenderName = trim($_POST['lender_name'] ?? '');
|
||||
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
|
||||
$annualInterestRate = (float)($_POST['annual_interest_rate'] ?? 0);
|
||||
$startDate = $_POST['start_date'] ?? date('Y-m-d');
|
||||
$graceMonths = (int)($_POST['grace_period_months'] ?? 0);
|
||||
$repaymentMonths = (int)($_POST['repayment_months'] ?? 0);
|
||||
$repaymentMethod = $_POST['repayment_method'] ?? '';
|
||||
$paymentDay = (int)($_POST['payment_day'] ?? 1);
|
||||
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$createFullHistory = !empty($_POST['create_full_history']) ? 1 : 0;
|
||||
|
||||
if ($loanName === '') {
|
||||
throw new RuntimeException('대출명을 입력하세요.');
|
||||
}
|
||||
|
||||
if ($principalAmount <= 0) {
|
||||
throw new RuntimeException('대출원금은 0보다 커야 합니다.');
|
||||
}
|
||||
|
||||
if ($annualInterestRate < 0) {
|
||||
throw new RuntimeException('연이자율이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($repaymentMonths <= 0) {
|
||||
throw new RuntimeException('상환개월 수는 1 이상이어야 합니다.');
|
||||
}
|
||||
|
||||
if ($paymentDay < 1 || $paymentDay > 31) {
|
||||
throw new RuntimeException('납부일은 1~31 사이여야 합니다.');
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
'interest_only_then_equal_payment',
|
||||
'interest_only_then_equal_principal',
|
||||
'interest_only_then_bullet',
|
||||
'equal_payment',
|
||||
'equal_principal',
|
||||
'bullet',
|
||||
];
|
||||
|
||||
if (!in_array($repaymentMethod, $allowed, true)) {
|
||||
throw new RuntimeException('상환방식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
update_loan_and_rebuild_full_history($uid, $id, [
|
||||
'account_id' => $accountId,
|
||||
'loan_name' => $loanName,
|
||||
'lender_name' => $lenderName,
|
||||
'principal_amount' => $principalAmount,
|
||||
'annual_interest_rate' => $annualInterestRate,
|
||||
'start_date' => $startDate,
|
||||
'grace_period_months' => $graceMonths,
|
||||
'repayment_months' => $repaymentMonths,
|
||||
'repayment_method' => $repaymentMethod,
|
||||
'payment_day' => $paymentDay,
|
||||
'description' => $description,
|
||||
'create_full_history' => $createFullHistory,
|
||||
'today_date' => date('Y-m-d'),
|
||||
]);
|
||||
|
||||
header('Location: /loans.php');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>대출 수정</h2>
|
||||
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="id" value="<?= $loan['id'] ?>">
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">대출명</label>
|
||||
<input type="text" name="loan_name" class="form-control" value="<?= h($loan['loan_name']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">대출기관</label>
|
||||
<input type="text" name="lender_name" class="form-control" value="<?= h($loan['lender_name'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">대출원금</label>
|
||||
<input type="number" name="principal_amount" class="form-control" min="1" step="1" value="<?= h((string)$loan['principal_amount']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">연이자율(%)</label>
|
||||
<input type="number" name="annual_interest_rate" class="form-control" min="0" step="0.01" value="<?= h((string)$loan['annual_interest_rate']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= h($loan['start_date']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">거치기간(개월)</label>
|
||||
<input type="number" name="grace_period_months" class="form-control" min="0" value="<?= h((string)$loan['grace_period_months']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">상환기간(개월)</label>
|
||||
<input type="number" name="repayment_months" class="form-control" min="1" value="<?= h((string)$loan['repayment_months']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">납부일</label>
|
||||
<input type="number" name="payment_day" class="form-control" min="1" max="31" value="<?= h((string)$loan['payment_day']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">상환방식</label>
|
||||
<select name="repayment_method" class="form-select" required>
|
||||
<option value="interest_only_then_equal_payment" <?= $loan['repayment_method'] === 'interest_only_then_equal_payment' ? 'selected' : '' ?>>거치 후 원리금균등</option>
|
||||
<option value="interest_only_then_equal_principal" <?= $loan['repayment_method'] === 'interest_only_then_equal_principal' ? 'selected' : '' ?>>거치 후 원금균등</option>
|
||||
<option value="interest_only_then_bullet" <?= $loan['repayment_method'] === 'interest_only_then_bullet' ? 'selected' : '' ?>>거치 후 만기일시상환</option>
|
||||
<option value="equal_payment" <?= $loan['repayment_method'] === 'equal_payment' ? 'selected' : '' ?>>원리금균등</option>
|
||||
<option value="equal_principal" <?= $loan['repayment_method'] === 'equal_principal' ? 'selected' : '' ?>>원금균등</option>
|
||||
<option value="bullet" <?= $loan['repayment_method'] === 'bullet' ? 'selected' : '' ?>>만기일시상환</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">입출금 계좌</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<option value="">선택안함</option>
|
||||
<?php foreach ($accounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>" <?= (int)$loan['account_id'] === (int)$acc['id'] ? 'selected' : '' ?>>
|
||||
<?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" class="form-control" value="<?= h($loan['description'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="loan-create-highlight">
|
||||
<div class="title">수정 시 전체 재계산</div>
|
||||
<div class="desc">
|
||||
대출 정보를 수정하면 기존 스케줄과 자동반영 이력을 다시 생성합니다.
|
||||
</div>
|
||||
|
||||
<div class="loan-create-check">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="create_full_history" id="create_full_history" value="1" checked>
|
||||
<label class="form-check-label" for="create_full_history">
|
||||
수정 후 자동반영까지 다시 적용
|
||||
</label>
|
||||
<small>
|
||||
과거 회차는 자동 반영, 미래 회차는 예정 상태로 다시 생성합니다.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary">저장</button>
|
||||
<a href="/loans.php" class="btn btn-outline-secondary">취소</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/loan_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM loans WHERE user_id = ? AND is_active = 1 ORDER BY id DESC");
|
||||
$stmt->execute([$uid]);
|
||||
$loans = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE user_id = ? AND is_active = 1 AND account_type IN ('bank','cash','other') ORDER BY id ASC");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM categories WHERE user_id = ? AND category_type = 'expense' AND is_active = 1 ORDER BY sort_order, id");
|
||||
$stmt->execute([$uid]);
|
||||
$categories = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$loanId = (int)($_POST['loan_id'] ?? 0);
|
||||
$accountId = (int)($_POST['account_id'] ?? 0);
|
||||
$paymentDate = $_POST['payment_date'] ?? date('Y-m-d');
|
||||
$totalAmount = (float)($_POST['total_amount'] ?? 0);
|
||||
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
|
||||
$interestAmount = (float)($_POST['interest_amount'] ?? 0);
|
||||
$feeAmount = (float)($_POST['fee_amount'] ?? 0);
|
||||
$categoryId = (int)($_POST['category_id'] ?? 0);
|
||||
$description = trim($_POST['description'] ?? '') ?: null;
|
||||
$skipDuplicate = isset($_POST['skip_duplicate']);
|
||||
|
||||
if ($loanId <= 0 || $accountId <= 0 || $categoryId <= 0) {
|
||||
throw new RuntimeException('대출, 출금계좌, 카테고리를 선택하세요.');
|
||||
}
|
||||
if ($totalAmount <= 0) {
|
||||
throw new RuntimeException('총 납부금액은 0보다 커야 합니다.');
|
||||
}
|
||||
if (($principalAmount + $interestAmount + $feeAmount) != $totalAmount) {
|
||||
throw new RuntimeException('원금 + 이자 + 수수료 합계가 총 납부금액과 같아야 합니다.');
|
||||
}
|
||||
|
||||
$inserted = create_loan_payment([
|
||||
'user_id' => $uid,
|
||||
'loan_id' => $loanId,
|
||||
'account_id' => $accountId,
|
||||
'payment_date' => $paymentDate,
|
||||
'total_amount' => $totalAmount,
|
||||
'principal_amount' => $principalAmount,
|
||||
'interest_amount' => $interestAmount,
|
||||
'fee_amount' => $feeAmount,
|
||||
'category_id' => $categoryId,
|
||||
'description' => $description,
|
||||
], $skipDuplicate);
|
||||
|
||||
$msg = $inserted ? '상환 내역이 저장되었습니다.' : '중복으로 판단되어 건너뛰었습니다.';
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<h2 class="mb-4">대출 상환 등록</h2>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">대출</label>
|
||||
<select name="loan_id" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<?php foreach ($loans as $loan): ?>
|
||||
<option value="<?= $loan['id'] ?>"><?= h($loan['loan_name']) ?> / <?= h($loan['institution_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">출금 계좌</label>
|
||||
<select name="account_id" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<?php foreach ($accounts as $account): ?>
|
||||
<option value="<?= $account['id'] ?>"><?= h($account['account_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">거래 카테고리</label>
|
||||
<select name="category_id" class="form-select" required>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?= $category['id'] ?>"><?= h($category['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">상환일</label>
|
||||
<input type="date" name="payment_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">총 납부금액</label>
|
||||
<input type="number" name="total_amount" class="form-control" min="1" step="1" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">원금</label>
|
||||
<input type="number" name="principal_amount" class="form-control" min="0" step="1" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">이자</label>
|
||||
<input type="number" name="interest_amount" class="form-control" min="0" step="1" value="0" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">수수료</label>
|
||||
<input type="number" name="fee_amount" class="form-control" min="0" step="1" value="0" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" class="form-control" placeholder="예: 5월 정기상환">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="skip_duplicate" id="skip_duplicate" checked>
|
||||
<label class="form-check-label" for="skip_duplicate">중복이면 건너뛰기</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary">상환 저장</button>
|
||||
<a href="/loans.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/loan_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM loans
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
$stmt->execute([$id, $uid]);
|
||||
$loan = $stmt->fetch();
|
||||
|
||||
if (!$loan) {
|
||||
exit('대출 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
AND account_type IN ('bank','cash','other')
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$accountId = !empty($_POST['account_id']) ? (int)$_POST['account_id'] : null;
|
||||
$paymentDate = $_POST['payment_date'] ?? date('Y-m-d');
|
||||
$principalAmount = (float)($_POST['principal_amount'] ?? 0);
|
||||
$interestAmount = (float)($_POST['interest_amount'] ?? 0);
|
||||
$feeAmount = (float)($_POST['fee_amount'] ?? 0);
|
||||
$description = trim($_POST['description'] ?? '') ?: null;
|
||||
|
||||
prepay_loan(
|
||||
$uid,
|
||||
$id,
|
||||
$accountId,
|
||||
$paymentDate,
|
||||
$principalAmount,
|
||||
$interestAmount,
|
||||
$feeAmount,
|
||||
$description
|
||||
);
|
||||
|
||||
$msg = '중도상환 처리되었습니다.';
|
||||
|
||||
$stmt->execute([$id, $uid]);
|
||||
$loan = $stmt->fetch();
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$summary = get_loan_remaining_summary($id);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM loan_schedules
|
||||
WHERE loan_id = ?
|
||||
ORDER BY cycle_no ASC
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$schedules = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM loan_payments
|
||||
WHERE loan_id = ?
|
||||
AND payment_type = 'prepayment'
|
||||
ORDER BY payment_date DESC, id DESC
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$prepayments = $stmt->fetchAll();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">대출 중도상환</h2>
|
||||
<a href="/loan_detail.php?id=<?= $loan['id'] ?>" class="btn btn-outline-secondary">대출 상세</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="eyebrow"><?= h($loan['lender_name'] ?: '-') ?></div>
|
||||
<div class="card-title-lg"><?= h($loan['loan_name']) ?></div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-6">
|
||||
<div class="stat-label">현재 남은 원금</div>
|
||||
<div class="stat-value"><?= won($loan['current_principal_balance']) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">연이자율</div>
|
||||
<div class="stat-value"><?= h((string)$loan['annual_interest_rate']) ?>%</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">남은 스케줄 원금</div>
|
||||
<div class="stat-value"><?= won($summary['remaining_principal']) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="stat-label">남은 스케줄 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($summary['remaining_interest']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">출금계좌</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<option value="">선택안함</option>
|
||||
<?php foreach ($accounts as $acc): ?>
|
||||
<option value="<?= $acc['id'] ?>"><?= h($acc['account_name']) ?> (<?= h($acc['account_type']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">중도상환일</label>
|
||||
<input type="date" name="payment_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">중도상환 원금</label>
|
||||
<input type="number" name="principal_amount" class="form-control" min="0" step="1" value="0" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">추가 이자</label>
|
||||
<input type="number" name="interest_amount" class="form-control" min="0" step="1" value="0">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">수수료</label>
|
||||
<input type="number" name="fee_amount" class="form-control" min="0" step="1" value="0">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" class="form-control" placeholder="예: 중도상환">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-danger" onclick="return confirm('중도상환 처리 후 남은 회차 스케줄이 재계산됩니다. 진행하시겠습니까?');">
|
||||
중도상환 실행
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">중도상환 이력</h5>
|
||||
<div class="mobile-scroll">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일자</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">수수료</th>
|
||||
<th class="text-end">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($prepayments as $p): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?= h($p['payment_date']) ?>
|
||||
<?php if (!empty($p['description'])): ?>
|
||||
<div class="small text-secondary"><?= h($p['description']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-end"><?= won($p['principal_amount']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($p['interest_amount']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($p['fee_amount']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($p['total_amount']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$prepayments): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-secondary py-4">중도상환 이력이 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">현재 스케줄</h5>
|
||||
<div class="mobile-scroll">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회차</th>
|
||||
<th>납부일</th>
|
||||
<th>구간</th>
|
||||
<th class="text-end">기초원금</th>
|
||||
<th class="text-end">원금</th>
|
||||
<th class="text-end">이자</th>
|
||||
<th class="text-end">합계</th>
|
||||
<th class="text-end">기말원금</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($schedules as $s): ?>
|
||||
<tr>
|
||||
<td><?= $s['cycle_no'] ?></td>
|
||||
<td><?= h($s['due_date']) ?></td>
|
||||
<td><?= $s['payment_phase'] === 'grace' ? '거치' : '상환' ?></td>
|
||||
<td class="text-end"><?= won($s['opening_principal']) ?></td>
|
||||
<td class="text-end"><?= won($s['scheduled_principal']) ?></td>
|
||||
<td class="text-end text-danger"><?= won($s['scheduled_interest']) ?></td>
|
||||
<td class="text-end fw-bold"><?= won($s['scheduled_total']) ?></td>
|
||||
<td class="text-end"><?= won($s['closing_principal']) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$s['is_paid'] === 1): ?>
|
||||
<span class="badge text-bg-success">납부완료</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-secondary">미납</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$schedules): ?>
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-secondary py-4">스케줄이 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/loan_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$mode = $_POST['mode'] ?? '';
|
||||
|
||||
if ($mode === 'delete') {
|
||||
$loanId = (int)($_POST['loan_id'] ?? 0);
|
||||
|
||||
if ($loanId <= 0) {
|
||||
throw new RuntimeException('대출 ID가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
delete_loan_with_history($uid, $loanId);
|
||||
$msg = '대출 및 관련 자동 생성 이력이 삭제되었습니다.';
|
||||
}
|
||||
|
||||
if ($mode === 'reset_auto_history') {
|
||||
$loanId = (int)($_POST['loan_id'] ?? 0);
|
||||
|
||||
if ($loanId <= 0) {
|
||||
throw new RuntimeException('대출 ID가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
reset_loan_auto_history_and_reapply($uid, $loanId, date('Y-m-d'));
|
||||
$msg = '자동반영 이력을 초기화하고 오늘 기준으로 다시 반영했습니다.';
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
l.*,
|
||||
a.account_name,
|
||||
a.institution_name,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
AND ls.is_paid = 0
|
||||
) AS unpaid_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
AND ls.is_paid = 1
|
||||
) AS paid_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
) AS total_count,
|
||||
(
|
||||
SELECT MIN(ls.due_date)
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
AND ls.is_paid = 0
|
||||
) AS next_due_date,
|
||||
(
|
||||
SELECT MAX(ls.due_date)
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
) AS maturity_due_date,
|
||||
(
|
||||
SELECT ls.scheduled_total
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
AND ls.is_paid = 0
|
||||
ORDER BY ls.due_date ASC, ls.cycle_no ASC
|
||||
LIMIT 1
|
||||
) AS next_payment_amount,
|
||||
(
|
||||
SELECT ls.payment_phase
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
AND ls.is_paid = 0
|
||||
ORDER BY ls.due_date ASC, ls.cycle_no ASC
|
||||
LIMIT 1
|
||||
) AS next_payment_phase,
|
||||
(
|
||||
SELECT COALESCE(SUM(ls.scheduled_total), 0)
|
||||
FROM loan_schedules ls
|
||||
WHERE ls.loan_id = l.id
|
||||
AND ls.is_paid = 0
|
||||
) AS remaining_total_amount
|
||||
FROM loans l
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN l.status = 'active' THEN 0 ELSE 1 END,
|
||||
l.id DESC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$loans = $stmt->fetchAll();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>대출 목록</h2>
|
||||
<a href="/loan_create.php" class="btn btn-primary">대출 등록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-3">
|
||||
<?php foreach ($loans as $loan): ?>
|
||||
<?php
|
||||
$summary = get_loan_remaining_summary((int)$loan['id']);
|
||||
|
||||
$paidCount = (int)($loan['paid_count'] ?? 0);
|
||||
$totalCount = (int)($loan['total_count'] ?? 0);
|
||||
$progress = $totalCount > 0 ? round(($paidCount / $totalCount) * 100, 1) : 0.0;
|
||||
|
||||
$phaseText = '-';
|
||||
if (($loan['next_payment_phase'] ?? '') === 'grace') {
|
||||
$phaseText = '거치 중';
|
||||
} elseif (($loan['next_payment_phase'] ?? '') === 'repayment') {
|
||||
$phaseText = '상환 중';
|
||||
} elseif ($loan['status'] === 'closed') {
|
||||
$phaseText = '종료';
|
||||
}
|
||||
?>
|
||||
<div class="col-12 col-xxl-6">
|
||||
<div class="card finance-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="eyebrow">
|
||||
<?= h($loan['lender_name'] ?: '-') ?>
|
||||
<?php if (!empty($loan['account_name'])): ?>
|
||||
· <?= h($loan['account_name']) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-title-lg"><?= h($loan['loan_name']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<span class="badge <?= $loan['status'] === 'active' ? 'text-bg-warning' : 'text-bg-success' ?>">
|
||||
<?= $loan['status'] === 'active' ? '진행중' : '종료' ?>
|
||||
</span>
|
||||
<div class="small text-secondary mt-2"><?= h($phaseText) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">대출원금</div>
|
||||
<div class="stat-value"><?= won($loan['principal_amount']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">연이자율</div>
|
||||
<div class="stat-value"><?= h((string)$loan['annual_interest_rate']) ?>%</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">거치기간</div>
|
||||
<div class="stat-value"><?= h((string)$loan['grace_period_months']) ?>개월</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">상환기간</div>
|
||||
<div class="stat-value"><?= h((string)$loan['repayment_months']) ?>개월</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 원금</div>
|
||||
<div class="stat-value"><?= won($summary['remaining_principal']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 이자</div>
|
||||
<div class="stat-value text-danger"><?= won($summary['remaining_interest']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 총액</div>
|
||||
<div class="stat-value"><?= won($loan['remaining_total_amount'] ?? 0) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">다음 회차 금액</div>
|
||||
<div class="stat-value"><?= won($loan['next_payment_amount'] ?? 0) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">다음 납부일</div>
|
||||
<div class="stat-value"><?= h($loan['next_due_date'] ?: '-') ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">만기일자</div>
|
||||
<div class="stat-value"><?= h($loan['maturity_due_date'] ?: '-') ?></div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">남은 회차 수</div>
|
||||
<div class="stat-value"><?= h((string)$loan['unpaid_count']) ?>회</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-label">진행률</div>
|
||||
<div class="stat-value"><?= number_format($progress, 1) ?>%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="finance-progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: <?= $progress ?>%"></div>
|
||||
</div>
|
||||
<div class="small text-secondary mt-2">
|
||||
완료 <?= h((string)$paidCount) ?>회 / 전체 <?= h((string)$totalCount) ?>회
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex flex-wrap gap-2">
|
||||
<a href="/loan_detail.php?id=<?= $loan['id'] ?>" class="btn btn-sm btn-outline-primary">상세/스케줄</a>
|
||||
<a href="/loan_edit.php?id=<?= $loan['id'] ?>" class="btn btn-sm btn-outline-secondary">수정</a>
|
||||
|
||||
<?php if ($loan['status'] === 'active'): ?>
|
||||
<a href="/loan_prepay.php?id=<?= $loan['id'] ?>" class="btn btn-sm btn-outline-danger">중도상환</a>
|
||||
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="reset_auto_history">
|
||||
<input type="hidden" name="loan_id" value="<?= $loan['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-warning" onclick="return confirm('자동반영 이력과 자동 생성 거래를 삭제하고 오늘 기준으로 다시 반영합니다. 계속하시겠습니까?');">
|
||||
자동반영 리셋 후 재적용
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="delete">
|
||||
<input type="hidden" name="loan_id" value="<?= $loan['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-dark" onclick="return confirm('대출, 상환 스케줄, 대출 납부이력, 자동 생성 거래내역까지 삭제됩니다. 계속하시겠습니까?');">
|
||||
삭제
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$loans): ?>
|
||||
<div class="col-12">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body empty-state">
|
||||
등록된 대출이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
header('Location: /dashboard.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$remember = !empty($_POST['remember']);
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new RuntimeException('아이디와 비밀번호를 입력하세요.');
|
||||
}
|
||||
|
||||
throttle_login_attempts($username);
|
||||
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? LIMIT 1");
|
||||
$stmt->execute([$username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
throw new RuntimeException('로그인 정보가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
clear_login_attempts($username);
|
||||
login_user($user, $remember);
|
||||
header('Location: /dashboard.php');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리</title>
|
||||
|
||||
<meta name="description" content="수입·지출 가계부, 계좌·카드 관리, 대출 상환 일정, 카드 할부 청구, 자동 분류 규칙까지 한 번에 관리하는 개인 금융 통합 서비스 Financial.">
|
||||
|
||||
<meta name="keywords" content="가계부, 자산관리, 개인재무, 대출관리, 할부관리, 카드관리, 수입지출, 금융관리, Financial">
|
||||
|
||||
<meta name="author" content="Financial">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="theme-color" content="#0b2a66">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Financial">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=2">
|
||||
<link rel="shortcut icon" href="/favicon.png?v=2">
|
||||
<link rel="apple-touch-icon" href="/favicon.png?v=2">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Financial">
|
||||
<meta property="og:title" content="Financial | 개인 자산관리 · 가계부 · 대출 · 할부 통합 관리">
|
||||
<meta property="og:description" content="계좌, 카드, 가계부, 대출, 할부를 한 곳에서 쉽고 체계적으로 관리하세요.">
|
||||
<meta property="og:image" content="https://seo.chaegeon.com/favicon.png">
|
||||
<meta property="og:url" content="https://seo.chaegeon.com/">
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Financial">
|
||||
<meta name="twitter:description" content="개인 금융 통합 관리 서비스">
|
||||
<meta name="twitter:image" content="https://seo.chaegeon.com/favicon.png">
|
||||
|
||||
<link href="/assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/app.css" rel="stylesheet">
|
||||
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5" style="max-width: 460px;">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="mb-4">로그인</h2>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">아이디</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">비밀번호</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="remember" id="remember" value="1">
|
||||
<label class="form-check-label" for="remember">자동로그인</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary w-100">로그인</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12 text-center">
|
||||
<a href="/register.php" class="text-decoration-none">PIN 코드로 회원가입</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://chaegeon.com/log/logger.js"></script>
|
||||
<script src="/assets/pwa.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
|
||||
logout_user();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Financial",
|
||||
"short_name": "Financial",
|
||||
"description": "개인 자산관리, 가계부, 대출, 할부 통합 관리",
|
||||
"start_url": "/dashboard.php",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f3f6fb",
|
||||
"theme_color": "#0b2a66",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png?v=2",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.png?v=2",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "거래 등록",
|
||||
"short_name": "등록",
|
||||
"url": "/transaction_create.php",
|
||||
"description": "새 수입, 지출, 이체 거래를 등록합니다."
|
||||
},
|
||||
{
|
||||
"name": "거래내역",
|
||||
"short_name": "내역",
|
||||
"url": "/transactions.php",
|
||||
"description": "월별 거래내역을 확인합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$mode = $_POST['mode'] ?? 'create';
|
||||
|
||||
if ($mode === 'create') {
|
||||
$keyword = trim($_POST['keyword'] ?? '');
|
||||
$categoryId = (int)($_POST['category_id'] ?? 0);
|
||||
$priority = (int)($_POST['priority'] ?? 100);
|
||||
|
||||
if ($keyword === '') {
|
||||
throw new RuntimeException('키워드를 입력하세요.');
|
||||
}
|
||||
if ($categoryId <= 0) {
|
||||
throw new RuntimeException('카테고리를 선택하세요.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO merchant_pattern_rules
|
||||
(user_id, pattern_text, normalized_pattern, match_type, category_id, priority, confidence, is_active, memo)
|
||||
VALUES (?, ?, ?, 'contains', ?, ?, 0.90, 1, 'manual')
|
||||
");
|
||||
$normalized = mb_strtolower($keyword,'UTF-8');
|
||||
$normalized = str_replace([' ','-','(',')','.',',','㈜'],'',$normalized);
|
||||
|
||||
$stmt->execute([$uid, $keyword, $normalized, $categoryId, $priority]);
|
||||
$msg = '규칙이 추가되었습니다.';
|
||||
}
|
||||
|
||||
if ($mode === 'update') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$keyword = trim($_POST['keyword'] ?? '');
|
||||
$categoryId = (int)($_POST['category_id'] ?? 0);
|
||||
$priority = (int)($_POST['priority'] ?? 100);
|
||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('규칙 ID가 올바르지 않습니다.');
|
||||
}
|
||||
if ($keyword === '') {
|
||||
throw new RuntimeException('키워드를 입력하세요.');
|
||||
}
|
||||
if ($categoryId <= 0) {
|
||||
throw new RuntimeException('카테고리를 선택하세요.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE merchant_pattern_rules
|
||||
SET pattern_text = ?, normalized_pattern = ?, category_id = ?, priority = ?, is_active = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
$normalized = mb_strtolower($keyword,'UTF-8');
|
||||
$normalized = str_replace([' ','-','(',')','.',',','㈜'],'',$normalized);
|
||||
|
||||
$stmt->execute([
|
||||
$keyword,
|
||||
$normalized,
|
||||
$categoryId,
|
||||
$priority,
|
||||
$isActive,
|
||||
$id,
|
||||
$uid
|
||||
]);
|
||||
$msg = '규칙이 수정되었습니다.';
|
||||
}
|
||||
|
||||
if ($mode === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('규칙 ID가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM merchant_pattern_rules WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$id, $uid]);
|
||||
$msg = '규칙이 삭제되었습니다.';
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, category_type, name
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY category_type, sort_order, id
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$categories = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
r.*,
|
||||
c.name AS category_name,
|
||||
c.category_type
|
||||
FROM merchant_pattern_rules r
|
||||
JOIN categories c ON c.id = r.category_id
|
||||
WHERE r.user_id = ?
|
||||
ORDER BY r.priority ASC, r.id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$rules = $stmt->fetchAll();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>상호명 자동분류 규칙</h2>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-success"><?= h($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">규칙 추가</h5>
|
||||
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="mode" value="create">
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">키워드</label>
|
||||
<input type="text" name="keyword" class="form-control" placeholder="예: 스타벅스, 쿠팡, 배달의민족" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select name="category_id" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option value="<?= $c['id'] ?>">
|
||||
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">우선순위</label>
|
||||
<input type="number" name="priority" class="form-control" value="100" min="1" required>
|
||||
<div class="form-text">숫자가 작을수록 우선 적용됩니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary">추가</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body mobile-scroll">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>키워드</th>
|
||||
<th>카테고리</th>
|
||||
<th>우선순위</th>
|
||||
<th>활성</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rules as $rule): ?>
|
||||
<tr>
|
||||
<td><?= $rule['id'] ?></td>
|
||||
<td><?= h($rule['pattern_text']) ?></td>
|
||||
<td>[<?= h($rule['category_type']) ?>] <?= h($rule['category_name']) ?></td>
|
||||
<td><?= $rule['priority'] ?></td>
|
||||
<td><?= $rule['is_active'] ? 'Y' : 'N' ?></td>
|
||||
<td class="text-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#rule-edit-<?= $rule['id'] ?>"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $rule['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('삭제하시겠습니까?');">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="collapse" id="rule-edit-<?= $rule['id'] ?>">
|
||||
<td colspan="6">
|
||||
<form method="post" class="row g-2 p-2">
|
||||
<input type="hidden" name="mode" value="update">
|
||||
<input type="hidden" name="id" value="<?= $rule['id'] ?>">
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">키워드</label>
|
||||
<input type="text" name="keyword" class="form-control" value="<?= h($rule['pattern_text']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select name="category_id" class="form-select" required>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option value="<?= $c['id'] ?>" <?= (int)$rule['category_id'] === (int)$c['id'] ? 'selected' : '' ?>>
|
||||
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">우선순위</label>
|
||||
<input type="number" name="priority" class="form-control" value="<?= $rule['priority'] ?>" min="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="is_active_<?= $rule['id'] ?>" <?= $rule['is_active'] ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="is_active_<?= $rule['id'] ?>">활성</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button class="btn btn-primary btn-sm w-100">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$rules): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-5">등록된 규칙이 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#0b2a66">
|
||||
<title>Financial 오프라인</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=2">
|
||||
<link href="/assets/app.css" rel="stylesheet">
|
||||
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5" style="max-width: 560px;">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="card-title-lg mb-3">오프라인 상태입니다</h1>
|
||||
<p class="text-secondary mb-0">금융 데이터는 기기에 저장하지 않습니다. 네트워크 연결 후 다시 접속하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/recurring_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$msg = '';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM accounts WHERE user_id = ? AND is_active = 1 ORDER BY id");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM categories WHERE user_id = ? AND is_active = 1 ORDER BY category_type, sort_order, id");
|
||||
$stmt->execute([$uid]);
|
||||
$categories = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$mode = $_POST['mode'] ?? 'create';
|
||||
|
||||
if ($mode === 'apply_month') {
|
||||
$ym = $_POST['ym'] ?? date('Y-m');
|
||||
$count = apply_recurring_transactions_for_month($uid, $ym, true);
|
||||
$msg = $ym . ' 적용 완료: ' . $count . '건 입력';
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO recurring_transactions
|
||||
(user_id, account_id, category_id, transaction_type, amount, day_of_month, merchant_name, description, related_account_id, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
");
|
||||
$stmt->execute([
|
||||
$uid,
|
||||
(int)$_POST['account_id'],
|
||||
(int)$_POST['category_id'],
|
||||
$_POST['transaction_type'],
|
||||
(float)$_POST['amount'],
|
||||
(int)$_POST['day_of_month'],
|
||||
trim($_POST['merchant_name'] ?? '') ?: null,
|
||||
trim($_POST['description'] ?? '') ?: null,
|
||||
!empty($_POST['related_account_id']) ? (int)$_POST['related_account_id'] : null,
|
||||
]);
|
||||
|
||||
redirect('/recurring.php');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT rt.*, a.account_name, c.name AS category_name, ra.account_name AS related_name
|
||||
FROM recurring_transactions rt
|
||||
JOIN accounts a ON rt.account_id = a.id
|
||||
JOIN categories c ON rt.category_id = c.id
|
||||
LEFT JOIN accounts ra ON rt.related_account_id = ra.id
|
||||
WHERE rt.user_id = ?
|
||||
ORDER BY rt.day_of_month, rt.id");
|
||||
$stmt->execute([$uid]);
|
||||
$list = $stmt->fetchAll();
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>고정지출 / 고정거래</h2>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
|
||||
<?php if ($msg): ?><div class="alert alert-success"><?= h($msg) ?></div><?php endif; ?>
|
||||
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="mode" value="apply_month">
|
||||
<input type="hidden" name="ym" value="<?= date('Y-m') ?>">
|
||||
<button class="btn btn-outline-primary" onclick="return confirm('이번 달 고정거래를 자동입력하시겠습니까?');">
|
||||
이번 달 자동입력 실행
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="mode" value="create">
|
||||
<div class="col-12">
|
||||
<label class="form-label">유형</label>
|
||||
<select name="transaction_type" class="form-select">
|
||||
<option value="expense">지출</option>
|
||||
<option value="income">수입</option>
|
||||
<option value="card_payment">카드대금납부</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">주 계좌</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option value="<?= $a['id'] ?>"><?= h($a['account_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">관련 계좌</label>
|
||||
<select name="related_account_id" class="form-select">
|
||||
<option value="">선택안함</option>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option value="<?= $a['id'] ?>"><?= h($a['account_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select name="category_id" class="form-select">
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option value="<?= $c['id'] ?>">[<?= h($c['category_type']) ?>] <?= h($c['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">금액</label>
|
||||
<input type="number" name="amount" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">매월 일자</label>
|
||||
<input type="number" name="day_of_month" min="1" max="31" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">사용처</label>
|
||||
<input type="text" name="merchant_name" class="form-control">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" class="form-control">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body mobile-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일자</th>
|
||||
<th>유형</th>
|
||||
<th>계좌</th>
|
||||
<th>관련</th>
|
||||
<th>카테고리</th>
|
||||
<th>금액</th>
|
||||
<th>마지막 적용월</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($list as $row): ?>
|
||||
<tr>
|
||||
<td><?= $row['day_of_month'] ?>일</td>
|
||||
<td><?= h($row['transaction_type']) ?></td>
|
||||
<td><?= h($row['account_name']) ?></td>
|
||||
<td><?= h($row['related_name'] ?? '-') ?></td>
|
||||
<td><?= h($row['category_name']) ?></td>
|
||||
<td><?= won($row['amount']) ?></td>
|
||||
<td><?= h($row['last_applied_ym'] ?? '-') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
header('Location: /dashboard.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$pin = trim($_POST['pin'] ?? '');
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$passwordConfirm = trim($_POST['password_confirm'] ?? '');
|
||||
|
||||
$requiredPin = getenv('FINANCIAL_REGISTER_PIN') ?: '0010';
|
||||
|
||||
if ($requiredPin === '' || !hash_equals($requiredPin, $pin)) {
|
||||
throw new RuntimeException('PIN 코드가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new RuntimeException('아이디와 비밀번호를 입력하세요.');
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
|
||||
throw new RuntimeException('아이디는 영문, 숫자, 밑줄 3~30자만 가능합니다.');
|
||||
}
|
||||
|
||||
if (mb_strlen($password, 'UTF-8') < 4) {
|
||||
throw new RuntimeException('비밀번호는 4자 이상 입력하세요.');
|
||||
}
|
||||
|
||||
if ($password !== $passwordConfirm) {
|
||||
throw new RuntimeException('비밀번호 확인이 일치하지 않습니다.');
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ? LIMIT 1");
|
||||
$stmt->execute([$username]);
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
throw new RuntimeException('이미 사용 중인 아이디입니다.');
|
||||
}
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO users (username, password_hash, created_at)
|
||||
VALUES (?, ?, NOW())
|
||||
");
|
||||
$stmt->execute([$username, $hash]);
|
||||
|
||||
$userId = (int)$pdo->lastInsertId();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$userId]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
login_user($user, true);
|
||||
|
||||
header('Location: /dashboard.php');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>회원가입</title>
|
||||
<meta name="theme-color" content="#0b2a66">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Financial">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=2">
|
||||
<link rel="apple-touch-icon" href="/favicon.png?v=2">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link href="/assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/app.css" rel="stylesheet">
|
||||
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5" style="max-width: 460px;">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="mb-4">회원가입</h2>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">가입 PIN 코드</label>
|
||||
<input type="password" name="pin" class="form-control" inputmode="numeric" required>
|
||||
<div class="form-text">관리자가 공유한 PIN 코드를 입력하세요.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">아이디</label>
|
||||
<input type="text" name="username" class="form-control" required autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">비밀번호</label>
|
||||
<input type="password" name="password" class="form-control" required autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">비밀번호 확인</label>
|
||||
<input type="password" name="password_confirm" class="form-control" required autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary w-100">가입하기</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12 text-center">
|
||||
<a href="/login.php" class="text-decoration-none">이미 계정이 있습니다</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://chaegeon.com/log/logger.js"></script>
|
||||
<script src="/assets/pwa.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
const CACHE_NAME = 'financial-static-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/offline.html',
|
||||
'/assets/vendor/bootstrap.min.css',
|
||||
'/assets/vendor/bootstrap.bundle.min.js',
|
||||
'/assets/vendor/chart.umd.js',
|
||||
'/assets/app.css',
|
||||
'/assets/pwa.js',
|
||||
'/favicon.png?v=2',
|
||||
'/manifest.webmanifest'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.addAll(STATIC_ASSETS))
|
||||
.catch(() => undefined)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
|
||||
))
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.origin !== location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/vendor/') || url.pathname === '/assets/app.css' || url.pathname === '/assets/pwa.js' || url.pathname === '/manifest.webmanifest' || url.pathname === '/favicon.png') {
|
||||
event.respondWith(
|
||||
caches.match(request).then(cached => cached || fetch(request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match('/offline.html'))
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,880 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/transaction_service.php';
|
||||
require_once __DIR__ . '/../app/lib/transaction_form_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY
|
||||
FIELD(account_type, 'bank', 'card', 'cash', 'other'),
|
||||
id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY category_type, sort_order, id
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$categories = $stmt->fetchAll();
|
||||
|
||||
$defaults = get_transaction_form_defaults($uid);
|
||||
$lastForm = $_SESSION['tx_create_last_form'] ?? [];
|
||||
|
||||
$form = [
|
||||
'transaction_type' => 'expense',
|
||||
'account_id' => (int)($defaults['default_account_id'] ?? 0),
|
||||
'related_account_id' => 0,
|
||||
'category_id' => (int)($defaults['default_expense_category_id'] ?? 0),
|
||||
'amount' => '',
|
||||
'transaction_date' => date('Y-m-d'),
|
||||
'merchant_name' => '',
|
||||
'description' => '',
|
||||
'is_installment' => 0,
|
||||
'installment_months' => '',
|
||||
'installment_interest_rate' => '0',
|
||||
'installment_interest_total' => '',
|
||||
'installment_total_billed' => '',
|
||||
'continue_after_save' => !empty($defaults['continue_after_save']) ? 1 : 0,
|
||||
'keep_last_values' => !empty($defaults['keep_last_values']) ? 1 : 0,
|
||||
'save_as_defaults' => 0,
|
||||
];
|
||||
|
||||
if (!empty($defaults['keep_last_values']) && !empty($lastForm)) {
|
||||
$form = array_merge($form, $lastForm);
|
||||
}
|
||||
|
||||
$copyId = (int)($_GET['copy_id'] ?? 0);
|
||||
|
||||
if ($copyId > 0 && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM transactions
|
||||
WHERE id = ?
|
||||
AND user_id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([$copyId, $uid]);
|
||||
$copy = $stmt->fetch();
|
||||
|
||||
if ($copy) {
|
||||
$form = array_merge($form, [
|
||||
'transaction_type' => $copy['transaction_type'],
|
||||
'account_id' => (int)$copy['account_id'],
|
||||
'related_account_id' => (int)($copy['related_account_id'] ?? 0),
|
||||
'category_id' => (int)$copy['category_id'],
|
||||
'amount' => number_format((float)$copy['amount'], 0),
|
||||
'transaction_date' => date('Y-m-d'),
|
||||
'merchant_name' => (string)($copy['merchant_name'] ?? ''),
|
||||
'description' => (string)($copy['description'] ?? ''),
|
||||
'is_installment' => (int)($copy['is_installment'] ?? 0),
|
||||
'installment_months' => $copy['installment_months'] ?? '',
|
||||
'installment_interest_rate' => (string)($copy['installment_interest_rate'] ?? '0'),
|
||||
'installment_interest_total' => !empty($copy['installment_interest_total']) ? number_format((float)$copy['installment_interest_total'], 0) : '',
|
||||
'installment_total_billed' => !empty($copy['installment_total_billed']) ? number_format((float)$copy['installment_total_billed'], 0) : '',
|
||||
'continue_after_save' => 0,
|
||||
'keep_last_values' => 0,
|
||||
'save_as_defaults' => 0,
|
||||
]);
|
||||
|
||||
$success = '기존 거래를 복사했습니다. 날짜/메모 수정 후 저장하세요.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$form = [
|
||||
'transaction_type' => $_POST['transaction_type'] ?? 'expense',
|
||||
'account_id' => (int)($_POST['account_id'] ?? 0),
|
||||
'related_account_id' => !empty($_POST['related_account_id']) ? (int)$_POST['related_account_id'] : 0,
|
||||
'category_id' => (int)($_POST['category_id'] ?? 0),
|
||||
'amount' => trim((string)($_POST['amount'] ?? '')),
|
||||
'transaction_date' => $_POST['transaction_date'] ?? date('Y-m-d'),
|
||||
'merchant_name' => trim((string)($_POST['merchant_name'] ?? '')),
|
||||
'description' => trim((string)($_POST['description'] ?? '')),
|
||||
'is_installment' => (int)($_POST['is_installment'] ?? 0),
|
||||
'installment_months' => trim((string)($_POST['installment_months'] ?? '')),
|
||||
'installment_interest_rate' => trim((string)($_POST['installment_interest_rate'] ?? '0')),
|
||||
'installment_interest_total' => trim((string)($_POST['installment_interest_total'] ?? '')),
|
||||
'installment_total_billed' => trim((string)($_POST['installment_total_billed'] ?? '')),
|
||||
'continue_after_save' => !empty($_POST['continue_after_save']) ? 1 : 0,
|
||||
'keep_last_values' => !empty($_POST['keep_last_values']) ? 1 : 0,
|
||||
'save_as_defaults' => !empty($_POST['save_as_defaults']) ? 1 : 0,
|
||||
];
|
||||
|
||||
$transactionType = $form['transaction_type'];
|
||||
$accountId = (int)$form['account_id'];
|
||||
$categoryId = (int)$form['category_id'];
|
||||
$amount = (float)str_replace(',', '', $form['amount']);
|
||||
$transactionDate = $form['transaction_date'];
|
||||
$merchantName = $form['merchant_name'];
|
||||
$description = $form['description'];
|
||||
$relatedAccountId = $form['related_account_id'] > 0 ? (int)$form['related_account_id'] : null;
|
||||
|
||||
$isInstallment = (int)$form['is_installment'];
|
||||
$installmentMonths = $form['installment_months'] !== '' ? (int)$form['installment_months'] : null;
|
||||
$installmentInterestRate = $form['installment_interest_rate'] !== '' ? (float)$form['installment_interest_rate'] : 0.0;
|
||||
$installmentInterestTotal = $form['installment_interest_total'] !== ''
|
||||
? (float)str_replace(',', '', $form['installment_interest_total'])
|
||||
: null;
|
||||
$installmentTotalBilled = $form['installment_total_billed'] !== ''
|
||||
? (float)str_replace(',', '', $form['installment_total_billed'])
|
||||
: null;
|
||||
|
||||
if ($amount <= 0) {
|
||||
throw new RuntimeException('금액은 0보다 커야 합니다.');
|
||||
}
|
||||
|
||||
if (!in_array($transactionType, ['income', 'expense', 'transfer', 'card_payment'], true)) {
|
||||
throw new RuntimeException('거래 유형이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
if ($accountId <= 0) {
|
||||
throw new RuntimeException('주 계좌/카드를 선택하세요.');
|
||||
}
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
throw new RuntimeException('카테고리를 선택하세요.');
|
||||
}
|
||||
|
||||
if (in_array($transactionType, ['transfer', 'card_payment'], true) && !$relatedAccountId) {
|
||||
throw new RuntimeException('관련 계좌를 선택해야 합니다.');
|
||||
}
|
||||
|
||||
if ($relatedAccountId && $relatedAccountId === $accountId) {
|
||||
throw new RuntimeException('주 계좌와 관련 계좌는 같을 수 없습니다.');
|
||||
}
|
||||
|
||||
if ($transactionType !== 'expense') {
|
||||
$isInstallment = 0;
|
||||
$installmentMonths = null;
|
||||
$installmentInterestRate = 0.0;
|
||||
$installmentInterestTotal = null;
|
||||
$installmentTotalBilled = null;
|
||||
}
|
||||
|
||||
create_transaction([
|
||||
'user_id' => $uid,
|
||||
'account_id' => $accountId,
|
||||
'category_id' => $categoryId,
|
||||
'transaction_type' => $transactionType,
|
||||
'amount' => $amount,
|
||||
'transaction_date' => $transactionDate,
|
||||
'merchant_name' => $merchantName !== '' ? $merchantName : null,
|
||||
'description' => $description !== '' ? $description : null,
|
||||
'related_account_id' => $relatedAccountId,
|
||||
'is_installment' => $isInstallment,
|
||||
'installment_months' => $installmentMonths,
|
||||
'installment_interest_rate' => $installmentInterestRate,
|
||||
'installment_interest_total' => $installmentInterestTotal,
|
||||
'installment_total_billed' => $installmentTotalBilled,
|
||||
]);
|
||||
|
||||
if ($form['save_as_defaults']) {
|
||||
$save = [
|
||||
'default_account_id' => 0,
|
||||
'default_card_account_id' => 0,
|
||||
'default_income_category_id' => 0,
|
||||
'default_expense_category_id' => 0,
|
||||
'default_transfer_category_id' => 0,
|
||||
'default_card_payment_category_id' => 0,
|
||||
'keep_last_values' => $form['keep_last_values'],
|
||||
'continue_after_save' => $form['continue_after_save'],
|
||||
];
|
||||
|
||||
if ($transactionType === 'income') {
|
||||
$save['default_account_id'] = $accountId;
|
||||
$save['default_income_category_id'] = $categoryId;
|
||||
} elseif ($transactionType === 'expense') {
|
||||
$save['default_account_id'] = $accountId;
|
||||
$save['default_expense_category_id'] = $categoryId;
|
||||
} elseif ($transactionType === 'transfer') {
|
||||
$save['default_account_id'] = $accountId;
|
||||
$save['default_transfer_category_id'] = $categoryId;
|
||||
} elseif ($transactionType === 'card_payment') {
|
||||
$save['default_account_id'] = $accountId;
|
||||
$save['default_card_payment_category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
foreach ($accounts as $acc) {
|
||||
if ((int)$acc['id'] === $accountId && $acc['account_type'] === 'card') {
|
||||
$save['default_card_account_id'] = $accountId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
save_transaction_form_defaults($uid, array_merge($defaults, $save));
|
||||
}
|
||||
|
||||
if ($form['keep_last_values']) {
|
||||
$_SESSION['tx_create_last_form'] = [
|
||||
'transaction_type' => $transactionType,
|
||||
'account_id' => $accountId,
|
||||
'related_account_id' => $relatedAccountId ?: 0,
|
||||
'category_id' => $categoryId,
|
||||
'amount' => '',
|
||||
'transaction_date' => date('Y-m-d'),
|
||||
'merchant_name' => $merchantName,
|
||||
'description' => '',
|
||||
'is_installment' => $isInstallment,
|
||||
'installment_months' => $installmentMonths ?? '',
|
||||
'installment_interest_rate' => (string)$installmentInterestRate,
|
||||
'installment_interest_total' => '',
|
||||
'installment_total_billed' => '',
|
||||
'continue_after_save' => $form['continue_after_save'],
|
||||
'keep_last_values' => $form['keep_last_values'],
|
||||
'save_as_defaults' => 0,
|
||||
];
|
||||
} else {
|
||||
unset($_SESSION['tx_create_last_form']);
|
||||
}
|
||||
|
||||
if ($form['continue_after_save']) {
|
||||
$success = '저장되었습니다. 계속 등록할 수 있습니다.';
|
||||
$form['amount'] = '';
|
||||
$form['description'] = '';
|
||||
$form['transaction_date'] = date('Y-m-d');
|
||||
$form['installment_interest_total'] = '';
|
||||
$form['installment_total_billed'] = '';
|
||||
} else {
|
||||
redirect('/transactions.php');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$accountsJson = [];
|
||||
foreach ($accounts as $acc) {
|
||||
$accountsJson[] = [
|
||||
'id' => (int)$acc['id'],
|
||||
'name' => $acc['account_name'],
|
||||
'type' => $acc['account_type'],
|
||||
'card_kind' => $acc['card_kind'] ?? null,
|
||||
'billing_day' => $acc['billing_day'] ?? null,
|
||||
'payment_day' => $acc['payment_day'] ?? null,
|
||||
'use_credit_grace_period' => !empty($acc['use_credit_grace_period']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$categoriesJson = [];
|
||||
foreach ($categories as $cat) {
|
||||
$categoriesJson[] = [
|
||||
'id' => (int)$cat['id'],
|
||||
'type' => $cat['category_type'],
|
||||
'name' => $cat['name'],
|
||||
];
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>거래 등록</h2>
|
||||
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><?= h($success) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3" id="transactionForm" autocomplete="off">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">거래 유형</label>
|
||||
<select name="transaction_type" id="transaction_type" class="form-select" required>
|
||||
<option value="expense" <?= $form['transaction_type'] === 'expense' ? 'selected' : '' ?>>지출</option>
|
||||
<option value="income" <?= $form['transaction_type'] === 'income' ? 'selected' : '' ?>>수입</option>
|
||||
<option value="transfer" <?= $form['transaction_type'] === 'transfer' ? 'selected' : '' ?>>계좌이체</option>
|
||||
<option value="card_payment" <?= $form['transaction_type'] === 'card_payment' ? 'selected' : '' ?>>카드대금납부</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">주 계좌/카드</label>
|
||||
<select name="account_id" id="account_id" class="form-select" required>
|
||||
<option value="0">선택하세요</option>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option
|
||||
value="<?= $a['id'] ?>"
|
||||
data-account-type="<?= h($a['account_type']) ?>"
|
||||
<?= (int)$form['account_id'] === (int)$a['id'] ? 'selected' : '' ?>
|
||||
>
|
||||
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text" id="account_help_text"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">관련 계좌</label>
|
||||
<select name="related_account_id" id="related_account_id" class="form-select">
|
||||
<option value="">선택안함</option>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option
|
||||
value="<?= $a['id'] ?>"
|
||||
data-account-type="<?= h($a['account_type']) ?>"
|
||||
<?= (int)$form['related_account_id'] === (int)$a['id'] ? 'selected' : '' ?>
|
||||
>
|
||||
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text" id="related_account_help">계좌이체/카드대금납부 시 선택</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select name="category_id" id="category_id" class="form-select" required>
|
||||
<option value="0">선택하세요</option>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option
|
||||
value="<?= $c['id'] ?>"
|
||||
data-type="<?= h($c['category_type']) ?>"
|
||||
<?= (int)$form['category_id'] === (int)$c['id'] ? 'selected' : '' ?>
|
||||
>
|
||||
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">금액</label>
|
||||
<input
|
||||
type="text"
|
||||
name="amount"
|
||||
id="amount"
|
||||
class="form-control"
|
||||
value="<?= h($form['amount']) ?>"
|
||||
inputmode="numeric"
|
||||
required
|
||||
>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="10000">+1만</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="50000">+5만</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="100000">+10만</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="amount_clear_btn">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">거래일</label>
|
||||
<input type="date" name="transaction_date" class="form-control" value="<?= h($form['transaction_date']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">결제 방식</label>
|
||||
<select name="is_installment" id="is_installment" class="form-select">
|
||||
<option value="0" <?= (int)$form['is_installment'] === 0 ? 'selected' : '' ?>>일시불</option>
|
||||
<option value="1" <?= (int)$form['is_installment'] === 1 ? 'selected' : '' ?>>할부</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_months_wrap" style="display:none;">
|
||||
<label class="form-label">할부 개월</label>
|
||||
<select name="installment_months" id="installment_months" class="form-select">
|
||||
<option value="">선택하세요</option>
|
||||
<?php foreach ([2,3,4,5,6,10,12,18,24] as $m): ?>
|
||||
<option value="<?= $m ?>" <?= (string)$form['installment_months'] === (string)$m ? 'selected' : '' ?>>
|
||||
<?= intvalf($m) ?>개월
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_rate_wrap" style="display:none;">
|
||||
<label class="form-label">연이자율(%)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="installment_interest_rate"
|
||||
id="installment_interest_rate"
|
||||
class="form-control"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value="<?= h($form['installment_interest_rate']) ?>"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_interest_wrap" style="display:none;">
|
||||
<label class="form-label">총 할부이자</label>
|
||||
<input
|
||||
type="text"
|
||||
name="installment_interest_total"
|
||||
id="installment_interest_total"
|
||||
class="form-control"
|
||||
inputmode="numeric"
|
||||
value="<?= h($form['installment_interest_total']) ?>"
|
||||
>
|
||||
<div class="form-text">비우면 연이자율로 자동 계산</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_total_wrap" style="display:none;">
|
||||
<label class="form-label">총 청구금액</label>
|
||||
<input
|
||||
type="text"
|
||||
name="installment_total_billed"
|
||||
id="installment_total_billed"
|
||||
class="form-control"
|
||||
inputmode="numeric"
|
||||
value="<?= h($form['installment_total_billed']) ?>"
|
||||
>
|
||||
<div class="form-text">비우면 원금+이자로 자동 계산</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">사용처 / 거래처</label>
|
||||
<input
|
||||
type="text"
|
||||
name="merchant_name"
|
||||
id="merchant_name"
|
||||
class="form-control"
|
||||
placeholder="예: 스타벅스, 쿠팡, 급여"
|
||||
autocomplete="off"
|
||||
value="<?= h($form['merchant_name']) ?>"
|
||||
>
|
||||
<div class="form-text">상호명 입력 시 카테고리를 자동 추천합니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">메모</label>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
class="form-control"
|
||||
placeholder="간단한 설명"
|
||||
value="<?= h($form['description']) ?>"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div id="category_suggest_box" class="alert alert-info py-2 px-3 d-none mb-0"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="loan-create-highlight">
|
||||
<div class="title">등록 편의 설정</div>
|
||||
<div class="desc">자주 쓰는 카드/계좌와 입력값을 계속 재사용할 수 있습니다.</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="continue_after_save" id="continue_after_save" value="1" <?= !empty($form['continue_after_save']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="continue_after_save">저장 후 계속 등록</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="keep_last_values" id="keep_last_values" value="1" <?= !empty($form['keep_last_values']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="keep_last_values">마지막 입력값 유지</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="save_as_defaults" id="save_as_defaults" value="1">
|
||||
<label class="form-check-label" for="save_as_defaults">현재 선택을 기본값으로 저장</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary">저장</button>
|
||||
<button type="submit" name="continue_after_save" value="1" class="btn btn-outline-primary">저장 후 계속 등록</button>
|
||||
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const ACCOUNTS = <?= json_encode($accountsJson, JSON_UNESCAPED_UNICODE) ?>;
|
||||
const CATEGORIES = <?= json_encode($categoriesJson, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
const transactionTypeEl = document.getElementById('transaction_type');
|
||||
const accountEl = document.getElementById('account_id');
|
||||
const accountHelpTextEl = document.getElementById('account_help_text');
|
||||
const relatedAccountEl = document.getElementById('related_account_id');
|
||||
const relatedHelpEl = document.getElementById('related_account_help');
|
||||
const categoryEl = document.getElementById('category_id');
|
||||
const merchantEl = document.getElementById('merchant_name');
|
||||
const suggestBoxEl = document.getElementById('category_suggest_box');
|
||||
|
||||
const installmentEl = document.getElementById('is_installment');
|
||||
const installmentWrapEl = document.getElementById('installment_months_wrap');
|
||||
const installmentMonthsEl = document.getElementById('installment_months');
|
||||
const installmentRateWrapEl = document.getElementById('installment_rate_wrap');
|
||||
const installmentRateEl = document.getElementById('installment_interest_rate');
|
||||
const installmentInterestWrapEl = document.getElementById('installment_interest_wrap');
|
||||
const installmentTotalWrapEl = document.getElementById('installment_total_wrap');
|
||||
const installmentInterestEl = document.getElementById('installment_interest_total');
|
||||
const installmentTotalEl = document.getElementById('installment_total_billed');
|
||||
const amountEl = document.getElementById('amount');
|
||||
|
||||
const quickAmtButtons = document.querySelectorAll('.quick-amt');
|
||||
const amountClearBtn = document.getElementById('amount_clear_btn');
|
||||
|
||||
const allCategoryOptions = Array.from(categoryEl.querySelectorAll('option')).map(option => ({
|
||||
value: option.value,
|
||||
text: option.textContent,
|
||||
type: option.dataset.type
|
||||
}));
|
||||
|
||||
let suggestTimer = null;
|
||||
let lastSuggestedCategoryId = null;
|
||||
|
||||
function parseNumber(value) {
|
||||
const normalized = String(value || '').replace(/,/g, '').trim();
|
||||
const n = parseFloat(normalized);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function formatWithComma(value) {
|
||||
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.]/g, '');
|
||||
if (normalized === '') return '';
|
||||
const parts = normalized.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
function selectedAccount() {
|
||||
const id = parseInt(accountEl.value || '0', 10);
|
||||
return ACCOUNTS.find(a => a.id === id) || null;
|
||||
}
|
||||
|
||||
function mapTransactionTypeToCategoryType(transactionType) {
|
||||
if (transactionType === 'income') return 'income';
|
||||
if (transactionType === 'expense') return 'expense';
|
||||
return 'transfer';
|
||||
}
|
||||
|
||||
function rebuildCategoryOptions() {
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const categoryType = mapTransactionTypeToCategoryType(transactionType);
|
||||
const currentValue = categoryEl.value;
|
||||
const filtered = allCategoryOptions.filter(item => item.type === categoryType);
|
||||
|
||||
categoryEl.innerHTML = '<option value="0">선택하세요</option>';
|
||||
|
||||
filtered.forEach((item) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.value;
|
||||
option.textContent = item.text;
|
||||
if (item.value === currentValue) option.selected = true;
|
||||
categoryEl.appendChild(option);
|
||||
});
|
||||
|
||||
if (![...categoryEl.options].some(opt => opt.value === currentValue)) {
|
||||
categoryEl.value = '0';
|
||||
}
|
||||
}
|
||||
|
||||
function filterRelatedAccountOptions() {
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const mainAccountType = accountEl.selectedOptions[0]?.dataset.accountType || '';
|
||||
const mainAccountId = accountEl.value;
|
||||
|
||||
Array.from(relatedAccountEl.options).forEach(option => {
|
||||
if (option.value === '') {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const optionType = option.dataset.accountType || '';
|
||||
let visible = true;
|
||||
|
||||
if (option.value === mainAccountId) {
|
||||
visible = false;
|
||||
} else if (transactionType === 'transfer') {
|
||||
visible =
|
||||
['bank', 'cash', 'other'].includes(mainAccountType) &&
|
||||
['bank', 'cash', 'other'].includes(optionType);
|
||||
} else if (transactionType === 'card_payment') {
|
||||
visible =
|
||||
['bank', 'cash', 'other'].includes(mainAccountType) &&
|
||||
optionType === 'card';
|
||||
}
|
||||
|
||||
option.hidden = !visible;
|
||||
|
||||
if (!visible && option.selected) {
|
||||
relatedAccountEl.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (transactionType === 'transfer') {
|
||||
relatedAccountEl.disabled = false;
|
||||
relatedAccountEl.required = true;
|
||||
relatedHelpEl.textContent = '같은 주 계좌는 제외되고, 입금 계좌만 선택 가능합니다.';
|
||||
} else if (transactionType === 'card_payment') {
|
||||
relatedAccountEl.disabled = false;
|
||||
relatedAccountEl.required = true;
|
||||
relatedHelpEl.textContent = '관련 계좌에는 카드 계정만 선택 가능합니다.';
|
||||
} else {
|
||||
relatedAccountEl.disabled = true;
|
||||
relatedAccountEl.required = false;
|
||||
relatedAccountEl.value = '';
|
||||
relatedHelpEl.textContent = '계좌이체/카드대금납부 시 선택';
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccountHelpText() {
|
||||
const acc = selectedAccount();
|
||||
if (!acc) {
|
||||
accountHelpTextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (acc.type === 'card' && acc.card_kind === 'check') {
|
||||
accountHelpTextEl.textContent = '체크카드 · 즉시 출금 기준';
|
||||
return;
|
||||
}
|
||||
|
||||
if (acc.type === 'card' && acc.card_kind === 'credit' && acc.use_credit_grace_period) {
|
||||
const billingDay = acc.billing_day ? `${acc.billing_day}일` : '-';
|
||||
const paymentDay = acc.payment_day ? `${acc.payment_day}일` : '-';
|
||||
accountHelpTextEl.textContent = `신용카드 · 결제기준일 ${billingDay} / 납부일 ${paymentDay}`;
|
||||
return;
|
||||
}
|
||||
|
||||
accountHelpTextEl.textContent = '';
|
||||
}
|
||||
|
||||
function calculateInterest() {
|
||||
const principal = parseNumber(amountEl.value);
|
||||
const months = parseInt(installmentMonthsEl.value || '0', 10) || 0;
|
||||
const annualRate = parseFloat(installmentRateEl.value || '0') || 0;
|
||||
|
||||
if (principal <= 0 || months <= 1 || annualRate <= 0) {
|
||||
if (!installmentInterestEl.dataset.userEdited) {
|
||||
installmentInterestEl.value = '';
|
||||
}
|
||||
syncInstallmentTotal();
|
||||
return;
|
||||
}
|
||||
|
||||
const monthlyRate = (annualRate / 100) / 12;
|
||||
const averageOutstanding = principal / 2;
|
||||
const interestTotal = Math.round((averageOutstanding * monthlyRate * months));
|
||||
|
||||
if (!installmentInterestEl.dataset.userEdited || installmentInterestEl.value === '') {
|
||||
installmentInterestEl.value = formatWithComma(interestTotal);
|
||||
}
|
||||
|
||||
syncInstallmentTotal();
|
||||
}
|
||||
|
||||
function syncInstallmentTotal() {
|
||||
const principal = parseNumber(amountEl.value);
|
||||
const interest = parseNumber(installmentInterestEl.value);
|
||||
const calculated = principal + interest;
|
||||
|
||||
if (!installmentTotalEl.dataset.userEdited || installmentTotalEl.value === '') {
|
||||
installmentTotalEl.value = calculated > 0 ? formatWithComma(calculated) : '';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInstallmentFields() {
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const acc = selectedAccount();
|
||||
const mainAccountType = acc ? acc.type : '';
|
||||
const isExpenseCard = (transactionType === 'expense' && mainAccountType === 'card');
|
||||
|
||||
if (isExpenseCard) {
|
||||
installmentEl.disabled = false;
|
||||
|
||||
if (String(installmentEl.value) === '1') {
|
||||
installmentWrapEl.style.display = '';
|
||||
installmentRateWrapEl.style.display = '';
|
||||
installmentInterestWrapEl.style.display = '';
|
||||
installmentTotalWrapEl.style.display = '';
|
||||
installmentMonthsEl.required = true;
|
||||
calculateInterest();
|
||||
} else {
|
||||
installmentWrapEl.style.display = 'none';
|
||||
installmentRateWrapEl.style.display = 'none';
|
||||
installmentInterestWrapEl.style.display = 'none';
|
||||
installmentTotalWrapEl.style.display = 'none';
|
||||
installmentMonthsEl.required = false;
|
||||
installmentMonthsEl.value = '';
|
||||
installmentRateEl.value = '0';
|
||||
installmentInterestEl.value = '';
|
||||
installmentTotalEl.value = '';
|
||||
installmentInterestEl.dataset.userEdited = '';
|
||||
installmentTotalEl.dataset.userEdited = '';
|
||||
}
|
||||
} else {
|
||||
installmentEl.value = '0';
|
||||
installmentEl.disabled = true;
|
||||
installmentWrapEl.style.display = 'none';
|
||||
installmentRateWrapEl.style.display = 'none';
|
||||
installmentInterestWrapEl.style.display = 'none';
|
||||
installmentTotalWrapEl.style.display = 'none';
|
||||
installmentMonthsEl.required = false;
|
||||
installmentMonthsEl.value = '';
|
||||
installmentRateEl.value = '0';
|
||||
installmentInterestEl.value = '';
|
||||
installmentTotalEl.value = '';
|
||||
installmentInterestEl.dataset.userEdited = '';
|
||||
installmentTotalEl.dataset.userEdited = '';
|
||||
}
|
||||
}
|
||||
|
||||
function hideSuggestBox() {
|
||||
suggestBoxEl.classList.add('d-none');
|
||||
suggestBoxEl.textContent = '';
|
||||
lastSuggestedCategoryId = null;
|
||||
}
|
||||
|
||||
function showSuggestBox(message) {
|
||||
suggestBoxEl.textContent = message;
|
||||
suggestBoxEl.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function fetchCategorySuggestion() {
|
||||
const merchantName = merchantEl.value.trim();
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const categoryType = mapTransactionTypeToCategoryType(transactionType);
|
||||
|
||||
if (merchantName.length < 2) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/api/category_suggest.php?merchant_name=${encodeURIComponent(merchantName)}&transaction_type=${encodeURIComponent(categoryType)}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.ok || !result.found) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOption = Array.from(categoryEl.options).find(option => option.value === String(result.category_id));
|
||||
if (!targetOption) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
categoryEl.value = String(result.category_id);
|
||||
lastSuggestedCategoryId = String(result.category_id);
|
||||
showSuggestBox(`자동 추천: ${result.category_name} (규칙: ${result.keyword ?? result.pattern_text ?? ''})`);
|
||||
} catch (error) {
|
||||
hideSuggestBox();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSuggestion() {
|
||||
clearTimeout(suggestTimer);
|
||||
suggestTimer = setTimeout(fetchCategorySuggestion, 250);
|
||||
}
|
||||
|
||||
quickAmtButtons.forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const current = parseNumber(amountEl.value);
|
||||
const add = parseInt(btn.dataset.add || '0', 10) || 0;
|
||||
amountEl.value = formatWithComma(current + add);
|
||||
calculateInterest();
|
||||
});
|
||||
});
|
||||
|
||||
amountClearBtn.addEventListener('click', function () {
|
||||
amountEl.value = '';
|
||||
calculateInterest();
|
||||
amountEl.focus();
|
||||
});
|
||||
|
||||
amountEl.addEventListener('input', function () {
|
||||
const cursorAtEnd = this.selectionStart === this.value.length;
|
||||
this.value = formatWithComma(this.value);
|
||||
if (cursorAtEnd) {
|
||||
this.setSelectionRange(this.value.length, this.value.length);
|
||||
}
|
||||
calculateInterest();
|
||||
});
|
||||
|
||||
installmentInterestEl.addEventListener('input', function () {
|
||||
installmentInterestEl.dataset.userEdited = '1';
|
||||
installmentInterestEl.value = formatWithComma(installmentInterestEl.value);
|
||||
syncInstallmentTotal();
|
||||
});
|
||||
|
||||
installmentTotalEl.addEventListener('input', function () {
|
||||
installmentTotalEl.dataset.userEdited = '1';
|
||||
installmentTotalEl.value = formatWithComma(installmentTotalEl.value);
|
||||
});
|
||||
|
||||
transactionTypeEl.addEventListener('change', function () {
|
||||
rebuildCategoryOptions();
|
||||
filterRelatedAccountOptions();
|
||||
toggleInstallmentFields();
|
||||
scheduleSuggestion();
|
||||
});
|
||||
|
||||
accountEl.addEventListener('change', function () {
|
||||
updateAccountHelpText();
|
||||
filterRelatedAccountOptions();
|
||||
toggleInstallmentFields();
|
||||
});
|
||||
|
||||
installmentEl.addEventListener('change', toggleInstallmentFields);
|
||||
installmentMonthsEl.addEventListener('change', calculateInterest);
|
||||
installmentRateEl.addEventListener('input', calculateInterest);
|
||||
|
||||
merchantEl.addEventListener('input', scheduleSuggestion);
|
||||
|
||||
categoryEl.addEventListener('change', function () {
|
||||
if (lastSuggestedCategoryId && categoryEl.value !== lastSuggestedCategoryId) {
|
||||
hideSuggestBox();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('transactionForm').addEventListener('submit', function () {
|
||||
amountEl.value = String(parseNumber(amountEl.value));
|
||||
installmentInterestEl.value = installmentInterestEl.value === '' ? '' : String(parseNumber(installmentInterestEl.value));
|
||||
installmentTotalEl.value = installmentTotalEl.value === '' ? '' : String(parseNumber(installmentTotalEl.value));
|
||||
});
|
||||
|
||||
rebuildCategoryOptions();
|
||||
updateAccountHelpText();
|
||||
filterRelatedAccountOptions();
|
||||
toggleInstallmentFields();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/transaction_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit('Method not allowed.');
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if ($id > 0) {
|
||||
delete_transaction($id, user_id());
|
||||
}
|
||||
|
||||
header('Location: /transactions.php');
|
||||
exit;
|
||||
@@ -0,0 +1,656 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
require_once __DIR__ . '/../app/lib/transaction_service.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$error = '';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM transactions WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$id, $uid]);
|
||||
$item = $stmt->fetch();
|
||||
|
||||
if (!$item) {
|
||||
exit('거래를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM accounts
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY FIELD(account_type, 'bank', 'card', 'cash', 'other'), id ASC
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY category_type, sort_order, id
|
||||
");
|
||||
$stmt->execute([$uid]);
|
||||
$categories = $stmt->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$transactionType = $_POST['transaction_type'] ?? '';
|
||||
$accountId = (int)($_POST['account_id'] ?? 0);
|
||||
$categoryId = (int)($_POST['category_id'] ?? 0);
|
||||
$amount = (float)str_replace(',', '', (string)($_POST['amount'] ?? 0));
|
||||
$transactionDate = $_POST['transaction_date'] ?? date('Y-m-d');
|
||||
$merchantName = trim($_POST['merchant_name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$relatedAccountId = !empty($_POST['related_account_id']) ? (int)$_POST['related_account_id'] : null;
|
||||
|
||||
$isInstallment = (int)($_POST['is_installment'] ?? 0);
|
||||
$installmentMonths = !empty($_POST['installment_months']) ? (int)$_POST['installment_months'] : null;
|
||||
$installmentInterestRate = !empty($_POST['installment_interest_rate']) ? (float)$_POST['installment_interest_rate'] : 0.0;
|
||||
$installmentInterestTotal = isset($_POST['installment_interest_total']) && $_POST['installment_interest_total'] !== ''
|
||||
? (float)str_replace(',', '', (string)$_POST['installment_interest_total'])
|
||||
: null;
|
||||
$installmentTotalBilled = isset($_POST['installment_total_billed']) && $_POST['installment_total_billed'] !== ''
|
||||
? (float)str_replace(',', '', (string)$_POST['installment_total_billed'])
|
||||
: null;
|
||||
|
||||
if ($amount <= 0) throw new RuntimeException('금액은 0보다 커야 합니다.');
|
||||
if (!in_array($transactionType, ['income', 'expense', 'transfer', 'card_payment'], true)) throw new RuntimeException('거래 유형이 올바르지 않습니다.');
|
||||
if ($accountId <= 0) throw new RuntimeException('주 계좌/카드를 선택하세요.');
|
||||
if ($categoryId <= 0) throw new RuntimeException('카테고리를 선택하세요.');
|
||||
if (in_array($transactionType, ['transfer', 'card_payment'], true) && !$relatedAccountId) throw new RuntimeException('관련 계좌를 선택해야 합니다.');
|
||||
if ($relatedAccountId && $relatedAccountId === $accountId) throw new RuntimeException('주 계좌와 관련 계좌는 같을 수 없습니다.');
|
||||
|
||||
if ($transactionType !== 'expense') {
|
||||
$isInstallment = 0;
|
||||
$installmentMonths = null;
|
||||
$installmentInterestRate = 0.0;
|
||||
$installmentInterestTotal = null;
|
||||
$installmentTotalBilled = null;
|
||||
}
|
||||
|
||||
update_transaction($id, $uid, [
|
||||
'account_id' => $accountId,
|
||||
'category_id' => $categoryId,
|
||||
'transaction_type' => $transactionType,
|
||||
'amount' => $amount,
|
||||
'transaction_date' => $transactionDate,
|
||||
'merchant_name' => $merchantName !== '' ? $merchantName : null,
|
||||
'description' => $description !== '' ? $description : null,
|
||||
'related_account_id' => $relatedAccountId,
|
||||
'is_installment' => $isInstallment,
|
||||
'installment_months' => $installmentMonths,
|
||||
'installment_interest_rate' => $installmentInterestRate,
|
||||
'installment_interest_total' => $installmentInterestTotal,
|
||||
'installment_total_billed' => $installmentTotalBilled,
|
||||
]);
|
||||
|
||||
redirect('/transactions.php');
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$accountsJson = [];
|
||||
foreach ($accounts as $acc) {
|
||||
$accountsJson[] = [
|
||||
'id' => (int)$acc['id'],
|
||||
'name' => $acc['account_name'],
|
||||
'type' => $acc['account_type'],
|
||||
'card_kind' => $acc['card_kind'] ?? null,
|
||||
'billing_day' => $acc['billing_day'] ?? null,
|
||||
'payment_day' => $acc['payment_day'] ?? null,
|
||||
'use_credit_grace_period' => !empty($acc['use_credit_grace_period']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>거래 수정</h2>
|
||||
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3" id="transactionEditForm" autocomplete="off">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">거래 유형</label>
|
||||
<select name="transaction_type" id="transaction_type" class="form-select" required>
|
||||
<option value="expense" <?= $item['transaction_type'] === 'expense' ? 'selected' : '' ?>>지출</option>
|
||||
<option value="income" <?= $item['transaction_type'] === 'income' ? 'selected' : '' ?>>수입</option>
|
||||
<option value="transfer" <?= $item['transaction_type'] === 'transfer' ? 'selected' : '' ?>>계좌이체</option>
|
||||
<option value="card_payment" <?= $item['transaction_type'] === 'card_payment' ? 'selected' : '' ?>>카드대금납부</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">주 계좌/카드</label>
|
||||
<select name="account_id" id="account_id" class="form-select" required>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option
|
||||
value="<?= $a['id'] ?>"
|
||||
data-account-type="<?= h($a['account_type']) ?>"
|
||||
<?= (int)$item['account_id'] === (int)$a['id'] ? 'selected' : '' ?>
|
||||
>
|
||||
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text" id="account_help_text"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">관련 계좌</label>
|
||||
<select name="related_account_id" id="related_account_id" class="form-select">
|
||||
<option value="">선택안함</option>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option
|
||||
value="<?= $a['id'] ?>"
|
||||
data-account-type="<?= h($a['account_type']) ?>"
|
||||
<?= (int)($item['related_account_id'] ?? 0) === (int)$a['id'] ? 'selected' : '' ?>
|
||||
>
|
||||
<?= h($a['account_name']) ?> (<?= h($a['account_type']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text" id="related_account_help">계좌이체/카드대금납부 시 선택</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select name="category_id" id="category_id" class="form-select" required>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option
|
||||
value="<?= $c['id'] ?>"
|
||||
data-type="<?= h($c['category_type']) ?>"
|
||||
<?= (int)$item['category_id'] === (int)$c['id'] ? 'selected' : '' ?>
|
||||
>
|
||||
[<?= h($c['category_type']) ?>] <?= h($c['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">금액</label>
|
||||
<input type="text" name="amount" id="amount" class="form-control" value="<?= h(money_plain($item['amount'])) ?>" inputmode="numeric" required>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="10000">+1만</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="50000">+5만</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary quick-amt" data-add="100000">+10만</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="amount_clear_btn">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">거래일</label>
|
||||
<input type="date" name="transaction_date" class="form-control" value="<?= h($item['transaction_date']) ?>" required>
|
||||
<?php if (!empty($item['billing_year_month'])): ?>
|
||||
<div class="form-text">현재 청구월: <?= h($item['billing_year_month']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">결제 방식</label>
|
||||
<select name="is_installment" id="is_installment" class="form-select">
|
||||
<option value="0" <?= empty($item['is_installment']) ? 'selected' : '' ?>>일시불</option>
|
||||
<option value="1" <?= !empty($item['is_installment']) ? 'selected' : '' ?>>할부</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_months_wrap" style="display:none;">
|
||||
<label class="form-label">할부 개월</label>
|
||||
<select name="installment_months" id="installment_months" class="form-select">
|
||||
<option value="">선택하세요</option>
|
||||
<?php foreach ([2,3,4,5,6,10,12,18,24] as $month): ?>
|
||||
<option value="<?= $month ?>" <?= (int)($item['installment_months'] ?? 0) === $month ? 'selected' : '' ?>>
|
||||
<?= intvalf($month) ?>개월
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_rate_wrap" style="display:none;">
|
||||
<label class="form-label">연이자율(%)</label>
|
||||
<input type="number" name="installment_interest_rate" id="installment_interest_rate" class="form-control" step="0.01" min="0" value="<?= h((string)($item['installment_interest_rate'] ?? 0)) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3" id="installment_interest_wrap" style="display:none;">
|
||||
<label class="form-label">총 할부이자</label>
|
||||
<input type="text" name="installment_interest_total" id="installment_interest_total" class="form-control" inputmode="numeric" value="<?= h(money_plain($item['installment_interest_total'] ?? 0)) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3"
|
||||
id="installment_total_wrap"
|
||||
style="display:<?= !empty($item['is_installment']) ? 'block' : 'none' ?>;">
|
||||
|
||||
<label class="form-label">총 청구금액</label>
|
||||
|
||||
<input type="text"
|
||||
name="installment_total_billed"
|
||||
id="installment_total_billed"
|
||||
class="form-control"
|
||||
inputmode="numeric"
|
||||
value="<?= h(!empty($item['installment_total_billed']) ? money_plain($item['installment_total_billed']) : '') ?>">
|
||||
|
||||
<?php if (!empty($item['is_installment'])): ?>
|
||||
<div class="form-text text-primary">
|
||||
현재 <?= intvalf($item['installment_months']) ?>개월 /
|
||||
<?= ((float)$item['installment_interest_rate'] > 0)
|
||||
? '연 ' . numf($item['installment_interest_rate'],2) . '%'
|
||||
: '무이자' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-text">
|
||||
이자 포함 실제 카드 청구 총액
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">사용처 / 거래처</label>
|
||||
<input type="text" name="merchant_name" id="merchant_name" class="form-control" value="<?= h($item['merchant_name'] ?? '') ?>" autocomplete="off">
|
||||
<div class="form-text">상호명 입력 시 카테고리를 자동 추천합니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">메모</label>
|
||||
<input type="text" name="description" class="form-control" value="<?= h($item['description'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div id="category_suggest_box" class="alert alert-info py-2 px-3 d-none mb-0"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary">수정 저장</button>
|
||||
|
||||
<a href="/transactions.php" class="btn btn-outline-secondary">목록</a>
|
||||
|
||||
<button type="submit"
|
||||
formaction="/transaction_delete.php"
|
||||
formmethod="post"
|
||||
name="id"
|
||||
value="<?= (int)$id ?>"
|
||||
class="btn btn-outline-danger"
|
||||
onclick="return confirm('거래를 삭제하시겠습니까?');">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const ACCOUNTS = <?= json_encode($accountsJson, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
const transactionTypeEl = document.getElementById('transaction_type');
|
||||
const accountEl = document.getElementById('account_id');
|
||||
const accountHelpTextEl = document.getElementById('account_help_text');
|
||||
const relatedAccountEl = document.getElementById('related_account_id');
|
||||
const relatedHelpEl = document.getElementById('related_account_help');
|
||||
const categoryEl = document.getElementById('category_id');
|
||||
const merchantEl = document.getElementById('merchant_name');
|
||||
const suggestBoxEl = document.getElementById('category_suggest_box');
|
||||
|
||||
const installmentEl = document.getElementById('is_installment');
|
||||
const installmentWrapEl = document.getElementById('installment_months_wrap');
|
||||
const installmentMonthsEl = document.getElementById('installment_months');
|
||||
const installmentRateWrapEl = document.getElementById('installment_rate_wrap');
|
||||
const installmentRateEl = document.getElementById('installment_interest_rate');
|
||||
const installmentInterestWrapEl = document.getElementById('installment_interest_wrap');
|
||||
const installmentTotalWrapEl = document.getElementById('installment_total_wrap');
|
||||
const installmentInterestEl = document.getElementById('installment_interest_total');
|
||||
const installmentTotalEl = document.getElementById('installment_total_billed');
|
||||
const amountEl = document.getElementById('amount');
|
||||
|
||||
const quickAmtButtons = document.querySelectorAll('.quick-amt');
|
||||
const amountClearBtn = document.getElementById('amount_clear_btn');
|
||||
|
||||
const allCategoryOptions = Array.from(categoryEl.querySelectorAll('option')).map(option => ({
|
||||
value: option.value,
|
||||
text: option.textContent,
|
||||
type: option.dataset.type,
|
||||
selected: option.selected
|
||||
}));
|
||||
|
||||
const initialCategoryValue = categoryEl.value;
|
||||
let suggestTimer = null;
|
||||
let lastSuggestedCategoryId = null;
|
||||
|
||||
function parseNumber(value) {
|
||||
const normalized = String(value || '').replace(/,/g, '').trim();
|
||||
const n = parseFloat(normalized);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function formatWithComma(value) {
|
||||
const normalized = String(value || '').replace(/,/g, '').replace(/[^\d.]/g, '');
|
||||
if (normalized === '') return '';
|
||||
const parts = normalized.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
function selectedAccount() {
|
||||
const id = parseInt(accountEl.value || '0', 10);
|
||||
return ACCOUNTS.find(a => a.id === id) || null;
|
||||
}
|
||||
|
||||
function mapTransactionTypeToCategoryType(transactionType) {
|
||||
if (transactionType === 'income') return 'income';
|
||||
if (transactionType === 'expense') return 'expense';
|
||||
return 'transfer';
|
||||
}
|
||||
|
||||
function rebuildCategoryOptions() {
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const categoryType = mapTransactionTypeToCategoryType(transactionType);
|
||||
const currentValue = categoryEl.value || initialCategoryValue;
|
||||
const filtered = allCategoryOptions.filter(item => item.type === categoryType);
|
||||
|
||||
categoryEl.innerHTML = '';
|
||||
|
||||
filtered.forEach((item, index) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.value;
|
||||
option.textContent = item.text;
|
||||
|
||||
if (item.value === currentValue) {
|
||||
option.selected = true;
|
||||
} else if (!filtered.some(x => x.value === currentValue) && index === 0) {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
categoryEl.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function filterRelatedAccountOptions() {
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const mainAccountType = accountEl.selectedOptions[0]?.dataset.accountType || '';
|
||||
const mainAccountId = accountEl.value;
|
||||
|
||||
Array.from(relatedAccountEl.options).forEach(option => {
|
||||
if (option.value === '') {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const optionType = option.dataset.accountType || '';
|
||||
let visible = true;
|
||||
|
||||
if (option.value === mainAccountId) {
|
||||
visible = false;
|
||||
} else if (transactionType === 'transfer') {
|
||||
visible = ['bank', 'cash', 'other'].includes(mainAccountType)
|
||||
&& ['bank', 'cash', 'other'].includes(optionType);
|
||||
} else if (transactionType === 'card_payment') {
|
||||
visible = ['bank', 'cash', 'other'].includes(mainAccountType)
|
||||
&& optionType === 'card';
|
||||
}
|
||||
|
||||
option.hidden = !visible;
|
||||
|
||||
if (!visible && option.selected) {
|
||||
relatedAccountEl.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (transactionType === 'transfer') {
|
||||
relatedAccountEl.disabled = false;
|
||||
relatedAccountEl.required = true;
|
||||
relatedHelpEl.textContent = '같은 주 계좌는 제외되고, 입금 계좌만 선택 가능합니다.';
|
||||
} else if (transactionType === 'card_payment') {
|
||||
relatedAccountEl.disabled = false;
|
||||
relatedAccountEl.required = true;
|
||||
relatedHelpEl.textContent =
|
||||
'관련 계좌에는 카드 계정만 선택 가능합니다. 저장 시 해당월 할부 청구도 자동 처리됩니다.';
|
||||
} else {
|
||||
relatedAccountEl.disabled = true;
|
||||
relatedAccountEl.required = false;
|
||||
relatedAccountEl.value = '';
|
||||
relatedHelpEl.textContent = '계좌이체/카드대금납부 시 선택';
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccountHelpText() {
|
||||
const acc = selectedAccount();
|
||||
if (!acc) {
|
||||
accountHelpTextEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (acc.type === 'card' && acc.card_kind === 'check') {
|
||||
accountHelpTextEl.textContent = '체크카드 · 즉시 출금 기준';
|
||||
return;
|
||||
}
|
||||
|
||||
if (acc.type === 'card' && acc.card_kind === 'credit' && acc.use_credit_grace_period) {
|
||||
const billingDay = acc.billing_day ? `${acc.billing_day}일` : '-';
|
||||
const paymentDay = acc.payment_day ? `${acc.payment_day}일` : '-';
|
||||
accountHelpTextEl.textContent = `신용카드 · 결제기준일 ${billingDay} / 납부일 ${paymentDay}`;
|
||||
return;
|
||||
}
|
||||
|
||||
accountHelpTextEl.textContent = '';
|
||||
}
|
||||
|
||||
function calculateInterest() {
|
||||
const principal = parseNumber(amountEl.value);
|
||||
const months = parseInt(installmentMonthsEl.value || '0', 10) || 0;
|
||||
const annualRate = parseFloat(installmentRateEl.value || '0') || 0;
|
||||
|
||||
if (principal <= 0 || months <= 1 || annualRate <= 0) {
|
||||
if (!installmentInterestEl.dataset.userEdited) {
|
||||
installmentInterestEl.value = '';
|
||||
}
|
||||
syncInstallmentTotal();
|
||||
return;
|
||||
}
|
||||
|
||||
const monthlyRate = (annualRate / 100) / 12;
|
||||
const averageOutstanding = principal / 2;
|
||||
const interestTotal = Math.round((averageOutstanding * monthlyRate * months));
|
||||
|
||||
if (!installmentInterestEl.dataset.userEdited || installmentInterestEl.value === '') {
|
||||
installmentInterestEl.value = formatWithComma(interestTotal);
|
||||
}
|
||||
|
||||
syncInstallmentTotal();
|
||||
}
|
||||
|
||||
function syncInstallmentTotal() {
|
||||
const principal = parseNumber(amountEl.value);
|
||||
const interest = parseNumber(installmentInterestEl.value);
|
||||
const calculated = principal + interest;
|
||||
|
||||
if (!installmentTotalEl.dataset.userEdited || installmentTotalEl.value === '') {
|
||||
installmentTotalEl.value = calculated > 0 ? formatWithComma(calculated) : '';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInstallmentFields() {
|
||||
const transactionType = transactionTypeEl.value;
|
||||
const acc = selectedAccount();
|
||||
const isExpenseCard = transactionType === 'expense' && acc && acc.type === 'card';
|
||||
|
||||
if (isExpenseCard) {
|
||||
installmentEl.disabled = false;
|
||||
|
||||
if (String(installmentEl.value) === '1') {
|
||||
installmentWrapEl.style.display = '';
|
||||
installmentRateWrapEl.style.display = '';
|
||||
installmentInterestWrapEl.style.display = '';
|
||||
installmentTotalWrapEl.style.display = '';
|
||||
installmentMonthsEl.required = true;
|
||||
calculateInterest();
|
||||
} else {
|
||||
installmentWrapEl.style.display = 'none';
|
||||
installmentRateWrapEl.style.display = 'none';
|
||||
installmentInterestWrapEl.style.display = 'none';
|
||||
installmentTotalWrapEl.style.display = 'none';
|
||||
installmentMonthsEl.required = false;
|
||||
installmentMonthsEl.value = '';
|
||||
installmentRateEl.value = '0';
|
||||
installmentInterestEl.value = '';
|
||||
installmentTotalEl.value = '';
|
||||
installmentInterestEl.dataset.userEdited = '';
|
||||
installmentTotalEl.dataset.userEdited = '';
|
||||
}
|
||||
} else {
|
||||
installmentEl.value = '0';
|
||||
installmentEl.disabled = true;
|
||||
installmentWrapEl.style.display = 'none';
|
||||
installmentRateWrapEl.style.display = 'none';
|
||||
installmentInterestWrapEl.style.display = 'none';
|
||||
installmentTotalWrapEl.style.display = 'none';
|
||||
installmentMonthsEl.required = false;
|
||||
installmentMonthsEl.value = '';
|
||||
installmentRateEl.value = '0';
|
||||
installmentInterestEl.value = '';
|
||||
installmentTotalEl.value = '';
|
||||
installmentInterestEl.dataset.userEdited = '';
|
||||
installmentTotalEl.dataset.userEdited = '';
|
||||
}
|
||||
}
|
||||
|
||||
function hideSuggestBox() {
|
||||
suggestBoxEl.classList.add('d-none');
|
||||
suggestBoxEl.textContent = '';
|
||||
lastSuggestedCategoryId = null;
|
||||
}
|
||||
|
||||
function showSuggestBox(message) {
|
||||
suggestBoxEl.textContent = message;
|
||||
suggestBoxEl.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function fetchCategorySuggestion() {
|
||||
const merchantName = merchantEl.value.trim();
|
||||
const categoryType = mapTransactionTypeToCategoryType(transactionTypeEl.value);
|
||||
|
||||
if (merchantName.length < 2) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/api/category_suggest.php?merchant_name=${encodeURIComponent(merchantName)}&transaction_type=${encodeURIComponent(categoryType)}`;
|
||||
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
|
||||
if (!response.ok) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.ok || !result.found) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOption = Array.from(categoryEl.options).find(option => option.value === String(result.category_id));
|
||||
if (!targetOption) {
|
||||
hideSuggestBox();
|
||||
return;
|
||||
}
|
||||
|
||||
categoryEl.value = String(result.category_id);
|
||||
lastSuggestedCategoryId = String(result.category_id);
|
||||
showSuggestBox(`자동 추천: ${result.category_name} (규칙: ${result.keyword ?? result.pattern_text ?? ''})`);
|
||||
} catch (error) {
|
||||
hideSuggestBox();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSuggestion() {
|
||||
clearTimeout(suggestTimer);
|
||||
suggestTimer = setTimeout(fetchCategorySuggestion, 250);
|
||||
}
|
||||
|
||||
quickAmtButtons.forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
amountEl.value = formatWithComma(parseNumber(amountEl.value) + parseInt(btn.dataset.add || '0', 10));
|
||||
calculateInterest();
|
||||
});
|
||||
});
|
||||
|
||||
amountClearBtn.addEventListener('click', function () {
|
||||
amountEl.value = '';
|
||||
calculateInterest();
|
||||
amountEl.focus();
|
||||
});
|
||||
|
||||
amountEl.addEventListener('input', function () {
|
||||
this.value = formatWithComma(this.value);
|
||||
calculateInterest();
|
||||
});
|
||||
|
||||
installmentInterestEl.addEventListener('input', function () {
|
||||
installmentInterestEl.dataset.userEdited = '1';
|
||||
installmentInterestEl.value = formatWithComma(installmentInterestEl.value);
|
||||
syncInstallmentTotal();
|
||||
});
|
||||
|
||||
installmentTotalEl.addEventListener('input', function () {
|
||||
installmentTotalEl.dataset.userEdited = '1';
|
||||
installmentTotalEl.value = formatWithComma(installmentTotalEl.value);
|
||||
});
|
||||
|
||||
transactionTypeEl.addEventListener('change', function () {
|
||||
rebuildCategoryOptions();
|
||||
filterRelatedAccountOptions();
|
||||
toggleInstallmentFields();
|
||||
scheduleSuggestion();
|
||||
});
|
||||
|
||||
accountEl.addEventListener('change', function () {
|
||||
updateAccountHelpText();
|
||||
filterRelatedAccountOptions();
|
||||
toggleInstallmentFields();
|
||||
});
|
||||
|
||||
installmentEl.addEventListener('change', toggleInstallmentFields);
|
||||
installmentMonthsEl.addEventListener('change', calculateInterest);
|
||||
installmentRateEl.addEventListener('input', calculateInterest);
|
||||
merchantEl.addEventListener('input', scheduleSuggestion);
|
||||
|
||||
categoryEl.addEventListener('change', function () {
|
||||
if (lastSuggestedCategoryId && categoryEl.value !== lastSuggestedCategoryId) {
|
||||
hideSuggestBox();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('transactionEditForm').addEventListener('submit', function () {
|
||||
amountEl.value = String(parseNumber(amountEl.value));
|
||||
installmentInterestEl.value = installmentInterestEl.value === '' ? '' : String(parseNumber(installmentInterestEl.value));
|
||||
installmentTotalEl.value = installmentTotalEl.value === '' ? '' : String(parseNumber(installmentTotalEl.value));
|
||||
});
|
||||
|
||||
rebuildCategoryOptions();
|
||||
updateAccountHelpText();
|
||||
filterRelatedAccountOptions();
|
||||
toggleInstallmentFields();
|
||||
|
||||
if (merchantEl.value.trim().length >= 2) {
|
||||
scheduleSuggestion();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../app/lib/auth.php';
|
||||
require_once __DIR__ . '/../app/lib/db.php';
|
||||
require_once __DIR__ . '/../app/lib/helpers.php';
|
||||
|
||||
check_auth();
|
||||
|
||||
$pdo = db();
|
||||
$uid = user_id();
|
||||
|
||||
$ym = $_GET['ym'] ?? date('Y-m');
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
$type = $_GET['type'] ?? '';
|
||||
$accountId = (int)($_GET['account_id'] ?? 0);
|
||||
$categoryId = (int)($_GET['category_id'] ?? 0);
|
||||
|
||||
$start = $ym . '-01';
|
||||
$end = date('Y-m-t', strtotime($start));
|
||||
|
||||
$params = [$uid, $start, $end];
|
||||
$where = [
|
||||
"t.user_id = ?",
|
||||
"t.transaction_date BETWEEN ? AND ?"
|
||||
];
|
||||
|
||||
if ($q !== '') {
|
||||
$where[] = "(t.merchant_name LIKE ? OR t.description LIKE ? OR c.name LIKE ? OR a.account_name LIKE ?)";
|
||||
$like = '%' . $q . '%';
|
||||
array_push($params, $like, $like, $like, $like);
|
||||
}
|
||||
|
||||
if (in_array($type, ['income', 'expense', 'transfer', 'card_payment'], true)) {
|
||||
$where[] = "t.transaction_type = ?";
|
||||
$params[] = $type;
|
||||
}
|
||||
|
||||
if ($accountId > 0) {
|
||||
$where[] = "t.account_id = ?";
|
||||
$params[] = $accountId;
|
||||
}
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$where[] = "t.category_id = ?";
|
||||
$params[] = $categoryId;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
t.*,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
a.card_kind,
|
||||
ra.account_name AS related_account_name,
|
||||
c.name AS category_name
|
||||
FROM transactions t
|
||||
JOIN accounts a ON t.account_id = a.id
|
||||
LEFT JOIN accounts ra ON t.related_account_id = ra.id
|
||||
JOIN categories c ON t.category_id = c.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY t.transaction_date DESC, t.id DESC
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$list = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, account_name FROM accounts WHERE user_id = ? AND is_active = 1 ORDER BY id ASC");
|
||||
$stmt->execute([$uid]);
|
||||
$accounts = $stmt->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, category_type, name FROM categories WHERE user_id = ? AND is_active = 1 ORDER BY category_type, sort_order, id");
|
||||
$stmt->execute([$uid]);
|
||||
$categories = $stmt->fetchAll();
|
||||
|
||||
/* 요약 */
|
||||
$sumIncome = 0;
|
||||
$sumExpense = 0;
|
||||
$sumTransfer = 0;
|
||||
$sumCardPay = 0;
|
||||
|
||||
foreach ($list as $row) {
|
||||
$amt = (float)$row['amount'];
|
||||
|
||||
if ($row['transaction_type'] === 'income') $sumIncome += $amt;
|
||||
elseif ($row['transaction_type'] === 'expense') $sumExpense += $amt;
|
||||
elseif ($row['transaction_type'] === 'transfer') $sumTransfer += $amt;
|
||||
elseif ($row['transaction_type'] === 'card_payment') $sumCardPay += $amt;
|
||||
}
|
||||
|
||||
function tx_label(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'income' => '수입',
|
||||
'expense' => '지출',
|
||||
'transfer' => '이체',
|
||||
'card_payment' => '카드납부',
|
||||
default => $type
|
||||
};
|
||||
}
|
||||
|
||||
require __DIR__ . '/../app/views/header.php';
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
<h2>거래내역</h2>
|
||||
<a href="/transaction_create.php" class="btn btn-primary">거래등록</a>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">월</label>
|
||||
<input type="month" name="ym" class="form-control" value="<?= h($ym) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">검색어</label>
|
||||
<input type="text" name="q" class="form-control" value="<?= h($q) ?>" placeholder="사용처, 메모, 카테고리">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">유형</label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="">전체</option>
|
||||
<option value="income" <?= $type === 'income' ? 'selected' : '' ?>>수입</option>
|
||||
<option value="expense" <?= $type === 'expense' ? 'selected' : '' ?>>지출</option>
|
||||
<option value="transfer" <?= $type === 'transfer' ? 'selected' : '' ?>>이체</option>
|
||||
<option value="card_payment" <?= $type === 'card_payment' ? 'selected' : '' ?>>카드납부</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">계좌</label>
|
||||
<select name="account_id" class="form-select">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($accounts as $account): ?>
|
||||
<option value="<?= $account['id'] ?>" <?= $accountId === (int)$account['id'] ? 'selected' : '' ?>>
|
||||
<?= h($account['account_name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select name="category_id" class="form-select">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?= $category['id'] ?>" <?= $categoryId === (int)$category['id'] ? 'selected' : '' ?>>
|
||||
[<?= h($category['category_type']) ?>] <?= h($category['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary">조회</button>
|
||||
<a href="/transactions.php" class="btn btn-outline-secondary">초기화</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">수입</div>
|
||||
<div class="stat-value text-primary"><?= won($sumIncome) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">지출</div>
|
||||
<div class="stat-value text-danger"><?= won($sumExpense) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">이체</div>
|
||||
<div class="stat-value"><?= won($sumTransfer) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card finance-card">
|
||||
<div class="card-body">
|
||||
<div class="stat-label">카드납부</div>
|
||||
<div class="stat-value"><?= won($sumCardPay) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card finance-card">
|
||||
<div class="card-body mobile-scroll">
|
||||
<table class="table table-hover align-middle mb-0 transaction-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>유형</th>
|
||||
<th>계좌</th>
|
||||
<th>관련</th>
|
||||
<th>카테고리</th>
|
||||
<th>사용처</th>
|
||||
<th class="text-end">금액</th>
|
||||
<th>부가정보</th>
|
||||
<th>메모</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<?php foreach ($list as $row): ?>
|
||||
<?php
|
||||
$class = 'amount-transfer';
|
||||
if ($row['transaction_type'] === 'income') $class = 'amount-income';
|
||||
if ($row['transaction_type'] === 'expense') $class = 'amount-expense';
|
||||
if ($row['transaction_type'] === 'card_payment') $class = 'amount-card';
|
||||
|
||||
$extra = [];
|
||||
|
||||
if (!empty($row['billing_year_month'])) {
|
||||
$extra[] = '청구월 ' . $row['billing_year_month'];
|
||||
}
|
||||
|
||||
if (!empty($row['is_installment']) && !empty($row['installment_months'])) {
|
||||
$extra[] = $row['installment_months'] . '개월 할부';
|
||||
}
|
||||
|
||||
if ($row['account_type'] === 'card' && $row['card_kind'] === 'check') {
|
||||
$extra[] = '체크카드';
|
||||
}
|
||||
|
||||
if ($row['account_type'] === 'card' && $row['card_kind'] === 'credit') {
|
||||
$extra[] = '신용카드';
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td data-label="날짜"><?= h($row['transaction_date']) ?></td>
|
||||
<td data-label="유형"><?= h(tx_label($row['transaction_type'])) ?></td>
|
||||
<td data-label="계좌"><?= h($row['account_name']) ?></td>
|
||||
<td data-label="관련"><?= h($row['related_account_name'] ?? '-') ?></td>
|
||||
<td data-label="카테고리"><?= h($row['category_name']) ?></td>
|
||||
<td data-label="사용처"><?= h($row['merchant_name'] ?: '-') ?></td>
|
||||
<td data-label="금액" class="text-end <?= $class ?>"><?= won($row['amount']) ?></td>
|
||||
<td data-label="부가정보"><?= h($extra ? implode(' / ', $extra) : '-') ?></td>
|
||||
<td data-label="메모"><?= h($row['description'] ?: '-') ?></td>
|
||||
<td data-label="관리" class="text-nowrap">
|
||||
<a href="/transaction_create.php?copy_id=<?= $row['id'] ?>"
|
||||
class="btn btn-sm btn-outline-secondary">복사</a>
|
||||
<a href="/transaction_edit.php?id=<?= $row['id'] ?>" class="btn btn-sm btn-outline-primary">수정</a>
|
||||
<form method="post" action="/transaction_delete.php" class="d-inline">
|
||||
<input type="hidden" name="id" value="<?= (int)$row['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('삭제하시겠습니까?');">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$list): ?>
|
||||
<tr>
|
||||
<td colspan="10" class="text-center text-secondary py-5">조회 결과가 없습니다.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../app/views/footer.php'; ?>
|
||||
Reference in New Issue
Block a user