| <!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> |
|
|