Files
njts-accounting-core/frontend/journal-entry.html
2026-01-17 23:50:22 +09:00

1649 lines
54 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" />
<title>仕訳入力</title>
<style>
body {
font-family: sans-serif;
margin: 24px;
}
h2 {
margin: 0 0 12px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 12px;
}
th,
td {
border: 1px solid #ccc;
padding: 6px;
text-align: center;
}
th {
background: #f5f5f5;
}
input,
select,
button {
padding: 6px;
}
.right {
text-align: right;
}
.row {
margin: 8px 0;
display: flex;
gap: 16px;
align-items: center;
}
.row label {
min-width: 120px;
}
.tax-row {
background: #f0f8ff;
}
.print-header {
display: none;
}
.page-break {
page-break-before: always;
}
.page-number {
display: none;
}
@media print {
@page {
margin: 20mm 20mm 25mm 20mm;
@bottom-center {
content: "ページ " counter(page);
font-size: 0.75em;
}
}
body * {
visibility: hidden;
}
#printArea,
#printArea * {
visibility: visible;
}
#printArea {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
button,
.no-print {
display: none !important;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 0.9em;
padding-bottom: 4px;
}
.print-header-left {
text-align: left;
}
.print-header-right {
text-align: right;
}
.page-number {
display: none !important;
}
#printArea table {
font-size: 0.85em;
}
#printArea th,
#printArea td {
padding: 3px 4px;
}
#printArea h3 {
font-size: 1.1em;
margin-bottom: 6px;
}
thead {
display: table-header-group;
}
}
</style>
</head>
<body>
<h2>仕訳入力</h2>
<div class="row">
<label>仕訳日</label>
<input
type="date"
id="entryDate"
min="1900-01-01"
max="2999-12-31"
onchange="onEntryDateChanged()"
/>
</div>
<div class="row">
<label>摘要</label>
<input
type="text"
id="description"
list="descriptionHistory"
style="flex: 3; min-width: 600px"
placeholder="例:売上入金/経費支払 など"
/>
<datalist id="descriptionHistory"></datalist>
</div>
<div class="row">
<label>仮払消費税等 勘定</label>
<select id="taxPaidSelect"></select>
<label>仮受消費税等 勘定</label>
<select id="taxReceivedSelect"></select>
</div>
<div style="margin-bottom: 10px">
<label>消費税 端数処理:</label>
<select id="roundingMode">
<option value="floor">切捨て</option>
<option value="round">四捨五入</option>
<option value="ceil">切上げ</option>
</select>
</div>
<table id="linesTable">
<thead>
<tr>
<th>科目</th>
<th>借方</th>
<th>貸方</th>
<th>税区分</th>
<th>税方向</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div
style="
margin-top: 10px;
padding: 10px;
background: #f9f9f9;
border: 1px solid #ddd;
display: flex;
gap: 40px;
font-weight: bold;
"
>
<div>
<label>借方合計:</label>
<span id="totalDebit" style="color: #0066cc; font-size: 16px">0</span>
</div>
<div>
<label>貸方合計:</label>
<span id="totalCredit" style="color: #cc0000; font-size: 16px">0</span>
</div>
<div
id="balanceStatus"
style="margin-left: auto; padding: 4px 12px; border-radius: 4px"
></div>
</div>
<div style="margin-top: 10px; display: flex; gap: 8px">
<button onclick="addRow()"> 行追加</button>
<button onclick="submitJournal()">登録</button>
</div>
<hr style="margin: 40px 0; border: none; border-top: 2px solid #ddd" />
<h3>月次锁定管理</h3>
<div style="margin-bottom: 8px">
<label>年度:</label>
<input
type="number"
id="lockYear"
value="2025"
min="1900"
max="2999"
style="width: 80px"
/>
<label>月份:</label>
<input
type="number"
id="lockMonth"
min="1"
max="12"
value="6"
style="width: 60px"
/>
</div>
<button onclick="lockMonth()">🔒 锁定</button>
<button onclick="unlockMonth()">🔓 解锁</button>
<div id="lockResult" style="margin-top: 10px; color: #333"></div>
<div style="margin-top: 12px">
<button onclick="refreshMonthLocks()">🔄 刷新锁定列表</button>
<pre id="lockList" style="background: #f7f7f7; padding: 8px"></pre>
</div>
<div
id="monthLockBanner"
style="
display: none;
margin: 8px 0;
padding: 6px 10px;
background: #ffecec;
border: 1px solid #ffaaaa;
color: #a00000;
"
>
🔒 该月份已锁定,不能编辑仕訳
</div>
<!-- ========================================
仕訳検索セクションjournal-list.htmlから統合
======================================== -->
<hr style="margin: 40px 0; border: none; border-top: 2px solid #ddd" />
<h2>仕訳検索</h2>
<div style="margin: 16px 0">
<label>期間</label>
<input
type="date"
id="searchFromDate"
min="1900-01-01"
max="2999-12-31"
/>
<input type="date" id="searchToDate" min="1900-01-01" max="2999-12-31" />
<label style="margin-left: 16px">摘要</label>
<input type="text" id="searchKeyword" style="min-width: 600px" />
<label style="margin-left: 16px">科目</label>
<select id="searchAccountId" style="min-width: 200px">
<option value="">-- すべて --</option>
</select>
<label style="margin-left: 16px">
<input type="checkbox" id="searchIncludeHistory" />
全検索(修正履歴を含む)
</label>
<button onclick="searchJournals()" style="margin-left: 8px">検索</button>
<button
onclick="printSearchResults()"
style="margin-left: 8px; color: blue"
>
🖨️ 印刷
</button>
</div>
<div id="printArea"></div>
<script>
let rowSeq = 0; // 各行唯一ID
const API = "http://127.0.0.1:18080";
// 千分位カンマフォーマット関数
function formatNumberInput(input) {
let value = input.value.replace(/,/g, ""); // 既存のカンマを削除
if (value === "" || isNaN(value)) {
input.value = "";
return;
}
// 数値をカンマ区切りに変換
input.value = Number(value).toLocaleString();
}
let accounts = [];
const ACCOUNT_GROUPS = [
{
label: "💰 現金・預金",
codes: ["101", "102", "103", "104", "105"],
},
{
label: "📦 流動資産",
codes: ["201", "202", "203", "204", "205", "210"],
},
{
label: "🏗️ 固定資産",
codes: [
"301",
"401",
"402",
"403",
"404",
"405",
"406",
"407",
"408",
],
},
{
label: "💳 負債",
codes: [
"501",
"502",
"503",
"504",
"505",
"506",
"507",
"508",
"509",
],
},
{
label: "🏢 純資産",
codes: ["601", "602"],
},
{
label: "📈 収益",
codes: ["701", "702", "703", "704", "705"],
},
{
label: "📉 費用",
codes: [
"801",
"802",
"803",
"804",
"805",
"806",
"807",
"808",
"809",
"810",
],
},
];
let taxPaidAccounts = [];
let taxReceivedAccounts = [];
// -----------------------------
// 初期化
// -----------------------------
async function init() {
const res = await fetch(`${API}/accounts`);
if (!res.ok) {
alert("科目一覧の取得に失敗しました");
return;
}
const data = await res.json();
// 🔴 ここ重要items がある場合と無い場合の両対応
accounts = data.items ?? data;
// 仮払消費税等(あなたの DB では 505 は「未払消費税等」)
taxPaidAccounts = accounts.filter((a) =>
a.account_name.includes("仮払")
);
// 仮受消費税等
taxReceivedAccounts = accounts.filter((a) =>
a.account_name.includes("仮受")
);
setupTaxSelectors();
setupSearchAccountSelector();
addRow();
document.getElementById("entryDate").value = new Date()
.toISOString()
.slice(0, 10);
await onEntryDateChanged();
// 摘要履歴を読み込み
loadDescriptionHistory();
// 各フィールドの変更時に合計を更新
document
.getElementById("entryDate")
.addEventListener("change", updateTotals);
document
.getElementById("description")
.addEventListener("input", updateTotals);
document
.getElementById("taxPaidSelect")
.addEventListener("change", updateTotals);
document
.getElementById("taxReceivedSelect")
.addEventListener("change", updateTotals);
document
.getElementById("roundingMode")
.addEventListener("change", () => {
// 端数処理変更時は全ての税行を再計算
const rows = document.querySelectorAll(
"#linesTable tbody tr:not(.tax-row)"
);
rows.forEach((row) => handleTax(row));
});
}
init();
// -----------------------------
// 検索用科目セレクタ
// -----------------------------
function setupSearchAccountSelector() {
const select = document.getElementById("searchAccountId");
select.innerHTML = '<option value="">-- すべて --</option>';
ACCOUNT_GROUPS.forEach((group) => {
const optgroup = document.createElement("optgroup");
optgroup.label = group.label;
const groupAccounts = accounts.filter((a) =>
group.codes.includes(a.account_code)
);
groupAccounts.forEach((acc) => {
const option = document.createElement("option");
option.value = acc.account_id;
option.textContent = `${acc.account_code} ${acc.account_name}`;
optgroup.appendChild(option);
});
select.appendChild(optgroup);
});
}
// -----------------------------
// 摘要履歴管理
// -----------------------------
function loadDescriptionHistory() {
const history = JSON.parse(
localStorage.getItem("descriptionHistory") || "[]"
);
const datalist = document.getElementById("descriptionHistory");
datalist.innerHTML = "";
history.forEach((desc) => {
const option = document.createElement("option");
option.value = desc;
datalist.appendChild(option);
});
}
function saveDescriptionToHistory(description) {
if (!description || description.trim() === "") return;
let history = JSON.parse(
localStorage.getItem("descriptionHistory") || "[]"
);
// 重複を避け、先頭に追加
history = history.filter((d) => d !== description);
history.unshift(description);
// 最大100件まで保存
if (history.length > 100) {
history = history.slice(0, 100);
}
localStorage.setItem("descriptionHistory", JSON.stringify(history));
loadDescriptionHistory();
}
// -----------------------------
// 仮払/仮受 セレクタ
// -----------------------------
function setupTaxSelectors() {
document.getElementById("taxPaidSelect").innerHTML =
`<option value="">-- 選択してください --</option>` +
taxPaidAccounts
.map(
(a) =>
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
)
.join("");
document.getElementById("taxReceivedSelect").innerHTML =
`<option value="">-- 選択してください --</option>` +
taxReceivedAccounts
.map(
(a) =>
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
)
.join("");
}
// -----------------------------
// 行追加
// -----------------------------
function addRow(isTaxRow = false, taxInfo = null, parentId = null) {
const tbody = document.querySelector("#linesTable tbody");
const tr = document.createElement("tr");
if (!isTaxRow) {
tr.dataset.rowId = String(++rowSeq);
} else {
tr.classList.add("tax-row");
tr.dataset.parentId = parentId;
}
tr.innerHTML = `
<td>
<select class="account" ${isTaxRow ? "disabled" : ""}>
${buildAccountOptions()}
</select>
</td>
<td><input type="text" class="debit right" inputmode="numeric" style="ime-mode: inactive;" ${
isTaxRow ? "readonly" : ""
} oninput="formatNumberInput(this)"></td>
<td><input type="text" class="credit right" inputmode="numeric" style="ime-mode: inactive;" ${
isTaxRow ? "readonly" : ""
} oninput="formatNumberInput(this)"></td>
<td>
<select class="taxType" ${isTaxRow ? "disabled" : ""}>
<option value="none">対象外</option>
<option value="10">10%</option>
<option value="8">8%</option>
</select>
</td>
<td>
<select class="taxDirection" ${isTaxRow ? "disabled" : ""}>
<option value="none">対象外</option>
<option value="paid">仮払(仕入・経費)</option>
<option value="received">仮受(売上)</option>
</select>
</td>
<td>${
isTaxRow ? "" : `<button onclick="removeRow(this)">削除</button>`
}</td>
`;
tbody.appendChild(tr);
// 税行の場合,填值
if (isTaxRow && taxInfo) {
const acc = tr.querySelector(".account");
acc.value = taxInfo.accountId || "";
if (taxInfo.side === "debit")
tr.querySelector(".debit").value = taxInfo.amount;
if (taxInfo.side === "credit")
tr.querySelector(".credit").value = taxInfo.amount;
}
// 普通行:绑定事件
if (!isTaxRow) {
const recalc = () => handleTax(tr);
const debitInput = tr.querySelector(".debit");
const creditInput = tr.querySelector(".credit");
const accountSelect = tr.querySelector(".account");
debitInput.addEventListener("input", recalc);
creditInput.addEventListener("input", recalc);
// blurフォーカス離脱時に合計を更新
debitInput.addEventListener("blur", updateTotals);
creditInput.addEventListener("blur", updateTotals);
// 科目選択時に履歴を保存
accountSelect.addEventListener("change", () => {
saveAccountToRecent(parseInt(accountSelect.value));
});
tr.querySelector(".taxType").addEventListener("change", recalc);
tr.querySelector(".taxDirection").addEventListener("change", recalc);
}
return tr;
}
// 删除该行的所有税行
function removeTaxRowsOf(row) {
const id = row.dataset.rowId;
if (!id) return;
document
.querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`)
.forEach((r) => r.remove());
}
function removeRow(btn) {
const row = btn.closest("tr");
removeTaxRowsOf(row);
row.remove();
updateTotals(); // 削除後に合計を更新
}
// -----------------------------
// 借方・貸方合計を計算して表示
// -----------------------------
function updateTotals() {
let totalDebit = 0;
let totalCredit = 0;
const rows = document.querySelectorAll("#linesTable tbody tr");
rows.forEach((row) => {
const debitValue =
row.querySelector(".debit")?.value.replace(/,/g, "") || "0";
const creditValue =
row.querySelector(".credit")?.value.replace(/,/g, "") || "0";
totalDebit += Number(debitValue) || 0;
totalCredit += Number(creditValue) || 0;
});
// 表示を更新
document.getElementById("totalDebit").textContent =
totalDebit.toLocaleString();
document.getElementById("totalCredit").textContent =
totalCredit.toLocaleString();
// 貸借一致チェック
const balanceStatus = document.getElementById("balanceStatus");
if (totalDebit === totalCredit && totalDebit > 0) {
balanceStatus.textContent = "✓ 貸借一致";
balanceStatus.style.background = "#d4edda";
balanceStatus.style.color = "#155724";
} else if (totalDebit === 0 && totalCredit === 0) {
balanceStatus.textContent = "";
balanceStatus.style.background = "";
} else {
balanceStatus.textContent = `✗ 差額: ${Math.abs(
totalDebit - totalCredit
).toLocaleString()}`;
balanceStatus.style.background = "#f8d7da";
balanceStatus.style.color = "#721c24";
}
}
// -----------------------------
// 税行 自動生成/更新10%/8%のみ、四捨五入)
// -----------------------------
function handleTax(row) {
// 清理旧税行
removeTaxRowsOf(row);
const taxType = row.querySelector(".taxType").value;
const taxDir = row.querySelector(".taxDirection").value;
const debit = Number(
row.querySelector(".debit").value.replace(/,/g, "") || 0
);
const credit = Number(
row.querySelector(".credit").value.replace(/,/g, "") || 0
);
if (taxType === "none" || taxDir === "none") {
updateTotals(); // 税なしでも合計更新
return;
}
const totalAmount = debit || credit;
if (!totalAmount) {
updateTotals();
return;
}
const rate = taxType === "8" ? 0.08 : 0.1;
const roundingMode = document.getElementById("roundingMode").value;
// 税抜額 × 税率 = 税額
const rawTax = totalAmount * rate;
let taxAmount;
switch (roundingMode) {
case "floor":
taxAmount = Math.floor(rawTax);
break;
case "ceil":
taxAmount = Math.ceil(rawTax);
break;
default:
taxAmount = Math.round(rawTax);
}
const parentId = row.dataset.rowId;
if (taxDir === "paid") {
const taxAcc = document.getElementById("taxPaidSelect").value;
if (!taxAcc) {
updateTotals();
return alert("仮払消費税等 勘定を選択してください");
}
addRow(
true,
{ accountId: taxAcc, amount: taxAmount, side: "debit" },
parentId
);
}
if (taxDir === "received") {
const taxAcc = document.getElementById("taxReceivedSelect").value;
if (!taxAcc) {
updateTotals();
return alert("仮受消費税等 勘定を選択してください");
}
addRow(
true,
{ accountId: taxAcc, amount: taxAmount, side: "credit" },
parentId
);
}
updateTotals(); // 税行追加後に合計更新
}
// 税行は常に「直後の1行」を使う
function removeTaxRow(row) {
const id = row.dataset.rowId;
if (!id) return;
document
.querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`)
.forEach((tr) => tr.remove());
}
function removeRow(btn) {
const row = btn.closest("tr");
removeTaxRow(row);
row.remove();
}
// -----------------------------
// 登録(税行自動生成版)
// -----------------------------
async function submitJournal() {
try {
const entryDate = document.getElementById("entryDate").value;
const description = document
.getElementById("description")
.value.trim();
if (!entryDate) {
alert("仕訳日を入力してください");
return;
}
// 明細収集(税情報も含む)
const rows = document.querySelectorAll("#linesTable tbody tr");
const lines = [];
rows.forEach((row) => {
if (row.classList.contains("tax-row")) return; // 税行は無視
const accountId = Number(row.querySelector(".account").value);
const debit = Number(
row.querySelector(".debit").value.replace(/,/g, "") || 0
);
const credit = Number(
row.querySelector(".credit").value.replace(/,/g, "") || 0
);
const taxType = row.querySelector(".taxType").value;
const taxDirection = row.querySelector(".taxDirection").value;
if (debit > 0 && credit > 0) {
alert("同一行不能同时填写借方和贷方");
throw new Error("invalid line");
}
if (debit === 0 && credit === 0) return;
const lineData = {
account_id: accountId,
debit: debit,
credit: credit,
};
// 税情報を追加
if (taxType !== "none" && taxDirection !== "none") {
lineData.tax_rate = parseInt(taxType);
lineData.tax_direction = taxDirection;
}
lines.push(lineData);
});
if (lines.length === 0) {
alert("仕訳明細がありません");
return;
}
// 税科目(仮払消費税等・仮受消費税等)が存在するかチェック
const hasTaxAccount = lines.some((line) => {
const account = accounts.find(
(a) => a.account_id === line.account_id
);
return (
account &&
(account.account_name.includes("仮払消費税") ||
account.account_name.includes("仮受消費税"))
);
});
// 税科目が存在し、かつ税情報を持つ行がある場合は警告
const hasTaxInfo = lines.some(
(line) => line.tax_rate != null && line.tax_direction != null
);
if (hasTaxAccount && hasTaxInfo) {
if (
!confirm(
"警告:仮払消費税等・仮受消費税等の行がすでに存在していますが、他の行に税区分・税方向が設定されています。\n" +
"このまま登録すると税が二重計算され、借貸不平衡になります。\n\n" +
"税区分・税方向をすべて「対象外」に設定してから登録することをお勧めします。\n\n" +
"このまま登録しますか?"
)
) {
return;
}
}
const payload = {
entry_date: entryDate,
description,
lines,
tax_paid_account_id:
Number(document.getElementById("taxPaidSelect").value) || null,
tax_received_account_id:
Number(document.getElementById("taxReceivedSelect").value) ||
null,
rounding_mode: document.getElementById("roundingMode").value,
};
console.log("送信するデータ:", JSON.stringify(payload, null, 2));
console.log("明細行数:", lines.length);
lines.forEach((line, index) => {
console.log(`${index + 1}:`, line);
});
const res = await fetch(`${API}/journal-entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
const errorMsg =
errorData.detail || `登録に失敗しました (HTTP ${res.status})`;
console.error("Error:", errorData);
alert(errorMsg);
return;
}
const result = await res.json();
// 摘要を履歴に保存
saveDescriptionToHistory(description);
alert(`登録完了ID=${result.journal_entry_id}`);
// フォームをクリア
document.getElementById("description").value = "";
const tbody = document.querySelector("#linesTable tbody");
tbody.innerHTML = "";
rowSeq = 0;
addRow();
// 借方合計・貸方合計を0にリセット
document.getElementById("totalDebit").textContent = "0";
document.getElementById("totalCredit").textContent = "0";
document.getElementById("balanceStatus").textContent = "";
document.getElementById("balanceStatus").style.background = "";
// 仕訳検索を自動実行して最新の登録状況を表示
searchJournals();
} catch (error) {
console.error("送信エラー:", error);
alert(
`エラーが発生しました: ${error.message}\n\nサーバーが起動しているか確認してください。`
);
}
}
const API_BASE = "http://127.0.0.1:18080";
async function callApi(path) {
try {
const res = await fetch(`${API_BASE}${path}`, { method: "POST" });
const text = await res.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { detail: text };
}
if (!res.ok) {
throw new Error(data.detail || `HTTP ${res.status}`);
}
return data;
} catch (e) {
throw new Error(e.message || "请求失败");
}
}
async function lockMonth() {
const year = document.getElementById("lockYear").value;
const month = document.getElementById("lockMonth").value;
const el = document.getElementById("lockResult");
el.innerText = "处理中...";
try {
await callApi(`/month-locks/lock?fiscal_year=${year}&month=${month}`);
el.innerText = `${year}${month}月 已锁定`;
await refreshMonthLocks(); // 可选:自动刷新列表
} catch (e) {
el.innerText = `❌ 错误:${e.message}`;
}
}
async function unlockMonth() {
const year = document.getElementById("lockYear").value;
const month = document.getElementById("lockMonth").value;
const el = document.getElementById("lockResult");
el.innerText = "处理中...";
try {
await callApi(
`/month-locks/unlock?fiscal_year=${year}&month=${month}`
);
el.innerText = `${year}${month}月 已解锁`;
await refreshMonthLocks(); // 可选:自动刷新列表
} catch (e) {
el.innerText = `❌ 错误:${e.message}`;
}
}
async function refreshMonthLocks() {
const el = document.getElementById("lockList");
el.textContent = "读取中...";
try {
const res = await fetch(`${API_BASE}/month-locks`);
const data = await res.json();
// 只显示 locked 的,避免太长
const locked = (data || []).filter((x) => x.is_locked);
if (locked.length === 0) {
el.textContent = "(当前没有锁定月份)";
return;
}
el.textContent = locked
.map(
(x) =>
`${x.fiscal_year}-${String(x.month).padStart(
2,
"0"
)} locked_by=${x.locked_by ?? ""}`
)
.join("\n");
} catch (e) {
el.textContent = `读取失败:${e.message || e}`;
}
}
async function checkMonthLockByDate(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
const year = d.getFullYear();
const month = d.getMonth() + 1;
const res = await fetch(`${API_BASE}/month-locks`);
const data = await res.json();
return (data || []).some(
(x) => x.fiscal_year === year && x.month === month && x.is_locked
);
}
async function onEntryDateChanged() {
const dateStr = document.getElementById("entryDate").value;
const locked = await checkMonthLockByDate(dateStr);
const banner = document.getElementById("monthLockBanner");
if (locked) {
banner.style.display = "block";
setJournalReadonly(true);
} else {
banner.style.display = "none";
setJournalReadonly(false);
}
}
function setJournalReadonly(readonly) {
// 保存按钮
const saveBtn = document.getElementById("saveButton");
if (saveBtn) saveBtn.disabled = readonly;
// 行追加按钮
const addRowBtn = document.getElementById("addRowButton");
if (addRowBtn) addRowBtn.disabled = readonly;
// 所有“删除行”按钮
document.querySelectorAll(".delete-row-btn").forEach((btn) => {
btn.disabled = readonly;
});
// 所有仕訳输入项(金额 / 科目等)
document.querySelectorAll(".journal-input").forEach((input) => {
input.disabled = readonly;
});
}
function buildAccountOptions() {
let html = "";
// 最近使った科目を表示
const recentAccountIds = JSON.parse(
localStorage.getItem("recentAccounts") || "[]"
);
if (recentAccountIds.length > 0) {
html += `<optgroup label="⭐ 最近使った科目">`;
recentAccountIds.forEach((accountId) => {
const account = accounts.find((a) => a.account_id === accountId);
if (account) {
html += `<option value="${account.account_id}">
${account.account_code} ${account.account_name}
</option>`;
}
});
html += `</optgroup>`;
}
// 已分组的科目
ACCOUNT_GROUPS.forEach((group) => {
html += `<optgroup label="${group.label}">`;
accounts
.filter((a) => group.codes.includes(a.account_code))
.forEach((a) => {
html += `<option value="${a.account_id}">
${a.account_code} ${a.account_name}
</option>`;
});
html += `</optgroup>`;
});
// 未分组的其他科目
const groupedCodes = ACCOUNT_GROUPS.flatMap((g) => g.codes);
const others = accounts.filter(
(a) => !groupedCodes.includes(a.account_code)
);
if (others.length > 0) {
html += `<optgroup label="📚 その他">`;
others.forEach((a) => {
html += `<option value="${a.account_id}">
${a.account_code} ${a.account_name}
</option>`;
});
html += `</optgroup>`;
}
return html;
}
// 科目選択履歴を保存
function saveAccountToRecent(accountId) {
if (!accountId) return;
let recentAccounts = JSON.parse(
localStorage.getItem("recentAccounts") || "[]"
);
// 重複を除去して先頭に追加
recentAccounts = recentAccounts.filter((id) => id !== accountId);
recentAccounts.unshift(accountId);
// 最大20件まで保持
if (recentAccounts.length > 20) {
recentAccounts = recentAccounts.slice(0, 20);
}
localStorage.setItem("recentAccounts", JSON.stringify(recentAccounts));
}
// ========================================
// 仕訳検索機能journal-list.htmlから統合
// ========================================
// 検索結果を印刷(検索を実行してから印刷)
async function printSearchResults() {
// まず検索を実行
const from = document.getElementById("searchFromDate").value;
const to = document.getElementById("searchToDate").value;
const key = document.getElementById("searchKeyword").value;
const accountId = document.getElementById("searchAccountId").value;
const includeHistory = document.getElementById(
"searchIncludeHistory"
).checked;
const qs = new URLSearchParams();
if (from) qs.append("from_date", from);
if (to) qs.append("to_date", to);
if (key) qs.append("keyword", key);
if (accountId) qs.append("account_id", accountId);
if (includeHistory) qs.append("include_history", "true");
const res = await fetch(`${API}/journal-entries?${qs.toString()}`);
const data = await res.json();
// 印刷用に昇順でソート
const printData = [...data].sort((a, b) => {
if (a.entry_date !== b.entry_date) {
return a.entry_date.localeCompare(b.entry_date);
}
return a.journal_entry_id - b.journal_entry_id;
});
const printArea = document.getElementById("printArea");
printArea.innerHTML = "";
// 月ごとにデータをグループ化
const monthGroups = {};
printData.forEach((e) => {
const entryMonth = e.entry_date.substring(0, 7);
if (!monthGroups[entryMonth]) {
monthGroups[entryMonth] = [];
}
monthGroups[entryMonth].push(e);
});
// 検索条件を生成
const accountSelect = document.getElementById("searchAccountId");
const accountName =
accountSelect.options[accountSelect.selectedIndex]?.text || "";
let conditions = [];
if (from || to) {
conditions.push(`期間: ${from || ""} ${to || ""}`);
}
if (key) {
conditions.push(`摘要: ${key}`);
}
if (accountName && accountName !== "-- すべて --") {
conditions.push(`科目: ${accountName}`);
}
if (includeHistory) {
conditions.push(`修正履歴を含む`);
}
const conditionsText =
conditions.length > 0 ? conditions.join(" | ") : "検索条件: すべて";
// 月ごとにテーブルを生成(印刷用)
Object.keys(monthGroups)
.sort()
.forEach((month, index) => {
const monthData = monthGroups[month];
const monthDisplay = new Date(month + "-01").toLocaleDateString(
"ja-JP",
{
year: "numeric",
month: "long",
}
);
const section = document.createElement("div");
if (index > 0) {
section.className = "page-break";
}
section.innerHTML = `
<table style="margin-bottom: 20px;">
<thead>
<tr>
<th colspan="6" style="text-align: center; font-size: 1.2em; padding: 8px; border: none; background: transparent;">仕訳検索結果</th>
</tr>
<tr>
<th colspan="3" style="text-align: left; font-weight: normal; font-size: 0.9em; padding: 4px; border: none; background: transparent;">${conditionsText}</th>
<th colspan="3" style="text-align: right; font-weight: normal; font-size: 0.9em; padding: 4px; border: none; background: transparent;">${monthDisplay}</th>
</tr>
<tr>
<th>日付</th>
<th>摘要</th>
<th>借方合計</th>
<th>貸方合計</th>
<th>税率</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="page-number">ページ ${index + 1}</div>
`;
const tbody = section.querySelector("tbody");
let monthDebitTotal = 0;
let monthCreditTotal = 0;
monthData.forEach((e) => {
monthDebitTotal += e.debit_total;
monthCreditTotal += e.credit_total;
const versionInfo =
includeHistory && e.version > 1 ? ` (v${e.version})` : "";
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${e.entry_date}</td>
<td style="text-align:left">${e.description}${versionInfo}</td>
<td style="text-align:right">${e.debit_total.toLocaleString()}</td>
<td style="text-align:right">${e.credit_total.toLocaleString()}</td>
<td style="text-align:center">${
e.tax_rates ? e.tax_rates + "%" : ""
}</td>
<td>
<button onclick="viewJournal(${
e.journal_entry_id
})">表示</button>
<button onclick="copyJournal(${
e.journal_entry_id
})" style="color: green;">複製</button>
<button onclick="reverseAndEdit(${
e.journal_entry_id
})">修正</button>
<button onclick="deleteJournalEntry(${
e.journal_entry_id
})" style="color: red;">削除</button>
</td>
`;
tbody.appendChild(tr);
});
// 月合計行を追加
const totalRow = document.createElement("tr");
totalRow.style.fontWeight = "bold";
totalRow.style.backgroundColor = "#f0f0f0";
totalRow.innerHTML = `
<td colspan="2" style="text-align:right">合計:</td>
<td style="text-align:right">${monthDebitTotal.toLocaleString()}</td>
<td style="text-align:right">${monthCreditTotal.toLocaleString()}</td>
<td colspan="2"></td>
`;
tbody.appendChild(totalRow);
printArea.appendChild(section);
});
// 少し待ってから印刷(データの表示を待つ)
setTimeout(() => {
window.print();
}, 100);
}
async function searchJournals() {
const from = document.getElementById("searchFromDate").value;
const to = document.getElementById("searchToDate").value;
const key = document.getElementById("searchKeyword").value;
const accountId = document.getElementById("searchAccountId").value;
const includeHistory = document.getElementById(
"searchIncludeHistory"
).checked;
// 検索条件を保存
localStorage.setItem(
"journalSearchConditions",
JSON.stringify({
fromDate: from,
toDate: to,
keyword: key,
accountId: accountId,
includeHistory: includeHistory,
})
);
const qs = new URLSearchParams();
if (from) qs.append("from_date", from);
if (to) qs.append("to_date", to);
if (key) qs.append("keyword", key);
if (accountId) qs.append("account_id", accountId);
if (includeHistory) qs.append("include_history", "true");
const res = await fetch(`${API}/journal-entries?${qs.toString()}`);
const data = await res.json();
const printArea = document.getElementById("printArea");
printArea.innerHTML = "";
// 月ごとにデータをグループ化
const monthGroups = {};
data.forEach((e) => {
const entryMonth = e.entry_date.substring(0, 7); // YYYY-MM
if (!monthGroups[entryMonth]) {
monthGroups[entryMonth] = [];
}
monthGroups[entryMonth].push(e);
});
// 検索条件を生成
const conditionsText = (() => {
let conditions = [];
if (from || to) {
conditions.push(`期間: ${from || ""} ${to || ""}`);
}
if (key) {
conditions.push(`摘要: ${key}`);
}
const accountSelect = document.getElementById("searchAccountId");
const accountName =
accountSelect.options[accountSelect.selectedIndex]?.text || "";
if (accountName && accountName !== "-- すべて --") {
conditions.push(`科目: ${accountName}`);
}
if (includeHistory) {
conditions.push(`修正履歴を含む`);
}
return conditions.length > 0
? conditions.join(" | ")
: "検索条件: すべて";
})();
// 月ごとにテーブルを生成(画面表示は降順)
Object.keys(monthGroups)
.sort((a, b) => b.localeCompare(a)) // 月を降順で表示
.forEach((month, index) => {
const monthData = monthGroups[month];
const monthDisplay = new Date(month + "-01").toLocaleDateString(
"ja-JP",
{
year: "numeric",
month: "long",
}
);
const section = document.createElement("div");
if (index > 0) {
section.className = "page-break";
}
section.innerHTML = `
<table style="margin-bottom: 20px;">
<thead>
<tr>
<th colspan="6" style="text-align: center; font-size: 1.2em; padding: 8px; border: none; background: transparent;">仕訳検索結果</th>
</tr>
<tr>
<th colspan="3" style="text-align: left; font-weight: normal; font-size: 0.9em; padding: 4px; border: none; background: transparent;">${conditionsText}</th>
<th colspan="3" style="text-align: right; font-weight: normal; font-size: 0.9em; padding: 4px; border: none; background: transparent;">${monthDisplay}</th>
</tr>
<tr>
<th>日付</th>
<th>摘要</th>
<th>借方合計</th>
<th>貸方合計</th>
<th>税率</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="page-number">ページ ${index + 1}</div>
`;
const tbody = section.querySelector("tbody");
let monthDebitTotal = 0;
let monthCreditTotal = 0;
// 月内のデータも降順で表示(画面表示用)
monthData.forEach((e) => {
monthDebitTotal += e.debit_total;
monthCreditTotal += e.credit_total;
const versionInfo =
includeHistory && e.version > 1 ? ` (v${e.version})` : "";
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${e.entry_date}</td>
<td style="text-align:left">${e.description}${versionInfo}</td>
<td style="text-align:right">${e.debit_total.toLocaleString()}</td>
<td style="text-align:right">${e.credit_total.toLocaleString()}</td>
<td style="text-align:center">${
e.tax_rates ? e.tax_rates + "%" : ""
}</td>
<td>
<button onclick="viewJournal(${
e.journal_entry_id
})">表示</button>
<button onclick="copyJournal(${
e.journal_entry_id
})" style="color: green;">複製</button>
<button onclick="reverseAndEdit(${
e.journal_entry_id
})">修正</button>
<button onclick="deleteJournalEntry(${
e.journal_entry_id
})" style="color: red;">削除</button>
</td>
`;
tbody.appendChild(tr);
});
// 月合計行を追加
const totalRow = document.createElement("tr");
totalRow.style.fontWeight = "bold";
totalRow.style.backgroundColor = "#f0f0f0";
totalRow.innerHTML = `
<td colspan="2" style="text-align:right">合計:</td>
<td style="text-align:right">${monthDebitTotal.toLocaleString()}</td>
<td style="text-align:right">${monthCreditTotal.toLocaleString()}</td>
<td colspan="2"></td>
`;
tbody.appendChild(totalRow);
printArea.appendChild(section);
});
}
function viewJournal(id) {
window.open(`journal-view.html?id=${id}`, "_blank");
}
// --------------------------------------------------
// 仕訳複製:既存の仕訳を入力フォームにコピー
// --------------------------------------------------
async function copyJournal(id) {
try {
const res = await fetch(`${API}/journal-entries/${id}`);
if (!res.ok) {
alert("仕訳の取得に失敗しました");
return;
}
const data = await res.json();
// 日付をコピー元の仕訳日に設定
document.getElementById("entryDate").value = data.entry_date;
// 摘要をコピー
document.getElementById("description").value = data.description || "";
// 既存の行をすべてクリア(税行も含む)
const tbody = document.querySelector("#linesTable tbody");
tbody.innerHTML = "";
rowSeq = 0;
// 仕訳明細をすべてコピー(税行も含む)
// 税の自動計算機能は実行しない(税区分・税方向を「対象外」に設定)
if (data.lines && data.lines.length > 0) {
data.lines.forEach((line) => {
const tr = addRow();
// 科目を設定
const accountSelect = tr.querySelector(".account");
if (accountSelect) {
accountSelect.value = line.account_id;
}
// 借方・貸方金額を設定(カンマフォーマット付き)
const debitInput = tr.querySelector(".debit");
const creditInput = tr.querySelector(".credit");
if (debitInput && line.debit) {
debitInput.value = Number(line.debit).toLocaleString();
}
if (creditInput && line.credit) {
creditInput.value = Number(line.credit).toLocaleString();
}
// 税区分・税方向は「対象外」に設定(自動計算を防ぐため)
const taxTypeSelect = tr.querySelector(".taxType");
const taxDirSelect = tr.querySelector(".taxDirection");
if (taxTypeSelect) {
taxTypeSelect.value = "none"; // 対象外
}
if (taxDirSelect) {
taxDirSelect.value = "none"; // 対象外
}
});
}
// ページ上部の仕訳入力フォームにスクロール
window.scrollTo({ top: 0, behavior: "smooth" });
console.log("コピーされたデータ:", data); // デバッグ用
console.log(
`${data.lines.length}行をコピーしました。税区分・税方向はすべて「対象外」に設定されています。`
);
alert(
`仕訳を複製しました(${data.lines.length}行)。\n税の自動計算を防ぐため、すべての行の税区分・税方向を「対象外」に設定しています。\n必要に応じて修正してから登録してください。`
);
} catch (error) {
console.error("Error:", error);
alert(`エラーが発生しました: ${error.message}`);
}
}
// --------------------------------------------------
// 修正仕訳:逆仕訳を作って修正画面へ遷移
// --------------------------------------------------
async function reverseAndEdit(id) {
if (
!confirm(
"この仕訳の逆仕訳を作成し、修正仕訳入力画面を開きます。よろしいですか?"
)
)
return;
try {
// ① 逆仕訳を作成
const res = await fetch(`${API}/journal-entries/${id}/reverse`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(
data.detail || `逆仕訳の作成に失敗しました (HTTP ${res.status})`
);
return;
}
const data = await res.json();
alert(
`逆仕訳を作成しましたID: ${data.reversed_journal_entry_id}`
);
// ② 元仕訳内容を取得して修正画面へ遷移
const srcRes = await fetch(`${API}/journal-entries/${id}`);
if (!srcRes.ok) {
alert("元仕訳の取得に失敗しました");
return;
}
const srcData = await srcRes.json();
// 逆仕訳IDを追加して保存【修正後】仕訳のparent_entry_idとして使用
srcData.reversed_journal_entry_id = data.reversed_journal_entry_id;
localStorage.setItem("editSource", JSON.stringify(srcData));
// 修正画面を開く
window.open("journal-edit.html", "_blank");
} catch (error) {
console.error("Error:", error);
alert(`エラーが発生しました: ${error.message}`);
}
}
// --------------------------------------------------
// 仕訳削除
// --------------------------------------------------
async function deleteJournalEntry(id) {
const reason = prompt("削除理由を入力してください:");
if (!reason || reason.trim() === "") {
alert("削除理由の入力が必要です");
return;
}
if (!confirm("この仕訳を削除してもよろしいですか?")) {
return;
}
const res = await fetch(`${API}/journal-entries/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deleted_reason: reason }),
});
const data = await res.json();
if (!res.ok) {
alert(data.detail || "削除に失敗しました");
return;
}
alert("削除しました");
searchJournals(); // リストを再読み込み
}
// ページロード後に検索条件を設定
window.addEventListener("DOMContentLoaded", () => {
// デフォルト期間を設定5/31決算6月1日現在
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1; // 1-12
let fiscalStartYear;
if (currentMonth >= 6) {
// 6月以降当年6月1日から
fiscalStartYear = currentYear;
} else {
// 1-5月前年6月1日から
fiscalStartYear = currentYear - 1;
}
const defaultFromDate = `${fiscalStartYear}-06-01`;
const defaultToDate = today.toISOString().slice(0, 10);
// 常にデフォルト期間を設定(会計年度に合わせるため)
document.getElementById("searchFromDate").value = defaultFromDate;
document.getElementById("searchToDate").value = defaultToDate;
// 摘要と科目のみ前回の検索条件を復元
const savedConditions = localStorage.getItem("journalSearchConditions");
if (savedConditions) {
const conditions = JSON.parse(savedConditions);
if (conditions.keyword)
document.getElementById("searchKeyword").value = conditions.keyword;
if (conditions.accountId)
document.getElementById("searchAccountId").value =
conditions.accountId;
if (conditions.includeHistory)
document.getElementById("searchIncludeHistory").checked =
conditions.includeHistory;
}
// ページロード時に自動的に検索を実行して最新データを表示
searchJournals();
});
</script>
</body>
</html>