Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
| 入力 | 台帳 |
|---|---|
| ![入力画面](docs/screenshots/entry.png) | ![台帳画面](docs/screenshots/ledger.png) |
| 金額を打ち、科目をタップして送信する。日付・取引先・按分は既定値で埋まり、必要なときだけ開く。証憑は ⌘V でその場に貼れる | 月ごとに取引を並べ、按分率と証憑の有無を行に出す。取消した取引は打ち消し線で残り、集計から外れるだけで消えない |
| 金額を打ち、科目をタップして送信する。日付・取引先・按分は既定値で埋まり、必要なときだけ開く。証憑は貼り付け(⌘V)またはファイル選択でその場に添付できる | 月ごとに取引を並べ、按分率と証憑の有無を行に出す。取消した取引は打ち消し線で残り、集計から外れるだけで消えない |

| 事業 | 申告 |
|---|---|
Expand Down
49 changes: 49 additions & 0 deletions issues/01_receipt-file-picker.md
Original file line number Diff line number Diff line change
@@ -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

素の `<input type="file">` は既存の UI と体裁が合わない。ラベル経由でボタンとして見せ、色は `:root` のトークン経由で参照する。新しい色トークンを増やさない。

## README.md / README.en.md

Overview に「証憑の添付だけはクリップボードからの貼り付けに限っているため PC で行う。スマホからは記帳と閲覧ができ、証憑は後から PC で付ける。」という一文がある(英語版も同じ位置に対応する記述)。ファイル選択が入ると前提が変わるので、実装後の実態に合わせて直すか、残す理由が無ければ削る。

Screenshots のキャプション(16行目)にも貼り付け前提の記述があるため併せて見直す。スクリーンショット画像自体の撮り直しは本 Issue に含めない。
51 changes: 36 additions & 15 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -261,7 +279,8 @@ function openEdit(row, e) {
<div class="shots">${(e.receipts ?? []).map((r) => `
<span class="shot"><a href="/api/receipts/${r}" target="_blank"><img src="/api/receipts/${r}" alt=""></a>
<button class="shot-x" data-r="${r}" title="この取引から外す">${icon('x')}</button></span>`).join('')}</div>
<p class="hint">${e.receipts?.length ? `証憑 ${e.receipts.length}件` : '証憑なし'}。ここにスクショを貼り付けると添付されます(PC)。</p>
<p class="hint">${e.receipts?.length ? `証憑 ${e.receipts.length}件` : '証憑なし'}。貼り付け(⌘V / Ctrl+V)またはファイル選択で添付できます。</p>
<label class="file-btn">${icon('image')}ファイルを選ぶ<input type="file" accept=".png,.jpg,.jpeg,.gif,.webp" hidden></label>
<div class="row">
<button data-a="save">保存</button>
<button data-a="void" class="warn-btn">${e.void ? '取消を戻す' : '取り消す'}</button>
Expand All @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
</div>
</div>

<div id="paste-area" class="paste-area" tabindex="0"><i data-icon="image"></i>クリックしてスクショを貼り付け(⌘V)</div>
<div class="attach-row">
<div id="paste-area" class="paste-area" tabindex="0"><i data-icon="image"></i>クリックして貼り付け(⌘V / Ctrl+V)</div>
<label class="file-btn"><i data-icon="image"></i>ファイルを選ぶ<input id="file-input" type="file" accept=".png,.jpg,.jpeg,.gif,.webp" hidden></label>
</div>
<div id="paste-preview" class="callout" hidden>
<img id="paste-img" alt="">
<div class="callout-row">
Expand Down
17 changes: 14 additions & 3 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,26 @@ button.danger .ic { width: 1rem; height: 1rem; }
.callout-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.callout-row .hint { margin: 0; }

.attach-row { display: flex; gap: .5rem; margin-top: .8rem; }
.paste-area {
display: flex; align-items: center; justify-content: center; gap: .45rem;
flex: 1; display: flex; align-items: center; justify-content: center; gap: .45rem;
border: 1px dashed var(--line); border-radius: var(--r); padding: 1.1rem 1rem;
color: var(--mute); font-size: .82rem; cursor: text; margin-top: .8rem;
color: var(--mute); font-size: .82rem; cursor: text;
}
.paste-area:hover { border-color: var(--faint); }
.paste-area:focus { outline: none; border-color: var(--accent); color: var(--accent); }
#paste-preview img { max-height: 130px; max-width: 100%; border-radius: var(--r-sm); margin-bottom: .5rem; display: block; }
@media (hover: none) and (pointer: coarse) { .paste-area, #paste-preview { display: none; } }
/* 貼り付けはポインタ操作の効かない端末では成立しない。ファイル選択(.file-btn)は残す。 */
@media (hover: none) and (pointer: coarse) { .paste-area { display: none; } }

.file-btn {
display: inline-flex; align-items: center; justify-content: center; gap: .35rem;
border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--btn); color: var(--fg);
font: 500 .82rem/1 var(--font); cursor: pointer; padding: .5rem .8rem; white-space: nowrap;
}
.file-btn:hover { background: var(--btn-hover); }
.file-btn .ic { width: 1rem; height: 1rem; }
.edit .file-btn { margin-top: .5rem; }

/* ---------- recent ---------- */
.recent { margin-top: 2.2rem; padding-top: 1.1rem; border-top: 1px solid var(--line); }
Expand Down