diff --git a/README.en.md b/README.en.md index 739dd57..8e641db 100644 --- a/README.en.md +++ b/README.en.md @@ -13,7 +13,7 @@ A web app that lets a salaried sole proprietor keep the books for a Japanese blu | Entry | Ledger | |---|---| |  |  | -| Type an amount, tap an account, submit. Date, counterparty, and apportionment are prefilled and only opened when needed. Receipts paste in place with ⌘V | Transactions grouped by month, with apportionment ratio and receipt presence on the row. Voided transactions remain struck through — dropped from the totals, never deleted | +| Type an amount, tap an account, submit. Date, counterparty, and apportionment are prefilled and only opened when needed. Receipts attach in place by pasting (⌘V) or picking a file | Transactions grouped by month, with apportionment ratio and receipt presence on the row. Voided transactions remain struck through — dropped from the totals, never deleted | | Business | Tax | |---|---| diff --git a/README.md b/README.md index a74a248..9f66389 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | 入力 | 台帳 | |---|---| |  |  | -| 金額を打ち、科目をタップして送信する。日付・取引先・按分は既定値で埋まり、必要なときだけ開く。証憑は ⌘V でその場に貼れる | 月ごとに取引を並べ、按分率と証憑の有無を行に出す。取消した取引は打ち消し線で残り、集計から外れるだけで消えない | +| 金額を打ち、科目をタップして送信する。日付・取引先・按分は既定値で埋まり、必要なときだけ開く。証憑は貼り付け(⌘V)またはファイル選択でその場に添付できる | 月ごとに取引を並べ、按分率と証憑の有無を行に出す。取消した取引は打ち消し線で残り、集計から外れるだけで消えない | | 事業 | 申告 | |---|---| diff --git a/issues/01_receipt-file-picker.md b/issues/01_receipt-file-picker.md new file mode 100644 index 0000000..f1993a8 --- /dev/null +++ b/issues/01_receipt-file-picker.md @@ -0,0 +1,49 @@ +## 証憑をローカルファイル選択でも添付できるようにする +id: 01 +branch-slug: receipt-file-picker +github_issue: +status: open +type: feat +対象: public/index.html, public/app.js, public/style.css, README.md, README.en.md +内容: 証憑の入口がクリップボード貼り付けしかなく、保存済みの画像ファイル(Web 領収書のダウンロード、他端末から受け取ったスクショ)を添付できない。入力画面と台帳の編集行の両方に、ファイル選択からの添付経路を足す。圧縮・アップロードの既存処理は共有し、API は変更しない。 +確認: 目視確認(`node dev/server.mjs` で入力画面と台帳の編集行の双方から、ファイル選択・貼り付けの両経路で添付できること)に加え、`node --test 'tests/*.test.mjs'` を通す + +--- + +### 保証 +- 新たに宣言する保証: なし(変更は `public/` の UI に閉じており、保証台帳の対象は `core/` の純関数と HTTP API の応答契約。API の要求・応答は変えない) +- 維持する保証: + - 証憑を取り外すと取引からの紐付けだけが外れ、退避先へ記録が残り、ファイル自体は取得できる + - 存在しない証憑の取得は 404 を返す + - 証憑の保存は書き込み失敗を例外にする(握り潰さない) + +## 制約 + +- **撮影の入口にしない。** 紙のレシートを写真で取り込むことは design-decisions.md「紙は電子化せず、電子取引データのみ扱う」で明示的に採らない判断をしている。ファイル入力に `capture` 属性を付けない。`accept` は `image/*` ではなく扱う形式を列挙し、モバイルのカメラ起動を既定の選択肢にしない +- 既存の貼り付け経路を置き換えない。並置する +- 圧縮(`compressImage`)とアップロード(`uploadReceipt`)は貼り付けと同じものを通す。二重に書かない +- 画像以外のファイルを選ばれたときは何もしない(貼り付け側が画像 MIME 以外を無視しているのと揃える) + +## public/app.js + +`takePaste`(123行目付近)はクリップボードイベントから画像を取り出す部分と、圧縮して `onDone` へ渡す部分が一体になっている。後者を `File`/`Blob` を受ける関数として切り出し、貼り付けとファイル選択の両方から呼ぶ。 + +- 入力画面(132行目付近の `document` の paste ハンドラ): 選択されたファイルも同じく `pending` に載せ、プレビューと「N KB → N KB に圧縮して添付します」の表示まで貼り付けと同じ扱いにする +- 台帳の編集行(277行目付近の `box.onpaste`): 取引が既にあるため、選択と同時に `uploadReceipt` して `renderList()` する。貼り付けと同じ +- 同じファイルを続けて選び直せるよう、処理後は input の値をクリアする + +## public/index.html + +53行目の `#paste-area` に、ファイル選択の入口を併置する。編集行は `public/app.js` 側で組み立てているため、そちらに同等の入口を足す。 + +文言は貼り付け前提の現状(`クリックしてスクショを貼り付け(⌘V / Ctrl+V)`)を、両経路があることが分かる表現へ直す。 + +## public/style.css + +素の `` は既存の UI と体裁が合わない。ラベル経由でボタンとして見せ、色は `:root` のトークン経由で参照する。新しい色トークンを増やさない。 + +## README.md / README.en.md + +Overview に「証憑の添付だけはクリップボードからの貼り付けに限っているため PC で行う。スマホからは記帳と閲覧ができ、証憑は後から PC で付ける。」という一文がある(英語版も同じ位置に対応する記述)。ファイル選択が入ると前提が変わるので、実装後の実態に合わせて直すか、残す理由が無ければ削る。 + +Screenshots のキャプション(16行目)にも貼り付け前提の記述があるため併せて見直す。スクリーンショット画像自体の撮り直しは本 Issue に含めない。 diff --git a/public/app.js b/public/app.js index 7e95c54..d885656 100644 --- a/public/app.js +++ b/public/app.js @@ -117,29 +117,47 @@ const kb = (n) => Math.round(n / 1024); const uploadReceipt = (id, blob) => fetch(`/api/receipts/${id}?name=${Date.now()}.${blob.type.split('/')[1] || 'png'}`, { method: 'POST', body: blob }); -// 入力画面ではまだ取引が存在しないため、登録が済むまで画像を保持しておく。 -let pending = null; +// 貼り付けとファイル選択の両方が、圧縮して onDone へ渡す先を共有する。 +// 画像以外が来たら何もしない(貼り付け・選択どちらでも同じ扱い)。 +async function attachImage(file, onDone) { + if (!file || !file.type.startsWith('image/')) return; + onDone(await compressImage(file), file); +} + async function takePaste(ev, onDone) { const item = [...(ev.clipboardData?.items ?? [])].find((i) => i.type.startsWith('image/')); if (!item) return; ev.preventDefault(); - const original = item.getAsFile(); - onDone(await compressImage(original), original); + await attachImage(item.getAsFile(), onDone); +} + +// 同じファイルを選び直せるよう、処理後は毎回 input の値をクリアする。 +function takeFile(onDone) { + return async (ev) => { + const file = ev.target.files?.[0]; + ev.target.value = ''; + await attachImage(file, onDone); + }; +} + +// 入力画面ではまだ取引が存在しないため、登録が済むまで画像を保持しておく。 +let pending = null; +function applyPending(blob, original) { + pending = blob; + $('#paste-img').src = URL.createObjectURL(blob); + $('#paste-preview').hidden = false; + $('#paste-status').textContent = blob === original + ? `${kb(blob.size)}KB を添付します` + : `${kb(original.size)}KB → ${kb(blob.size)}KB に圧縮して添付します`; } $('#paste-area').onclick = () => $('#paste-area').focus(); $('#paste-clear').onclick = clearPending; document.addEventListener('paste', (ev) => { if (!$('#entry').classList.contains('on')) return; - takePaste(ev, (blob, original) => { - pending = blob; - $('#paste-img').src = URL.createObjectURL(blob); - $('#paste-preview').hidden = false; - $('#paste-status').textContent = blob === original - ? `${kb(blob.size)}KB を添付します` - : `${kb(original.size)}KB → ${kb(blob.size)}KB に圧縮して添付します`; - }); + takePaste(ev, applyPending); }); +$('#file-input').onchange = takeFile(applyPending); function clearPending() { pending = null; @@ -261,7 +279,8 @@ function openEdit(row, e) {
-${e.receipts?.length ? `証憑 ${e.receipts.length}件` : '証憑なし'}。ここにスクショを貼り付けると添付されます(PC)。
+${e.receipts?.length ? `証憑 ${e.receipts.length}件` : '証憑なし'}。貼り付け(⌘V / Ctrl+V)またはファイル選択で添付できます。
+