Corin1998 commited on
Commit
05d2672
·
verified ·
1 Parent(s): a5eb452

Update static/app.html

Browse files
Files changed (1) hide show
  1. static/app.html +332 -305
static/app.html CHANGED
@@ -1,331 +1,358 @@
1
  <!doctype html>
2
  <html lang="ja">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Mini Invoice/Estimate SaaS</title>
7
- <script>
8
- const BASE = location.origin;
9
- let API_KEY = localStorage.getItem("X_API_Key") || "dev";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- async function api(path, method="GET", body=null) {
12
- const res = await fetch(`${BASE}${path}`, {
13
- method,
14
- headers: {
15
- "X-API-Key": API_KEY,
16
- "Content-Type": "application/json"
17
- },
18
- body: body ? JSON.stringify(body) : null
19
- });
20
- if (!res.ok) {
21
- const txt = await res.text();
22
- throw new Error(`${res.status} ${txt}`);
23
- }
24
- const ct = res.headers.get("content-type") || "";
25
- return ct.includes("application/json") ? res.json() : res.text();
26
- }
27
 
28
- function saveKey() {
29
- API_KEY = document.querySelector("#api_key").value || "dev";
30
- localStorage.setItem("X_API_Key", API_KEY);
31
- log("X-API-Key を設定: " + API_KEY);
32
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- // ---- Customers ----
35
- async function createCustomer() {
36
- const name = document.querySelector("#c_name").value;
37
- const email = document.querySelector("#c_email").value;
38
- const phone = document.querySelector("#c_phone").value;
39
- const address = document.querySelector("#c_addr").value;
40
- const city = document.querySelector("#c_city").value;
41
- const country = document.querySelector("#c_country").value;
42
- const r = await api("/customers", "POST", {name, email, phone, address, city, country});
43
- document.querySelector("#customer_id").value = r.id;
44
- log("顧客作成 OK: id=" + r.id);
45
- await searchCustomers(); // 反映
46
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- async function searchCustomers() {
49
- const q = document.querySelector("#c_query").value;
50
- const url = q ? `/customers?q=${encodeURIComponent(q)}` : "/customers";
51
- const r = await api(url, "GET");
52
- const tbody = document.querySelector("#customers_tbody");
53
- tbody.innerHTML = "";
54
- for (const c of r.data || []) {
55
- const tr = document.createElement("tr");
56
- tr.innerHTML = `
57
- <td>${c.id}</td>
58
- <td>${c.name || ""}</td>
59
- <td>${c.email || ""}</td>
60
- <td>${c.phone || ""}</td>
61
- <td><button data-id="${c.id}">選択</button></td>`;
62
- tr.querySelector("button").onclick = () => {
63
- document.querySelector("#customer_id").value = c.id;
64
- log("顧客を選択: id=" + c.id);
65
- };
66
- tbody.appendChild(tr);
67
- }
68
- }
 
 
 
69
 
70
- // ---- Quotes ----
71
- async function createQuote() {
72
- const customer_id = +document.querySelector("#customer_id").value;
73
- const valid_until = document.querySelector("#q_valid").value || null;
74
- const notes = document.querySelector("#q_notes").value || null;
75
- const q = await api("/quotes", "POST", {customer_id, valid_until, notes});
76
- document.querySelector("#quote_id").value = q.id;
77
- log("見積作成 OK: id=" + q.id);
78
- }
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- async function addQuoteItem() {
81
- const id = +document.querySelector("#quote_id").value;
82
- const description = document.querySelector("#qit_desc").value;
83
- const quantity = parseFloat(document.querySelector("#qit_qty").value || "1");
84
- const unit_price = parseFloat(document.querySelector("#qit_price").value || "0");
85
- const tax_rate = parseFloat(document.querySelector("#qit_tax").value || "0");
86
- await api(`/quotes/${id}/items`, "POST", {description, quantity, unit_price, tax_rate});
87
- log("見積 明細追加 OK");
88
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- // ---- Invoices ----
91
- async function createInvoice() {
92
- const customer_id = +document.querySelector("#customer_id").value;
93
- const due_date = document.querySelector("#i_due").value || null;
94
- const notes = document.querySelector("#i_notes").value || null;
95
- const quote_id = document.querySelector("#quote_to_copy").value ? +document.querySelector("#quote_to_copy").value : null;
96
- const payload = {customer_id, due_date, notes};
97
- if (quote_id) payload.quote_id = quote_id; // 見積→請求 変換
98
- const r = await api("/invoices", "POST", payload);
99
- document.querySelector("#invoice_id").value = r.id;
100
- log("請求書作成 OK: id=" + r.id + (quote_id ? "(見積コピーあり)" : ""));
101
- }
102
 
103
- async function addInvoiceItem() {
104
- const id = +document.querySelector("#invoice_id").value;
105
- const description = document.querySelector("#it_desc").value;
106
- const quantity = parseFloat(document.querySelector("#it_qty").value || "1");
107
- const unit_price = parseFloat(document.querySelector("#it_price").value || "0");
108
- const tax_rate = parseFloat(document.querySelector("#it_tax").value || "0");
109
- await api(`/invoices/${id}/items`, "POST", {description, quantity, unit_price, tax_rate});
110
- log("請求 明細追加 OK");
111
- }
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- async function viewInvoice() {
114
- const id = +document.querySelector("#invoice_id").value;
115
- const r = await api(`/invoices/${id}`, "GET");
116
- document.querySelector("#totals").textContent =
117
- `小計: ${r.totals.subtotal} / 税額: ${r.totals.tax} / 合計: ${r.totals.total}`;
118
- log("請求書取得 OK");
119
- return r;
120
  }
 
 
 
 
121
 
122
- async function markPaid() {
123
- const id = +document.querySelector("#invoice_id").value;
124
- const paid_amount = parseFloat(document.querySelector("#paid_amount").value || "0");
125
- const payment_method = document.querySelector("#paid_method").value || "bank_transfer";
126
- const r = await api(`/invoices/${id}/pay`, "POST", {paid_amount, payment_method});
127
- log("入金登録 OK: " + JSON.stringify(r.invoice));
128
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
- function downloadPDF() {
131
- const id = +document.querySelector("#invoice_id").value;
132
- const url = `${BASE}/invoices/${id}/pdf`;
133
- fetch(url, { headers: { "X-API-Key": API_KEY }})
134
- .then(res => {
135
- if (!res.ok) throw new Error("PDF生成に失���");
136
- return res.blob();
137
- })
138
- .then(blob => {
139
- const a = document.createElement("a");
140
- a.href = URL.createObjectURL(blob);
141
- a.download = `invoice_${id}.pdf`;
142
- a.click();
143
- }).catch(e => log("PDF失敗: " + e.message));
144
- }
 
145
 
146
- async function sendEmail() {
147
- const id = +document.querySelector("#invoice_id").value;
148
- const to = document.querySelector("#m_to").value;
149
- const subject = document.querySelector("#m_subject").value;
150
- const body = document.querySelector("#m_body").value;
151
- const r = await api(`/invoices/${id}/email`, "POST", {to, subject, body, attach_pdf: true});
152
- log("メール送信: " + (r.ok ? "OK" : "NG") + " / " + r.detail);
153
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
- // ---- AI 文面生成(/ai/generate-email)----
156
- async function generateAIEmail() {
157
- const id = +document.querySelector("#invoice_id").value;
158
- if (!id) {
159
- log("先に請求書を作成し、invoice_id を入力してください");
160
- return;
161
- }
162
- const detail = await viewInvoice(); // invoice, items, totals を取得
163
- const invoice = detail.invoice;
164
- const items = (detail.items || []).map(it => ({
165
- description: it.description,
166
- quantity: it.quantity,
167
- unit_price: it.unit_price,
168
- tax_rate: it.tax_rate
169
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- // 顧客名を取得(請求先の見栄え強化)
172
- let customerName = `Customer #${invoice.customer_id}`;
173
- try {
174
- const custList = await api(`/customers?q=${invoice.customer_id}`, "GET");
175
- const hit = (custList.data || []).find(c => c.id === invoice.customer_id);
176
- if (hit && hit.name) customerName = hit.name + " 御中";
177
- } catch {}
 
 
 
 
 
 
 
 
178
 
179
- const payload = {
180
  kind: "invoice",
181
- company_name: "HitC Inc.",
182
- customer_name: customerName,
183
  language: "ja",
 
184
  tone: "polite",
185
- due_date: invoice.due_date || null,
186
- notes: invoice.notes || null,
187
- items
 
 
 
 
188
  };
189
-
190
- try {
191
- const res = await api("/ai/generate-email", "POST", payload);
192
- // モデルは「件名と本文」を返すよう実装済み
193
- // ヒューリスティックで件名/本文に分割
194
- const txt = res.email || "";
195
- const m = txt.match(/^(?:件名|Subject)[::]\s*(.+)\n([\s\S]*)$/m);
196
- if (m) {
197
- document.querySelector("#m_subject").value = m[1].trim();
198
- document.querySelector("#m_body").value = m[2].trim();
199
- } else {
200
- document.querySelector("#m_subject").value = "ご請求書送付の件";
201
- document.querySelector("#m_body").value = txt;
202
- }
203
- log("AI 文面生成 OK");
204
- } catch (e) {
205
- log("AI 文面生成に失敗: " + e.message);
206
  }
207
- }
208
-
209
- function log(msg) {
210
- const el = document.querySelector("#log");
211
- el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + el.textContent;
212
- }
213
-
214
- window.addEventListener("load", () => {
215
- document.querySelector("#api_key").value = API_KEY;
216
- searchCustomers().catch(()=>{});
217
- });
218
- </script>
219
- <style>
220
- body { font-family: system-ui, sans-serif; margin: 16px; }
221
- fieldset { border: 1px solid #ddd; padding: 12px; margin-bottom: 12px; }
222
- legend { padding: 0 6px; }
223
- label { display:block; margin-top:6px; }
224
- input, textarea { width: 100%; padding: 6px; }
225
- button { margin-top: 8px; padding: 6px 10px; }
226
- table { width:100%; border-collapse: collapse; margin-top: 6px; }
227
- th, td { border:1px solid #eee; padding:6px; }
228
- th { background:#fafafa; }
229
- #log { white-space: pre-wrap; border:1px solid #eee; padding:8px; min-height:80px; }
230
- .row { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
231
- @media (max-width: 800px){ .row { grid-template-columns: 1fr; } }
232
- </style>
233
- </head>
234
- <body>
235
- <h1>Mini Invoice/Estimate SaaS</h1>
236
-
237
- <fieldset>
238
- <legend>設定</legend>
239
- <label> X-API-Key <input id="api_key" placeholder="dev"/></label>
240
- <button onclick="saveKey()">保存</button>
241
- <div>UI: <code>/app</code> / API例: <code>/invoices</code> / AI: <code>/ai/generate-email</code></div>
242
- </fieldset>
243
-
244
- <div class="row">
245
- <fieldset>
246
- <legend>顧客(作成)</legend>
247
- <label>会社名 <input id="c_name" /></label>
248
- <label>メール <input id="c_email" /></label>
249
- <label>電話 <input id="c_phone" /></label>
250
- <label>住所 <input id="c_addr" /></label>
251
- <label>市区町村 <input id="c_city" /></label>
252
- <label>国 <input id="c_country" value="JP" /></label>
253
- <button onclick="createCustomer()">顧客を作成</button>
254
- <label>customer_id <input id="customer_id" placeholder="自動セット"/></label>
255
- </fieldset>
256
-
257
- <fieldset>
258
- <legend>顧客(一覧/検索)</legend>
259
- <label>キーワード <input id="c_query" placeholder="社名/メール/電話"/></label>
260
- <button onclick="searchCustomers()">検索</button>
261
- <table>
262
- <thead><tr><th>ID</th><th>名称</th><th>メール</th><th>電話</th><th>操作</th></tr></thead>
263
- <tbody id="customers_tbody"></tbody>
264
- </table>
265
- </fieldset>
266
- </div>
267
-
268
- <div class="row">
269
- <fieldset>
270
- <legend>見積</legend>
271
- <label>有効期限(yyyy-mm-dd) <input id="q_valid" /></label>
272
- <label>備考 <input id="q_notes" /></label>
273
- <button onclick="createQuote()">見積を作成</button>
274
- <label>quote_id <input id="quote_id" placeholder="自動セット"/></label>
275
-
276
- <h4>見積 明細</h4>
277
- <label>内容 <input id="qit_desc" /></label>
278
- <label>数量 <input id="qit_qty" value="1" /></label>
279
- <label>単価 <input id="qit_price" value="0" /></label>
280
- <label>税率(例 0.1) <input id="qit_tax" value="0.1" /></label>
281
- <button onclick="addQuoteItem()">見積 明細を追加</button>
282
- </fieldset>
283
-
284
- <fieldset>
285
- <legend>請求書</legend>
286
- <label>支払期日(yyyy-mm-dd) <input id="i_due" /></label>
287
- <label>備考 <input id="i_notes" /></label>
288
- <label>見積IDからコピー(任意) <input id="quote_to_copy" placeholder="例: 1"/></label>
289
- <button onclick="createInvoice()">請求書を作成</button>
290
- <label>invoice_id <input id="invoice_id" placeholder="自動セット"/></label>
291
-
292
- <h4>請求 明細</h4>
293
- <label>内容 <input id="it_desc" /></label>
294
- <label>数量 <input id="it_qty" value="1" /></label>
295
- <label>単価 <input id="it_price" value="0" /></label>
296
- <label>税率(例 0.1) <input id="it_tax" value="0.1" /></label>
297
- <button onclick="addInvoiceItem()">請求 明細を追加</button>
298
- <button onclick="viewInvoice()">合計を表示</button>
299
- <div id="totals" style="margin-top:6px;color:#0a0"></div>
300
- </fieldset>
301
- </div>
302
-
303
- <div class="row">
304
- <fieldset>
305
- <legend>入金登録</legend>
306
- <label>受取金額 <input id="paid_amount" value="0" /></label>
307
- <label>方法 <input id="paid_method" value="bank_transfer" /></label>
308
- <button onclick="markPaid()">入金反映</button>
309
- </fieldset>
310
-
311
- <fieldset>
312
- <legend>PDF / メール</legend>
313
- <button onclick="downloadPDF()">PDFダウンロード</button>
314
- <hr/>
315
- <label>宛先(To) <input id="m_to" /></label>
316
- <label>件名 <input id="m_subject" value="ご請求書送付の件" /></label>
317
- <label>本文 <textarea id="m_body" rows="8">いつもお世話になっております。ご請求書をお送りします。ご確認をお願いいたします。</textarea></label>
318
- <div style="display:flex; gap:8px; flex-wrap:wrap;">
319
- <button onclick="generateAIEmail()">AIで文面作成</button>
320
- <button onclick="sendEmail()">メール送信(PDF添付)</button>
321
- </div>
322
- <div style="color:#888; margin-top:6px;">※ SMTP が未設定の場合は送信スキップ(詳細はレスポンスに表示)</div>
323
- </fieldset>
324
- </div>
325
 
326
- <fieldset>
327
- <legend>ログ</legend>
328
- <div id="log"></div>
329
- </fieldset>
330
  </body>
331
  </html>
 
1
  <!doctype html>
2
  <html lang="ja">
3
+ <meta charset="utf-8" />
4
+ <title>Mini Invoice/Estimate SaaS</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <style>
7
+ :root { --gap:12px }
8
+ body { font-family: system-ui, sans-serif; margin: 24px; line-height: 1.5; }
9
+ h1 { margin: 0 0 8px }
10
+ .row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); }
11
+ .card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin: 0 0 var(--gap); }
12
+ label { display:block; font-size: 12px; color:#555; margin:8px 0 4px }
13
+ input, textarea, select { width: 100%; padding: 10px; border:1px solid #ccc; border-radius:8px; font: inherit; }
14
+ button { padding: 10px 14px; border: 0; border-radius: 10px; background:#155EEF; color:#fff; cursor:pointer; }
15
+ button.secondary { background:#666; }
16
+ .ok { color: #0a7; font-weight: bold; }
17
+ .err { color: #c33; white-space: pre-wrap; }
18
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
19
+ .grid-3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:var(--gap) }
20
+ .muted { color:#666; font-size:12px }
21
+ .table { width:100%; border-collapse: collapse; }
22
+ .table th,.table td { border:1px solid #eee; padding:6px 8px; text-align:left }
23
+ .flex { display:flex; gap:8px; align-items:center }
24
+ </style>
25
 
26
+ <body>
27
+ <h1>Mini Invoice/Estimate SaaS</h1>
28
+ <p class="muted">同一オリジンの API を呼びます。最初に X-API-Key を設定してください(デフォルト <code>dev</code>)。</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
+ <!-- 設定 -->
31
+ <div class="card">
32
+ <h3>設定</h3>
33
+ <div class="row">
34
+ <div>
35
+ <label>API Base URL</label>
36
+ <input id="base" placeholder="例: (空なら同一オリジン)" />
37
+ </div>
38
+ <div>
39
+ <label>X-API-Key</label>
40
+ <input id="apikey" value="dev" />
41
+ </div>
42
+ </div>
43
+ <div class="flex">
44
+ <button onclick="saveCfg()">保存</button>
45
+ <span id="cfg_msg" class="muted"></span>
46
+ <a href="/docs" target="_blank" class="muted">/docs を開く →</a>
47
+ </div>
48
+ </div>
49
 
50
+ <!-- 顧客 -->
51
+ <div class="card">
52
+ <h3>顧客</h3>
53
+ <div class="grid-3">
54
+ <div><label>会社名</label><input id="c_name" /></div>
55
+ <div><label>メール</label><input id="c_email" /></div>
56
+ <div><label>電話</label><input id="c_phone" /></div>
57
+ </div>
58
+ <div class="row">
59
+ <div><label>住所</label><input id="c_addr" /></div>
60
+ <div class="grid-3">
61
+ <div><label>市区町村</label><input id="c_city" /></div>
62
+ <div><label>国</label><input id="c_country" value="JP" /></div>
63
+ <div style="display:flex;align-items:end"><button onclick="createCustomer()">顧客を作成</button></div>
64
+ </div>
65
+ </div>
66
+ <div class="row">
67
+ <div>
68
+ <div class="flex">
69
+ <label style="margin:0">検索</label>
70
+ <input id="c_q" placeholder="name/email/phone を含む" />
71
+ <button class="secondary" onclick="searchCustomers()">検索</button>
72
+ </div>
73
+ <table class="table" id="c_table"></table>
74
+ </div>
75
+ <div>
76
+ <label>選択中 customer_id</label>
77
+ <input id="customer_id" class="mono" readonly />
78
+ </div>
79
+ </div>
80
+ <div id="c_msg" class="muted"></div>
81
+ </div>
82
 
83
+ <!-- 請求 -->
84
+ <div class="card">
85
+ <h3>請求書</h3>
86
+ <div class="row">
87
+ <div>
88
+ <label>支払期日 (YYYY-MM-DD)</label><input id="inv_due" placeholder="例: 2025-09-30" />
89
+ </div>
90
+ <div>
91
+ <label>備考</label><input id="inv_notes" />
92
+ </div>
93
+ </div>
94
+ <div class="row">
95
+ <div>
96
+ <label>見積IDからコピー (任意)</label><input id="inv_from_quote" class="mono" placeholder="quote_id" />
97
+ </div>
98
+ <div style="display:flex;align-items:end"><button onclick="createInvoice()">請求書を作成</button></div>
99
+ </div>
100
+ <div class="row">
101
+ <div>
102
+ <label>invoice_id</label><input id="invoice_id" class="mono" readonly />
103
+ </div>
104
+ <div id="inv_msg" class="muted"></div>
105
+ </div>
106
+ </div>
107
 
108
+ <!-- 明細 -->
109
+ <div class="card">
110
+ <h3>明細</h3>
111
+ <div class="grid-3">
112
+ <div><label>内容</label><input id="it_desc" /></div>
113
+ <div><label>数量</label><input id="it_qty" type="number" step="0.01" value="1" /></div>
114
+ <div><label>単価</label><input id="it_price" type="number" step="0.01" value="300000" /></div>
115
+ </div>
116
+ <div class="row">
117
+ <div><label>税率(例 0.1)</label><input id="it_tax" type="number" step="0.01" value="0.1" /></div>
118
+ <div style="display:flex;align-items:end"><button onclick="addItem()">請求 明細を追加</button></div>
119
+ </div>
120
+ <div class="row">
121
+ <div style="display:flex;gap:8px;align-items:center">
122
+ <button class="secondary" onclick="fetchTotals()">合計を表示</button>
123
+ <span id="totals" class="mono"></span>
124
+ </div>
125
+ </div>
126
+ <div id="it_msg" class="muted"></div>
127
+ </div>
128
 
129
+ <!-- 送付 -->
130
+ <div class="card">
131
+ <h3>送付(PDF・メール)</h3>
132
+ <div class="row">
133
+ <div class="flex">
134
+ <button class="secondary" onclick="downloadPDF()">PDFダウンロード</button>
135
+ <span id="pdf_msg" class="muted"></span>
136
+ </div>
137
+ </div>
138
+ <div class="row">
139
+ <div><label>宛先</label><input id="m_to" placeholder="you@example.com" /></div>
140
+ <div><label>件名</label><input id="m_subj" /></div>
141
+ </div>
142
+ <div>
143
+ <div class="flex" style="justify-content:space-between">
144
+ <label>本文</label>
145
+ <button class="secondary" onclick="aiEmail()">AIで文面作成</button>
146
+ </div>
147
+ <textarea id="m_body" rows="6"></textarea>
148
+ </div>
149
+ <div class="flex">
150
+ <button onclick="sendMail()">メール送信</button>
151
+ <span class="muted">※ SMTP未設定なら「send skipped」になります</span>
152
+ </div>
153
+ <div id="mail_msg" class="muted"></div>
154
+ </div>
155
 
156
+ <!-- 入金 -->
157
+ <div class="card">
158
+ <h3>入金処理</h3>
159
+ <div class="row">
160
+ <div><label>受領金額</label><input id="p_amount" type="number" step="0.01" /></div>
161
+ <div><label>受領日 (空=今)</label><input id="p_date" placeholder="YYYY-MM-DDTHH:MM:SS" /></div>
162
+ </div>
163
+ <div class="flex">
164
+ <button onclick="pay()">入金反映</button>
165
+ <span id="pay_msg" class="muted"></span>
166
+ </div>
167
+ </div>
168
 
169
+ <script>
170
+ // ------- 設定の保存/読込 -------
171
+ const S = {
172
+ get base(){ return localStorage.getItem("base") || "" },
173
+ set base(v){ localStorage.setItem("base", v||"") },
174
+ get key(){ return localStorage.getItem("apikey") || "dev" },
175
+ set key(v){ localStorage.setItem("apikey", v||"") },
176
+ };
177
+ function el(id){ return document.getElementById(id) }
178
+ function saveCfg(){
179
+ S.base = el("base").value.trim();
180
+ S.key = el("apikey").value.trim() || "dev";
181
+ el("cfg_msg").textContent = "保存しました";
182
+ setTimeout(()=>el("cfg_msg").textContent="", 1500);
183
+ }
184
+ // UI初期化
185
+ (function(){
186
+ el("base").value = S.base;
187
+ el("apikey").value = S.key;
188
+ })();
189
 
190
+ function url(p){ return (S.base ? S.base.replace(/\/$/,"") : "") + p }
191
+ async function api(p, opt={}){
192
+ const headers = Object.assign({"Content-Type":"application/json","X-API-Key": S.key}, opt.headers||{});
193
+ const res = await fetch(url(p), Object.assign(opt,{headers}));
194
+ if(!res.ok){
195
+ const t = await res.text();
196
+ throw new Error(`HTTP ${res.status}: ${t}`);
197
  }
198
+ // pdf は blob, それ以外は json
199
+ if (p.endsWith("/pdf")) return await res.blob();
200
+ return await res.json();
201
+ }
202
 
203
+ // ------- 顧客 -------
204
+ async function createCustomer(){
205
+ try{
206
+ const body = {
207
+ name: el("c_name").value,
208
+ email: el("c_email").value || null,
209
+ phone: el("c_phone").value || null,
210
+ address: el("c_addr").value || null,
211
+ city: el("c_city").value || null,
212
+ country: el("c_country").value || null,
213
+ };
214
+ const r = await api("/customers",{method:"POST",body:JSON.stringify(body)});
215
+ el("customer_id").value = r.id;
216
+ el("c_msg").innerHTML = `<span class="ok">作成しました (id=${r.id})</span>`;
217
+ }catch(e){ el("c_msg").innerHTML = `<span class="err">${e.message}</span>`; }
218
+ }
219
+ async function searchCustomers(){
220
+ try{
221
+ const q = el("c_q").value.trim();
222
+ const r = await api(`/customers${q?`?q=${encodeURIComponent(q)}`:""}`);
223
+ const rows = r.data || [];
224
+ const tbl = [["ID","会社名","メール","電話"]];
225
+ rows.forEach(x=>tbl.push([x.id, x.name||"", x.email||"", x.phone||""]));
226
+ el("c_table").innerHTML = tbl.map((row,i)=>`<tr>${row.map((c,j)=>`<${i? "td":"th"}>${c}</${i? "td":"th"}>`).join("")}</tr>`).join("");
227
+ // 行クリックで選択
228
+ [...el("c_table").querySelectorAll("tr")].forEach((tr,i)=>{
229
+ if(!i) return;
230
+ tr.style.cursor="pointer";
231
+ tr.onclick = ()=>{ el("customer_id").value = tr.children[0].innerText; };
232
+ });
233
+ el("c_msg").textContent = `${rows.length}件`;
234
+ }catch(e){ el("c_msg").innerHTML = `<span class="err">${e.message}</span>`; }
235
+ }
236
 
237
+ // ------- 請求 -------
238
+ async function createInvoice(){
239
+ try{
240
+ const customer_id = +el("customer_id").value;
241
+ if(!customer_id) throw new Error("customer_id を選択してください");
242
+ const body = {
243
+ customer_id,
244
+ due_date: el("inv_due").value || null,
245
+ notes: el("inv_notes").value || null,
246
+ quote_id: el("inv_from_quote").value ? +el("inv_from_quote").value : null,
247
+ };
248
+ const r = await api("/invoices",{method:"POST",body:JSON.stringify(body)});
249
+ el("invoice_id").value = r.id;
250
+ el("inv_msg").innerHTML = `<span class="ok">作成しました (id=${r.id})</span>`;
251
+ }catch(e){ el("inv_msg").innerHTML = `<span class="err">${e.message}</span>`; }
252
+ }
253
 
254
+ // ------- 明細 -------
255
+ async function addItem(){
256
+ try{
257
+ const invoice_id = +el("invoice_id").value;
258
+ if(!invoice_id) throw new Error("invoice_id を先に作成してください");
259
+ const body = {
260
+ description: el("it_desc").value,
261
+ quantity: +el("it_qty").value,
262
+ unit_price: +el("it_price").value,
263
+ tax_rate: +el("it_tax").value,
264
+ };
265
+ const r = await api(`/invoices/${invoice_id}/items`,{method:"POST",body:JSON.stringify(body)});
266
+ el("it_msg").innerHTML = `<span class="ok">追加しました (item id=${r.id})</span>`;
267
+ }catch(e){ el("it_msg").innerHTML = `<span class="err">${e.message}</span>`; }
268
+ }
269
+ async function fetchTotals(){
270
+ try{
271
+ const id = +el("invoice_id").value;
272
+ if(!id) throw new Error("invoice_id が未設定です");
273
+ const r = await api(`/invoices/${id}`);
274
+ const t = r.totals || {};
275
+ el("totals").textContent = `小計 ${t.subtotal ?? "-"} / 税 ${t.tax ?? "-"} / 合計 ${t.total ?? "-"}`;
276
+ }catch(e){ el("it_msg").innerHTML = `<span class="err">${e.message}</span>`; }
277
+ }
278
 
279
+ // ------- PDF / メール -------
280
+ async function downloadPDF(){
281
+ try{
282
+ const id = +el("invoice_id").value;
283
+ if(!id) throw new Error("invoice_id が未設定です");
284
+ const blob = await api(`/invoices/${id}/pdf`);
285
+ const a = document.createElement("a");
286
+ a.href = URL.createObjectURL(blob);
287
+ a.download = `invoice_${id}.pdf`;
288
+ a.click();
289
+ URL.revokeObjectURL(a.href);
290
+ el("pdf_msg").textContent = "PDF をダウンロードしました";
291
+ }catch(e){ el("pdf_msg").innerHTML = `<span class="err">${e.message}</span>`; }
292
+ }
293
+ async function sendMail(){
294
+ try{
295
+ const id = +el("invoice_id").value;
296
+ if(!id) throw new Error("invoice_id が未設定です");
297
+ const body = {
298
+ to: el("m_to").value,
299
+ subject: el("m_subj").value,
300
+ body: el("m_body").value,
301
+ attach_pdf: true
302
+ };
303
+ const r = await api(`/invoices/${id}/email`,{method:"POST",body:JSON.stringify(body)});
304
+ el("mail_msg").textContent = r.ok ? `送信: ${r.detail}` : `失敗: ${r.detail}`;
305
+ }catch(e){ el("mail_msg").innerHTML = `<span class="err">${e.message}</span>`; }
306
+ }
307
 
308
+ // ------- AI 文面 -------
309
+ async function aiEmail(){
310
+ try{
311
+ const id = +el("invoice_id").value;
312
+ const custId = +el("customer_id").value;
313
+ if(!id || !custId) throw new Error("先に 顧客 請求書 を作成してください");
314
+ // 請求の明細と���計を取得
315
+ const inv = await api(`/invoices/${id}`);
316
+ const items = inv.items || [];
317
+ const due = inv.invoice?.due_date || "";
318
+ const total = inv.totals?.total || "";
319
+ // 顧客名を取るため一旦顧客検索(簡易)
320
+ const custList = await api(`/customers?q=`);
321
+ const target = (custList.data||[]).find(c => c.id === custId);
322
+ const customer_name = target?.name || "ご担当者様";
323
 
324
+ const reqBody = {
325
  kind: "invoice",
326
+ company_name: "貴社",
327
+ customer_name,
328
  language: "ja",
329
+ due_date: due,
330
  tone: "polite",
331
+ items: items.map(it => ({
332
+ description: it.description,
333
+ quantity: it.quantity,
334
+ unit_price: it.unit_price,
335
+ tax_rate: it.tax_rate
336
+ })),
337
+ notes: `合計金額: ${total}`
338
  };
339
+ const r = await api("/ai/generate-email",{
340
+ method:"POST",
341
+ body: JSON.stringify(reqBody)
342
+ });
343
+ // 返却は {email: "..."}(件名と本文が含まれる想定)
344
+ const txt = r.email || "";
345
+ // ざっくり件名/本文を分離("Subject:" を使っていれば)
346
+ const m = txt.match(/(件名|Subject)\s*[::]\s*(.+)\n([\s\S]*)/);
347
+ if(m){
348
+ el("m_subj").value = m[2].trim();
349
+ el("m_body").value = m[3].trim();
350
+ }else{
351
+ el("m_body").value = txt;
 
 
 
 
352
  }
353
+ }catch(e){ alert("AI文面の作成に失敗: " + e.message); }
354
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
+ </script>
 
 
 
357
  </body>
358
  </html>