Files
njts-accounting-core/frontend/payroll-calculation.html
2026-01-22 21:05:48 +09:00

1452 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>給与管理 - 月次給与計算</title>
<link rel="stylesheet" href="/css/style.css" />
<style>
.filters {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.filters label {
margin-right: 15px;
}
.payroll-list {
margin-top: 20px;
}
.payroll-item {
padding: 15px;
border: 1px solid #ddd;
margin-bottom: 10px;
border-radius: 5px;
cursor: pointer;
}
.payroll-item:hover {
background: #f9f9f9;
}
.status-badge {
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}
.status-draft {
background: #ffc107;
color: #000;
}
.status-calculated {
background: #17a2b8;
color: #fff;
}
.status-approved {
background: #28a745;
color: #fff;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.btn {
padding: 10px 20px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.payroll-detail {
margin-top: 20px;
}
/* 明細を中央に寄せ、横幅を狭める(読みやすくするため) */
.payroll-detail {
max-width: 760px;
margin-left: auto;
margin-right: auto;
padding-left: 8px;
padding-right: 8px;
}
.detail-section {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ddd;
}
.detail-row:last-child {
border-bottom: none;
}
.total-row {
font-weight: bold;
font-size: 1.2em;
color: #007bff;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
/* タブスタイル */
.nav-tabs-calc {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.nav-tab-calc {
padding: 10px 20px;
cursor: pointer;
border: none;
background: #f0f0f0;
border-radius: 5px 5px 0 0;
}
.nav-tab-calc.active {
background: #007bff;
color: white;
}
.tab-content-calc {
display: none;
}
.tab-content-calc.active {
display: block;
}
</style>
</head>
<body>
<div class="container">
<a href="payroll.html" class="back-link">← 給与管理メニューに戻る</a>
<h1>給与管理 - 給与・賞与計算</h1>
<div class="nav-tabs-calc">
<button class="nav-tab-calc active" onclick="switchTab('salary')">
給与計算
</button>
<button class="nav-tab-calc" onclick="switchTab('bonus')">
賞与計算
</button>
</div>
<!-- 給与計算タブ -->
<div id="tab-salary" class="tab-content-calc active">
<div class="filters">
<label>
対象年月:
<input
type="month"
id="filterYearMonth"
style="width: 160px"
placeholder="YYYY-MM"
/>
</label>
<button class="btn btn-primary" onclick="loadPayrolls()">検索</button>
<button class="btn btn-success" onclick="showCalculateForm()">
新規計算
</button>
</div>
<div id="payrollList" class="payroll-list"></div>
<!-- 給与計算フォーム(モーダル風) -->
<div
id="calculateModal"
style="
display: none;
position: fixed;
top: 50px;
left: 50%;
transform: translateX(-50%);
width: 800px;
max-height: 90vh;
overflow-y: auto;
background: white;
padding: 30px;
border: 2px solid #007bff;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
"
>
<h2>給与計算</h2>
<form id="calculateForm" onsubmit="calculatePayroll(event)">
<div class="form-group">
<label>従業員 *</label>
<select name="employee_id" id="employeeSelect" required>
<option value="">選択してください</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>対象年 *</label>
<input
type="text"
name="payroll_year"
inputmode="numeric"
pattern="\d{4}"
maxlength="4"
oninput="sanitizeDigits(this,4)"
required
/>
</div>
<div class="form-group">
<label>対象月 *</label>
<select name="payroll_month" required>
<option value="">選択してください</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
</select>
</div>
<div class="form-group">
<label>支給日 *</label>
<input
type="date"
name="payment_date"
required
min="1900-01-01"
max="2099-12-31"
/>
</div>
</div>
<div class="detail-section">
<h3>勤怠情報</h3>
<div class="form-row">
<div class="form-group">
<label>出勤日数</label>
<input
type="number"
name="working_days"
step="0.5"
value="0"
/>
</div>
<div class="form-group">
<label>勤務時間</label>
<input
type="number"
name="working_hours"
step="0.5"
value="0"
/>
</div>
<div class="form-group">
<label>残業時間</label>
<input
type="number"
name="overtime_hours"
step="0.5"
value="0"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>休日出勤時間</label>
<input
type="number"
name="holiday_hours"
step="0.5"
value="0"
/>
</div>
<div class="form-group">
<label>遅刻時間</label>
<input type="number" name="late_hours" step="0.5" value="0" />
</div>
<div class="form-group">
<label>欠勤日数</label>
<input
type="number"
name="absent_days"
step="0.5"
value="0"
/>
</div>
</div>
</div>
<div class="detail-section">
<h3>その他</h3>
<div class="form-row">
<div class="form-group">
<label>その他手当</label>
<input
type="number"
name="other_allowance"
step="0.01"
value="0"
/>
</div>
<div class="form-group">
<label>住民税</label>
<input
type="number"
name="resident_tax"
step="0.01"
value="0"
/>
</div>
<div class="form-group">
<label>その他控除</label>
<input
type="number"
name="other_deduction"
step="0.01"
value="0"
/>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">計算実行</button>
<button
type="button"
class="btn btn-secondary"
onclick="closeCalculateForm()"
>
キャンセル
</button>
</form>
</div>
<!-- 給与明細表示 -->
<div
id="payrollDetail"
class="payroll-detail"
style="display: none"
></div>
</div>
<!-- 賞与計算タブ -->
<div id="tab-bonus" class="tab-content-calc">
<div class="filters">
<label>
対象年月:
<input
type="month"
id="filterBonusYearMonth"
style="width: 160px"
placeholder="YYYY-MM"
/>
</label>
<button class="btn btn-primary" onclick="loadBonus()">検索</button>
<button class="btn btn-success" onclick="showBonusForm()">
新規計算
</button>
</div>
<div id="bonusList" class="payroll-list"></div>
<!-- 賞与計算フォーム -->
<div
id="bonusModal"
style="
display: none;
position: fixed;
top: 50px;
left: 50%;
transform: translateX(-50%);
width: 800px;
max-height: 90vh;
overflow-y: auto;
background: white;
padding: 30px;
border: 2px solid #007bff;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
"
>
<h2>賞与計算</h2>
<form id="bonusForm" onsubmit="calculateBonus(event)">
<div class="form-group">
<label>従業員 *</label>
<select name="employee_id" id="bonusEmployeeSelect" required>
<option value="">選択してください</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>対象年 *</label>
<input
type="text"
name="bonus_year"
inputmode="numeric"
pattern="\d{4}"
maxlength="4"
oninput="sanitizeDigits(this,4)"
required
/>
</div>
<div class="form-group">
<label>対象月 *</label>
<select name="bonus_month" required>
<option value="">選択してください</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
</select>
</div>
<div class="form-group">
<label>賞与種別 *</label>
<select name="bonus_type" required>
<option value="">選択してください</option>
<option value="夏季賞与">夏季賞与</option>
<option value="冬季賞与">冬季賞与</option>
<option value="決算賞与">決算賞与</option>
<option value="その他">その他</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>支給日 *</label>
<input
type="date"
name="payment_date"
required
min="1900-01-01"
max="2099-12-31"
/>
</div>
<div class="form-group">
<label>基本賞与額</label>
<input type="number" name="base_bonus" step="0.01" value="0" />
</div>
<div class="form-group">
<label>業績賞与</label>
<input
type="number"
name="performance_bonus"
step="0.01"
value="0"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>その他賞与</label>
<input type="number" name="other_bonus" step="0.01" value="0" />
</div>
<div class="form-group">
<label>住民税</label>
<input
type="number"
name="resident_tax"
step="0.01"
value="0"
/>
</div>
<div class="form-group">
<label>その他控除</label>
<input
type="number"
name="other_deduction"
step="0.01"
value="0"
/>
</div>
</div>
<button type="submit" class="btn btn-primary">計算実行</button>
<button
type="button"
class="btn btn-secondary"
onclick="closeBonusForm()"
>
キャンセル
</button>
</form>
</div>
<!-- 賞与明細表示 -->
<div
id="bonusDetail"
class="payroll-detail"
style="display: none"
></div>
</div>
</div>
<script>
// Use relative API paths so frontend works when served via nginx on the NAS.
// The nginx service will proxy API requests to the backend container.
const API_BASE = "";
let employeeMap = {};
// sanitize input to digits only and limit length
function sanitizeDigits(el, maxLen) {
if (!el) return;
// remove non-digits
el.value = el.value.replace(/\D/g, "");
if (maxLen && el.value.length > maxLen)
el.value = el.value.slice(0, maxLen);
}
function validateYearMonth(yearStr, monthStr) {
const now = new Date();
const currentYear = now.getFullYear();
if (!/^[0-9]{4}$/.test(String(yearStr)))
return {
ok: false,
message: "対象年は4桁の数字で入力してください例: 2026",
};
const year = Number(yearStr);
if (year < 1900 || year > currentYear + 1)
return {
ok: false,
message: `対象年は1900〜${currentYear + 1}の範囲で入力してください`,
};
if (!/^[0-9]{1,2}$/.test(String(monthStr)))
return {
ok: false,
message: "対象月は数字のみで入力してください1〜12",
};
const month = Number(monthStr);
if (month < 1 || month > 12)
return {
ok: false,
message: "対象月は1〜12の範囲で入力してください",
};
return { ok: true, year, month };
}
async function loadEmployees() {
const response = await fetch(
`${API_BASE}/payroll/employees/?is_active=true`
);
const employees = await response.json();
// マップを作成して再利用できるようにする
employeeMap = {};
employees.forEach((emp) => {
employeeMap[emp.employee_id] = emp;
});
// 給与フォームと賞与フォーム両方のセレクトボックスを更新
const optionsHtml =
'<option value="">選択してください</option>' +
employees
.map(
(emp) =>
`<option value="${emp.employee_id}">${emp.employee_code} - ${emp.name}</option>`
)
.join("");
const salarySelect = document.getElementById("employeeSelect");
if (salarySelect) salarySelect.innerHTML = optionsHtml;
const bonusSelect = document.getElementById("bonusEmployeeSelect");
if (bonusSelect) bonusSelect.innerHTML = optionsHtml;
return employees;
}
async function loadPayrolls() {
const ym = document.getElementById("filterYearMonth").value;
let year = null;
let month = null;
if (ym) {
const parts = ym.split("-");
if (parts.length === 2) {
year = Number(parts[0]);
month = Number(parts[1]);
}
}
const params = new URLSearchParams();
if (year) params.append("payroll_year", year);
if (month) params.append("payroll_month", month);
// employeeMap が空なら読み込む
if (!employeeMap || Object.keys(employeeMap).length === 0) {
await loadEmployees();
}
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/?${params}`
);
const payrolls = await response.json();
const html = payrolls
.map((p) => {
const emp = employeeMap[p.employee_id];
const empLabel = emp
? `従業員ID: ${p.employee_id} | ${emp.employee_code} - ${emp.name}`
: `従業員ID: ${p.employee_id}`;
return `
<div class="payroll-item" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1; cursor: pointer;" onclick="viewPayroll(${
p.payroll_id
})">
<div>
<strong>${p.payroll_year}${
p.payroll_month
}月</strong>
<span class="status-badge status-${
p.status
}">${getStatusLabel(p.status)}</span>
</div>
<div>
${empLabel} | 支給日: ${p.payment_date}
</div>
<div>
<strong>差引支給額: ¥${Number(
p.net_payment
).toLocaleString()}</strong>
(総支給: ¥${Number(
p.total_payment
).toLocaleString()} - 控除: ¥${Number(
p.total_deduction
).toLocaleString()})
</div>
</div>
<button class="btn btn-danger btn-small" onclick="deletePayroll(${
p.payroll_id
}); event.stopPropagation();" style="margin-left: 10px; white-space: nowrap; background-color: #dc3545; color: #fff; border: none;">
削除
</button>
</div>
`;
})
.join("");
document.getElementById("payrollList").innerHTML =
html || "<p>給与データがありません</p>";
} catch (error) {
alert("給与一覧の取得に失敗しました");
console.error(error);
}
}
function getStatusLabel(status) {
const labels = {
draft: "下書き",
calculated: "計算済み",
approved: "承認済み",
paid: "支払済み",
};
return labels[status] || status;
}
function showCalculateForm() {
document.getElementById("calculateModal").style.display = "block";
loadEmployees();
// デフォルト値設定
const now = new Date();
document.querySelector('[name="payroll_year"]').value =
now.getFullYear();
document.querySelector('[name="payroll_month"]').value =
now.getMonth() + 1;
}
function closeCalculateForm() {
document.getElementById("calculateModal").style.display = "none";
document.getElementById("calculateForm").reset();
}
async function calculatePayroll(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// validate year/month
const yearStr = formData.get("payroll_year");
const monthStr = formData.get("payroll_month");
const valid = validateYearMonth(yearStr, monthStr);
if (!valid.ok) {
alert(valid.message);
return;
}
const data = {};
formData.forEach((value, key) => {
// convert numeric-like fields to Number, keep others as-is
data[key] = isNaN(value) || value === "" ? value : Number(value);
});
// ensure payroll_year/month are numbers
data.payroll_year = valid.year;
data.payroll_month = valid.month;
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/calculate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
if (response.ok) {
const result = await response.json();
alert("給与計算が完了しました");
closeCalculateForm();
viewPayroll(result.payroll_id);
} else {
const error = await response.json();
const errorMessage =
typeof error.detail === "string"
? error.detail
: JSON.stringify(error.detail || error);
alert("計算に失敗しました:\n\n" + errorMessage);
}
} catch (error) {
alert("計算に失敗しました");
console.error(error);
}
}
async function viewPayroll(payrollId) {
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}`
);
const payroll = await response.json();
// 従業員情報と扶養家族情報を取得
const empResponse = await fetch(
`${API_BASE}/payroll/employees/${payroll.employee_id}`
);
const employee = await empResponse.json();
// 源泉税控除対象人数を計算16歳以上19歳未満の一般扶養親族 + 19歳以上23歳未満の特定扶養親族
const dependentCount = employee.dependents
? employee.dependents.filter((d) => {
// 有効期間チェック
if (d.valid_to && new Date(d.valid_to) < new Date())
return false;
// 生年月日から年齢を計算その年の12月31日時点
if (!d.birth_date) return false;
const birthDate = new Date(d.birth_date);
const targetYear = payroll.payroll_year;
const yearEnd = new Date(targetYear, 11, 31); // 12月31日
let age = yearEnd.getFullYear() - birthDate.getFullYear();
const monthDiff = yearEnd.getMonth() - birthDate.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && yearEnd.getDate() < birthDate.getDate())
) {
age--;
}
// 16歳以上が源泉税控除対象16-18歳は一般扶養、19-22歳は特定扶養
return age >= 16;
}).length
: 0;
const html = `
<h2>${payroll.payroll_year}${
payroll.payroll_month
}月 給与明細</h2>
<p><strong>従業員:</strong> ${employee.employee_code} - ${
employee.name
} | <strong>支給日:</strong> ${
payroll.payment_date
} | <strong>甲欄扶養親族人数(源泉税控除対象):</strong> ${dependentCount}人</p>
<p>ステータス: <span class="status-badge status-${
payroll.status
}">${getStatusLabel(payroll.status)}</span></p>
<div class="detail-section">
<h3>勤怠情報 ${
payroll.status === "calculated"
? '<button class="btn btn-primary" onclick="editPayroll(' +
payroll.payroll_id +
')" style="padding: 5px 10px; font-size: 12px; float: right;">編集</button>'
: ""
}</h3>
<div class="detail-row"><span>出勤日数:</span><span>${
payroll.working_days
}日</span></div>
<div class="detail-row"><span>勤務時間:</span><span>${
payroll.working_hours
}時間</span></div>
<div class="detail-row"><span>残業時間:</span><span>${
payroll.overtime_hours
}時間</span></div>
<div class="detail-row"><span>休日出勤:</span><span>${
payroll.holiday_hours
}時間</span></div>
<div class="detail-row"><span>欠勤日数:</span><span>${
payroll.absent_days
}日</span></div>
</div>
<div class="detail-section">
<h3>支給項目</h3>
<div class="detail-row"><span>基本給:</span><span>¥${Number(
payroll.base_salary
).toLocaleString()}</span></div>
<div class="detail-row"><span>残業手当:</span><span>¥${Number(
payroll.overtime_pay
).toLocaleString()}</span></div>
<div class="detail-row"><span>休日手当:</span><span>¥${Number(
payroll.holiday_pay
).toLocaleString()}</span></div>
<div class="detail-row"><span>通勤手当:</span><span>¥${Number(
payroll.commute_allowance
).toLocaleString()}</span></div>
<div class="detail-row"><span>その他手当:</span><span>¥${Number(
payroll.other_allowance
).toLocaleString()}</span></div>
<div class="detail-row total-row"><span>総支給額:</span><span>¥${Number(
payroll.total_payment
).toLocaleString()}</span></div>
<div class="detail-row"><span>健康保険:</span><span>¥${Number(
payroll.health_insurance
).toLocaleString()}</span></div>
<div class="detail-row"><span>介護保険:</span><span>¥${Number(
payroll.care_insurance || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>厚生年金:</span><span>¥${Number(
payroll.pension_insurance
).toLocaleString()}</span></div>
<div class="detail-row"><span>雇用保険:</span><span>¥${Number(
payroll.employment_insurance
).toLocaleString()}</span></div>
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${Number(
Number(payroll.health_insurance || 0) +
Number(payroll.care_insurance || 0) +
Number(payroll.pension_insurance || 0) +
Number(payroll.employment_insurance || 0)
).toLocaleString()}</span></div>
<div class="detail-row">
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
<span>¥${Number(
payroll.income_tax
).toLocaleString()}</span>
</div>
<div style="padding: 5px 10px; background: #f0f8ff; border-radius: 3px; margin: 5px 0; font-size: 12px; color: #666;">
<small>
※課税対象額から源泉徴収税額表(甲欄)を参照して計算<br>
 扶養親族${dependentCount}16歳以上が対象
</small>
</div>
<div class="detail-row"><span>住民税:</span><span>¥${Number(
payroll.resident_tax
).toLocaleString()}</span></div>
<div class="detail-row"><span>その他控除:</span><span>¥${Number(
payroll.other_deduction
).toLocaleString()}</span></div>
<div class="detail-row total-row"><span>総控除額:</span><span>¥${Number(
payroll.total_deduction
).toLocaleString()}</span></div>
</div>
<div class="detail-section" style="background:#e7f3ff;">
<div class="detail-row total-row" style="font-size:1.5em;">
<span>差引支給額:</span>
<span>¥${Number(
payroll.net_payment
).toLocaleString()}</span>
</div>
</div>
${
payroll.status === "calculated"
? `
<button class="btn btn-success" onclick="approvePayroll(${payroll.payroll_id})">承認</button>
<button class="btn btn-primary" onclick="recalculatePayroll(${payroll.payroll_id})">再計算</button>
`
: ""
}
<button class="btn btn-secondary" onclick="document.getElementById('payrollDetail').style.display='none'">閉じる</button>
`;
document.getElementById("payrollDetail").innerHTML = html;
document.getElementById("payrollDetail").style.display = "block";
document
.getElementById("payrollDetail")
.scrollIntoView({ behavior: "smooth" });
} catch (error) {
alert("給与明細の取得に失敗しました");
console.error(error);
}
}
async function editPayroll(payrollId) {
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}`
);
const payroll = await response.json();
// 編集フォームを表示
const html = `
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 800px; max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000;">
<h2>給与データ編集</h2>
<form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
<div class="detail-section">
<h3>勤怠情報</h3>
<div class="form-row">
<div class="form-group">
<label>出勤日数</label>
<input type="number" name="working_days" step="0.5" value="${payroll.working_days}" />
</div>
<div class="form-group">
<label>勤務時間</label>
<input type="number" name="working_hours" step="0.5" value="${payroll.working_hours}" />
</div>
<div class="form-group">
<label>残業時間</label>
<input type="number" name="overtime_hours" step="0.5" value="${payroll.overtime_hours}" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>休日出勤時間</label>
<input type="number" name="holiday_hours" step="0.5" value="${payroll.holiday_hours}" />
</div>
<div class="form-group">
<label>遅刻時間</label>
<input type="number" name="late_hours" step="0.5" value="${payroll.late_hours}" />
</div>
<div class="form-group">
<label>欠勤日数</label>
<input type="number" name="absent_days" step="0.5" value="${payroll.absent_days}" />
</div>
</div>
</div>
<div class="detail-section">
<h3>その他</h3>
<div class="form-row">
<div class="form-group">
<label>その他手当</label>
<input type="number" name="other_allowance" step="0.01" value="${payroll.other_allowance}" />
</div>
<div class="form-group">
<label>住民税</label>
<input type="number" name="resident_tax" step="0.01" value="${payroll.resident_tax}" />
</div>
<div class="form-group">
<label>その他控除</label>
<input type="number" name="other_deduction" step="0.01" value="${payroll.other_deduction}" />
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">保存して再計算</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('div').remove()">キャンセル</button>
</form>
</div>
`;
const editDiv = document.createElement("div");
editDiv.innerHTML = html;
document.body.appendChild(editDiv);
} catch (error) {
alert("給与データの取得に失敗しました");
console.error(error);
}
}
async function savePayrollEdit(event, payrollId) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
data[key] = isNaN(value) || value === "" ? value : Number(value);
});
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
if (response.ok) {
alert("給与データを更新しました");
form.closest("div").remove();
// 再計算
await recalculatePayroll(payrollId);
} else {
const error = await response.json();
alert("更新に失敗しました: " + error.detail);
}
} catch (error) {
alert("更新に失敗しました");
console.error(error);
}
}
async function approvePayroll(payrollId) {
if (!confirm("この給与を承認しますか?")) return;
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}/approve`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ approved_by: "admin" }),
}
);
if (response.ok) {
alert("承認しました");
viewPayroll(payrollId);
loadPayrolls();
} else {
const error = await response.json();
alert("承認に失敗しました: " + error.detail);
}
} catch (error) {
alert("承認に失敗しました");
console.error(error);
}
}
async function recalculatePayroll(payrollId) {
if (!confirm("給与を再計算しますか?")) return;
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}/recalculate`,
{
method: "POST",
}
);
if (response.ok) {
alert("再計算が完了しました");
viewPayroll(payrollId);
} else {
const error = await response.json();
alert("再計算に失敗しました: " + error.detail);
}
} catch (error) {
alert("再計算に失敗しました");
console.error(error);
}
}
async function deletePayroll(payrollId) {
if (
!confirm("この給与データを削除しますか?\nこの操作は取り消せません。")
)
return;
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}`,
{
method: "DELETE",
}
);
if (response.ok) {
alert("給与データを削除しました");
document.getElementById("payrollDetail").style.display = "none";
loadPayrolls();
} else {
const error = await response.json();
alert(
"削除に失敗しました: " + JSON.stringify(error.detail || error)
);
}
} catch (error) {
alert("削除に失敗しました");
console.error(error);
}
}
// 初期化: 月ピッカーにセットして一覧を読み込む
const now = new Date();
const ym =
now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
document.getElementById("filterYearMonth").value = ym;
document.getElementById("filterBonusYearMonth").value = ym;
loadPayrolls();
// =====================================================
// タブ切り替え機能
// =====================================================
let currentTab = "salary";
function switchTab(tab) {
currentTab = tab;
// タブボタンの表示切り替え
document
.querySelectorAll(".nav-tab-calc")
.forEach((btn) => btn.classList.remove("active"));
event.target.classList.add("active");
// タブコンテンツの表示切り替え
document.getElementById("tab-salary").classList.remove("active");
document.getElementById("tab-bonus").classList.remove("active");
document.getElementById("tab-" + tab).classList.add("active");
// 初回表示時に一覧を読み込む
if (tab === "bonus") {
loadBonus();
}
}
// =====================================================
// 賞与計算関連
// =====================================================
async function loadBonus() {
const ym = document.getElementById("filterBonusYearMonth").value;
let year = null;
let month = null;
if (ym) {
const parts = ym.split("-");
if (parts.length === 2) {
year = Number(parts[0]);
month = Number(parts[1]);
}
}
const params = new URLSearchParams();
if (year) params.append("bonus_year", year);
if (month) params.append("bonus_month", month);
// employeeMapが空なら読み込む
if (!employeeMap || Object.keys(employeeMap).length === 0) {
await loadEmployees();
}
try {
const response = await fetch(`${API_BASE}/payroll/bonus/?${params}`);
const bonuses = await response.json();
const html = bonuses
.map((b) => {
const emp = employeeMap[b.employee_id];
const empLabel = emp
? `${emp.employee_code} - ${emp.name}`
: `従業員: 未登録`;
return `
<div class="payroll-item" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1; cursor: pointer;" onclick="viewBonus(${
b.bonus_id
})">
<div>
<strong>${b.bonus_year}${
b.bonus_month
}月</strong>
<span class="status-badge status-calculated">${
b.bonus_type
}</span>
</div>
<div>
${empLabel} | 支給日: ${b.payment_date}
</div>
<div>
<strong>差引支給額: ¥${Number(
b.net_bonus
).toLocaleString()}</strong>
(総支給: ¥${Number(
b.total_bonus
).toLocaleString()} - 控除: ¥${Number(
b.total_deduction || 0
).toLocaleString()})
</div>
</div>
<button class="btn btn-danger btn-small" onclick="deleteBonus(${
b.bonus_id
}); event.stopPropagation();" style="margin-left: 10px; white-space: nowrap; background-color: #dc3545; color: #fff; border: none;">
削除
</button>
</div>
`;
})
.join("");
document.getElementById("bonusList").innerHTML =
html || "<p>賞与データがありません</p>";
} catch (error) {
alert("賞与一覧の取得に失敗しました");
console.error(error);
}
}
function showBonusForm() {
document.getElementById("bonusModal").style.display = "block";
loadEmployees();
// デフォルト値設定
const now = new Date();
document.querySelector("#bonusForm [name='bonus_year']").value =
now.getFullYear();
document.querySelector("#bonusForm [name='bonus_month']").value =
now.getMonth() + 1;
}
function closeBonusForm() {
document.getElementById("bonusModal").style.display = "none";
document.getElementById("bonusForm").reset();
}
async function calculateBonus(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// validate year/month
const yearStr = formData.get("bonus_year");
const monthStr = formData.get("bonus_month");
const valid = validateYearMonth(yearStr, monthStr);
if (!valid.ok) {
alert(valid.message);
return;
}
const data = {};
formData.forEach((value, key) => {
data[key] = isNaN(value) || value === "" ? value : Number(value);
});
data.bonus_year = valid.year;
data.bonus_month = valid.month;
try {
const response = await fetch(`${API_BASE}/payroll/bonus/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (response.ok) {
const result = await response.json();
alert("賞与計算が完了しました");
closeBonusForm();
viewBonus(result.bonus_id);
} else {
const error = await response.json();
const errorMessage =
typeof error.detail === "string"
? error.detail
: JSON.stringify(error.detail || error);
alert("計算に失敗しました:\n\n" + errorMessage);
}
} catch (error) {
alert("計算に失敗しました");
console.error(error);
}
}
async function viewBonus(bonusId) {
try {
const response = await fetch(`${API_BASE}/payroll/bonus/${bonusId}`);
const bonus = await response.json();
// 従業員情報を取得
const empResponse = await fetch(
`${API_BASE}/payroll/employees/${bonus.employee_id}`
);
const employee = await empResponse.json();
const html = `
<h2>${bonus.bonus_year}${
bonus.bonus_month
}月 賞与明細</h2>
<p><strong>従業員:</strong> ${employee.employee_code} - ${
employee.name
} | <strong>支給日:</strong> ${
bonus.payment_date
} | <strong>賞与種別:</strong> ${bonus.bonus_type}</p>
<div class="detail-section">
<h3>支給項目</h3>
<div class="detail-row"><span>基本賞与額:</span><span>¥${Number(
bonus.base_bonus || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>業績賞与:</span><span>¥${Number(
bonus.performance_bonus || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>その他賞与:</span><span>¥${Number(
bonus.other_bonus || 0
).toLocaleString()}</span></div>
<div class="detail-row total-row"><span>総支給額:</span><span>¥${Number(
bonus.total_bonus || 0
).toLocaleString()}</span></div>
</div>
<div class="detail-section">
<h3>控除項目</h3>
<div class="detail-row"><span>健康保険:</span><span>¥${Number(
bonus.health_insurance || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>介護保険:</span><span>¥${Number(
bonus.care_insurance || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>厚生年金:</span><span>¥${Number(
bonus.pension_insurance || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>雇用保険:</span><span>¥${Number(
bonus.employment_insurance || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${Number(
Number(bonus.health_insurance || 0) +
Number(bonus.care_insurance || 0) +
Number(bonus.pension_insurance || 0) +
Number(bonus.employment_insurance || 0)
).toLocaleString()}</span></div>
<div class="detail-row"><span>所得税:</span><span>¥${Number(
bonus.income_tax || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>住民税:</span><span>¥${Number(
bonus.resident_tax || 0
).toLocaleString()}</span></div>
<div class="detail-row"><span>その他控除:</span><span>¥${Number(
bonus.other_deduction || 0
).toLocaleString()}</span></div>
<div class="detail-row total-row"><span>総控除額:</span><span>¥${Number(
bonus.total_deduction || 0
).toLocaleString()}</span></div>
</div>
<div class="detail-section" style="background:#e7f3ff;">
<div class="detail-row total-row" style="font-size:1.5em;">
<span>差引支給額:</span>
<span>¥${Number(
bonus.net_bonus || 0
).toLocaleString()}</span>
</div>
</div>
<button class="btn btn-primary" onclick="recalculateBonus(${
bonus.bonus_id
})">再計算</button>
<button class="btn btn-secondary" onclick="document.getElementById('bonusDetail').style.display='none'">閉じる</button>
`;
document.getElementById("bonusDetail").innerHTML = html;
document.getElementById("bonusDetail").style.display = "block";
document
.getElementById("bonusDetail")
.scrollIntoView({ behavior: "smooth" });
} catch (error) {
alert("賞与明細の取得に失敗しました");
console.error(error);
}
}
async function deleteBonus(bonusId) {
if (
!confirm("この賞与データを削除しますか?\nこの操作は取り消せません。")
)
return;
try {
const response = await fetch(`${API_BASE}/payroll/bonus/${bonusId}`, {
method: "DELETE",
});
if (response.ok) {
alert("賞与データを削除しました");
document.getElementById("bonusDetail").style.display = "none";
loadBonus();
} else {
const error = await response.json();
alert(
"削除に失敗しました: " + JSON.stringify(error.detail || error)
);
}
} catch (error) {
alert("削除に失敗しました");
console.error(error);
}
}
async function recalculateBonus(bonusId) {
if (!confirm("賞与を再計算しますか?")) return;
try {
const response = await fetch(
`${API_BASE}/payroll/bonus/${bonusId}/recalculate`,
{
method: "POST",
}
);
if (response.ok) {
alert("再計算が完了しました");
viewBonus(bonusId);
} else {
const error = await response.json();
alert("再計算に失敗しました: " + error.detail);
}
} catch (error) {
alert("再計算に失敗しました");
console.error(error);
}
}
</script>
</body>
</html>