This commit is contained in:
admin
2026-03-04 00:35:51 +09:00
parent 079057ef41
commit 99d16d8219
5 changed files with 1012 additions and 72 deletions

View File

@@ -7,6 +7,26 @@ from datetime import date
router = APIRouter(prefix="/journal-entries", tags=["仕訳"]) router = APIRouter(prefix="/journal-entries", tags=["仕訳"])
# journal_lines に line_description カラムが存在するかキャッシュ
_line_description_column_exists: Optional[bool] = None
def _check_line_description_column() -> bool:
"""journal_lines テーブルに line_description カラムが存在するか確認(キャッシュあり)"""
global _line_description_column_exists
if _line_description_column_exists is None:
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'journal_lines'
AND column_name = 'line_description'
""")
_line_description_column_exists = cur.fetchone() is not None
except Exception:
_line_description_column_exists = False
return _line_description_column_exists
class JournalLine(BaseModel): class JournalLine(BaseModel):
account_id: int account_id: int
@@ -14,6 +34,7 @@ class JournalLine(BaseModel):
credit: Decimal = Decimal("0") credit: Decimal = Decimal("0")
tax_rate: Optional[int] = None # 10 or 8 or None tax_rate: Optional[int] = None # 10 or 8 or None
tax_direction: Optional[str] = None # "paid" or "received" or None tax_direction: Optional[str] = None # "paid" or "received" or None
line_description: Optional[str] = None # 行摘要(任意)
class JournalEntryRequest(BaseModel): class JournalEntryRequest(BaseModel):
@@ -205,7 +226,9 @@ def get_journal_entry(journal_id: int):
raise HTTPException(status_code=404, detail="仕訳が存在しません") raise HTTPException(status_code=404, detail="仕訳が存在しません")
# 明細取得 # 明細取得
cur.execute(""" has_line_desc = _check_line_description_column()
line_desc_col = ", jl.line_description" if has_line_desc else ""
cur.execute(f"""
SELECT SELECT
jl.journal_line_id, jl.journal_line_id,
jl.account_id, jl.account_id,
@@ -215,6 +238,7 @@ def get_journal_entry(journal_id: int):
jl.credit, jl.credit,
jl.tax_rate, jl.tax_rate,
jl.tax_direction jl.tax_direction
{line_desc_col}
FROM journal_lines jl FROM journal_lines jl
JOIN accounts a ON jl.account_id = a.account_id JOIN accounts a ON jl.account_id = a.account_id
WHERE jl.journal_entry_id = %s WHERE jl.journal_entry_id = %s
@@ -493,6 +517,7 @@ def create_journal_entry(req: JournalEntryRequest):
entry_id = cur.fetchone()["journal_entry_id"] entry_id = cur.fetchone()["journal_entry_id"]
# ④ 插入交易明細 # ④ 插入交易明細
has_line_desc = _check_line_description_column()
for line in all_lines: for line in all_lines:
# 轉換 tax_direction # 轉換 tax_direction
tax_dir_db = None tax_dir_db = None
@@ -504,24 +529,46 @@ def create_journal_entry(req: JournalEntryRequest):
else: else:
tax_dir_db = line.tax_direction.upper() tax_dir_db = line.tax_direction.upper()
cur.execute(""" if has_line_desc:
INSERT INTO journal_lines ( cur.execute("""
journal_entry_id, INSERT INTO journal_lines (
account_id, journal_entry_id,
debit, account_id,
credit, debit,
tax_rate, credit,
tax_direction tax_rate,
) tax_direction,
VALUES (%s, %s, %s, %s, %s, %s) line_description
""", ( )
entry_id, VALUES (%s, %s, %s, %s, %s, %s, %s)
line.account_id, """, (
line.debit, entry_id,
line.credit, line.account_id,
line.tax_rate, line.debit,
tax_dir_db line.credit,
)) line.tax_rate,
tax_dir_db,
line.line_description or None
))
else:
cur.execute("""
INSERT INTO journal_lines (
journal_entry_id,
account_id,
debit,
credit,
tax_rate,
tax_direction
)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
entry_id,
line.account_id,
line.debit,
line.credit,
line.tax_rate,
tax_dir_db
))
conn.commit() conn.commit()
@@ -543,6 +590,7 @@ def create_journal_entry(req: JournalEntryRequest):
entry_id = cur.fetchone()["journal_entry_id"] entry_id = cur.fetchone()["journal_entry_id"]
# 插入交易明細 # 插入交易明細
has_line_desc = _check_line_description_column()
for line in all_lines: for line in all_lines:
# 轉換 tax_direction # 轉換 tax_direction
tax_dir_db = None tax_dir_db = None
@@ -554,19 +602,35 @@ def create_journal_entry(req: JournalEntryRequest):
else: else:
tax_dir_db = line.tax_direction.upper() tax_dir_db = line.tax_direction.upper()
cur.execute(""" if has_line_desc:
INSERT INTO journal_lines ( cur.execute("""
journal_entry_id, account_id, debit, credit, INSERT INTO journal_lines (
tax_rate, tax_direction journal_entry_id, account_id, debit, credit,
) VALUES (%s, %s, %s, %s, %s, %s) tax_rate, tax_direction, line_description
""", ( ) VALUES (%s, %s, %s, %s, %s, %s, %s)
entry_id, """, (
line.account_id, entry_id,
line.debit, line.account_id,
line.credit, line.debit,
line.tax_rate, line.credit,
tax_dir_db line.tax_rate,
)) tax_dir_db,
line.line_description or None
))
else:
cur.execute("""
INSERT INTO journal_lines (
journal_entry_id, account_id, debit, credit,
tax_rate, tax_direction
) VALUES (%s, %s, %s, %s, %s, %s)
""", (
entry_id,
line.account_id,
line.debit,
line.credit,
line.tax_rate,
tax_dir_db
))
conn.commit() conn.commit()

