554 lines
24 KiB
PHP
554 lines
24 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/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'; ?>
|