Files
njts-accounting-core/frontend/journal-entry.html
2026-01-13 16:29:34 +09:00

1011 lines
30 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;
}
</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; 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" />
<button onclick="searchJournals()" style="margin-left: 8px">検索</button>
</div>
<table>
<thead>
<tr>
<th>日付</th>
<th>摘要</th>
<th>借方合計</th>
<th>貸方合計</th>
<th>操作</th>
</tr>
</thead>
<tbody id="searchResultList"></tbody>
</table>
<script>
let rowSeq = 0; // 各行唯一ID
const API = "http://127.0.0.1:18080";
let accounts = [];
const ACCOUNT_GROUPS = [
{
label: "💰 資金",
codes: ["101", "102", "103", "104"],
},
{
label: "📦 流動資産",
codes: ["201", "202", "203", "204", "205"],
},
{
label: "🏗 固定資産",
codes: ["401", "402", "403", "404", "405", "406", "407", "408"],
},
{
label: "💳 負債",
codes: [
"501",
"502",
"503",
"504",
"505",
"506",
"507",
"508",
"509",
"210",
],
},
{
label: "🏢 資本",
codes: ["601", "602"],
},
{
label: "📈 収益",
codes: ["701"],
},
];
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();
addRow();
document.getElementById("entryDate").value = new Date()
.toISOString()
.slice(0, 10);
await onEntryDateChanged();
// 摘要履歴を読み込み
loadDescriptionHistory();
}
init();
// -----------------------------
// 摘要履歴管理
// -----------------------------
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);
// 最大20件まで保存
if (history.length > 20) {
history = history.slice(0, 20);
}
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="number" class="debit right" min="0" ${
isTaxRow ? "readonly" : ""
}></td>
<td><input type="number" class="credit right" min="0" ${
isTaxRow ? "readonly" : ""
}></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);
tr.querySelector(".debit").addEventListener("input", recalc);
tr.querySelector(".credit").addEventListener("input", recalc);
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();
}
// -----------------------------
// 税行 自動生成/更新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 || 0);
const credit = Number(row.querySelector(".credit").value || 0);
if (taxType === "none" || taxDir === "none") return;
const totalAmount = debit || credit;
if (!totalAmount) 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) return alert("仮払消費税等 勘定を選択してください");
addRow(
true,
{ accountId: taxAcc, amount: taxAmount, side: "debit" },
parentId
);
}
if (taxDir === "received") {
const taxAcc = document.getElementById("taxReceivedSelect").value;
if (!taxAcc) return alert("仮受消費税等 勘定を選択してください");
addRow(
true,
{ accountId: taxAcc, amount: taxAmount, side: "credit" },
parentId
);
}
}
// 税行は常に「直後の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() {
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 || 0);
const credit = Number(row.querySelector(".credit").value || 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 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,
};
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}`);
location.reload();
}
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 = "";
// 已分组的科目
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;
}
// ========================================
// 仕訳検索機能journal-list.htmlから統合
// ========================================
async function searchJournals() {
const from = document.getElementById("searchFromDate").value;
const to = document.getElementById("searchToDate").value;
const key = document.getElementById("searchKeyword").value;
// 検索条件を保存
localStorage.setItem(
"journalSearchConditions",
JSON.stringify({
fromDate: from,
toDate: to,
keyword: key,
})
);
const qs = new URLSearchParams();
if (from) qs.append("from_date", from);
if (to) qs.append("to_date", to);
if (key) qs.append("keyword", key);
const res = await fetch(`${API}/journal-entries?${qs.toString()}`);
const data = await res.json();
const tbody = document.getElementById("searchResultList");
tbody.innerHTML = "";
data.forEach((e) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${e.entry_date}</td>
<td>${e.description}</td>
<td style="text-align:right">${e.debit_total.toLocaleString()}</td>
<td style="text-align:right">${e.credit_total.toLocaleString()}</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);
});
}
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 = new Date()
.toISOString()
.slice(0, 10);
// 摘要をコピー
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 = line.debit;
}
if (creditInput && line.credit) {
creditInput.value = line.credit;
}
// 税区分を設定
const taxTypeSelect = tr.querySelector(".taxType");
const taxDirSelect = tr.querySelector(".taxDirection");
if (line.tax_rate && taxTypeSelect) {
taxTypeSelect.value = String(line.tax_rate);
}
if (line.tax_direction && taxDirSelect) {
taxDirSelect.value = line.tax_direction;
}
});
}
// ページ上部の仕訳入力フォームにスクロール
window.scrollTo({ top: 0, behavior: "smooth" });
alert(
"仕訳を複製しました。日付や金額などを修正して登録してください。"
);
} 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();
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);
const savedConditions = localStorage.getItem("journalSearchConditions");
if (savedConditions) {
const conditions = JSON.parse(savedConditions);
if (conditions.fromDate)
document.getElementById("searchFromDate").value =
conditions.fromDate;
else
document.getElementById("searchFromDate").value = defaultFromDate;
if (conditions.toDate)
document.getElementById("searchToDate").value = conditions.toDate;
else document.getElementById("searchToDate").value = defaultToDate;
if (conditions.keyword)
document.getElementById("searchKeyword").value = conditions.keyword;
// 自動的に検索を実行
searchJournals();
} else {
// 保存された条件がない場合はデフォルト値を設定
document.getElementById("searchFromDate").value = defaultFromDate;
document.getElementById("searchToDate").value = defaultToDate;
}
});
</script>
</body>
</html>