View File

@@ -0,0 +1,24 @@
-- ============================================================================
-- マイグレーション: journal_lines テーブルに line_description カラム追加
-- 実行方法: admin または postgres 超级用户で実行
-- psql -h 192.168.0.61 -p 55432 -U admin -d njts_acct -f add_line_description.sql
-- ============================================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'journal_lines' AND column_name = 'line_description'
) THEN
ALTER TABLE journal_lines ADD COLUMN line_description TEXT;
RAISE NOTICE '✓ line_description カラムを journal_lines に追加しました';
ELSE
RAISE NOTICE '✓ line_description カラムはすでに存在しています';
END IF;
END $$;
-- 確認
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'journal_lines'
ORDER BY ordinal_position;

View File

@@ -28,6 +28,10 @@
button { button {
padding: 6px; padding: 6px;
} }
#linesTable td input {
box-sizing: border-box;
width: 100%;
}
.right { .right {
text-align: right; text-align: right;
} }
@@ -122,10 +126,11 @@
<table id="linesTable"> <table id="linesTable">
<thead> <thead>
<tr> <tr>
<th>科目</th> <th style="width: 220px">科目</th>
<th>借方</th> <th style="width: 120px">借方</th>
<th>貸方</th> <th style="width: 120px">貸方</th>
<th>操作</th> <th style="width: 180px">摘要(行)</th>
<th style="width: 60px">操作</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -135,6 +140,7 @@
<th id="debitTotal" class="right">0</th> <th id="debitTotal" class="right">0</th>
<th id="creditTotal" class="right">0</th> <th id="creditTotal" class="right">0</th>
<th></th> <th></th>
<th></th>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -277,7 +283,11 @@
filtered = accounts.filter((acc) => { filtered = accounts.filter((acc) => {
const code = acc.account_code.toLowerCase(); const code = acc.account_code.toLowerCase();
const name = acc.account_name.toLowerCase(); const name = acc.account_name.toLowerCase();
return code.includes(searchText) || name.includes(searchText); return (
code.includes(searchText) ||
name.includes(searchText) ||
`${code} ${name}`.includes(searchText)
);
}); });
} }
@@ -426,8 +436,9 @@
<div class="account-dropdown"></div> <div class="account-dropdown"></div>
</div> </div>
</td> </td>
<td><input type="number" min="0"></td> <td><input type="number" min="0" style="text-align:right"></td>
<td><input type="number" min="0"></td> <td><input type="number" min="0" style="text-align:right"></td>
<td><input type="text" class="line-memo-input" placeholder="行摘要(任意)"></td>
<td><button onclick="removeRow(this)">削除</button></td> <td><button onclick="removeRow(this)">削除</button></td>
`; `;
@@ -453,6 +464,9 @@
const creditVal = Number(line.credit) || 0; const creditVal = Number(line.credit) || 0;
numberInputs[0].value = debitVal > 0 ? String(debitVal) : ""; numberInputs[0].value = debitVal > 0 ? String(debitVal) : "";
numberInputs[1].value = creditVal > 0 ? String(creditVal) : ""; numberInputs[1].value = creditVal > 0 ? String(creditVal) : "";
// 摘要(行)を設定
const memoInput = tr.querySelector(".line-memo-input");
if (memoInput) memoInput.value = line.line_description || "";
} }
// 入力時に合計を自動更新 // 入力時に合計を自動更新
@@ -540,8 +554,15 @@
const numberInputs = tr.querySelectorAll("input[type='number']"); const numberInputs = tr.querySelectorAll("input[type='number']");
const debit = Number(numberInputs[0]?.value || 0); const debit = Number(numberInputs[0]?.value || 0);
const credit = Number(numberInputs[1]?.value || 0); const credit = Number(numberInputs[1]?.value || 0);
const lineDesc =
tr.querySelector(".line-memo-input")?.value.trim() || undefined;
if (debit === 0 && credit === 0) return; if (debit === 0 && credit === 0) return;
lines.push({ account_id: accountId, debit, credit }); lines.push({
account_id: accountId,
debit,
credit,
...(lineDesc ? { line_description: lineDesc } : {}),
});
d += debit; d += debit;
c += credit; c += credit;
}); });

