|
|
<!doctype html> |
|
|
<html lang="ja"> |
|
|
<meta charset="utf-8"> |
|
|
<title>かんたん請求書作成</title> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
|
<style> |
|
|
body{font-family:system-ui,-apple-system,"Noto Sans JP",sans-serif;margin:24px} |
|
|
h1{font-size:28px;margin-bottom:12px} |
|
|
.row{display:flex;gap:16px;flex-wrap:wrap} |
|
|
.card{border:1px solid #e5e7eb;border-radius:14px;padding:16px;flex:1;min-width:320px;background:#fff} |
|
|
label{display:block;font-size:12px;color:#374151;margin-top:8px} |
|
|
input,textarea{width:100%;padding:10px;border:1px solid #d1d5db;border-radius:8px} |
|
|
table{width:100%;border-collapse:collapse} |
|
|
th,td{border-bottom:1px solid #eee;padding:8px;text-align:left} |
|
|
.btn{padding:10px 14px;border-radius:8px;border:1px solid #0ea5e9;background:#0ea5e9;color:#fff;cursor:pointer} |
|
|
.btn.secondary{background:#fff;color:#0ea5e9} |
|
|
.muted{color:#6b7280;font-size:12px} |
|
|
.right{ text-align:right } |
|
|
</style> |
|
|
<body> |
|
|
<h1>かんたん請求書作成</h1> |
|
|
<div style="margin-bottom:10px"> |
|
|
API Key <input id="apiKey" style="width:160px" placeholder="dev"> <button class="btn secondary" id="saveKey">保存</button> |
|
|
<span class="muted" style="margin-left:8px">※ /docs の Authorize と同じキーです(既定: dev)</span> |
|
|
</div> |
|
|
|
|
|
<div class="row"> |
|
|
<div class="card" style="max-width:520px"> |
|
|
<h3>顧客</h3> |
|
|
<label>既存顧客を検索</label> |
|
|
<input id="q" placeholder="社名・メール・電話"> |
|
|
<div style="margin-top:6px"><button class="btn secondary" id="refresh">一覧更新</button></div> |
|
|
<div id="custErr" class="muted" style="margin-top:6px;color:#ef4444"></div> |
|
|
<div style="max-height:180px;overflow:auto;margin-top:6px;border:1px solid #eee;border-radius:8px"> |
|
|
<table> |
|
|
<thead><tr><th>ID</th><th>社名</th><th>連絡先</th><th></th></tr></thead> |
|
|
<tbody id="custTb"></tbody> |
|
|
</table> |
|
|
</div> |
|
|
|
|
|
<div class="muted" style="margin-top:12px">新規作成する場合はこちらに入力</div> |
|
|
<label>会社名*</label><input id="c_name"> |
|
|
<label>メール</label><input id="c_email"> |
|
|
<label>電話</label><input id="c_phone"> |
|
|
<label>住所</label><input id="c_addr"> |
|
|
<div class="row"> |
|
|
<div style="flex:1"><label>市区町村</label><input id="c_city"></div> |
|
|
<div style="flex:1"><label>国</label><input id="c_country" placeholder="JP"></div> |
|
|
</div> |
|
|
<div class="muted" style="margin-top:6px">※ 既存顧客をクリックすると上記 <code>customer_id</code> として選択されます。</div> |
|
|
<div id="chosen" class="muted" style="margin-top:6px">選択中の customer_id: <b id="cid">(未選択)</b></div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h3>明細</h3> |
|
|
<table> |
|
|
<thead><tr><th>内容</th><th class="right">数量</th><th class="right">単価</th><th class="right">税率</th><th></th></tr></thead> |
|
|
<tbody id="items"></tbody> |
|
|
</table> |
|
|
<div style="margin-top:6px"><button class="btn secondary" id="add">+ 行を追加</button></div> |
|
|
|
|
|
<div class="row" style="margin-top:16px"> |
|
|
<div class="card" style="min-width:280px"> |
|
|
<h4>請求情報</h4> |
|
|
<label>支払期日</label><input type="date" id="due"> |
|
|
<label>備考</label><textarea id="notes" rows="4" placeholder="いつもお世話になっております…"></textarea> |
|
|
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap"> |
|
|
<button class="btn" id="btnCreate">一括で作成</button> |
|
|
<button class="btn secondary" id="btnAi">AI文面(件名+本文)</button> |
|
|
</div> |
|
|
<div id="msg" class="muted" style="margin-top:8px;color:#ef4444"></div> |
|
|
</div> |
|
|
|
|
|
<div class="card" style="min-width:260px"> |
|
|
<h4>合計</h4> |
|
|
<div>小計 <span id="sub" class="right" style="float:right">0</span></div> |
|
|
<div>税額 <span id="tax" class="right" style="float:right">0</span></div> |
|
|
<div style="font-weight:700">合計 <span id="tot" class="right" style="float:right">0</span></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="muted" style="margin-top:8px"> |
|
|
作成後:PDFダウンロードは <code>GET /invoices/{id}/pdf</code>、メール送信は <code>POST /invoices/{id}/email</code>。 |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const BASE = location.origin; |
|
|
const LSKEY = "mini_invoice_api_key"; |
|
|
|
|
|
const apiKeyEl = document.getElementById('apiKey'); |
|
|
apiKeyEl.value = localStorage.getItem(LSKEY) || "dev"; |
|
|
document.getElementById('saveKey').onclick = () => { |
|
|
localStorage.setItem(LSKEY, apiKeyEl.value.trim()); |
|
|
alert("API Key を保存しました"); |
|
|
loadCustomers(); |
|
|
}; |
|
|
|
|
|
const headers = () => ({ |
|
|
"Content-Type": "application/json", |
|
|
"X-API-Key": (localStorage.getItem(LSKEY) || "dev") |
|
|
}); |
|
|
|
|
|
|
|
|
const fmtDate = (s) => { |
|
|
if (!s) return null; |
|
|
const t = String(s).trim().replace(/\//g,"-"); |
|
|
const d = new Date(t); |
|
|
if (isNaN(d)) return null; |
|
|
return d.toISOString().slice(0,10); |
|
|
}; |
|
|
|
|
|
let selectedCustomerId = null; |
|
|
const cidEl = document.getElementById('cid'); |
|
|
const msgEl = document.getElementById('msg'); |
|
|
|
|
|
function rowItem(desc="", qty=1, price=0, tax=0.1){ |
|
|
const tr = document.createElement('tr'); |
|
|
tr.innerHTML = ` |
|
|
<td><input class="d" value="${desc}"></td> |
|
|
<td class="right"><input class="q" type="number" step="0.01" value="${qty}" style="width:90px"></td> |
|
|
<td class="right"><input class="p" type="number" step="1" value="${price}" style="width:110px"></td> |
|
|
<td class="right"><input class="t" type="number" step="0.01" value="${tax}" style="width:90px"></td> |
|
|
<td><button class="btn secondary del">×</button></td> |
|
|
`; |
|
|
tr.querySelector('.del').onclick = () => { tr.remove(); recalc(); }; |
|
|
return tr; |
|
|
} |
|
|
|
|
|
function recalc(){ |
|
|
const rows = [...document.querySelectorAll('#items tr')]; |
|
|
let sub = 0, tax = 0; |
|
|
rows.forEach(r=>{ |
|
|
const q = parseFloat(r.querySelector('.q').value || 0); |
|
|
const p = parseFloat(r.querySelector('.p').value || 0); |
|
|
const t = parseFloat(r.querySelector('.t').value || 0); |
|
|
const line = q*p; sub += line; tax += line*t; |
|
|
}); |
|
|
document.getElementById('sub').innerText = Math.round(sub).toLocaleString(); |
|
|
document.getElementById('tax').innerText = Math.round(tax).toLocaleString(); |
|
|
document.getElementById('tot').innerText = Math.round(sub+tax).toLocaleString(); |
|
|
} |
|
|
|
|
|
document.getElementById('add').onclick = ()=>{ |
|
|
const tr = rowItem("Webサイト制作一式", 1, 300000, 0.1); |
|
|
document.getElementById('items').append(tr); |
|
|
recalc(); |
|
|
}; |
|
|
document.getElementById('add').click(); |
|
|
|
|
|
async function loadCustomers(){ |
|
|
const q = document.getElementById('q').value.trim(); |
|
|
const url = new URL(BASE + "/customers"); |
|
|
if(q) url.searchParams.set("q", q); |
|
|
const res = await fetch(url, {headers:headers()}); |
|
|
const tb = document.getElementById('custTb'); tb.innerHTML=""; |
|
|
if(!res.ok){ |
|
|
document.getElementById('custErr').innerText = "読み込みエラー: " + (await res.text()); |
|
|
return; |
|
|
} |
|
|
document.getElementById('custErr').innerText = ""; |
|
|
const js = await res.json(); |
|
|
(js.data||[]).forEach(c=>{ |
|
|
const tr = document.createElement('tr'); |
|
|
tr.innerHTML = `<td>${c.id}</td><td>${c.name}</td><td>${[c.email,c.phone].filter(Boolean).join(" / ")}</td><td><button class="btn secondary">選択</button></td>`; |
|
|
tr.querySelector('button').onclick = ()=>{ |
|
|
selectedCustomerId = c.id; cidEl.innerText = c.id; |
|
|
document.getElementById('c_name').value = c.name||""; |
|
|
document.getElementById('c_email').value = c.email||""; |
|
|
document.getElementById('c_phone').value = c.phone||""; |
|
|
document.getElementById('c_addr').value = c.address||""; |
|
|
document.getElementById('c_city').value = c.city||""; |
|
|
document.getElementById('c_country').value = c.country||""; |
|
|
}; |
|
|
tb.append(tr); |
|
|
}); |
|
|
} |
|
|
document.getElementById('refresh').onclick = loadCustomers; |
|
|
loadCustomers(); |
|
|
|
|
|
|
|
|
document.getElementById('btnCreate').onclick = async ()=>{ |
|
|
msgEl.style.color = "#ef4444"; |
|
|
const items = [...document.querySelectorAll('#items tr')].map(r=>({ |
|
|
description: r.querySelector('.d').value, |
|
|
quantity: parseFloat(r.querySelector('.q').value||0), |
|
|
unit_price: parseFloat(r.querySelector('.p').value||0), |
|
|
tax_rate: parseFloat(r.querySelector('.t').value||0), |
|
|
})); |
|
|
const cust = selectedCustomerId ? {id:selectedCustomerId} : { |
|
|
name: document.getElementById('c_name').value||null, |
|
|
email: document.getElementById('c_email').value||null, |
|
|
phone: document.getElementById('c_phone').value||null, |
|
|
address: document.getElementById('c_addr').value||null, |
|
|
city: document.getElementById('c_city').value||null, |
|
|
country: document.getElementById('c_country').value||null, |
|
|
}; |
|
|
const payload = { |
|
|
customer: cust, |
|
|
due_date: fmtDate(document.getElementById('due').value), |
|
|
notes: document.getElementById('notes').value || null, |
|
|
items |
|
|
}; |
|
|
const res = await fetch(BASE + "/wizard/invoice", { |
|
|
method:"POST", headers: headers(), body: JSON.stringify(payload) |
|
|
}); |
|
|
if(!res.ok){ |
|
|
const t = await res.text(); |
|
|
msgEl.innerText = "作成に失敗: " + t; |
|
|
return; |
|
|
} |
|
|
const js = await res.json(); |
|
|
msgEl.style.color = "#16a34a"; |
|
|
msgEl.innerText = `作成成功! invoice_id=${js.invoice_id} / 合計=${js.totals.total}`; |
|
|
}; |
|
|
|
|
|
|
|
|
document.getElementById('btnAi').onclick = async ()=>{ |
|
|
msgEl.style.color = "#ef4444"; |
|
|
const company_name = document.getElementById('c_name').value || "(社名)"; |
|
|
const customer_name = company_name + " 御中"; |
|
|
const items = [...document.querySelectorAll('#items tr')].map(r=>({ |
|
|
description: r.querySelector('.d').value, |
|
|
quantity: parseFloat(r.querySelector('.q').value||0), |
|
|
unit_price: parseFloat(r.querySelector('.p').value||0), |
|
|
tax_rate: parseFloat(r.querySelector('.t').value||0), |
|
|
})); |
|
|
const due = fmtDate(document.getElementById('due').value); |
|
|
const res = await fetch(BASE + "/ai/generate-email", { |
|
|
method:"POST", headers: headers(), |
|
|
body: JSON.stringify({ kind:"invoice", company_name, customer_name, language:"ja", items, due_date: due, tone:"polite" }) |
|
|
}); |
|
|
if(!res.ok){ msgEl.innerText = "AI文面生成に失敗しました: " + await res.text(); return; } |
|
|
const js = await res.json(); |
|
|
alert(js.email); |
|
|
}; |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|