Initial financial project import

This commit is contained in:
seo committed 2026-06-07 00:33:58 +09:00
commit faeab5f0f5
50 files changed
+11267

No files matched your search

+329
View File
@@ -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'; ?>
+362
View File
@@ -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'; ?>
+174
View File
@@ -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'; ?>
+61
View File
@@ -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);
}
+576
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
(function () {
if (!('serviceWorker' in navigator)) {
return;
}
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function () {});
});
})();
+7
View File
@@ -0,0 +1,7 @@
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slLine truncated
//# sourceMappingURL=bootstrap.bundle.min.js.map
+6
View File
@@ -0,0 +1,6 @@
@charset "UTF-8";/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
+14
View File
@@ -0,0 +1,14 @@
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
/*!
* @kurkle/color v0.3.2
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* Released under the MIT License
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
//# sourceMappingURL=chart.umd.min.js.map
+117
View File
@@ -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'; ?>
+498
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
<?php
header('Location: /dashboard.php');
exit;
+430
View File
@@ -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'; ?>
+554
View File
@@ -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'; ?>
+325
View File
@@ -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'; ?>
+247
View File
@@ -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'; ?>
+206
View File
@@ -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'; ?>
+223
View File
@@ -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'; ?>
+153
View File
@@ -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'; ?>
+271
View File
@@ -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'; ?>
+276
View File
@@ -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'; ?>
+125
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../app/lib/auth.php';
logout_user();
header('Location: /login.php');
exit;
+39
View File
@@ -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": "월별 거래내역을 확인합니다."
}
]
}
+264
View File
@@ -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'; ?>
+22
View File
@@ -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>
+182
View File
@@ -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'; ?>
+139
View File
@@ -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>
+60
View File
@@ -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'))
);
}
});
+880
View File
@@ -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'; ?>
+19
View File
@@ -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;
+656
View File
@@ -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'; ?>
+280
View File
@@ -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'; ?>