From d604ea7ae59df9320d9ca28a8a662f078a610cfa Mon Sep 17 00:00:00 2001 From: yktsnet Date: Mon, 10 Aug 2026 12:14:42 +0900 Subject: [PATCH 1/2] chore(issues): open 01_receipt-file-picker.md --- issues/01_receipt-file-picker.md | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 issues/01_receipt-file-picker.md 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 に含めない。 From 129948cc5076f3bfa9a44a19c17f3df5dfc3664a Mon Sep 17 00:00:00 2001 From: yktsnet Date: Mon, 10 Aug 2026 12:19:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E8=A8=BC=E6=86=91=E3=82=92?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=AB=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E9=81=B8=E6=8A=9E=E3=81=A7=E3=82=82=E6=B7=BB=E4=BB=98?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 変更内容 証憑の入口がクリップボード貼り付けしかなく、保存済みの画像ファイル(Web 領収書のダウンロード、他端末から受け取ったスクショ)を添付できなかった。入力画面と台帳の編集行の両方に、ファイル選択からの添付経路を足した。圧縮・アップロードの既存処理は共有し、API は変更していない。 - `public/app.js`: `takePaste` から圧縮して `onDone` へ渡す部分を `attachImage(file, onDone)` として切り出し、貼り付け(`takePaste`)とファイル選択(`takeFile`)の両方から呼ぶ共通経路にした。入力画面は `applyPending` で貼り付けと選択を同じプレビュー表示に統一。台帳の編集行は選択と同時に `uploadReceipt` して `renderList()`(貼り付けと同じ)。ファイル選択後は毎回 `input.value` をクリアし、同じファイルを選び直せるようにした。 - `public/index.html`: `#paste-area` の隣にファイル選択の入口(`label.file-btn` + `input[type=file]`)を並置。`accept` は `image/*` ではなく `.png,.jpg,.jpeg,.gif,.webp` を列挙し、モバイルでカメラが既定の選択肢にならないようにした(`capture` 属性は付けない)。文言も両経路があることが分かる表現に直した。 - `public/style.css`: 素の `` をラベル経由でボタンとして見せる `.file-btn` を追加。色は既存の `:root` トークンのみを参照し、新規トークンは増やしていない。貼り付け専用の `.paste-area` はポインタ操作の効かない端末で従来どおり非表示にする一方、ファイル選択は端末を問わず使えるよう `#paste-preview` を非表示対象から外した。 - `README.md` / `README.en.md`: Screenshots のキャプションにあった貼り付け前提の記述を、貼り付け・ファイル選択の両方があることが分かる表現に直した。issue が言及していた Overview 中の「証憑の添付は PC のみ」という一文は、現在の README には存在しなかったため変更していない(grep で確認済み)。 ## 保証 - 新たに宣言する保証: なし(issue記載のとおり、変更は `public/` の UI に閉じており、保証台帳の対象は `core/` の純関数と HTTP API の応答契約。API の要求・応答は変えていない) - 維持する保証(いずれも `public/` の変更では触っていない `core/`・`functions/` 側の既存テストがそのまま担保): - 証憑を取り外すと取引からの紐付けだけが外れ、退避先へ記録が残り、ファイル自体は取得できる → `tests/api.test.mjs` - 存在しない証憑の取得は 404 を返す → `tests/api.test.mjs` - 証憑の保存は書き込み失敗を例外にする(握り潰さない)→ `tests/api.test.mjs`「証憑の保存も書き込み失敗を例外にする」 ## 静的確認結果 - `node --check public/app.js`: 構文エラーなし - `node --test 'tests/*.test.mjs'`: 50 pass / 0 fail - caller・import の整合性: `attachImage` / `takeFile` / `applyPending` は同一ファイル内で定義後にすべての呼び出し元(貼り付けハンドラ、`#file-input` の `onchange`、`openEdit` 内の `.file-btn input` の `onchange`)から参照されており未解決参照なし。`index.html` の `#file-input` id と `app.js` の `$('#file-input')` セレクタが一致することを確認。API 呼び出し(`uploadReceipt`, `/api/entries` 等)は変更していない。 - `git diff --name-only --cached`: ``` README.en.md README.md public/app.js public/index.html public/style.css ``` issue の「対象」フィールドと完全一致。 ## 検証手順 `node dev/server.mjs` で起動し、入力画面と台帳の編集行の双方から、ファイル選択・貼り付けの両経路で証憑を添付できることを目視確認してください(本コミットではサーバー起動・目視確認は行っていません)。 --- README.en.md | 2 +- README.md | 2 +- public/app.js | 51 +++++++++++++++++++++++++++++++++-------------- public/index.html | 5 ++++- public/style.css | 17 +++++++++++++--- 5 files changed, 56 insertions(+), 21 deletions(-) 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 | |---|---| | ![Entry screen](docs/screenshots/entry.png) | ![Ledger screen](docs/screenshots/ledger.png) | -| 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 @@ | 入力 | 台帳 | |---|---| | ![入力画面](docs/screenshots/entry.png) | ![台帳画面](docs/screenshots/ledger.png) | -| 金額を打ち、科目をタップして送信する。日付・取引先・按分は既定値で埋まり、必要なときだけ開く。証憑は ⌘V でその場に貼れる | 月ごとに取引を並べ、按分率と証憑の有無を行に出す。取消した取引は打ち消し線で残り、集計から外れるだけで消えない | +| 金額を打ち、科目をタップして送信する。日付・取引先・按分は既定値で埋まり、必要なときだけ開く。証憑は貼り付け(⌘V)またはファイル選択でその場に添付できる | 月ごとに取引を並べ、按分率と証憑の有無を行に出す。取消した取引は打ち消し線で残り、集計から外れるだけで消えない | | 事業 | 申告 | |---|---| 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 ?? []).map((r) => ` `).join('')}
-

${e.receipts?.length ? `証憑 ${e.receipts.length}件` : '証憑なし'}。ここにスクショを貼り付けると添付されます(PC)。

+

${e.receipts?.length ? `証憑 ${e.receipts.length}件` : '証憑なし'}。貼り付け(⌘V / Ctrl+V)またはファイル選択で添付できます。

+
@@ -274,10 +293,12 @@ function openEdit(row, e) { const [ratio] = [...box.querySelectorAll('input[type=number]')].slice(1); const [payee, note] = box.querySelectorAll('input[type=text]'); - box.onpaste = (ev) => takePaste(ev, async (blob) => { + const attachToEntry = async (blob) => { await uploadReceipt(e.id, blob); renderList(); - }); + }; + box.onpaste = (ev) => takePaste(ev, attachToEntry); + box.querySelector('.file-btn input').onchange = takeFile(attachToEntry); const patch = (body) => api(`/api/entries/${e.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), diff --git a/public/index.html b/public/index.html index 2e21b80..b1507c3 100644 --- a/public/index.html +++ b/public/index.html @@ -50,7 +50,10 @@
-
クリックしてスクショを貼り付け(⌘V)
+
+
クリックして貼り付け(⌘V / Ctrl+V)
+ +