Files
njts-accounting-core/frontend/journal-entry.html
2025-12-22 17:30:32 +09:00

649 lines
18 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" onchange="onEntryDateChanged()" />
</div>
<div class="row">
<label>摘要</label>
<input
type="text"
id="description"
style="flex: 1"
placeholder="例:売上入金/経費支払 など"
/>
</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="round">四捨五入</option>
<option value="floor">切捨て</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>
<h3>月次锁定管理</h3>
<div style="margin-bottom: 8px">
<label>年度:</label>
<input type="number" id="lockYear" value="2025" 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>
<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();
}
init();
// -----------------------------
// 仮払/仮受 セレクタ
// -----------------------------
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) / (1 + 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 = [];
let totalDebit = 0,
totalCredit = 0;
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);
if (debit > 0 && credit > 0) {
alert("同一行不能同时填写借方和贷方");
throw new Error("invalid line");
}
if (debit === 0 && credit === 0) return;
lines.push({ account_id: accountId, debit: debit, credit: credit });
totalDebit += debit;
totalCredit += credit;
});
if (lines.length === 0) {
alert("仕訳明細がありません");
return;
}
if (lines.length < 2) {
alert("至少需要借方和贷方各一行");
return;
}
// 借貸一致チェック(最小)
if (totalDebit !== totalCredit) {
alert(
`借方(${totalDebit.toLocaleString()})と貸方(${totalCredit.toLocaleString()})が一致しません`
);
return;
}
const payload = { entry_date: entryDate, description, lines };
const res = await fetch(`${API}/journal-entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
alert("登録に失敗しました");
return;
}
const result = await res.json();
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;
}
</script>
</body>
</html>