View File

@@ -342,14 +342,20 @@
<div class="row"> <div class="row">
<label>摘要</label> <label>摘要</label>
<input <div
type="text" class="account-search-container"
id="description" style="flex: 3; min-width: 600px; max-width: 900px; position: relative"
list="descriptionHistory" >
style="flex: 3; min-width: 600px" <input
placeholder="例:売上入金/経費支払 など" type="text"
/> id="description"
<datalist id="descriptionHistory"></datalist> class="account-search-input"
style="width: 100%"
placeholder="例:売上入金/経費支払 など"
autocomplete="off"
/>
<div class="account-dropdown" id="descriptionDropdown"></div>
</div>
</div> </div>
<div class="row"> <div class="row">
@@ -406,6 +412,14 @@
<option value="8r">8%(軽減)</option> <option value="8r">8%(軽減)</option>
<option value="5">5%</option> <option value="5">5%</option>
</select> </select>
<label style="margin-left: 16px; white-space: nowrap"
>小数点以下端数処理</label
>
<select id="entryRoundingMode" style="width: 120px" onchange="calcTax()">
<option value="floor" selected>切り捨て</option>
<option value="round">四捨五入</option>
<option value="ceil">切り上げ</option>
</select>
</div> </div>
<div class="row"> <div class="row">
@@ -453,6 +467,257 @@
<hr style="margin: 40px 0; border: none; border-top: 2px solid #ddd" /> <hr style="margin: 40px 0; border: none; border-top: 2px solid #ddd" />
<!-- ========================================
仕訳詳細入力(多行対応)
======================================== -->
<details
style="margin: 16px 0"
open
id="detailSection"
ontoggle="
document.getElementById('detailSummaryText').textContent = this.open
? '▼ 仕訳詳細入力(複数行・借貸直接入力)'
: '▶ 仕訳詳細入力(複数行・借貸直接入力)'
"
>
<summary
style="
cursor: pointer;
font-size: 18px;
font-weight: bold;
padding: 12px;
background: #eaf0fb;
border: 1px solid #c8d8f0;
border-radius: 4px;
user-select: none;
list-style: none;
"
>
<span id="detailSummaryText"
>▼ 仕訳詳細入力(複数行・借貸直接入力)</span
>
</summary>
<div
style="
background: #f9fbff;
border: 1px solid #c8d8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 20px;
margin-bottom: 16px;
"
>
<!-- 仕訳日 -->
<div class="row" style="margin-bottom: 10px">
<label style="min-width: 80px">仕訳日</label>
<input
type="date"
id="detailEntryDate"
min="1900-01-01"
max="2099-12-31"
style="padding: 6px"
/>
</div>
<!-- 摘要 -->
<div class="row" style="margin-bottom: 10px">
<label style="min-width: 80px">摘要</label>
<div
class="account-search-container"
style="
flex: 3;
min-width: 600px;
max-width: 900px;
position: relative;
"
>
<input
type="text"
id="detailDescription"
class="account-search-input"
style="width: 100%"
placeholder="例:売上入金/経費支払 など"
autocomplete="off"
/>
<div class="account-dropdown" id="detailDescriptionDropdown"></div>
</div>
</div>
<!-- 明細テーブル -->
<table
id="detailLinesTable"
style="border-collapse: collapse; width: 100%; margin-top: 8px"
>
<thead>
<tr style="background: #eaf0fb">
<th
style="
border: 1px solid #ccc;
padding: 7px 10px;
width: 24%;
text-align: center;
"
>
科目
</th>
<th
style="
border: 1px solid #ccc;
padding: 7px 10px;
width: 22%;
text-align: center;
"
>
借方金額
</th>
<th
style="
border: 1px solid #ccc;
padding: 7px 10px;
width: 22%;
text-align: center;
"
>
貸方金額
</th>
<th
style="
border: 1px solid #ccc;
padding: 7px 10px;
width: 14%;
text-align: center;
"
>
摘要(行)
</th>
<th
style="
border: 1px solid #ccc;
padding: 7px 4px;
width: 6%;
text-align: center;
"
></th>
</tr>
</thead>
<tbody id="detailLinesTbody"></tbody>
<tfoot>
<tr style="background: #f5f5f5; font-weight: bold">
<td
style="
border: 1px solid #ccc;
padding: 7px 10px;
text-align: center;
"
>
合計
</td>
<td
style="
border: 1px solid #ccc;
padding: 7px 10px;
text-align: right;
"
id="detailDebitTotal"
>
0
</td>
<td
style="
border: 1px solid #ccc;
padding: 7px 10px;
text-align: right;
"
id="detailCreditTotal"
>
0
</td>
<td
colspan="2"
style="
border: 1px solid #ccc;
padding: 7px 10px;
text-align: center;
"
>
<span id="detailBalanceStatus" style="font-size: 13px"></span>
</td>
</tr>
</tfoot>
</table>
<!-- ボタン類 -->
<div
style="
margin-top: 12px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
"
>
<button
onclick="detailAddLine()"
style="
padding: 7px 18px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
"
>
行追加
</button>
<button
onclick="submitDetailJournal()"
style="
padding: 8px 28px;
font-size: 15px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
"
>
登録
</button>
<label
style="
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
margin-left: 8px;
"
>
<input type="checkbox" id="detailKeepAfterSubmit" />
<span style="font-size: 14px">登録後に明細を保持する</span>
</label>
<button
onclick="detailClearAll()"
style="
padding: 7px 14px;
background: #fff;
color: #dc3545;
border: 1px solid #dc3545;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
margin-left: auto;
"
>
🗑 クリア
</button>
</div>
</div>
</details>
<hr style="margin: 40px 0; border: none; border-top: 2px solid #ddd" />
<!-- 月次锁定管理(折叠式) --> <!-- 月次锁定管理(折叠式) -->
<details style="margin: 16px 0"> <details style="margin: 16px 0">
<summary <summary
@@ -722,6 +987,8 @@
.value.replace(/,/g, ""); .value.replace(/,/g, "");
const amount = Number(amountStr) || 0; const amount = Number(amountStr) || 0;
const rateVal = document.getElementById("entryTaxRate").value; const rateVal = document.getElementById("entryTaxRate").value;
const roundingMode =
document.getElementById("entryRoundingMode")?.value || "floor";
if (amount === 0 || rateVal === "0") { if (amount === 0 || rateVal === "0") {
document.getElementById("entryTaxAmount").value = ""; document.getElementById("entryTaxAmount").value = "";
@@ -730,8 +997,17 @@
// 税率を数値に変換8r = 軽減8% // 税率を数値に変換8r = 軽減8%
const rate = rateVal === "8r" ? 8 : Number(rateVal); const rate = rateVal === "8r" ? 8 : Number(rateVal);
// 税込金額から消費税額を計算(切上げ) const rawTax = (amount * rate) / (100 + rate);
const taxAmount = Math.ceil((amount * rate) / (100 + rate));
// 端数処理
const roundFn =
roundingMode === "ceil"
? Math.ceil
: roundingMode === "round"
? Math.round
: Math.floor;
const taxAmount = roundFn(rawTax);
document.getElementById("entryTaxAmount").value = document.getElementById("entryTaxAmount").value =
taxAmount.toLocaleString(); taxAmount.toLocaleString();
} }
@@ -820,7 +1096,11 @@
const filtered = accounts.filter((acc) => { const filtered = accounts.filter((acc) => {
const code = acc.account_code.toLowerCase(); const code = acc.account_code.toLowerCase();
const name = acc.account_name.toLowerCase(); const name = acc.account_name.toLowerCase();
return code.includes(searchText) || name.includes(searchText); return (
code.includes(searchText) ||
name.includes(searchText) ||
`${code} ${name}`.includes(searchText)
);
}); });
renderDropdown(filtered, true); renderDropdown(filtered, true);
@@ -895,6 +1175,17 @@
// 摘要履歴を読み込み // 摘要履歴を読み込み
loadDescriptionHistory(); loadDescriptionHistory();
// 摘要検索ドロップダウンをセットアップ
setupDescriptionSearch();
// 詳細入力セクション初期化
setupDetailDescriptionSearch();
detailClearAll();
// 今日の日付をデフォルト設定
document.getElementById("detailEntryDate").value = new Date()
.toISOString()
.slice(0, 10);
console.log("✓ init() 完成"); console.log("✓ init() 完成");
} }
@@ -1006,7 +1297,11 @@
const filtered = accounts.filter((acc) => { const filtered = accounts.filter((acc) => {
const code = acc.account_code.toLowerCase(); const code = acc.account_code.toLowerCase();
const name = acc.account_name.toLowerCase(); const name = acc.account_name.toLowerCase();
return code.includes(searchText) || name.includes(searchText); return (
code.includes(searchText) ||
name.includes(searchText) ||
`${code} ${name}`.includes(searchText)
);
}); });
renderSearchDropdown(filtered, true); renderSearchDropdown(filtered, true);
@@ -1043,17 +1338,7 @@
// 摘要履歴管理 // 摘要履歴管理
// ----------------------------- // -----------------------------
function loadDescriptionHistory() { function loadDescriptionHistory() {
const history = JSON.parse( // 旧datalist方式の互換用に残す現在はカスタムドロップダウンで管理
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) { function saveDescriptionToHistory(description) {
@@ -1067,22 +1352,564 @@
history = history.filter((d) => d !== description); history = history.filter((d) => d !== description);
history.unshift(description); history.unshift(description);
// 最大100件まで保存 // 最大500件まで保存
if (history.length > 100) { if (history.length > 500) {
history = history.slice(0, 100); history = history.slice(0, 500);
} }
localStorage.setItem("descriptionHistory", JSON.stringify(history)); localStorage.setItem("descriptionHistory", JSON.stringify(history));
loadDescriptionHistory(); }
// 摘要検索ドロップダウン
function setupDescriptionSearch() {
const input = document.getElementById("description");
const dropdown = document.getElementById("descriptionDropdown");
if (!input || !dropdown) return;
let debounceTimer = null;
function makeSectionLabel(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 addOption(text, onSelect) {
const opt = document.createElement("div");
opt.className = "account-option";
opt.style.cssText = "font-size:14px;";
opt.textContent = text;
opt.addEventListener("mousedown", (ev) => {
ev.preventDefault();
input.value = text;
dropdown.classList.remove("active");
if (onSelect) onSelect();
});
dropdown.appendChild(opt);
}
function renderDropdown(historyItems, apiItems) {
dropdown.innerHTML = "";
// 充てる候補なし
if (historyItems.length === 0 && apiItems.length === 0) {
dropdown.classList.remove("active");
return;
}
if (historyItems.length > 0) {
dropdown.appendChild(makeSectionLabel("🗒️ 入力履歴"));
historyItems.slice(0, 20).forEach((t) => addOption(t));
}
if (apiItems.length > 0) {
const sep = document.createElement("div");
sep.style.cssText = "border-top:1px solid #e8e8e8;margin:2px 0;";
if (historyItems.length > 0) dropdown.appendChild(sep);
dropdown.appendChild(makeSectionLabel("📝 過去の仕訳から"));
apiItems.slice(0, 20).forEach((t) => addOption(t));
}
dropdown.classList.add("active");
}
async function doSearch(text) {
const history = JSON.parse(
localStorage.getItem("descriptionHistory") || "[]",
);
const historyFiltered = text
? history.filter((d) =>
d.toLowerCase().includes(text.toLowerCase()),
)
: history.slice(0, 30);
// APIから過去仕訳の摘要を検索
let apiItems = [];
try {
const qs = new URLSearchParams();
if (text) qs.append("keyword", text);
qs.append("limit", "50");
const res = await fetch(`${API}/journal-entries?${qs.toString()}`);
if (res.ok) {
const data = await res.json();
const entries = data.items ?? data;
const historySet = new Set(historyFiltered);
const seen = new Set();
apiItems = entries
.map((e) => e.description)
.filter(
(d) => d && !historySet.has(d) && !seen.has(d) && seen.add(d),
)
.slice(0, 20);
}
} catch (_) {
// APIエラーは無視
}
renderDropdown(historyFiltered, apiItems);
}
input.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(input.value.trim()), 250);
});
input.addEventListener("focus", () => {
doSearch(input.value.trim());
});
input.addEventListener("click", () => {
if (!dropdown.classList.contains("active")) {
doSearch(input.value.trim());
}
});
document.addEventListener("click", (e) => {
if (!input.parentElement.contains(e.target)) {
dropdown.classList.remove("active");
}
});
// Enterキー・Tabキーでドロップダウンを閉じる
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === "Tab") {
dropdown.classList.remove("active");
}
});
} }
// ----------------------------- // -----------------------------
// (setupTaxSelectors, addRow, handleTax, updateTotals は新UIでは不要) // (setupTaxSelectors, addRow, handleTax, updateTotals は新UIでは不要)
// ----------------------------- // -----------------------------
// ----------------------------- // ============================================================
// 登録(簡易入力版 // 仕訳詳細入力(多行対応
// ----------------------------- // ============================================================
let detailRowSeq = 0;
function detailAddLine() {
const tbody = document.getElementById("detailLinesTbody");
const rowId = ++detailRowSeq;
const tr = document.createElement("tr");
tr.id = `detailRow_${rowId}`;
tr.innerHTML = `
<td style="border:1px solid #ccc;padding:4px 6px;">
<div class="account-search-container" style="position:relative;">
<input type="text" class="account-search-input detail-account-input"
data-account-id="" autocomplete="off"
placeholder="科目を検索" style="width:100%;font-size:13px;" />
<div class="account-dropdown"></div>
</div>
</td>
<td style="border:1px solid #ccc;padding:4px 6px;">
<input type="text" class="detail-debit right" inputmode="numeric"
style="width:100%;text-align:right;padding:5px;box-sizing:border-box;ime-mode:inactive;"
placeholder="0" oninput="formatNumberInput(this);detailUpdateTotals();" />
</td>
<td style="border:1px solid #ccc;padding:4px 6px;">
<input type="text" class="detail-credit right" inputmode="numeric"
style="width:100%;text-align:right;padding:5px;box-sizing:border-box;ime-mode:inactive;"
placeholder="0" oninput="formatNumberInput(this);detailUpdateTotals();" />
</td>
<td style="border:1px solid #ccc;padding:4px 6px;">
<input type="text" class="detail-line-memo"
style="width:100%;padding:5px;box-sizing:border-box;font-size:12px;"
placeholder="行摘要(任意)" />
</td>
<td style="border:1px solid #ccc;padding:4px 2px;text-align:center;">
<button onclick="detailRemoveLine(${rowId})"
style="padding:4px 10px;background:#dc3545;color:white;border:none;border-radius:4px;cursor:pointer;font-size:12px;">削除</button>
</td>
`;
tbody.appendChild(tr);
// 科目検索セットアップ
const input = tr.querySelector(".detail-account-input");
setupDetailAccountSearch(input);
detailUpdateTotals();
return tr;
}
function detailRemoveLine(rowId) {
const tr = document.getElementById(`detailRow_${rowId}`);
if (tr) tr.remove();
detailUpdateTotals();
}
function detailGetNumber(input) {
return Number((input.value || "0").replace(/,/g, "")) || 0;
}
function detailUpdateTotals() {
const tbody = document.getElementById("detailLinesTbody");
let debitSum = 0,
creditSum = 0;
tbody.querySelectorAll("tr").forEach((tr) => {
debitSum += detailGetNumber(tr.querySelector(".detail-debit"));
creditSum += detailGetNumber(tr.querySelector(".detail-credit"));
});
const fmt = (n) => n.toLocaleString();
document.getElementById("detailDebitTotal").textContent = fmt(debitSum);
document.getElementById("detailCreditTotal").textContent =
fmt(creditSum);
const el = document.getElementById("detailBalanceStatus");
if (debitSum === 0 && creditSum === 0) {
el.textContent = "";
} else if (debitSum === creditSum) {
el.textContent = "✅ 貸借一致";
el.style.color = "#28a745";
} else {
const diff = Math.abs(debitSum - creditSum);
el.textContent = `⚠ 差額 ${diff.toLocaleString()}`;
el.style.color = "#dc3545";
}
}
function setupDetailAccountSearch(inputElement) {
const container = inputElement.closest(".account-search-container");
const dropdown = container.querySelector(".account-dropdown");
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 appendOpt(acc) {
const opt = document.createElement("div");
opt.className = "account-option";
opt.innerHTML = `<span class="account-option-strong">${acc.account_code}</span><span class="account-option-light">${acc.account_name}</span>`;
opt.addEventListener("mousedown", (ev) => {
ev.preventDefault();
inputElement.value = `${acc.account_code} ${acc.account_name}`;
inputElement.dataset.accountId = acc.account_id;
dropdown.classList.remove("active");
saveAccountToRecent(acc.account_id);
});
dropdown.appendChild(opt);
}
function renderDropdown(list, isFiltered) {
dropdown.innerHTML = "";
if (list.length === 0) {
dropdown.innerHTML =
'<div style="padding:8px 12px;color:#999;">該当する科目がありません</div>';
dropdown.classList.add("active");
return;
}
if (isFiltered) {
list
.slice()
.sort((a, b) => a.account_code.localeCompare(b.account_code))
.slice(0, 50)
.forEach(appendOpt);
} else {
const recentIds = JSON.parse(
localStorage.getItem("recentAccounts") || "[]",
);
const recentItems = recentIds
.map((id) => list.find((a) => a.account_id === id))
.filter(Boolean);
const recentSet = new Set(recentIds);
const otherItems = list
.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 q = e.target.value.toLowerCase().trim();
inputElement.dataset.accountId = "";
const filtered = q
? accounts.filter(
(a) =>
a.account_code.toLowerCase().includes(q) ||
a.account_name.toLowerCase().includes(q) ||
`${a.account_code.toLowerCase()} ${a.account_name.toLowerCase()}`.includes(
q,
),
)
: accounts;
renderDropdown(filtered, !!q);
});
inputElement.addEventListener("focus", () => {
const q = inputElement.value.toLowerCase().trim();
renderDropdown(
q
? accounts.filter(
(a) =>
a.account_code.toLowerCase().includes(q) ||
a.account_name.toLowerCase().includes(q) ||
`${a.account_code.toLowerCase()} ${a.account_name.toLowerCase()}`.includes(
q,
),
)
: accounts,
!!q,
);
});
inputElement.addEventListener("click", () => {
if (!dropdown.classList.contains("active")) {
const q = inputElement.value.toLowerCase().trim();
renderDropdown(
q
? accounts.filter(
(a) =>
a.account_code.toLowerCase().includes(q) ||
a.account_name.toLowerCase().includes(q) ||
`${a.account_code.toLowerCase()} ${a.account_name.toLowerCase()}`.includes(
q,
),
)
: accounts,
!!q,
);
}
});
document.addEventListener("click", (e) => {
if (!container.contains(e.target))
dropdown.classList.remove("active");
});
}
// 詳細入力の摘要オートコンプリート
function setupDetailDescriptionSearch() {
const input = document.getElementById("detailDescription");
const dropdown = document.getElementById("detailDescriptionDropdown");
if (!input || !dropdown) return;
let timer = null;
function makeSectionLabel(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 addOpt(text) {
const opt = document.createElement("div");
opt.className = "account-option";
opt.style.fontSize = "14px";
opt.textContent = text;
opt.addEventListener("mousedown", (ev) => {
ev.preventDefault();
input.value = text;
dropdown.classList.remove("active");
});
dropdown.appendChild(opt);
}
async function doSearch(text) {
const history = JSON.parse(
localStorage.getItem("descriptionHistory") || "[]",
);
const histFiltered = text
? history.filter((d) =>
d.toLowerCase().includes(text.toLowerCase()),
)
: history.slice(0, 30);
let apiItems = [];
try {
const qs = new URLSearchParams();
if (text) qs.append("keyword", text);
qs.append("limit", "50");
const res = await fetch(`${API}/journal-entries?${qs.toString()}`);
if (res.ok) {
const data = await res.json();
const entries = data.items ?? data;
const hSet = new Set(histFiltered);
const seen = new Set();
apiItems = entries
.map((e) => e.description)
.filter((d) => d && !hSet.has(d) && !seen.has(d) && seen.add(d))
.slice(0, 20);
}
} catch (_) {}
dropdown.innerHTML = "";
if (histFiltered.length === 0 && apiItems.length === 0) {
dropdown.classList.remove("active");
return;
}
if (histFiltered.length > 0) {
dropdown.appendChild(makeSectionLabel("🗒️ 入力履歴"));
histFiltered.slice(0, 20).forEach(addOpt);
}
if (apiItems.length > 0) {
if (histFiltered.length > 0) {
const s = document.createElement("div");
s.style.cssText = "border-top:1px solid #e8e8e8;margin:2px 0;";
dropdown.appendChild(s);
}
dropdown.appendChild(makeSectionLabel("📝 過去の仕訳から"));
apiItems.forEach(addOpt);
}
dropdown.classList.add("active");
}
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => doSearch(input.value.trim()), 250);
});
input.addEventListener("focus", () => doSearch(input.value.trim()));
input.addEventListener("click", () => {
if (!dropdown.classList.contains("active"))
doSearch(input.value.trim());
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === "Tab")
dropdown.classList.remove("active");
});
document.addEventListener("click", (e) => {
if (!input.parentElement.contains(e.target))
dropdown.classList.remove("active");
});
}
async function submitDetailJournal() {
const dateInput = document.getElementById("detailEntryDate");
const entryDate = dateInput.value;
const description = document
.getElementById("detailDescription")
.value.trim();
if (dateInput.validity.badInput) {
alert(
"日付が正しくありません。存在しない日付が入力されています11月31日は無効です",
);
return;
}
if (!entryDate) {
alert("仕訳日を入力してください");
return;
}
if (!description) {
alert("摘要を入力してください");
return;
}
const lines = [];
let debitSum = 0,
creditSum = 0;
let valid = true;
document.querySelectorAll("#detailLinesTbody tr").forEach((tr) => {
const accountInput = tr.querySelector(".detail-account-input");
const accountId = Number(accountInput?.dataset.accountId || 0);
const debit = detailGetNumber(tr.querySelector(".detail-debit"));
const credit = detailGetNumber(tr.querySelector(".detail-credit"));
if (debit === 0 && credit === 0) return; // 空行スキップ
if (!accountId) {
alert("科目が選択されていない行があります");
valid = false;
return;
}
if (debit > 0 && credit > 0) {
alert("1行に借方と貸方の両方を入力することはできません");
valid = false;
return;
}
const lineDesc =
tr.querySelector(".detail-line-memo")?.value.trim() || null;
lines.push({
account_id: accountId,
debit,
credit,
line_description: lineDesc || undefined,
});
debitSum += debit;
creditSum += credit;
});
if (!valid) return;
if (lines.length < 2) {
alert("仕訳明細は2行以上必要です");
return;
}
if (debitSum !== creditSum) {
alert(
`貸借が一致していません(差額:${Math.abs(debitSum - creditSum).toLocaleString()} 円)`,
);
return;
}
const payload = {
entry_date: entryDate,
description,
lines,
tax_paid_account_id:
taxPaidAccounts.length > 0 ? taxPaidAccounts[0].account_id : null,
tax_received_account_id:
taxReceivedAccounts.length > 0
? taxReceivedAccounts[0].account_id
: null,
rounding_mode: "floor",
};
try {
const res = await fetch(`${API}/journal-entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "登録失敗");
}
saveDescriptionToHistory(description);
alert("仕訳を登録しました");
const keep = document.getElementById("detailKeepAfterSubmit").checked;
if (!keep) {
detailClearAll();
}
// keep=true の場合は仕訳日・摘要・明細・金額すべてそのまま保持
searchJournals();
} catch (e) {
alert("登録エラー: " + e.message);
}
}
function detailClearAll() {
document.getElementById("detailLinesTbody").innerHTML = "";
document.getElementById("detailDescription").value = "";
document.getElementById("detailEntryDate").value = new Date()
.toISOString()
.slice(0, 10);
detailRowSeq = 0;
detailUpdateTotals();
// デフォルト2行追加
detailAddLine();
detailAddLine();
}
async function submitJournal() { async function submitJournal() {
try { try {
const entryDateInput = document.getElementById("entryDate"); const entryDateInput = document.getElementById("entryDate");
@@ -1216,7 +2043,8 @@
taxReceivedAccounts.length > 0 taxReceivedAccounts.length > 0
? taxReceivedAccounts[0].account_id ? taxReceivedAccounts[0].account_id
: null, : null,
rounding_mode: "floor", rounding_mode:
document.getElementById("entryRoundingMode")?.value || "floor",
}; };
console.log("送信するデータ:", JSON.stringify(payload, null, 2)); console.log("送信するデータ:", JSON.stringify(payload, null, 2));

View File

@@ -44,10 +44,11 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>科目コード</th> <th style="width: 80px">科目コード</th>
<th>科目名</th> <th>科目名</th>
<th>借方</th> <th>借方</th>
<th>貸方</th> <th>貸方</th>
<th>摘要(行)</th>
</tr> </tr>
</thead> </thead>
<tbody id="lines"></tbody> <tbody id="lines"></tbody>
@@ -123,6 +124,7 @@
<td style="text-align:right">${ <td style="text-align:right">${
line.credit > 0 ? line.credit.toLocaleString() : "" line.credit > 0 ? line.credit.toLocaleString() : ""
}</td> }</td>
<td>${line.line_description || ""}</td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
@@ -137,6 +139,7 @@
<td colspan="2" style="text-align:center">合計</td> <td colspan="2" style="text-align:center">合計</td>
<td style="text-align:right">${totalDebit.toLocaleString()}</td> <td style="text-align:right">${totalDebit.toLocaleString()}</td>
<td style="text-align:right">${totalCredit.toLocaleString()}</td> <td style="text-align:right">${totalCredit.toLocaleString()}</td>
<td></td>
`; `;
tbody.appendChild(totalRow); tbody.appendChild(totalRow);
} }