Files
njts-accounting-core/frontend/payroll-calculation.html
2026-06-01 00:35:29 +09:00

2679 lines
106 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate"
/>
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<script src="js/auth.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>給与管理 - 月次給与計算</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style>
.container {
max-width: 1440px;
margin: 0 auto;
padding: 0 20px;
}
.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;
}
.calc-note {
padding: 3px 10px 6px 14px;
font-size: 11px;
color: #555;
background: #f0f4f8;
border-left: 3px solid #b0c4de;
margin: -1px 0 4px 0;
line-height: 1.8;
}
.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;
}
/* 左右分割レイアウト */
.split-layout {
display: flex;
gap: 0;
align-items: flex-start;
}
.split-left {
flex: 0 0 420px;
min-width: 320px;
max-width: 420px;
max-height: calc(100vh - 160px);
overflow-y: auto;
position: sticky;
top: 20px;
}
.split-right {
display: none;
flex: 1;
min-width: 0;
border-left: 2px solid #e0e8f0;
padding-left: 24px;
max-height: calc(100vh - 160px);
overflow-y: auto;
position: sticky;
top: 20px;
}
.split-right .payroll-detail {
max-width: none;
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
margin-top: 0;
}
</style>
</head>
<body>
<div class="container">
<button
onclick="location.href = 'payroll.html'"
class="back-button"
style="
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-bottom: 20px;
"
>
← 給与管理メニューに戻る
</button>
<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>
<button class="nav-tab-calc" onclick="switchTab('voucher')">
📄 账票出力
</button>
</div>
<!-- 給与計算タブ -->
<div id="tab-salary" class="tab-content-calc active">
<div class="split-layout">
<div class="split-left">
<div
class="filters"
style="display: flex; flex-direction: column; gap: 8px"
>
<label style="display: flex; align-items: center; gap: 8px">
<span style="white-space: nowrap; min-width: 60px"
>対象年:</span
>
<input
type="number"
id="filterYear"
style="width: 90px"
value="2026"
min="2000"
max="2099"
/>
</label>
<label style="display: flex; align-items: center; gap: 8px">
<span style="white-space: nowrap; min-width: 60px"
>従業員:</span
>
<select id="filterEmployee" style="flex: 1">
<option value="">全員</option>
</select>
</label>
<div style="display: flex; gap: 8px">
<button class="btn btn-primary" onclick="loadPayrolls()">
検索
</button>
<button class="btn btn-success" onclick="showCalculateForm()">
新規計算
</button>
</div>
</div>
<div id="payrollList" class="payroll-list"></div>
</div>
<!-- /split-left -->
<div class="split-right" id="splitRightSalary">
<div
id="payrollDetail"
class="payroll-detail"
style="display: none"
></div>
</div>
<!-- /split-right -->
</div>
<!-- /split-layout -->
<!-- 給与計算フォーム(モーダル風) -->
<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>
<div class="detail-section">
<h3>計算オプション</h3>
<div style="display: flex; gap: 30px; padding: 8px 0">
<label
style="
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
"
>
<input type="checkbox" id="calcIncomeTax" checked />
所得税を計算する
</label>
<label
style="
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
"
>
<input type="checkbox" id="calcSocialInsurance" checked />
社会保険を計算する
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">計算実行</button>
<button
type="button"
class="btn btn-secondary"
onclick="closeCalculateForm()"
>
キャンセル
</button>
</form>
</div>
</div>
<!-- 賞与計算タブ -->
<div id="tab-bonus" class="tab-content-calc">
<div class="split-layout">
<div class="split-left">
<div class="filters">
<label>
対象年:
<input
type="number"
id="filterBonusYear"
style="width: 90px"
value="2026"
min="2000"
max="2099"
/>
</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>
<!-- /split-left -->
<div class="split-right" id="splitRightBonus">
<div
id="bonusDetail"
class="payroll-detail"
style="display: none"
></div>
</div>
<!-- /split-right -->
</div>
<!-- /split-layout -->
<!-- 賞与計算フォーム -->
<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>
<!-- 账票出力タブ -->
<div id="tab-voucher" class="tab-content-calc">
<div class="filters">
<h3>出力条件の設定</h3>
</div>
<!-- 時間範囲選択 -->
<div class="detail-section" style="background: #f9f9f9">
<h3>期間選択</h3>
<div class="form-row">
<div class="form-group">
<label>開始年月 *</label>
<div style="display: flex; gap: 6px; align-items: center">
<input
type="text"
id="voucherStartYear"
style="width: 90px"
placeholder="年"
maxlength="4"
pattern="[0-9]{4}"
inputmode="numeric"
required
/>
<span style="color: #666"></span>
<select id="voucherStartMonth" 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>
<span style="color: #666"></span>
</div>
</div>
<div class="form-group">
<label>終了年月</label>
<div style="display: flex; gap: 6px; align-items: center">
<input
type="text"
id="voucherEndYear"
style="width: 90px"
placeholder="年"
maxlength="4"
pattern="[0-9]{4}"
inputmode="numeric"
/>
<span style="color: #666"></span>
<select id="voucherEndMonth">
<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>
<span style="color: #666"></span>
</div>
</div>
<div class="form-group">
<label style="color: #999; font-size: 12px">
※終了年月を空にすると開始月のみを対象にします
</label>
</div>
</div>
</div>
<!-- 従業員選択 -->
<div class="detail-section" style="background: #f9f9f9">
<h3>従業員選択</h3>
<div style="margin-bottom: 10px">
<button
class="btn btn-primary"
style="padding: 8px 15px"
onclick="selectAllVoucherEmployees()"
>
全選択
</button>
<button
class="btn btn-secondary"
style="padding: 8px 15px"
onclick="clearAllVoucherEmployees()"
>
全解除
</button>
<span
id="voucherEmployeeCount"
style="margin-left: 20px; font-weight: bold"
>選択数: 0</span
>
</div>
<div
id="voucherEmployeeList"
style="
border: 1px solid #ddd;
padding: 15px;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
background: white;
"
>
<p style="color: #999">従業員を読み込み中...</p>
</div>
</div>
<!-- 支払類型選択 -->
<div class="detail-section" style="background: #f9f9f9">
<h3>支払タイプ</h3>
<div style="display: flex; gap: 30px">
<label style="font-weight: normal">
<input type="radio" name="voucherType" value="all" checked />
<span>給与・賞与両方</span>
</label>
<label style="font-weight: normal">
<input type="radio" name="voucherType" value="salary" />
<span>給与のみ</span>
</label>
<label style="font-weight: normal">
<input type="radio" name="voucherType" value="bonus" />
<span>賞与のみ</span>
</label>
</div>
</div>
<!-- 操作ボタン -->
<div style="margin: 20px 0; display: flex; gap: 10px">
<button
class="btn btn-success"
id="printVouchersBtn"
onclick="printVouchers()"
style="background: #007bff"
>
📄 印刷
</button>
<button
class="btn btn-success"
onclick="exportVouchersCSV()"
style="background: #28a745"
>
CSV出力
</button>
<!-- PDF出力は後の実装用 -->
<!-- <button class="btn btn-info" onclick="exportVouchersPDF()">PDF出力</button> -->
</div>
<!-- 結果表示エリア -->
<div id="voucherResultArea" style="display: none; margin-top: 30px">
<div
id="voucherResultSummary"
style="padding: 15px; border-radius: 5px; margin-bottom: 20px"
>
<!-- 結果摘要がここに表示される -->
</div>
<div
id="voucherResultData"
style="
background: white;
border: 1px solid #ddd;
border-radius: 5px;
padding: 20px;
"
>
<!-- 詳細データがここに表示される -->
</div>
</div>
</div>
</div>
<!-- 無データアラートモーダル -->
<div
id="noDataModal"
style="
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
justify-content: center;
align-items: center;
"
>
<div
style="
background: white;
padding: 30px;
border-radius: 10px;
max-width: 500px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
"
>
<h2 style="color: #d9534f; margin-top: 0">⚠️ データがありません</h2>
<p
id="noDataMessage"
style="font-size: 16px; line-height: 1.6; color: #333"
>
<!-- メッセージがここに表示される -->
</p>
<div style="text-align: right; margin-top: 20px">
<button
class="btn btn-primary"
onclick="
document.getElementById('noDataModal').style.display = 'none'
"
>
OK
</button>
</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;
// 検索用フィルタのドロップダウンも更新
const filterSelect = document.getElementById("filterEmployee");
if (filterSelect) {
const currentVal = filterSelect.value;
filterSelect.innerHTML =
'<option value="">全員</option>' +
employees
.map(
(emp) =>
`<option value="${emp.employee_id}">${emp.employee_code} - ${emp.name}</option>`,
)
.join("");
filterSelect.value = currentVal;
}
return employees;
}
async function loadPayrolls() {
// 前回の給与明細をクリア
const detailDiv = document.getElementById("payrollDetail");
if (detailDiv) {
detailDiv.style.display = "none";
detailDiv.innerHTML = "";
}
const year =
Number(document.getElementById("filterYear")?.value) || null;
const employeeId =
document.getElementById("filterEmployee")?.value || null;
const params = new URLSearchParams();
if (year) params.append("payroll_year", year);
if (employeeId) params.append("employee_id", employeeId);
// 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();
if (!payrolls || payrolls.length === 0) {
document.getElementById("payrollList").innerHTML =
"<p>給与データがありません</p>";
return;
}
// 月別にグループ化
const byMonth = {};
payrolls.forEach((p) => {
const key = `${p.payroll_year}-${String(p.payroll_month).padStart(2, "0")}`;
if (!byMonth[key]) byMonth[key] = [];
byMonth[key].push(p);
});
// 月順にソート
const sortedMonths = Object.keys(byMonth).sort();
const html = sortedMonths
.map((monthKey) => {
const [y, m] = monthKey.split("-");
const items = byMonth[monthKey]
.map((p) => {
const emp = employeeMap[p.employee_id];
const empLabel = emp
? `${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; min-width:0; cursor:pointer;" onclick="viewPayroll(${p.payroll_id})">
<div>
<strong>${empLabel}</strong>
<span class="status-badge status-${p.status}">${getStatusLabel(p.status)}</span>
</div>
<div style="font-size:12px; color:#666; margin-top:2px; white-space:nowrap;">支給日: ${p.payment_date}</div>
<div style="margin-top:4px; white-space:nowrap;"><strong>差引支給額: ¥${Number(p.net_payment).toLocaleString()}</strong></div>
<div style="color:#666; font-size:13px; white-space:nowrap;">(総支給: ¥${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; flex-shrink:0;">
削除
</button>
</div>
`;
})
.join("");
return `
<div style="margin-bottom:20px;">
<div style="background:#e8f0fe; border-left:4px solid #007bff; padding:8px 14px; font-weight:bold; font-size:15px; margin-bottom:6px;">
${Number(y)}${Number(m)}
</div>
${items}
</div>
`;
})
.join("");
document.getElementById("payrollList").innerHTML = html;
} 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;
// 計算オプションフラグ
data.calc_income_tax =
document.getElementById("calcIncomeTax")?.checked ?? true;
data.calc_social_insurance =
document.getElementById("calcSocialInsurance")?.checked ?? true;
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();
await loadPayrolls();
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 _health = Number(payroll.health_insurance || 0);
const _care = Number(payroll.care_insurance || 0);
const _pension = Number(payroll.pension_insurance || 0);
const _emp = Number(payroll.employment_insurance || 0);
const _child = Number(payroll.child_support || 0);
const _totalPay = Number(payroll.total_payment || 0);
// 標準報酬月額を厚生年金保険料(個人負担率 9.150%)から逆算
const _stdRemun = _pension > 0 ? Math.round(_pension / 0.0915) : 0;
const _stdFmt =
_stdRemun > 0 ? "¥" + _stdRemun.toLocaleString() : "-";
const _hRateFull =
_stdRemun > 0
? (((_health * 2) / _stdRemun) * 100).toFixed(2)
: "-";
const _hRateHalf =
_stdRemun > 0 ? ((_health / _stdRemun) * 100).toFixed(2) : "-";
const _cRateFull =
_stdRemun > 0 && _care > 0
? (((_care * 2) / _stdRemun) * 100).toFixed(2)
: null;
const _cRateHalf =
_stdRemun > 0 && _care > 0
? ((_care / _stdRemun) * 100).toFixed(2)
: null;
const _empRate =
_totalPay > 0 ? ((_emp / _totalPay) * 100).toFixed(3) : "-";
const _csRateFull =
_stdRemun > 0 && _child > 0
? (((_child * 2) / _stdRemun) * 100).toFixed(3)
: null;
const _csRateHalf =
_stdRemun > 0 && _child > 0
? ((_child / _stdRemun) * 100).toFixed(3)
: null;
const _socialSub = _health + _care + _pension + _emp + _child;
const _healthCareChild = _health + _care + _child;
const _taxable = _totalPay - _socialSub;
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>¥${_health.toLocaleString()}</span></div>
<div class="calc-note">
${_stdFmt}(標準報酬月額)× ${_hRateFull}%(保険料率・全体)÷ 2折半<br>
個人負担率:${_hRateHalf}% ※協会けんぽ東京
</div>
<div class="detail-row"><span>介護保険:</span><span>¥${_care.toLocaleString()}</span></div>
<div class="calc-note">
${
_cRateFull
? `${_stdFmt}(標準報酬月額)× ${_cRateFull}%(保険料率・全体)÷ 2折半<br>個人負担率:${_cRateHalf}% ※40歳以上65歳未満が対象`
: "介護保険非該当40歳未満または65歳以上"
}
</div>
<div class="detail-row"><span>厚生年金:</span><span>¥${_pension.toLocaleString()}</span></div>
<div class="calc-note">
${_stdFmt}(標準報酬月額)× 18.300%(保険料率・全体)÷ 2折半<br>
個人負担率9.150%(固定)
</div>
<div class="detail-row"><span>雇用保険:</span><span>¥${_emp.toLocaleString()}</span></div>
<div class="calc-note">
総支給額 ¥${_totalPay.toLocaleString()} × ${_empRate}%(雇用保険料率・従業員負担分)<br>
※標準報酬でなく実際の総支給額に料率を乗じる
</div>
<div class="detail-row"><span>子ども・子育て支援金:</span><span>¥${_child.toLocaleString()}</span></div>
<div class="calc-note">
${
_csRateFull
? `${_stdFmt}(標準報酬月額)× ${_csRateFull}%(拠出金率・全体)÷ 2折半<br>個人負担率:${_csRateHalf}% ※令和8年5月支払分4月分より控除開始`
: "令和8年5月支払分4月分保険料より控除開始 → 当月は対象外"
}
</div>
<div class="calc-note" style="background:#eef6ee; border-left:3px solid #28a745; padding:6px 10px; margin:6px 0; font-size:0.88em; color:#1a5c1a; line-height:1.7">
健康保険 ¥${_health.toLocaleString()} 介護保険 ¥${_care.toLocaleString()} 子ども・子育て支援金 ¥${_child.toLocaleString()} <strong>¥${_healthCareChild.toLocaleString()}</strong><br>
¥${_healthCareChild.toLocaleString()}(上記合計) 厚生年金 ¥${_pension.toLocaleString()}${' '}${_emp > 0 ? ` 雇用保険 ¥${_emp.toLocaleString()}` : ''} <strong>社会保険小計 ¥${_socialSub.toLocaleString()}</strong>
</div>
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_socialSub.toLocaleString()}</span></div>
<div class="detail-row">
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
<span>¥${Number(payroll.income_tax).toLocaleString()}</span>
</div>
<div class="calc-note">
課税対象額 = 総支給額 ¥${_totalPay.toLocaleString()} 社会保険料計 ¥${_socialSub.toLocaleString()} = ¥${_taxable.toLocaleString()}<br>
→ 源泉徴収税額表(甲欄・扶養${dependentCount}人)を参照して計算
</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'; var sr=document.getElementById('splitRightSalary'); if(sr) sr.style.display='none';">閉じる</button>
`;
document.getElementById("payrollDetail").innerHTML = html;
const splitRightSalary = document.getElementById("splitRightSalary");
if (splitRightSalary) splitRightSalary.style.display = "block";
document.getElementById("payrollDetail").style.display = "block";
// モバイルでは明細パネルへ自動スクロール
if (window.innerWidth <= 767 && splitRightSalary) {
setTimeout(function() { splitRightSalary.scrollIntoView({ behavior: "smooth", block: "start" }); }, 50);
}
} 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: min(800px, 92vw); 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; box-sizing: border-box;">
<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>
<div class="detail-section">
<h3>計算オプション</h3>
<div style="display:flex; gap:30px; padding:8px 0;">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" id="editCalcIncomeTax" checked />
所得税を計算する
</label>
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" id="editCalcSocialInsurance" checked />
社会保険を計算する
</label>
</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) {
// チェックボックスの値をフォーム削除前に取得
const calcIncomeTax =
document.getElementById("editCalcIncomeTax")?.checked ?? true;
const calcSocialInsurance =
document.getElementById("editCalcSocialInsurance")?.checked ??
true;
alert("給与データを更新しました");
form.closest("div").remove();
// 再計算
await recalculatePayroll(payrollId, {
calc_income_tax: calcIncomeTax,
calc_social_insurance: calcSocialInsurance,
});
} 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, calcOptions = null) {
if (!confirm("給与を再計算しますか?")) return;
try {
const body = calcOptions || {};
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}/recalculate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (response.ok) {
alert("再計算が完了しました");
await loadPayrolls();
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");
const filterYearEl = document.getElementById("filterYear");
if (filterYearEl) filterYearEl.value = now.getFullYear();
const filterBonusYearEl = document.getElementById("filterBonusYear");
if (filterBonusYearEl) filterBonusYearEl.value = now.getFullYear();
loadPayrolls();
// =====================================================
// タブ切り替え機能
// =====================================================
var 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-voucher").classList.remove("active");
document.getElementById("tab-" + tab).classList.add("active");
// 初回表示時に一覧を読み込む
if (tab === "bonus") {
loadBonus();
} else if (tab === "voucher") {
loadVoucherEmployees();
// 開始年月をデフォルト値として設定
const now = new Date();
const ym =
now.getFullYear() +
"-" +
String(now.getMonth() + 1).padStart(2, "0");
document.getElementById("voucherStartYear").value = now.getFullYear();
document.getElementById("voucherStartMonth").value =
now.getMonth() + 1;
document.getElementById("voucherEndYear").value = "";
document.getElementById("voucherEndMonth").value = "";
}
}
// =====================================================
// 賞与計算関連
// =====================================================
async function loadBonus() {
const year = document.getElementById("filterBonusYear").value;
const params = new URLSearchParams();
if (year) params.append("bonus_year", Number(year));
// 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; min-width: 0; 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 style="font-size: 12px; color: #666; margin-top: 2px; white-space: nowrap;">
${empLabel} | 支給日: ${b.payment_date}
</div>
<div style="white-space: nowrap;"><strong>差引支給額: ¥${Number(b.net_bonus).toLocaleString()}</strong></div>
<div style="color: #666; font-size: 13px; white-space: nowrap;">(総支給: ¥${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; flex-shrink: 0; 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();
await loadBonus();
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 _bHealth = Number(bonus.health_insurance || 0);
// 源泉税控除対象人数を計算(給与と同じロジック)
const bDependentCount = employee.dependents
? employee.dependents.filter((d) => {
if (d.valid_to && new Date(d.valid_to) < new Date())
return false;
if (!d.birth_date) return false;
const birthDate = new Date(d.birth_date);
const targetYear = bonus.bonus_year;
const yearEnd = new Date(targetYear, 11, 31);
let age = yearEnd.getFullYear() - birthDate.getFullYear();
const monthDiff = yearEnd.getMonth() - birthDate.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && yearEnd.getDate() < birthDate.getDate())
) {
age--;
}
return age >= 16;
}).length
: 0;
const _bCare = Number(bonus.care_insurance || 0);
const _bPension = Number(bonus.pension_insurance || 0);
const _bEmp = Number(bonus.employment_insurance || 0);
const _bChild = Number(bonus.child_support || 0);
const _bTotal = Number(bonus.total_bonus || 0);
const _bHRateFull =
_bTotal > 0 && _bHealth > 0
? (((_bHealth * 2) / _bTotal) * 100).toFixed(3)
: "-";
const _bHRateHalf =
_bTotal > 0 && _bHealth > 0
? ((_bHealth / _bTotal) * 100).toFixed(3)
: "-";
const _bCRateFull =
_bTotal > 0 && _bCare > 0
? (((_bCare * 2) / _bTotal) * 100).toFixed(3)
: null;
const _bCRateHalf =
_bTotal > 0 && _bCare > 0
? ((_bCare / _bTotal) * 100).toFixed(3)
: null;
const _bPRateFull =
_bTotal > 0 && _bPension > 0
? (((_bPension * 2) / _bTotal) * 100).toFixed(3)
: "-";
const _bPRateHalf =
_bTotal > 0 && _bPension > 0
? ((_bPension / _bTotal) * 100).toFixed(3)
: "-";
const _bEmpRate =
_bTotal > 0 && _bEmp > 0
? ((_bEmp / _bTotal) * 100).toFixed(3)
: "-";
const _bCSRateFull =
_bTotal > 0 && _bChild > 0
? (((_bChild * 2) / _bTotal) * 100).toFixed(4)
: null;
const _bCSRateHalf =
_bTotal > 0 && _bChild > 0
? ((_bChild / _bTotal) * 100).toFixed(4)
: null;
const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild;
const _bHealthCareChild = _bHealth + _bCare + _bChild;
const _bTaxable = _bTotal - _bSocialSub;
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>¥${_bHealth.toLocaleString()}</span></div>
<div class="calc-note">
¥${_bTotal.toLocaleString()}(賞与総支給額)× ${_bHRateFull}%(保険料率・全体)÷ 2折半<br>
個人負担率:${_bHRateHalf}% ※協会けんぽ東京
</div>
<div class="detail-row"><span>介護保険:</span><span>¥${_bCare.toLocaleString()}</span></div>
<div class="calc-note">
${
_bCRateFull
? `¥${_bTotal.toLocaleString()}(賞与総支給額)× ${_bCRateFull}%(保険料率・全体)÷ 2折半<br>個人負担率:${_bCRateHalf}% ※40歳以上65歳未満が対象`
: "介護保険非該当40歳未満または65歳以上"
}
</div>
<div class="detail-row"><span>厚生年金:</span><span>¥${_bPension.toLocaleString()}</span></div>
<div class="calc-note">
¥${_bTotal.toLocaleString()}(賞与総支給額)× ${_bPRateFull}%(保険料率・全体)÷ 2折半<br>
個人負担率:${_bPRateHalf}%固定 ※上限標準賞与額150万円/月
</div>
<div class="detail-row"><span>雇用保険:</span><span>¥${_bEmp.toLocaleString()}</span></div>
<div class="calc-note">
${
_bEmp > 0
? `¥${_bTotal.toLocaleString()}(賞与総支給額)× ${_bEmpRate}%(雇用保険料率・従業員負担分)`
: "賞与に対する雇用保険は別途計算方式によるまたは0円"
}
</div>
<div class="detail-row"><span>子ども・子育て支援金:</span><span>¥${_bChild.toLocaleString()}</span></div>
<div class="calc-note">
${
_bCSRateFull
? `¥${_bTotal.toLocaleString()}(賞与総支給額)× ${_bCSRateFull}%(拠出金率・全体)÷ 2折半<br>個人負担率:${_bCSRateHalf}% ※令和8年5月支払分4月分より控除開始`
: "令和8年5月支払分4月分保険料より控除開始 → 当賞与は対象外"
}
</div>
<div class="calc-note" style="background:#eef6ee; border-left:3px solid #28a745; padding:6px 10px; margin:6px 0; font-size:0.88em; color:#1a5c1a; line-height:1.7">
健康保険 ¥${_bHealth.toLocaleString()} 介護保険 ¥${_bCare.toLocaleString()} 子ども・子育て支援金 ¥${_bChild.toLocaleString()} <strong>¥${_bHealthCareChild.toLocaleString()}</strong><br>
¥${_bHealthCareChild.toLocaleString()}(上記合計) 厚生年金 ¥${_bPension.toLocaleString()}${' '}${_bEmp > 0 ? ` 雇用保険 ¥${_bEmp.toLocaleString()}` : ''} <strong>社会保険小計 ¥${_bSocialSub.toLocaleString()}</strong>
</div>
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_bSocialSub.toLocaleString()}</span></div>
<div class="detail-row">
<span>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span>
<span>¥${Number(bonus.income_tax || 0).toLocaleString()}</span>
</div>
<div class="calc-note">
課税対象額 = 賞与総支給額 ¥${_bTotal.toLocaleString()} 社会保険料計 ¥${_bSocialSub.toLocaleString()} = ¥${_bTaxable.toLocaleString()}<br>
→ 賞与に対する源泉徴収税額の算出率の表(甲欄・扶養${bDependentCount}人)を参照して計算
</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="editBonus(${
bonus.bonus_id
})">編集</button>
<button class="btn btn-primary" onclick="recalculateBonus(${
bonus.bonus_id
})">再計算</button>
<button class="btn btn-secondary" onclick="document.getElementById('bonusDetail').style.display='none'; var sr=document.getElementById('splitRightBonus'); if(sr) sr.style.display='none';">閉じる</button>
`;
document.getElementById("bonusDetail").innerHTML = html;
const splitRightBonus = document.getElementById("splitRightBonus");
if (splitRightBonus) splitRightBonus.style.display = "block";
document.getElementById("bonusDetail").style.display = "block";
// モバイルでは明細パネルへ自動スクロール
if (window.innerWidth <= 767 && splitRightBonus) {
setTimeout(function() { splitRightBonus.scrollIntoView({ behavior: "smooth", block: "start" }); }, 50);
}
} catch (error) {
alert("賞与明細の取得に失敗しました");
console.error(error);
}
}
async function editBonus(bonusId) {
try {
const response = await fetch(`${API_BASE}/payroll/bonus/${bonusId}`);
const bonus = await response.json();
const html = `
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: min(600px, 92vw); 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; box-sizing: border-box;">
<h2>賞与データ編集</h2>
<form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})">
<div class="detail-section">
<h3>基本情報</h3>
<div class="form-row">
<div class="form-group">
<label>支給日</label>
<input type="date" name="payment_date" value="${bonus.payment_date}" required />
</div>
<div class="form-group">
<label>賞与種別</label>
<select name="bonus_type">
<option value="夏季賞与" ${bonus.bonus_type === "夏季賞与" ? "selected" : ""}>夏季賞与</option>
<option value="冬季賞与" ${bonus.bonus_type === "冬季賞与" ? "selected" : ""}>冬季賞与</option>
<option value="決算賞与" ${bonus.bonus_type === "決算賞与" ? "selected" : ""}>決算賞与</option>
<option value="その他" ${bonus.bonus_type === "その他" ? "selected" : ""}>その他</option>
</select>
</div>
</div>
</div>
<div class="detail-section">
<h3>支給項目</h3>
<div class="form-row">
<div class="form-group">
<label>基本賞与額</label>
<input type="number" name="base_bonus" step="1" value="${bonus.base_bonus}" required />
</div>
<div class="form-group">
<label>業績賞与</label>
<input type="number" name="performance_bonus" step="1" value="${bonus.performance_bonus}" />
</div>
<div class="form-group">
<label>その他賞与</label>
<input type="number" name="other_bonus" step="1" value="${bonus.other_bonus}" />
</div>
</div>
</div>
<div class="detail-section">
<h3>控除項目(手動入力)</h3>
<div class="form-row">
<div class="form-group">
<label>その他控除</label>
<input type="number" name="other_deduction" step="1" value="${bonus.other_deduction}" />
</div>
</div>
</div>
<p style="font-size:12px; color:#666;">※保存後に再計算を行うと社会保険料・所得税が自動更新されます。</p>
<button type="submit" class="btn btn-primary">保存して再計算</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('div').parentElement.remove()">キャンセル</button>
</form>
</div>
`;
const editDiv = document.createElement("div");
editDiv.innerHTML = html;
document.body.appendChild(editDiv);
} catch (error) {
alert("賞与データの取得に失敗しました");
console.error(error);
}
}
async function saveBonusEdit(event, bonusId) {
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/bonus/${bonusId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (response.ok) {
alert("賞与データを更新しました");
form.closest("div").parentElement.remove();
await recalculateBonus(bonusId, true);
} else {
const error = await response.json();
alert("更新に失敗しました: " + error.detail);
}
} 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, skipConfirm = false) {
if (!skipConfirm && !confirm("賞与を再計算しますか?")) return;
try {
const response = await fetch(
`${API_BASE}/payroll/bonus/${bonusId}/recalculate`,
{
method: "POST",
},
);
if (response.ok) {
alert("再計算が完了しました");
await loadBonus();
viewBonus(bonusId);
} else {
const error = await response.json();
alert("再計算に失敗しました: " + error.detail);
}
} catch (error) {
alert("再計算に失敗しました");
console.error(error);
}
}
// =====================================================
// 账票出力機能
// =====================================================
let voucherEmployeeSelection = {}; // 従業員選択状態を記録
async function loadVoucherEmployees() {
try {
const response = await fetch(
`${API_BASE}/payroll/vouchers/employees`,
);
const data = await response.json();
const employees = data.employees || [];
// 従業員選択を初期化
voucherEmployeeSelection = {};
// 従業員リストをHTMLで表示
const html = employees
.map((emp) => {
return `
<label style="display: flex; align-items: center; padding: 8px; cursor: pointer;">
<input type="checkbox"
class="voucherEmployeeCheckbox"
value="${emp.id}"
onchange="updateVoucherEmployeeCount()"
style="margin-right: 10px; cursor: pointer;">
<span>${emp.code} - ${emp.name}</span>
</label>
`;
})
.join("");
document.getElementById("voucherEmployeeList").innerHTML =
html || "<p>従業員がありません</p>";
// 選択数を0にリセット
document.getElementById("voucherEmployeeCount").textContent =
"選択数: 0";
} catch (error) {
console.error("従業員リスト取得エラー:", error);
document.getElementById("voucherEmployeeList").innerHTML =
"<p style='color: red;'>従業員リスト読み込みエラー</p>";
}
}
function updateVoucherEmployeeCount() {
const checkboxes = document.querySelectorAll(
".voucherEmployeeCheckbox:checked",
);
const count = checkboxes.length;
document.getElementById("voucherEmployeeCount").textContent =
`選択数: ${count}`;
}
function selectAllVoucherEmployees() {
document
.querySelectorAll(".voucherEmployeeCheckbox")
.forEach((cb) => (cb.checked = true));
updateVoucherEmployeeCount();
}
function clearAllVoucherEmployees() {
document
.querySelectorAll(".voucherEmployeeCheckbox")
.forEach((cb) => (cb.checked = false));
updateVoucherEmployeeCount();
}
function getSelectedVoucherEmployees() {
const checkboxes = document.querySelectorAll(
".voucherEmployeeCheckbox:checked",
);
return Array.from(checkboxes).map((cb) => parseInt(cb.value));
}
async function printVouchers() {
// 検証
const startYear = Number(
document.getElementById("voucherStartYear").value,
);
const startMonth = Number(
document.getElementById("voucherStartMonth").value,
);
if (!startYear || !startMonth) {
alert("開始年月を選択してください");
return;
}
const employeeIds = getSelectedVoucherEmployees();
if (employeeIds.length === 0) {
alert("最少1人の従業員を選択してください");
return;
}
// 年月を解析
const endYearVal = document.getElementById("voucherEndYear").value;
const endMonthVal = document.getElementById("voucherEndMonth").value;
const endYear = endYearVal ? Number(endYearVal) : null;
const endMonth = endMonthVal ? Number(endMonthVal) : null;
const voucherType = document.querySelector(
"input[name='voucherType']:checked",
).value;
// APIを呼び出す
try {
const payload = {
start_year: startYear,
start_month: startMonth,
end_year: endYear,
end_month: endMonth,
employee_ids: employeeIds,
voucher_type: voucherType,
format: "preview",
};
console.log("送信ペイロード:", JSON.stringify(payload, null, 2));
const response = await fetch(`${API_BASE}/payroll/vouchers/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
console.log("レスポンス:", result);
if (!result.has_data) {
alert(result.message || "データが見つかりません");
return;
}
// 新しいウィンドウを開く
const printWindow = window.open("", "", "height=1600,width=1800");
// 打印用HTML构建
let printHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>給与・賞与明細</title>
<style>
* { margin: 0; padding: 0; }
body {
font-family: 'MS Gothic', 'Courier New', monospace;
font-size: 12px;
color: #333;
}
.page {
page-break-after: always;
padding: 30px 40px;
min-height: 1000px;
}
.page:last-child {
page-break-after: auto;
}
.page-title {
text-align: center;
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
}
.employee-info {
font-size: 14px;
margin-bottom: 5px;
}
.detail-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
}
.detail-table td {
border: 1px solid #333;
padding: 6px 8px;
}
.detail-table .label {
background: #f5f5f5;
font-weight: bold;
width: 40%;
}
.detail-table .value {
text-align: right;
font-family: 'Courier New', monospace;
}
.section-title {
font-weight: bold;
margin-top: 10px;
margin-bottom: 5px;
border-bottom: 1px solid #333;
padding-bottom: 2px;
}
.total-row {
background: #f0f0f0;
font-weight: bold;
}
@media print {
body { margin: 0; padding: 0; }
.page { padding: 20px; }
}
</style>
</head>
<body>
`;
// 月末日を計算する関数
function getLastDayOfMonth(year, month) {
const date = new Date(year, month, 0);
return String(date.getDate()).padStart(2, "0");
}
const generationDate = new Date().toLocaleDateString("ja-JP");
// グループ化: 従業員ごと、年月ごと、給与/賞与ごと
let currentKey = null;
let pageOpen = false;
if (result.summary && Array.isArray(result.summary)) {
result.summary.forEach((emp) => {
if (!emp.has_salary_data && !emp.has_bonus_data) return;
const salaries = (result.data?.salary || []).filter(
(s) => s.employee_id === emp.employee_id,
);
const bonuses = (result.data?.bonus || []).filter(
(b) => b.employee_id === emp.employee_id,
);
// 給与データの処理
salaries.forEach((salary) => {
const salaryYearMonth = `${salary.payroll_year}-${salary.payroll_month}`;
const currentKey_new = `${emp.employee_id}-${salaryYearMonth}-salary`;
const paymentDate = `${salary.payroll_year}${String(salary.payroll_month).padStart(2, "0")}${getLastDayOfMonth(salary.payroll_year, salary.payroll_month)}`;
// 新しいキーが異なる場合は新しいページを開く
if (currentKey !== currentKey_new) {
if (pageOpen) {
printHtml += "</div>";
}
currentKey = currentKey_new;
printHtml += '<div class="page">';
printHtml += `<div style="font-size: 11px; color: #999; margin-bottom: 10px; text-align: right;">帳票生成日: ${generationDate}</div>`;
pageOpen = true;
}
printHtml += `
<div class="page-title">${salary.payroll_year}${String(salary.payroll_month).padStart(2, "0")}月 給与明細</div>
<div class="employee-info">従業員: ${emp.employee_code} ${emp.employee_name}</div>
<div class="employee-info" style="color: #666; font-size: 12px;">支給日: ${paymentDate}</div>
${emp.employment_type ? `<div class="employee-info" style="color: #666; font-size: 12px;">雇用形態:${emp.employment_type}</div>` : ""}
<div class="section-title">勤怠情報</div>
<table class="detail-table">
<tr>
<td class="label">出勤日数</td>
<td class="value">${Number(salary.working_days).toFixed(1)}日</td>
<td class="label">勤務時間</td>
<td class="value">${Number(salary.working_hours).toFixed(1)}時間</td>
</tr>
<tr>
<td class="label">残業時間</td>
<td class="value">${Number(salary.overtime_hours).toFixed(1)}時間</td>
<td class="label">休日出勤</td>
<td class="value">${Number(salary.holiday_hours).toFixed(1)}時間</td>
</tr>
<tr>
<td class="label">遅刻時間</td>
<td class="value">${Number(salary.late_hours).toFixed(1)}時間</td>
<td class="label">欠勤日数</td>
<td class="value">${Number(salary.absent_days).toFixed(1)}日</td>
</tr>
</table>
<div class="section-title">支給項目</div>
<table class="detail-table">
<tr>
<td class="label">基本給</td>
<td class="value">¥${Number(salary.base_salary).toLocaleString()}</td>
<td class="label">残業手当</td>
<td class="value">¥${Number(salary.overtime_pay).toLocaleString()}</td>
</tr>
<tr>
<td class="label">休日手当</td>
<td class="value">¥${Number(salary.holiday_pay).toLocaleString()}</td>
<td class="label">通勤手当</td>
<td class="value">¥${Number(salary.commute_allowance).toLocaleString()}</td>
</tr>
<tr>
<td class="label">その他手当</td>
<td class="value">¥${Number(salary.other_allowance).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
<tr class="total-row">
<td class="label">総支給額</td>
<td class="value">¥${Number(salary.total_payment).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
</table>
<div class="section-title">控除項目</div>
<table class="detail-table">
<tr>
<td class="label">健康保険</td>
<td class="value">¥${Number(salary.health_insurance).toLocaleString()}</td>
<td class="label">介護保険</td>
<td class="value">¥${Number(salary.care_insurance).toLocaleString()}</td>
</tr>
<tr>
<td class="label">厚生年金</td>
<td class="value">¥${Number(salary.pension_insurance).toLocaleString()}</td>
<td class="label">雇用保険</td>
<td class="value">¥${Number(salary.employment_insurance).toLocaleString()}</td>
</tr>
<tr>
<td class="label">子ども・子育て支援金</td>
<td class="value">¥${Number(salary.child_support || 0).toLocaleString()}</td>
<td class="label">所得税</td>
<td class="value">¥${Number(salary.income_tax).toLocaleString()}</td>
</tr>
<tr>
<td class="label">住民税</td>
<td class="value">¥${Number(salary.resident_tax).toLocaleString()}</td>
<td class="label">その他控除</td>
<td class="value">¥${Number(salary.other_deduction).toLocaleString()}</td>
</tr>
<tr class="total-row">
<td class="label">総控除額</td>
<td class="value">¥${Number(salary.total_deduction).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
</table>
<div style="margin-top: 20px; padding: 15px; border: 2px solid #000; text-align: right;">
<div style="font-size: 14px; font-weight: bold;">差引支給額</div>
<div style="font-size: 24px; font-weight: bold; margin-top: 5px;">¥${Number(salary.net_payment).toLocaleString()}</div>
</div>
`;
});
// 賞与データの処理
bonuses.forEach((bonus) => {
const bonusYearMonth = `${bonus.bonus_year}-${bonus.bonus_month}`;
const currentKey_new = `${emp.employee_id}-${bonusYearMonth}-bonus`;
// DBに保存された実際の支給日を使用。なければ月末日を計算
const bonusPaymentDate = bonus.payment_date
? (() => {
const d = new Date(bonus.payment_date);
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
})()
: `${bonus.bonus_year}${String(bonus.bonus_month).padStart(2, "0")}${getLastDayOfMonth(bonus.bonus_year, bonus.bonus_month)}`;
if (currentKey && currentKey !== currentKey_new) {
if (pageOpen) {
printHtml += "</div>";
}
currentKey = currentKey_new;
printHtml += '<div class="page">';
printHtml += `<div style="font-size: 11px; color: #999; margin-bottom: 10px; text-align: right;">帳票生成日: ${generationDate}</div>`;
pageOpen = true;
}
printHtml += `
<div class="page-title">${bonus.bonus_year}${String(bonus.bonus_month).padStart(2, "0")}月 賞与明細${bonus.bonus_type ? ` (${bonus.bonus_type})` : ""}</div>
<div class="employee-info">従業員: ${emp.employee_code} ${emp.employee_name}</div>
<div class="employee-info" style="color: #666; font-size: 12px;">支給日: ${bonusPaymentDate}</div>
${emp.employment_type ? `<div class="employee-info" style="color: #666; font-size: 12px;">雇用形態:${emp.employment_type}</div>` : ""}
<div class="section-title">支給項目</div>
<table class="detail-table">
<tr>
<td class="label">基本賞与額</td>
<td class="value">¥${Number(bonus.base_bonus).toLocaleString()}</td>
<td class="label">業績賞与</td>
<td class="value">¥${Number(bonus.performance_bonus).toLocaleString()}</td>
</tr>
<tr>
<td class="label">その他賞与</td>
<td class="value">¥${Number(bonus.other_bonus).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
<tr class="total-row">
<td class="label">総支給額</td>
<td class="value">¥${Number(bonus.total_bonus).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
</table>
<div class="section-title">控除項目</div>
<table class="detail-table">
<tr>
<td class="label">健康保険</td>
<td class="value">¥${Number(bonus.health_insurance).toLocaleString()}</td>
<td class="label">介護保険</td>
<td class="value">¥${Number(bonus.care_insurance).toLocaleString()}</td>
</tr>
<tr>
<td class="label">厚生年金</td>
<td class="value">¥${Number(bonus.pension_insurance).toLocaleString()}</td>
<td class="label">雇用保険</td>
<td class="value">¥${Number(bonus.employment_insurance).toLocaleString()}</td>
</tr>
<tr>
<td class="label">子ども・子育て支援金</td>
<td class="value">¥${Number(bonus.child_support || 0).toLocaleString()}</td>
<td class="label">所得税</td>
<td class="value">¥${Number(bonus.income_tax).toLocaleString()}</td>
</tr>
<tr>
<td class="label">その他控除</td>
<td class="value">¥${Number(bonus.other_deduction).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
<tr class="total-row">
<td class="label">総控除額</td>
<td class="value">¥${Number(bonus.total_deduction).toLocaleString()}</td>
<td class="label"></td>
<td class="value"></td>
</tr>
</table>
<div style="margin-top: 20px; padding: 15px; border: 2px solid #000; text-align: right;">
<div style="font-size: 14px; font-weight: bold;">差引支給額</div>
<div style="font-size: 24px; font-weight: bold; margin-top: 5px;">¥${Number(bonus.net_bonus).toLocaleString()}</div>
</div>
`;
});
});
if (pageOpen) {
printHtml += "</div>";
}
}
printHtml += `
</body>
</html>
`;
// 打印内容を設定して打印
printWindow.document.write(printHtml);
printWindow.document.close();
// 少し遅延させて打印ダイアログを表示
setTimeout(() => {
printWindow.print();
}, 1000);
} catch (error) {
console.error("エラー:", error);
alert("印刷準備中にエラーが発生しました");
}
}
function showNoDataAlert(message) {
document.getElementById("noDataMessage").textContent = message;
document.getElementById("noDataModal").style.display = "flex";
}
async function exportVouchersCSV() {
// 検証
const startYear = Number(
document.getElementById("voucherStartYear").value,
);
const startMonth = Number(
document.getElementById("voucherStartMonth").value,
);
if (!startYear || !startMonth) {
alert("開始年月を選択してください");
return;
}
const employeeIds = getSelectedVoucherEmployees();
if (employeeIds.length === 0) {
alert("最少1人の従業員を選択してください");
return;
}
const endYearVal = document.getElementById("voucherEndYear").value;
const endMonthVal = document.getElementById("voucherEndMonth").value;
const endYear = endYearVal ? Number(endYearVal) : null;
const endMonth = endMonthVal ? Number(endMonthVal) : null;
const voucherType = document.querySelector(
"input[name='voucherType']:checked",
).value;
try {
const payload = {
start_year: startYear,
start_month: startMonth,
end_year: endYear,
end_month: endMonth,
employee_ids: employeeIds,
voucher_type: voucherType,
format: "csv",
};
const response = await fetch(`${API_BASE}/payroll/vouchers/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!result.has_data) {
alert(result.message || "データが見つかりません");
return;
}
// CSVデータを生成
let csvContent =
"データ,従業員コード,従業員名,年月,支給額,控除額,差引支給額\n";
// 給与データ
if (result.data.salary) {
(result.data.salary || []).forEach((salary) => {
csvContent += `給与,${salary.employee_code},${salary.employee_name},${salary.payroll_year}-${String(salary.payroll_month).padStart(2, "0")},${salary.total_payment},${salary.total_deduction},${salary.net_payment}\n`;
});
}
// 賞与データ
if (result.data.bonus) {
(result.data.bonus || []).forEach((bonus) => {
csvContent += `賞与,${bonus.employee_code},${bonus.employee_name},${bonus.bonus_year}-${String(bonus.bonus_month).padStart(2, "0")},${bonus.total_bonus},${bonus.total_deduction},${bonus.net_bonus}\n`;
});
}
// ダウンロード
const blob = new Blob([csvContent], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `vouchers_${new Date().getTime()}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
alert("CSVを出力しました");
} catch (error) {
console.error("CSVエラー:", error);
alert("CSV出力に失敗しました");
}
}
</script>
</body>
</html>