Invoice / static /index.html
Corin1998's picture
Update static/index.html
71b1041 verified
<!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")
});
// 日付を ISO に整形
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(); // 初期1行
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}`;
};
// AI 文面
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>