File size: 10,315 Bytes
9f4d112 71b1041 05d2672 71b1041 05d2672 71b1041 05d2672 71b1041 05d2672 9f4d112 71b1041 05d2672 71b1041 05d2672 9f4d112 71b1041 05d2672 71b1041 9f4d112 71b1041 05d2672 71b1041 0c586ff 71b1041 9f4d112 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
<!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>
|