diff --git a/README.ko.md b/README.ko.md index 4aab301..7350765 100644 --- a/README.ko.md +++ b/README.ko.md @@ -11,7 +11,7 @@ 셀프호스팅 가정용 재고·바코드 관리 — 기존 제품 바코드(UPC/EAN), 직접 발급한 QR 라벨, Matter 페어링 코드를 스캔해서 집안 물건을 관리합니다. 연속 스캔으로 입고·소비를 처리하고, 대시보드에서 재고부족·유통기한을 한눈에 보고, 라벨을 인쇄합니다. -> 현재 릴리스: **v0.6.2** +> 현재 릴리스: **v0.6.3** 문서: [`docs/ROADMAP.md`](./docs/ROADMAP.md) @@ -20,10 +20,10 @@ ## 기능 - 중첩 위치(방 / 선반 / 상자)를 들여쓰기 트리로 표시, 카테고리 -- 수량, 재고부족 기준 수량, 유통기한·보증 만료일, 가격, 사진, 메모 — 이름·단위는 아이템 상세에서 바로 인라인 편집. 직접 등록 폼에서 촬영 또는 갤러리 선택으로 바로 사진을 넣을 수 있고(업로드 시 자동 리사이즈), 가격은 매번 입력하는 대신 설정에서 한 번 고른 기본 통화(원/달러)를 그대로 씀 +- 수량, 재고부족 기준 수량, 유통기한·보증 만료일, 가격, 메모 — 이름·단위는 아이템 상세에서 바로 인라인 편집. 직접 등록 폼에서 촬영 또는 갤러리 선택으로 사진을 하나 골라둘 수 있고(업로드 시 자동 리사이즈), 가격은 매번 입력하는 대신 설정에서 한 번 고른 기본 통화(원/달러)를 그대로 씀 - 통합 `Item` + `Barcode` 모델 — 기존 UPC/EAN, 직접 발급 내부 QR(아이템 딥링크), Matter 페어링 코드, 수동 입력 시리얼번호(Matter 코드는 실제 커미셔닝이 아니라 식별자 용도임을 안내하는 문구 포함)를 하나의 바코드 타입으로 - 수량 관리와 별개로 자산(기기) 모드 — 아이템을 **자산**으로 전환하면 개별 기기 하나를 추적: 상태(신품/사용중/수리필요/폐기), 시리얼번호 바코드 등록, 정비 이력(날짜·내용·비용) 기록. 자산은 수량 스텝퍼·재고부족 필드가 숨겨지고 장보기/재고부족 로직에서 제외되며, 자산 바코드를 스캔하면 수량 조정 없이 해당 자산의 상세 페이지로 바로 이동합니다. -- 영수증·설명서·보증서 등 파일 첨부 — 아이템이나 자산에 PDF/이미지 형식의 여러 문서를 직접 업로드하여 보관할 수 있고, 업로드 시 이미지는 자동 리사이즈됩니다. +- 영수증·설명서·보증서·사진 등 파일 첨부 — 업로드 창구를 하나로 통합해 아이템·자산마다 PDF/이미지 문서를 여러 개 올려 보관하고, 파일 링크가 아니라 실제 썸네일로 미리 보여줍니다(업로드 시 이미지는 자동 리사이즈). 이미지 첨부 중 아무거나 대표 사진으로 지정할 수 있고, 이름 옆에 프로필 사진처럼 작게 표시됩니다 — 이미지가 하나뿐이면 자동으로 대표가 되고, 여러 장이면 직접 골라야 합니다. - 연속 카메라 스캔: **입고(+1)** / **소비(−1)** 모드, 화면 전환 없이 계속 스캔 — 직접 등록 폼이나 바코드/Matter 코드 추가에서도 카메라 스캔 지원. 실사용 인식률/속도를 위해 포맷 제한·고해상도·연속 오토포커스로 튜닝했고, 스캔 성공 시 삑 소리·진동, 저조도용 플래시 토글 지원. 새로 자동 생성된 아이템은 그 자리에서 바로 위치·기준수량을 채우는 미니시트 제공 - 외부 제품 조회 연동을 다중 선택 가능(Open Food Facts, UPCItemDB, 네이버쇼핑) — 원하는 제공자만 켜거나 아예 꺼서 조회 자체를 생략 - 대시보드: 총 자산가치, 재고부족·유통기한 임박 항목을 전면에, 첫 사용 시 온보딩 체크리스트(위치·알림·공개URL) 제공 diff --git a/README.md b/README.md index 2d50717..d870a44 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Self-hosted home inventory & barcode manager — track everything you own by scanning existing product barcodes (UPC/EAN), self-issued QR labels, or Matter pairing codes. Restock/consume with one continuous scan, watch low-stock and expiry from the dashboard, and print labels. -> Current release: **v0.6.2** +> Current release: **v0.6.3** Docs: [`docs/ROADMAP.md`](./docs/ROADMAP.md) @@ -20,10 +20,10 @@ Docs: [`docs/ROADMAP.md`](./docs/ROADMAP.md) ## Features - Nested locations (rooms / shelves / boxes) rendered as an indented tree, and categories -- Items with quantity, low-stock threshold, expiry & warranty dates, price, photo, notes — name and unit are inline-editable right on the item detail page. Photos can be taken *or* picked from the gallery right in the "add manually" form (auto-resized on upload), and price uses a default currency (KRW/USD, set once in Settings) instead of typing it on every item +- Items with quantity, low-stock threshold, expiry & warranty dates, price, notes — name and unit are inline-editable right on the item detail page. An optional photo can be taken *or* picked from the gallery right in the "add manually" form (auto-resized on upload), and price uses a default currency (KRW/USD, set once in Settings) instead of typing it on every item - Unified `Item` + `Barcode` model — existing UPC/EAN, self-issued internal QR (deep-links to the item), a Matter pairing code, or a manually-entered serial number (with inline copy explaining Matter codes are an identifier only, not real commissioning), all as one barcode type - Asset mode alongside quantity tracking — flip an item to **Asset** to track a single physical device: condition (new / in use / needs repair / retired), a serial-number barcode entry, and a maintenance-history log (date, description, cost). Asset items hide the quantity stepper/low-stock fields, are excluded from shopping-list/low-stock logic, and scanning an asset's barcode redirects to its detail page instead of adjusting quantity -- File attachments for receipts, manuals, and warranties — upload and store multiple PDF or image documents per item/asset, with image auto-resizing on upload +- File attachments for receipts, manuals, warranties, and photos — a single upload flow stores multiple PDF or image documents per item/asset, shown as real thumbnails (not just file links), with image auto-resizing on upload. Any image attachment can be set as the item's representative photo — shown as a small profile-style avatar next to the item name, automatic when there's only one image and a manual picker once there are several - Continuous camera scanning: **Restock (+1)** / **Consume (−1)** modes, no screen transition between scans; camera scan is also available when registering an item manually or adding a barcode/Matter code. Tuned for real-world speed/accuracy (format hints, higher resolution, continuous autofocus), with an audible beep, haptic buzz, and a low-light flash toggle. A newly auto-created item gets a quick inline sheet to set its location/threshold on the spot - Pluggable external product lookup (Open Food Facts, UPCItemDB, Naver Shopping) — pick which providers to use per barcode scan, or turn lookup off entirely - Dashboard: total inventory value, low-stock and expiring-soon items front and center, plus a first-run onboarding checklist (location, notifications, public URL) diff --git a/apps/api/package.json b/apps/api/package.json index 5b8b074..8cce174 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@stash/api", - "version": "0.6.2", + "version": "0.6.3", "private": true, "type": "module", "scripts": { diff --git a/apps/api/src/routes/items.ts b/apps/api/src/routes/items.ts index 6377e1a..5225c88 100644 --- a/apps/api/src/routes/items.ts +++ b/apps/api/src/routes/items.ts @@ -408,7 +408,10 @@ export async function itemRoutes(app: FastifyInstance) { const item = await prisma.item.update({ where: { id }, data, - include: ITEM_INCLUDE, + // attachments도 함께 내려줘야 한다 — 그냥 ITEM_INCLUDE만 쓰면 이름/위치 등 아무 + // 필드나 하나 수정할 때마다 응답에 attachments가 빠져서 화면에서 첨부파일 목록이 + // 사라져 보이는 문제가 있었다(대표 사진 지정도 이 PATCH를 타므로 같은 증상 발생). + include: { ...ITEM_INCLUDE, attachments: true }, }); void fireInventoryWebhook("item.updated", item); return item; diff --git a/apps/web/app/categories/page.tsx b/apps/web/app/categories/page.tsx index b9707e1..b193e1c 100644 --- a/apps/web/app/categories/page.tsx +++ b/apps/web/app/categories/page.tsx @@ -85,7 +85,7 @@ export default function CategoriesPage() { {c.parentId && · {t("subOf", { name: parentName(c.parentId) ?? "" })}} · {t("itemCount", { n: c._count?.items ?? 0 })} - diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 5e5cfa7..4e092a4 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -128,6 +128,7 @@ h1 { display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin-bottom: 16px; } @@ -248,6 +249,18 @@ button:disabled { gap: 12px; } +/* 대표 사진은 아이템의 "메인 콘텐츠"가 아니라 이름 옆에 붙는 프로필 사진 같은 식별용 + 아이콘이다 — 전체 사진을 다 보여주려 하지 않고, 목록의 .thumb과 같은 방식(고정 크기 + + object-fit: cover)으로 헤더 안에 작게 자리잡게 한다. */ +.item-avatar { + width: 64px; + height: 64px; + border-radius: 12px; + object-fit: cover; + flex-shrink: 0; + background: var(--color-surface-hover); +} + .item-card .thumb { width: 56px; height: 56px; @@ -791,10 +804,122 @@ button:disabled { .tree-row-actions { display: flex; + align-items: center; gap: 8px; flex-shrink: 0; } +/* 첨부파일/바코드 같은 목록 행의 보조 액션 — 기본 button보다 훨씬 작게, garage 프로젝트의 + .btn-action 컨벤션을 그대로 따른다. */ +.btn-action { + min-height: 24px; + height: 24px; + font-size: 0.75rem; + padding: 0 8px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 500; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); + text-decoration: none; +} + +.btn-action:hover { + background: var(--color-surface-hover); +} + +.btn-action-danger { + color: var(--color-danger); +} + +.btn-action-danger:hover { + background: var(--color-danger-bg); + border-color: var(--color-danger); + color: var(--color-danger-text); +} + +/* 첨부파일을 텍스트 링크가 아니라 실제 썸네일로 미리보여준다 — garage 프로젝트의 + AttachmentList 타일 컨벤션(정사각 썸네일 + 모서리에 겹친 삭제 배지)을 그대로 따른다. */ +.attachment-grid { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 14px; + margin-bottom: 8px; +} + +.attachment-tile { + position: relative; + width: 84px; +} + +.attachment-thumb { + display: flex; + align-items: center; + justify-content: center; + width: 84px; + height: 84px; + border-radius: 12px; + overflow: hidden; + background: var(--color-surface-hover); + border: 1px solid var(--color-border); +} + +.attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.attachment-file-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.attachment-primary-badge { + position: absolute; + top: 4px; + left: 4px; + font-size: 0.65rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: var(--color-primary); + color: var(--color-primary-text); + pointer-events: none; +} + +.attachment-remove-btn { + position: absolute; + top: -8px; + right: -8px; + width: 24px; + height: 24px; + min-height: 24px; + padding: 0; + border-radius: 50%; + border: 2px solid var(--color-surface); + background: var(--color-danger); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.attachment-set-primary { + width: 100%; + margin-top: 6px; +} + .fab-row { display: flex; gap: 8px; diff --git a/apps/web/app/items/[id]/page.tsx b/apps/web/app/items/[id]/page.tsx index aab396c..9a806f9 100644 --- a/apps/web/app/items/[id]/page.tsx +++ b/apps/web/app/items/[id]/page.tsx @@ -19,6 +19,33 @@ const REASON_KEY: Record = { ADJUST: "reasonAdjust", }; +// 이모지는 폰트/OS별로 모양이 달라져서 아이콘으로 쓰지 않는다 — 스트로크 SVG로 통일한다. +function CopyIcon() { + return ( + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function FileIcon() { + return ( + + + + + ); +} + export default function ItemDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); @@ -30,7 +57,6 @@ export default function ItemDetailPage() { const [categories, setCategories] = useState([]); const [busy, setBusy] = useState(true); const [manualBarcode, setManualBarcode] = useState(""); - const [uploading, setUploading] = useState(false); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const [torchSupported, setTorchSupported] = useState(false); @@ -94,6 +120,7 @@ export default function ItemDetailPage() { async function refresh() { const data = await apiJson(`/api/items/${id}`); setItem(data); + return data; } useEffect(() => { @@ -252,30 +279,35 @@ export default function ItemDetailPage() { } } - async function handlePhotoUpload(e: ChangeEvent) { - const file = e.target.files?.[0]; - if (!file || !item) return; - setUploading(true); + async function setPhotoUrl(itemId: string, url: string | null) { try { - const formData = new FormData(); - formData.append("file", file); - const res = await apiFetch(`/api/attachments?itemId=${item.id}`, { method: "POST", body: formData }); - if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(typeof body?.error === "string" ? body.error : t("uploadFailFallback")); - } - const attachment = await res.json(); - await updateField("photoUrl", `${API_URL}/api/attachments/file/${attachment.filePath}`); - show(t("photoUploadedToast"), "success"); + const updated = await apiJson(`/api/items/${itemId}`, { + method: "PATCH", + body: JSON.stringify({ photoUrl: url }), + }); + setItem(updated); } catch (err: any) { show(err.message, "error"); - } finally { - setUploading(false); } } - // 사진(photoUrl)과 달리 이 목록은 item.photoUrl을 건드리지 않는다 — 영수증·설명서· - // 보증서 등 대표 사진이 아닌 부가 파일을 여러 개 쌓아두는 용도. + // 이미지 첨부가 1장이면 그게 곧 대표 사진 — 자동으로 photoUrl을 맞춰준다. 2장 이상이면 + // 어느 게 대표인지 알 수 없으니 사용자가 직접 골라야 하고, 대표였던 게 삭제돼서 후보가 + // 여럿(또는 0장) 남으면 대표 지정을 해제한다. + async function syncRepresentativePhoto() { + const data = await refresh(); + const images = data.attachments?.filter((a) => a.mimeType.startsWith("image/")) ?? []; + if (images.length === 1) { + const url = `${API_URL}/api/attachments/file/${images[0].filePath}`; + if (data.photoUrl !== url) await setPhotoUrl(data.id, url); + } else if (data.photoUrl && !images.some((a) => `${API_URL}/api/attachments/file/${a.filePath}` === data.photoUrl)) { + await setPhotoUrl(data.id, null); + } + } + + // 사진과 첨부파일을 별도 업로드 경로로 뒀더니 사진을 새로 올릴 때마다 이전 파일이 + // 정리되지 않고 쌓이는 문제가 있었다 — 업로드 경로를 첨부파일 하나로 합치고, 대표 사진은 + // 이미지 첨부 중에서 고르는 방식으로 바꿔서 파일이 항상 첨부파일 삭제 하나로만 정리되게 한다. async function handleAttachmentUpload(e: ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; @@ -289,7 +321,7 @@ export default function ItemDetailPage() { const body = await res.json().catch(() => null); throw new Error(typeof body?.error === "string" ? body.error : t("uploadFailFallback")); } - await refresh(); + await syncRepresentativePhoto(); show(t("attachmentUploadedToast"), "success"); } catch (err: any) { show(err.message, "error"); @@ -301,7 +333,7 @@ export default function ItemDetailPage() { async function removeAttachment(attachmentId: string) { try { await apiJson(`/api/attachments/${attachmentId}`, { method: "DELETE" }); - await refresh(); + await syncRepresentativePhoto(); } catch (err: any) { show(err.message, "error"); } @@ -339,6 +371,10 @@ export default function ItemDetailPage() { return (
+ {item.photoUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + )} )} - {item.attachments && item.attachments.length > 0 && ( - - )} - - {item.photoUrl && ( - // eslint-disable-next-line @next/next/no-img-element - {item.name} - )} -
- + {t("viewLabel")} -
@@ -706,24 +700,52 @@ export default function ItemDetailPage() {

{t("attachmentsSectionTitle")}

{(!item.attachments || item.attachments.length === 0) &&

{t("noAttachments")}

} - {item.attachments?.map((a) => ( -
-
- - {a.mimeType === "application/pdf" ? t("attachmentTypePdf") : t("attachmentTypeImage")} - {" "} - {formatDateTime(a.uploadedAt)} -
-
- - {t("openAttachmentLabel")} - - -
+ {item.attachments && item.attachments.length > 0 && ( +
+ {(() => { + const imageCount = item.attachments!.filter((a) => a.mimeType.startsWith("image/")).length; + return item.attachments!.map((a) => { + const isImage = a.mimeType.startsWith("image/"); + const fileUrl = `${API_URL}/api/attachments/file/${a.filePath}`; + const isPrimary = isImage && item.photoUrl === fileUrl; + return ( +
+ + {isImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {t("attachmentTypeImage")} + ) : ( + + + {t("attachmentTypePdf")} + + )} + + {isPrimary && {t("primaryPhotoBadge")}} + + {isImage && imageCount > 1 && ( + + )} +
+ ); + }); + })()}
- ))} + )} - +
); diff --git a/apps/web/app/locations/page.tsx b/apps/web/app/locations/page.tsx index 33abd8e..791adb9 100644 --- a/apps/web/app/locations/page.tsx +++ b/apps/web/app/locations/page.tsx @@ -112,7 +112,7 @@ export default function LocationsPage() { {l.name} · {t("itemCount", { n: l._count?.items ?? 0 })}
-
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index abbb937..4daab32 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -19,12 +19,18 @@ interface OnboardingPushStatus { subscribed: boolean; } +// 외부 조회가 실패한 채로 스캔 등록된 아이템의 이름 패턴 — apps/api/src/routes/items.ts의 +// /scan 라우트가 만드는 문자열과 반드시 동일해야 한다. 스캔 미니시트에서 바로 못 고치고 +// 넘어간 아이템을 여기서 안전망으로 다시 보여준다. +const UNNAMED_ITEM_PREFIX = "미확인 상품 ("; + export default function DashboardPage() { const router = useRouter(); const { user, loading, isAdmin } = useAuth(); const { t } = useLocale(); const [lowStock, setLowStock] = useState([]); const [expiringSoon, setExpiringSoon] = useState([]); + const [needsNaming, setNeedsNaming] = useState([]); const [recent, setRecent] = useState([]); const [stats, setStats] = useState(null); const [busy, setBusy] = useState(true); @@ -48,15 +54,17 @@ export default function DashboardPage() { Promise.all([ apiJson("/api/items?lowStock=true"), apiJson("/api/items?expiringSoon=true"), + apiJson(`/api/items?q=${encodeURIComponent(UNNAMED_ITEM_PREFIX)}&limit=8`), apiJson("/api/items?limit=8"), apiJson("/api/items/stats"), apiJson("/api/locations").catch(() => []), getPushStatus().catch(() => ({ configured: false, subscribed: false, subscriptionCount: 0 })), settingsPromise, ]) - .then(([low, expiring, recentItems, itemStats, locs, push, settingsRows]) => { + .then(([low, expiring, unnamed, recentItems, itemStats, locs, push, settingsRows]) => { setLowStock(low); setExpiringSoon(expiring); + setNeedsNaming(unnamed); setRecent(recentItems); setStats(itemStats); setOnboardLocations(locs); @@ -136,7 +144,7 @@ export default function DashboardPage() {
)} - {!busy && !loadFailed && recent.length > 0 && lowStock.length === 0 && expiringSoon.length === 0 && ( + {!busy && !loadFailed && recent.length > 0 && lowStock.length === 0 && expiringSoon.length === 0 && needsNaming.length === 0 && (

{t("noUrgentItems")}

)} @@ -171,6 +179,16 @@ export default function DashboardPage() { )} + {needsNaming.length > 0 && ( +
+

{t("needsNamingSection")} ({needsNaming.length})

+

{t("needsNamingHint")}

+ {needsNaming.map((item) => ( + + ))} +
+ )} + {recent.length > 0 && (

{t("recentSection")}

diff --git a/apps/web/app/scan/page.tsx b/apps/web/app/scan/page.tsx index 76ce8f3..f27d7da 100644 --- a/apps/web/app/scan/page.tsx +++ b/apps/web/app/scan/page.tsx @@ -42,6 +42,7 @@ export default function ScanPage() { // 새로 생성된 아이템의 id — 이 값과 lastResult.item.id가 같을 때만 미니시트를 보여준다. // 이미 있던 아이템의 수량 조정(created:false)에서는 뜨지 않아야 연속 스캔 흐름이 안 끊긴다. const [quickEditFor, setQuickEditFor] = useState(null); + const [quickName, setQuickName] = useState(""); const [quickLocationId, setQuickLocationId] = useState(""); const [quickMinQuantity, setQuickMinQuantity] = useState(""); const [quickSaving, setQuickSaving] = useState(false); @@ -199,6 +200,7 @@ export default function ScanPage() { if (result.created) { show(t("scanCreatedToast", { name: result.item.name }), "success"); setQuickEditFor(result.item.id); + setQuickName(result.item.name); setQuickLocationId(""); setQuickMinQuantity(""); } else if (activeMode === "consume") { @@ -221,15 +223,18 @@ export default function ScanPage() { } } - // 신규 생성된 아이템은 위치·최소수량이 비어 있어 대시보드 재고부족/장보기 로직이 바로 - // 동작하지 않는다 — 스캔 흐름을 벗어나지 않은 채로 그 자리에서 바로 채울 수 있게 한다. + // 신규 생성된 아이템은 이름이 조회 실패 시 "미확인 상품 (바코드)"로, 위치·최소수량은 + // 비어 있는 채로 만들어진다 — 스캔 흐름을 벗어나지 않은 채로 그 자리에서 바로 채울 수 있게 한다. async function saveQuickEdit() { if (!lastResult) return; + const trimmedName = quickName.trim(); + if (!trimmedName) return; setQuickSaving(true); try { const updated = await apiJson(`/api/items/${lastResult.item.id}`, { method: "PATCH", body: JSON.stringify({ + name: trimmedName, locationId: quickLocationId || null, minQuantity: quickMinQuantity ? Number(quickMinQuantity) : null, }), @@ -355,6 +360,14 @@ export default function ScanPage() { {lastResult && quickEditFor === lastResult.item.id && (

{t("quickEditHint")}

+ setQuickName(e.target.value)} + autoFocus + onFocus={(e) => e.target.select()} + style={{ marginBottom: 8 }} + />
markWantedBought(item.id)} /> - {t("boughtCheckboxLabel")} - - )} -
+ item.notes && ( +

+ {item.notes} +

) } + onMarkBought={() => markBought(item)} /> ))}
diff --git a/apps/web/components/ItemCard.tsx b/apps/web/components/ItemCard.tsx index fd1fe9e..3b236c2 100644 --- a/apps/web/components/ItemCard.tsx +++ b/apps/web/components/ItemCard.tsx @@ -11,6 +11,26 @@ function daysUntil(dateStr: string): number { return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); } +// 이모지는 폰트/OS별로 모양이 달라져서 아이콘으로 쓰지 않는다 — 다른 곳(BottomNav 등)과 +// 같은 방식의 스트로크 SVG로 통일한다. +function CartIcon() { + return ( + + + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + const CONDITION_BADGE_KEY: Record = { NEW: "conditionNew", IN_USE: "conditionInUse", @@ -25,12 +45,18 @@ interface Props { selected?: boolean; onToggleSelect?: () => void; // 이 카드가 여러 화면(목록/대시보드/장보기)에서 재사용되다 보니, 화면별로만 필요한 - // 내용(장보기의 메모·구매완료 체크 등)은 ItemCard가 직접 알 필요 없이 여기로 끼워 넣는다. + // 내용(장보기의 메모 등)은 ItemCard가 직접 알 필요 없이 여기로 끼워 넣는다. extra?: ReactNode; + // 수량 +/- 버튼과 같은 줄에 장보기 추가/제거 토글을 붙인다 — 상세페이지까지 들어가지 + // 않고 목록에서 수량을 보면서 바로 담을 수 있게. 아이템 목록에서만 켠다. + showWantedToggle?: boolean; + // 장보기 화면 전용 — wanted 여부와 무관하게 모든 항목에 "구매완료" 버튼을 하나씩 붙인다. + // 클릭 시 실제로 무엇을 할지(wanted 해제 vs 수량 채우기)는 화면 쪽에서 결정해 넘긴다. + onMarkBought?: () => void; } // 목록에서 바로 수량을 조정할 수 있는 카드 — 상세 페이지까지 들어가지 않아도 되게 하는 게 핵심. -export function ItemCard({ item, onChange, selectable, selected, onToggleSelect, extra }: Props) { +export function ItemCard({ item, onChange, selectable, selected, onToggleSelect, extra, showWantedToggle, onMarkBought }: Props) { const { show } = useToast(); const { t } = useLocale(); const [current, setCurrent] = useState(item); @@ -59,6 +85,23 @@ export function ItemCard({ item, onChange, selectable, selected, onToggleSelect, } } + async function toggleWanted() { + if (busy) return; + setBusy(true); + try { + const updated = await apiJson(`/api/items/${current.id}`, { + method: "PATCH", + body: JSON.stringify({ wanted: !current.wanted }), + }); + setCurrent(updated); + onChange?.(updated); + } catch (err: any) { + show(err.message, "error"); + } finally { + setBusy(false); + } + } + const isLow = current.minQuantity != null && current.quantity <= current.minQuantity; const expiringSoon = current.expiryDate && daysUntil(current.expiryDate) <= 14; @@ -117,6 +160,22 @@ export function ItemCard({ item, onChange, selectable, selected, onToggleSelect, + {showWantedToggle && ( + + )} + {onMarkBought && ( + + )} )} diff --git a/apps/web/lib/i18n/translations.ts b/apps/web/lib/i18n/translations.ts index df6008d..57c6cb2 100644 --- a/apps/web/lib/i18n/translations.ts +++ b/apps/web/lib/i18n/translations.ts @@ -67,6 +67,11 @@ const dict = { }, lowStockSection: { ko: "재고부족", en: "Low stock" }, expiringSection: { ko: "유통기한 임박", en: "Expiring soon" }, + needsNamingSection: { ko: "이름 정리 필요", en: "Needs a proper name" }, + needsNamingHint: { + ko: "조회에 실패한 바코드로 등록되어 이름이 임시로 채워졌습니다. 눌러서 실제 제품명으로 바꿔주세요.", + en: "Registered from a barcode lookup that came up empty, so the name is a placeholder. Tap to give it a real name.", + }, recentSection: { ko: "최근 등록", en: "Recently added" }, // shopping list @@ -77,7 +82,6 @@ const dict = { en: "Items running low. Tap + for each one you buy — it drops off once it's back above the threshold.", }, shoppingListEmpty: { ko: "장 볼 항목이 없습니다.", en: "Nothing to buy right now." }, - boughtCheckboxLabel: { ko: "구매완료", en: "Bought" }, // scan scanTitle: { ko: "스캔", en: "Scan" }, @@ -226,7 +230,6 @@ const dict = { qrIssuedToast: { ko: "내부 QR 라벨이 발급되었습니다", en: "Internal QR label issued" }, printRequestedToast: { ko: "출력 요청을 보냈습니다", en: "Print request sent" }, generateAndPrintQrButton: { ko: "QR 생성 및 인쇄", en: "Generate & Print QR" }, - photoUploadedToast: { ko: "사진이 등록되었습니다", en: "Photo added" }, uploadFailFallback: { ko: "업로드 실패", en: "Upload failed" }, confirmDeleteItem: { ko: '"{name}"을(를) 휴지통으로 이동할까요? 나중에 복구할 수 있습니다.', @@ -243,10 +246,9 @@ const dict = { categoryLabel: { ko: "카테고리", en: "Category" }, noCategoryOption: { ko: "카테고리 없음", en: "No category" }, minQuantityLabel: { ko: "재고부족 기준 수량", en: "Low-stock threshold" }, - addToShoppingListLabel: { - ko: "장보기 목록에 추가 (재고와 무관하게)", - en: "Add to shopping list (regardless of stock level)", - }, + addToShoppingListButton: { ko: "장보기 목록에 추가", en: "Add to shopping list" }, + removeFromShoppingListButton: { ko: "장보기 목록에서 제거", en: "Remove from shopping list" }, + markBoughtButton: { ko: "구매완료", en: "Bought" }, expiryLabel: { ko: "유통기한", en: "Expiry date" }, warrantyLabel: { ko: "보증 만료일", en: "Warranty expiry" }, photoUploadLabel: { ko: "사진 촬영/업로드", en: "Take or upload photo" }, @@ -297,6 +299,9 @@ const dict = { en: "Add a file (receipt, manual, warranty, etc.)", }, attachmentUploadedToast: { ko: "파일이 추가되었습니다", en: "File added" }, + primaryPhotoBadge: { ko: "대표 사진", en: "Primary" }, + setPrimaryPhotoButton: { ko: "대표 사진으로 지정", en: "Set as primary" }, + unsetPrimaryPhotoButton: { ko: "대표 해제", en: "Unset primary" }, // locations locationsTitle: { ko: "위치", en: "Locations" }, diff --git a/apps/web/package.json b/apps/web/package.json index 01dabaf..7880009 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@stash/web", - "version": "0.6.2", + "version": "0.6.3", "private": true, "scripts": { "dev": "next dev", diff --git a/package-lock.json b/package-lock.json index 9f03a5c..b126bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stash", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stash", - "version": "0.6.2", + "version": "0.6.3", "workspaces": [ "apps/*", "packages/*" @@ -17,7 +17,7 @@ }, "apps/api": { "name": "@stash/api", - "version": "0.6.2", + "version": "0.6.3", "dependencies": { "@fastify/cors": "^9.0.1", "@fastify/jwt": "^8.0.1", @@ -53,7 +53,7 @@ }, "apps/web": { "name": "@stash/web", - "version": "0.6.2", + "version": "0.6.3", "dependencies": { "@stash/shared": "*", "@zxing/browser": "^0.1.5", @@ -9892,7 +9892,7 @@ }, "packages/shared": { "name": "@stash/shared", - "version": "0.6.2", + "version": "0.6.3", "dependencies": { "zod": "^3.23.8" } diff --git a/package.json b/package.json index ac7c571..c3c96ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stash", "private": true, - "version": "0.6.2", + "version": "0.6.3", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/shared/package.json b/packages/shared/package.json index 38f3765..df91fbf 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@stash/shared", - "version": "0.6.2", + "version": "0.6.3", "private": true, "type": "module", "main": "dist/index.js",