Files
njts-accounting-core/frontend/journal-edit.html
2026-05-22 23:05:25 +09:00

748 lines
23 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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script>
<link rel="stylesheet" href="/css/mobile.css" />
<title>修正仕訳入力</title>
<style>
body {
font-family: sans-serif;
margin: 24px;
}
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;
}
#linesTable td input {
box-sizing: border-box;
width: 100%;
}
.right {
text-align: right;
}
.error {
color: #c00;
margin-top: 8px;
}
.ok {
color: #090;
margin-top: 8px;
}
/* 账户搜索下拉框相关样式 */
.account-search-container {
position: relative;
width: 100%;
box-sizing: border-box;
}
.account-search-input {
width: 100%;
padding: 6px !important;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.account-search-input:focus {
outline: none;
border-color: #4caf50;
box-shadow: 0 0 4px rgba(76, 175, 80, 0.3);
}
.account-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.account-dropdown.active {
display: block;
}
.account-option {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
text-align: left;
display: block;
width: 100%;
}
.account-option:hover,
.account-option.selected {
background-color: #e3f2fd;
}
.account-option-strong {
font-weight: bold;
color: #333;
display: inline;
margin-right: 6px;
}
.account-option-light {
color: #555;
font-size: 14px;
display: inline;
}
</style>
</head>
<body>
<h2>修正仕訳入力</h2>
<label>仕訳日</label>
<input type="date" id="entryDate" min="1900-01-01" max="2099-12-31" />
<label>摘要</label>
<input type="text" id="description" style="width: 60%" />
<table id="linesTable">
<thead>
<tr>
<th style="width: 220px">科目</th>
<th style="width: 120px">借方</th>
<th style="width: 120px">貸方</th>
<th style="width: 90px">税率</th>
<th style="width: 180px">摘要(行)</th>
<th style="width: 60px">操作</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<th>合計</th>
<th id="debitTotal" class="right">0</th>
<th id="creditTotal" class="right">0</th>
<th></th>
<th></th>
<th></th>
</tr>
</tfoot>
</table>
<div style="margin-top: 12px">
<button onclick="addRow()"> 行追加</button>
<button onclick="submitEntry()">修正版仕訳を登録</button>
<button onclick="cancelEdit()" style="background-color: #f0f0f0">
キャンセル
</button>
</div>
<div id="result"></div>
<script>
const API = "";
let accounts = [];
// カスタムアラートモーダル
function showAlert(msg, type, callback) {
const configs = {
success: {
icon: "✔",
iconColor: "#22a84b",
bg: "#f0faf4",
borderTop: "#22a84b",
},
error: {
icon: "✖",
iconColor: "#e53935",
bg: "#fff5f5",
borderTop: "#e53935",
},
warning: {
icon: "⚠",
iconColor: "#e67e00",
bg: "#fffbf0",
borderTop: "#f0a500",
},
};
const c = configs[type] || configs.warning;
const overlay = document.getElementById("alertModalOverlay");
document.getElementById("alertModalIcon").innerHTML =
`<span style="font-size:52px;line-height:1;color:${c.iconColor};">${c.icon}</span>`;
document.getElementById("alertModalMsg").textContent = msg;
const box = document.getElementById("alertModalBox");
box.style.borderTop = `5px solid ${c.borderTop}`;
box.style.background = c.bg;
overlay.style.display = "flex";
document.getElementById("alertModalOkBtn").onclick = function () {
overlay.style.display = "none";
if (callback) callback();
};
}
// 科目グループ定義(仕訳入力と同期)
const ACCOUNT_GROUPS = [
// 資産の部
{
label: "現金・預金",
codes: ["101", "102", "103", "104", "105"],
},
{
label: "売上債権",
codes: ["201", "202"],
},
{
label: "有価証券",
codes: ["301"],
},
{
label: "棚卸資産",
codes: ["210"],
},
{
label: "他流動資産",
codes: ["203", "204", "205", "206", "207", "208"],
},
{
label: "有形固定資産",
codes: ["401", "402", "403", "404", "405", "406", "407"],
},
{
label: "無形固定資産",
codes: ["408"],
},
{
label: "投資その他資産",
codes: ["410"],
},
// 負債の部
{
label: "仕入債務",
codes: ["501"],
},
{
label: "その他流動負債",
codes: ["502", "503", "504", "505", "506", "507", "508"],
},
{
label: "固定負債",
codes: ["509"],
},
// 資本の部
{
label: "資本金",
codes: ["601"],
},
{
label: "利益剰余金",
codes: ["602"],
},
// 収益・費用
{
label: "収益",
codes: ["701", "702", "703", "704", "705"],
},
{
label: "費用",
codes: [
"801",
"802",
"803",
"804",
"805",
"806",
"807",
"808",
"809",
"810",
"811",
"812",
"813",
"814",
"815",
"816",
"817",
"818",
"819",
"820",
],
},
];
// ========================================
// 账户搜索功能
// ========================================
function setupAccountSearch(inputElement, row) {
const container = inputElement.closest(".account-search-container");
const dropdown = container.querySelector(".account-dropdown");
// 科目選択履歴を保存journal-entry.htmlと共通のlocalStorageキー
function saveAccountToRecentEdit(accountId) {
if (!accountId) return;
let recent = JSON.parse(
localStorage.getItem("recentAccounts") || "[]",
);
recent = recent.filter((id) => id !== accountId);
recent.unshift(accountId);
if (recent.length > 20) recent = recent.slice(0, 20);
localStorage.setItem("recentAccounts", JSON.stringify(recent));
}
function makeLbl(text) {
const lbl = document.createElement("div");
lbl.style.cssText =
"padding:3px 12px;font-size:11px;color:#888;background:#f5f5f5;font-weight:bold;border-bottom:1px solid #e0e0e0;";
lbl.textContent = text;
return lbl;
}
// 显示科目下拉列表的函数
function showAccountList(searchText = "") {
const isFiltered = searchText.length > 0;
let filtered = accounts;
if (isFiltered) {
filtered = accounts.filter((acc) => {
const code = acc.account_code.toLowerCase();
const name = acc.account_name.toLowerCase();
return (
code.includes(searchText) ||
name.includes(searchText) ||
`${code} ${name}`.includes(searchText)
);
});
}
dropdown.innerHTML = "";
function appendOpt(acc) {
const option = document.createElement("div");
option.className = "account-option";
option.innerHTML = `<span class="account-option-strong">${acc.account_code}</span><span class="account-option-light">${acc.account_name}</span>`;
option.addEventListener("click", () => {
inputElement.value = `${acc.account_code} ${acc.account_name}`;
inputElement.dataset.accountId = acc.account_id;
dropdown.classList.remove("active");
saveAccountToRecentEdit(acc.account_id);
});
dropdown.appendChild(option);
}
if (filtered.length === 0) {
dropdown.innerHTML = `<div style="padding: 8px 12px; color: #999;">該当する科目がありません</div>`;
} else if (isFiltered) {
const sorted = filtered
.slice()
.sort((a, b) => a.account_code.localeCompare(b.account_code));
sorted.slice(0, 50).forEach(appendOpt);
} else {
const recentIds = JSON.parse(
localStorage.getItem("recentAccounts") || "[]",
);
const recentItems = recentIds
.map((id) => filtered.find((a) => a.account_id === id))
.filter(Boolean);
const recentSet = new Set(recentIds);
const otherItems = filtered
.filter((a) => !recentSet.has(a.account_id))
.sort((a, b) => a.account_code.localeCompare(b.account_code));
if (recentItems.length > 0) {
dropdown.appendChild(makeLbl("⭐ 最近使った科目"));
recentItems.forEach(appendOpt);
const sep = document.createElement("div");
sep.style.cssText = "border-top:1px solid #e8e8e8;margin:2px 0;";
dropdown.appendChild(sep);
}
dropdown.appendChild(makeLbl("📋 すべての科目"));
otherItems.slice(0, 50).forEach(appendOpt);
}
dropdown.classList.add("active");
}
// 入力時:フィルター
inputElement.addEventListener("input", (e) => {
const searchText = e.target.value.toLowerCase().trim();
showAccountList(searchText);
});
// クリック以外の場所のドロップダウンを非表示
document.addEventListener("click", (e) => {
if (!container.contains(e.target)) {
dropdown.classList.remove("active");
}
});
// フォーカス時:すべての科目を表示
inputElement.addEventListener("focus", () => {
showAccountList(inputElement.value.toLowerCase().trim());
});
}
// 科目選択肢をグループ分けして構築
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;
}
// ------------------------------------
// 初期化
// ------------------------------------
async function init() {
// 科目取得
const res = await fetch(`${API}/accounts`);
const data = await res.json();
accounts = data.items ?? data;
// 元仕訳取得
const src = localStorage.getItem("editSource");
if (!src) {
showError("修正元の仕訳データが見つかりません");
return;
}
const srcData = JSON.parse(src);
// ヘッダ反映
document.getElementById("entryDate").value = srcData.entry_date;
document.getElementById("description").value =
`【修正後】${srcData.description}`;
// 明細反映
srcData.lines.forEach((l) => addRow(l));
recalc();
}
init();
// ------------------------------------
// 行操作
// ------------------------------------
function addRow(line = null) {
const tbody = document.querySelector("#linesTable tbody");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>
<div class="account-search-container">
<input
type="text"
class="account-search-input account"
placeholder="科目を入力・検索"
autocomplete="off" />
<div class="account-dropdown"></div>
</div>
</td>
<td><input type="number" min="0" style="text-align:right"></td>
<td><input type="number" min="0" style="text-align:right"></td>
<td>
<select class="tax-rate-select" style="width:100%;padding:4px;">
<option value="">—</option>
<option value="10">10%</option>
<option value="8">8%(軽減)</option>
<option value="0">0%(非課税)</option>
</select>
</td>
<td><input type="text" class="line-memo-input" placeholder="行摘要(任意)"></td>
<td><button onclick="removeRow(this)">削除</button></td>
`;
tbody.appendChild(tr);
// Setup account search for this row
const accountInput = tr.querySelector(".account-search-input");
setupAccountSearch(accountInput, tr);
if (line) {
// Find the account and populate the input
const account = accounts.find(
(a) => a.account_id === line.account_id,
);
if (account) {
accountInput.value = `${account.account_code} ${account.account_name}`;
accountInput.dataset.accountId = account.account_id;
}
// type="number" のみを取得
const numberInputs = tr.querySelectorAll("input[type='number']");
// Decimal 値を数値に変換してから代入
const debitVal = Number(line.debit) || 0;
const creditVal = Number(line.credit) || 0;
numberInputs[0].value = debitVal > 0 ? String(debitVal) : "";
numberInputs[1].value = creditVal > 0 ? String(creditVal) : "";
// 税率を設定
if (line.tax_rate != null) {
const sel = tr.querySelector(".tax-rate-select");
sel.value = String(line.tax_rate);
}
// 摘要(行)を設定
const memoInput = tr.querySelector(".line-memo-input");
if (memoInput) memoInput.value = line.line_description || "";
}
// 入力時に合計を自動更新
tr.querySelectorAll("input").forEach((input) => {
input.addEventListener("input", recalc);
input.addEventListener("change", recalc);
});
}
function removeRow(btn) {
btn.closest("tr").remove();
recalc();
}
// ------------------------------------
// 合計再計算
// ------------------------------------
function recalc() {
let d = 0,
c = 0;
document.querySelectorAll("#linesTable tbody tr").forEach((tr) => {
// type="number" のみを取得(科目検索入力框を除外)
const numberInputs = tr.querySelectorAll("input[type='number']");
if (numberInputs.length >= 2) {
const debitStr = numberInputs[0]?.value;
const creditStr = numberInputs[1]?.value;
// 空または 0 を含む場合は 0、そうでなければ数値に変換
const debitVal =
debitStr && debitStr.trim()
? Number(debitStr.toString().replace(/,/g, ""))
: 0;
const creditVal =
creditStr && creditStr.trim()
? Number(creditStr.toString().replace(/,/g, ""))
: 0;
// NaN チェック
if (!isNaN(debitVal)) d += debitVal;
if (!isNaN(creditVal)) c += creditVal;
}
});
document.getElementById("debitTotal").textContent = d.toLocaleString();
document.getElementById("creditTotal").textContent = c.toLocaleString();
}
// ------------------------------------
// 登録
// ------------------------------------
async function submitEntry() {
const result = document.getElementById("result");
result.textContent = "";
result.className = "";
const entryDateInput = document.getElementById("entryDate");
const entryDate = entryDateInput.value;
const desc = document.getElementById("description").value;
if (entryDateInput.validity.badInput) {
showError(
"日付が正しくありません。存在しない日付が入力されています11月31日は無効です",
);
return;
}
if (!entryDate && !desc) {
showError("日付と摘要は必須です");
return;
}
if (!entryDate) {
showError("日付を入力してください");
return;
}
if (!desc) {
showError("摘要を入力してください");
return;
}
const lines = [];
let d = 0,
c = 0;
document.querySelectorAll("#linesTable tbody tr").forEach((tr) => {
const accountInput = tr.querySelector(".account-search-input");
const accountId = Number(accountInput.dataset.accountId || 0);
const numberInputs = tr.querySelectorAll("input[type='number']");
const debit = Number(numberInputs[0]?.value || 0);
const credit = Number(numberInputs[1]?.value || 0);
const lineDesc =
tr.querySelector(".line-memo-input")?.value.trim() || undefined;
const taxRateRaw = tr.querySelector(".tax-rate-select")?.value;
const taxRate =
taxRateRaw !== "" && taxRateRaw != null
? Number(taxRateRaw)
: undefined;
if (debit === 0 && credit === 0) return;
lines.push({
account_id: accountId,
debit,
credit,
...(taxRate !== undefined ? { tax_rate: taxRate } : {}),
...(lineDesc ? { line_description: lineDesc } : {}),
});
d += debit;
c += credit;
});
if (lines.length < 2) return showError("仕訳行は2行以上必要です");
if (d !== c) return showError("借方合計と貸方合計が一致していません");
// 源仕訳IDを親IDとして取得修正の場合
const src = localStorage.getItem("editSource");
const srcData = src ? JSON.parse(src) : null;
// 【修正仕訳】の original_entry_id は、修正元の仕訳ID
// 如果当前是修正版本,传递它的 original_entry_id真正的原始版本
// 如果当前是原始版本,传递它自己的 journal_entry_id
const originalEntryId =
srcData?.original_entry_id || srcData?.journal_entry_id || null;
const payload = {
entry_date: entryDate,
description: desc,
lines,
tax_paid_account_id: null,
tax_received_account_id: null,
rounding_mode: "floor",
original_entry_id: originalEntryId,
};
try {
const res = await fetch(`${API}/journal-entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) return showError(data.detail || "登録失敗");
// 登録成功 - 親窓口の検索を再実行してから画面を閉じる
showAlert(
`登録しましたID: ${data.journal_entry_id}`,
"success",
() => {
localStorage.removeItem("editSource");
if (window.opener && window.opener.searchJournals) {
window.opener.searchJournals();
}
window.close();
},
);
} catch {
showError("通信エラー");
}
}
function showError(msg) {
const r = document.getElementById("result");
r.textContent = msg;
r.className = "error";
}
function showOk(msg) {
const r = document.getElementById("result");
r.textContent = msg;
r.className = "ok";
}
function cancelEdit() {
localStorage.removeItem("editSource");
window.close();
}
</script>
<!-- アラートモーダル -->
<div
id="alertModalOverlay"
style="
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9999;
align-items: center;
justify-content: center;
"
>
<div
id="alertModalBox"
style="
background: #fff;
border-radius: 10px;
padding: 32px 36px;
min-width: 320px;
max-width: 480px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
text-align: center;
"
>
<div id="alertModalIcon" style="margin-bottom: 14px"></div>
<div
id="alertModalMsg"
style="
font-size: 15px;
color: #333;
margin-bottom: 26px;
white-space: pre-wrap;
"
></div>
<button
id="alertModalOkBtn"
style="
padding: 9px 36px;
border-radius: 6px;
border: none;
font-size: 14px;
cursor: pointer;
background: #4a90d9;
color: #fff;
font-weight: 600;
"
>
OK
</button>
</div>
</div>
</body>
</html>