Files
njts-accounting-core/frontend/payroll-calculation.html
2026-02-07 14:25:28 +09:00

2176 lines
81 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" />
<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" />
<style>
.container {
max-width: 900px;
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;
}
.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>
<button class="nav-tab-calc" onclick="switchTab('voucher')">
📄 账票出力
</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 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>
<input type="month" id="voucherStartYearMonth" required />
</div>
<div class="form-group">
<label>終了年月</label>
<input type="month" id="voucherEndYearMonth" />
</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;
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-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("voucherStartYearMonth").value = ym;
document.getElementById("voucherEndYearMonth").value = "";
}
}
// =====================================================
// 賞与計算関連
// =====================================================
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);
}
}
// =====================================================
// 账票出力機能
// =====================================================
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 startYM = document.getElementById("voucherStartYearMonth").value;
if (!startYM) {
alert("開始年月を選択してください");
return;
}
const employeeIds = getSelectedVoucherEmployees();
if (employeeIds.length === 0) {
alert("最少1人の従業員を選択してください");
return;
}
// 年月を解析
const [startYear, startMonth] = startYM.split("-").map(Number);
const endYM = document.getElementById("voucherEndYearMonth").value;
const endParts = endYM ? endYM.split("-").map(Number) : null;
const endYear = endParts ? endParts[0] : null;
const endMonth = endParts ? endParts[1] : 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>
<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.income_tax).toLocaleString()}</td>
<td class="label">住民税</td>
<td class="value">¥${Number(salary.resident_tax).toLocaleString()}</td>
</tr>
<tr>
<td class="label">その他控除</td>
<td class="value">¥${Number(salary.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(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`;
const bonusPaymentDate = `${bonus.bonus_year}${String(bonus.bonus_month).padStart(2, "0")}${getLastDayOfMonth(bonus.bonus_year, bonus.bonus_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">${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>
<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.income_tax).toLocaleString()}</td>
<td class="label">その他控除</td>
<td class="value">¥${Number(bonus.other_deduction).toLocaleString()}</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 startYM = document.getElementById("voucherStartYearMonth").value;
if (!startYM) {
alert("開始年月を選択してください");
return;
}
const employeeIds = getSelectedVoucherEmployees();
if (employeeIds.length === 0) {
alert("最少1人の従業員を選択してください");
return;
}
const [startYear, startMonth] = startYM.split("-").map(Number);
const endYM = document.getElementById("voucherEndYearMonth").value;
const endParts = endYM ? endYM.split("-").map(Number) : null;
const endYear = endParts ? endParts[0] : null;
const endMonth = endParts ? endParts[1] : 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>