Files
financial/public/transaction_edit.php
2026-06-07 00:33:58 +09:00

657 lines
29 KiB
PHP

<?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'; ?>