#12 — partial consume: - POST /inventory/items/{id}/consume now accepts optional {quantity} body; decrements by that amount and only marks status=consumed when quantity reaches zero (store.partial_consume_item) - OFFs barcode scan pre-fills sub-unit quantity when product data includes a pack size (number_of_units or 'N x ...' quantity string) - Consume button shows quantity-aware label and opens ActionDialog with number input for multi-unit items ('use some or all') - consumeItem() in api.ts now returns InventoryItem and accepts optional quantity param #60 — disposal logging: - Migration 031: adds disposal_reason TEXT column to inventory_items (status='discarded' was already in the CHECK constraint) - POST /inventory/items/{id}/discard endpoint with optional DiscardRequest body (free text or preset reason) - Calm framing: 'item not used' not 'wasted'; reason presets avoid blame language ('went bad before I could use it', 'too much — had excess') - Muted discard button (X icon, tertiary color) — not alarming Shared: - New ActionDialog.vue component for dialogs with inline inputs (quantity stepper or reason dropdown); keeps ConfirmDialog simple - disposal_reason field added to InventoryItemResponse Closes #12 Closes #60
This commit is contained in:
parent
443e68ba3f
commit
fb18a9c78c
8 changed files with 539 additions and 23 deletions
|
|
@ -22,10 +22,12 @@ from app.models.schemas.inventory import (
|
|||
BulkAddByNameRequest,
|
||||
BulkAddByNameResponse,
|
||||
BulkAddItemResult,
|
||||
DiscardRequest,
|
||||
InventoryItemCreate,
|
||||
InventoryItemResponse,
|
||||
InventoryItemUpdate,
|
||||
InventoryStats,
|
||||
PartialConsumeRequest,
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductUpdate,
|
||||
|
|
@ -239,13 +241,52 @@ async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
|||
|
||||
|
||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||
async def consume_item(item_id: int, store: Store = Depends(get_store)):
|
||||
async def consume_item(
|
||||
item_id: int,
|
||||
body: Optional[PartialConsumeRequest] = None,
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
"""Consume an inventory item fully or partially.
|
||||
|
||||
When body.quantity is provided, decrements by that amount and only marks
|
||||
status=consumed when quantity reaches zero. Omit body to consume all.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if body is not None:
|
||||
item = await asyncio.to_thread(
|
||||
store.partial_consume_item, item_id, body.quantity, now
|
||||
)
|
||||
else:
|
||||
item = await asyncio.to_thread(
|
||||
store.update_inventory_item,
|
||||
item_id,
|
||||
status="consumed",
|
||||
consumed_at=now,
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
||||
async def discard_item(
|
||||
item_id: int,
|
||||
body: DiscardRequest = DiscardRequest(),
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
"""Mark an item as discarded (not used, spoiled, etc).
|
||||
|
||||
Optional reason field accepts free text or a preset label
|
||||
('not used', 'spoiled', 'excess', 'other').
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
item = await asyncio.to_thread(
|
||||
store.update_inventory_item,
|
||||
item_id,
|
||||
status="consumed",
|
||||
status="discarded",
|
||||
consumed_at=datetime.now(timezone.utc).isoformat(),
|
||||
disposal_reason=body.reason,
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
|
|
@ -305,10 +346,14 @@ async def scan_barcode_text(
|
|||
tier=session.tier,
|
||||
has_byok=session.has_byok,
|
||||
)
|
||||
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
|
||||
resolved_qty = product_info.get("pack_quantity") or body.quantity
|
||||
resolved_unit = product_info.get("pack_unit") or "count"
|
||||
inventory_item = await asyncio.to_thread(
|
||||
store.add_inventory_item,
|
||||
product["id"], body.location,
|
||||
quantity=body.quantity,
|
||||
quantity=resolved_qty,
|
||||
unit=resolved_unit,
|
||||
expiration_date=str(exp) if exp else None,
|
||||
source="barcode_scan",
|
||||
)
|
||||
|
|
@ -383,10 +428,13 @@ async def scan_barcode_image(
|
|||
tier=session.tier,
|
||||
has_byok=session.has_byok,
|
||||
)
|
||||
resolved_qty = product_info.get("pack_quantity") or quantity
|
||||
resolved_unit = product_info.get("pack_unit") or "count"
|
||||
inventory_item = await asyncio.to_thread(
|
||||
store.add_inventory_item,
|
||||
product["id"], location,
|
||||
quantity=quantity,
|
||||
quantity=resolved_qty,
|
||||
unit=resolved_unit,
|
||||
expiration_date=str(exp) if exp else None,
|
||||
source="barcode_scan",
|
||||
)
|
||||
|
|
|
|||
4
app/db/migrations/031_disposal_reason.sql
Normal file
4
app/db/migrations/031_disposal_reason.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Migration 031: add disposal_reason for waste logging (#60)
|
||||
-- status='discarded' already exists in the CHECK constraint from migration 002.
|
||||
-- This column stores free-text reason (optional) and calm-framing presets.
|
||||
ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT;
|
||||
|
|
@ -218,7 +218,8 @@ class Store:
|
|||
|
||||
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
||||
allowed = {"quantity", "unit", "location", "sublocation",
|
||||
"expiration_date", "opened_date", "status", "notes", "consumed_at"}
|
||||
"expiration_date", "opened_date", "status", "notes", "consumed_at",
|
||||
"disposal_reason"}
|
||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if not updates:
|
||||
return self.get_inventory_item(item_id)
|
||||
|
|
@ -231,6 +232,32 @@ class Store:
|
|||
self.conn.commit()
|
||||
return self.get_inventory_item(item_id)
|
||||
|
||||
def partial_consume_item(
|
||||
self,
|
||||
item_id: int,
|
||||
consume_qty: float,
|
||||
consumed_at: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Decrement quantity by consume_qty. Mark consumed when quantity reaches 0."""
|
||||
row = self.get_inventory_item(item_id)
|
||||
if row is None:
|
||||
return None
|
||||
remaining = max(0.0, round(row["quantity"] - consume_qty, 6))
|
||||
if remaining <= 0:
|
||||
self.conn.execute(
|
||||
"UPDATE inventory_items SET quantity = 0, status = 'consumed',"
|
||||
" consumed_at = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(consumed_at, item_id),
|
||||
)
|
||||
else:
|
||||
self.conn.execute(
|
||||
"UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')"
|
||||
" WHERE id = ?",
|
||||
(remaining, item_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_inventory_item(item_id)
|
||||
|
||||
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(
|
||||
"""SELECT i.*, p.name as product_name, p.category
|
||||
|
|
|
|||
|
|
@ -93,6 +93,15 @@ class InventoryItemUpdate(BaseModel):
|
|||
opened_date: Optional[date] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
disposal_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PartialConsumeRequest(BaseModel):
|
||||
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
|
||||
|
||||
|
||||
class DiscardRequest(BaseModel):
|
||||
reason: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
|
|
@ -111,6 +120,7 @@ class InventoryItemResponse(BaseModel):
|
|||
opened_expiry_date: Optional[str] = None
|
||||
status: str
|
||||
notes: Optional[str]
|
||||
disposal_reason: Optional[str] = None
|
||||
source: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ class OpenFoodFactsService:
|
|||
allergens = product.get("allergens_tags", [])
|
||||
labels = product.get("labels_tags", [])
|
||||
|
||||
# Pack size detection: prefer explicit unit_count, fall back to serving count
|
||||
pack_quantity, pack_unit = self._extract_pack_size(product)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
|
|
@ -124,9 +127,47 @@ class OpenFoodFactsService:
|
|||
"nutrition_data": nutrition_data,
|
||||
"allergens": allergens,
|
||||
"labels": labels,
|
||||
"pack_quantity": pack_quantity,
|
||||
"pack_unit": pack_unit,
|
||||
"raw_data": product, # Store full response for debugging
|
||||
}
|
||||
|
||||
def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
"""Return (quantity, unit) for multi-pack products, or (None, None).
|
||||
|
||||
OFFs fields tried in order:
|
||||
1. `number_of_units` (explicit count, highest confidence)
|
||||
2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt)
|
||||
3. Parse `quantity` string like "4 x 113 g" or "6 pack"
|
||||
|
||||
Returns None, None when data is absent, ambiguous, or single-unit.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Field 1: explicit unit count
|
||||
unit_count = product.get("number_of_units")
|
||||
if unit_count:
|
||||
try:
|
||||
n = float(unit_count)
|
||||
if n > 1:
|
||||
return n, product.get("serving_size_unit") or "unit"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Field 2: parse quantity string for "N x ..." pattern
|
||||
qty_str = product.get("quantity", "")
|
||||
if qty_str:
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip())
|
||||
if m:
|
||||
n = float(m.group(1))
|
||||
if n > 1:
|
||||
# Try to get a sensible sub-unit label from the rest
|
||||
rest = qty_str[m.end():].strip()
|
||||
unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit"
|
||||
return n, unit_label
|
||||
|
||||
return None, None
|
||||
|
||||
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract nutrition facts from product data.
|
||||
|
|
|
|||
275
frontend/src/components/ActionDialog.vue
Normal file
275
frontend/src/components/ActionDialog.vue
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<template>
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="modal-overlay" @click="handleCancel">
|
||||
<div class="modal-container" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>{{ message }}</p>
|
||||
|
||||
<!-- Partial quantity input -->
|
||||
<div v-if="inputType === 'quantity'" class="action-input-row">
|
||||
<label class="action-input-label">{{ inputLabel }}</label>
|
||||
<div class="qty-input-group">
|
||||
<input
|
||||
v-model.number="inputNumber"
|
||||
type="number"
|
||||
:min="0.01"
|
||||
:max="inputMax"
|
||||
step="0.5"
|
||||
class="action-number-input"
|
||||
:aria-label="inputLabel"
|
||||
/>
|
||||
<span class="qty-unit">{{ inputUnit }}</span>
|
||||
</div>
|
||||
<button class="btn-use-all" @click="inputNumber = inputMax">
|
||||
Use all ({{ inputMax }} {{ inputUnit }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reason select -->
|
||||
<div v-if="inputType === 'select'" class="action-input-row">
|
||||
<label class="action-input-label">{{ inputLabel }}</label>
|
||||
<select v-model="inputSelect" class="action-select" :aria-label="inputLabel">
|
||||
<option value="">— skip —</option>
|
||||
<option v-for="opt in inputOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="handleCancel">Cancel</button>
|
||||
<button :class="['btn', `btn-${type}`]" @click="handleConfirm">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
type?: 'primary' | 'danger' | 'warning' | 'secondary'
|
||||
inputType?: 'quantity' | 'select' | null
|
||||
inputLabel?: string
|
||||
inputMax?: number
|
||||
inputUnit?: string
|
||||
inputOptions?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Confirm',
|
||||
confirmText: 'Confirm',
|
||||
type: 'primary',
|
||||
inputType: null,
|
||||
inputLabel: '',
|
||||
inputMax: 1,
|
||||
inputUnit: '',
|
||||
inputOptions: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [value: number | string | undefined]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const inputNumber = ref<number>(props.inputMax)
|
||||
const inputSelect = ref<string>('')
|
||||
|
||||
watch(() => props.inputMax, (v) => { inputNumber.value = v })
|
||||
watch(() => props.show, (v) => {
|
||||
if (v) {
|
||||
inputNumber.value = props.inputMax
|
||||
inputSelect.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
if (props.inputType === 'quantity') {
|
||||
const qty = Math.min(Math.max(0.01, inputNumber.value || props.inputMax), props.inputMax)
|
||||
emit('confirm', qty)
|
||||
} else if (props.inputType === 'select') {
|
||||
emit('confirm', inputSelect.value || undefined)
|
||||
} else {
|
||||
emit('confirm', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.action-input-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qty-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-number-input {
|
||||
width: 90px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.qty-unit {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-use-all {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-select {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--color-bg-primary); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--color-error-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active { transition: opacity 0.3s ease; }
|
||||
.modal-enter-active .modal-container,
|
||||
.modal-leave-active .modal-container { transition: transform 0.3s ease; }
|
||||
.modal-enter-from,
|
||||
.modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .modal-container,
|
||||
.modal-leave-to .modal-container { transform: scale(0.9) translateY(-20px); }
|
||||
</style>
|
||||
|
|
@ -354,11 +354,28 @@
|
|||
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
|
||||
<button
|
||||
v-if="item.status === 'available'"
|
||||
@click="markAsConsumed(item)"
|
||||
class="btn-icon btn-icon-success"
|
||||
:aria-label="item.quantity > 1 ? `Use some (${item.quantity} ${item.unit})` : 'Mark as used'"
|
||||
:title="item.quantity > 1 ? `Use some or all (${item.quantity} ${item.unit})` : 'Mark as used'"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<polyline points="4 10 8 14 16 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'available'"
|
||||
@click="markAsDiscarded(item)"
|
||||
class="btn-icon btn-icon-discard"
|
||||
aria-label="Mark as not used"
|
||||
title="I didn't use this (went bad, too much, etc)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<path d="M4 4l12 12M4 16L16 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<polyline points="3 6 5 6 17 6"/>
|
||||
|
|
@ -400,6 +417,22 @@
|
|||
@cancel="confirmDialog.show = false"
|
||||
/>
|
||||
|
||||
<!-- Action Dialog (partial consume / discard reason) -->
|
||||
<ActionDialog
|
||||
:show="actionDialog.show"
|
||||
:title="actionDialog.title"
|
||||
:message="actionDialog.message"
|
||||
:type="actionDialog.type"
|
||||
:confirm-text="actionDialog.confirmText"
|
||||
:input-type="actionDialog.inputType"
|
||||
:input-label="actionDialog.inputLabel"
|
||||
:input-max="actionDialog.inputMax"
|
||||
:input-unit="actionDialog.inputUnit"
|
||||
:input-options="actionDialog.inputOptions"
|
||||
@confirm="(v) => { actionDialog.onConfirm(v); actionDialog.show = false }"
|
||||
@cancel="actionDialog.show = false"
|
||||
/>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<ToastNotification
|
||||
:show="toast.show"
|
||||
|
|
@ -421,6 +454,7 @@ import type { InventoryItem } from '../services/api'
|
|||
import { formatQuantity } from '../utils/units'
|
||||
import EditItemModal from './EditItemModal.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ActionDialog from './ActionDialog.vue'
|
||||
import ToastNotification from './ToastNotification.vue'
|
||||
|
||||
const store = useInventoryStore()
|
||||
|
|
@ -466,6 +500,20 @@ const confirmDialog = reactive({
|
|||
onConfirm: () => {},
|
||||
})
|
||||
|
||||
const actionDialog = reactive({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary' as 'primary' | 'danger' | 'warning' | 'secondary',
|
||||
confirmText: 'Confirm',
|
||||
inputType: null as 'quantity' | 'select' | null,
|
||||
inputLabel: '',
|
||||
inputMax: 1,
|
||||
inputUnit: '',
|
||||
inputOptions: [] as string[],
|
||||
onConfirm: (_v: number | string | undefined) => {},
|
||||
})
|
||||
|
||||
// Toast Notification
|
||||
const toast = reactive({
|
||||
show: false,
|
||||
|
|
@ -585,24 +633,65 @@ async function markAsOpened(item: InventoryItem) {
|
|||
}
|
||||
}
|
||||
|
||||
async function markAsConsumed(item: InventoryItem) {
|
||||
showConfirm(
|
||||
`Mark ${item.product_name || 'item'} as consumed?`,
|
||||
async () => {
|
||||
function markAsConsumed(item: InventoryItem) {
|
||||
const isMulti = item.quantity > 1
|
||||
const label = item.product_name || 'item'
|
||||
Object.assign(actionDialog, {
|
||||
show: true,
|
||||
title: 'Mark as Used',
|
||||
message: isMulti
|
||||
? `How much of ${label} did you use?`
|
||||
: `Mark ${label} as used?`,
|
||||
type: 'primary',
|
||||
confirmText: isMulti ? 'Use' : 'Mark as Used',
|
||||
inputType: isMulti ? 'quantity' : null,
|
||||
inputLabel: 'Amount used:',
|
||||
inputMax: item.quantity,
|
||||
inputUnit: item.unit,
|
||||
inputOptions: [],
|
||||
onConfirm: async (val: number | string | undefined) => {
|
||||
const qty = isMulti ? (val as number) : undefined
|
||||
try {
|
||||
await inventoryAPI.consumeItem(item.id)
|
||||
await inventoryAPI.consumeItem(item.id, qty)
|
||||
await refreshItems()
|
||||
showToast(`${item.product_name || 'item'} marked as consumed`, 'success')
|
||||
} catch (err) {
|
||||
showToast('Failed to mark item as consumed', 'error')
|
||||
const verb = qty !== undefined && qty < item.quantity ? 'partially used' : 'marked as used'
|
||||
showToast(`${label} ${verb}`, 'success')
|
||||
} catch {
|
||||
showToast('Could not update item', 'error')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Mark as Consumed',
|
||||
type: 'primary',
|
||||
confirmText: 'Mark as Consumed',
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function markAsDiscarded(item: InventoryItem) {
|
||||
const label = item.product_name || 'item'
|
||||
Object.assign(actionDialog, {
|
||||
show: true,
|
||||
title: 'Item Not Used',
|
||||
message: `${label} — what happened to it?`,
|
||||
type: 'secondary',
|
||||
confirmText: 'Log It',
|
||||
inputType: 'select',
|
||||
inputLabel: 'Reason (optional):',
|
||||
inputMax: 1,
|
||||
inputUnit: '',
|
||||
inputOptions: [
|
||||
'went bad before I could use it',
|
||||
'too much — had excess',
|
||||
'changed my mind',
|
||||
'other',
|
||||
],
|
||||
onConfirm: async (val: number | string | undefined) => {
|
||||
const reason = typeof val === 'string' && val ? val : undefined
|
||||
try {
|
||||
await inventoryAPI.discardItem(item.id, reason)
|
||||
await refreshItems()
|
||||
showToast(`${label} logged as not used`, 'info')
|
||||
} catch {
|
||||
showToast('Could not update item', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Scanner Gun Functions
|
||||
|
|
@ -1201,6 +1290,16 @@ function getItemClass(item: InventoryItem): string {
|
|||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
/* "Item not used" discard button — muted, not alarming */
|
||||
.btn-icon-discard {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.btn-icon-discard:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Opened badge — distinct icon prefix signals this is after-open expiry */
|
||||
.expiry-opened {
|
||||
letter-spacing: 0;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface InventoryItem {
|
|||
status: string
|
||||
source: string
|
||||
notes: string | null
|
||||
disposal_reason: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -235,10 +236,21 @@ export const inventoryAPI = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Mark item as consumed
|
||||
* Mark item as consumed fully or partially.
|
||||
* Pass quantity to decrement; omit to consume all.
|
||||
*/
|
||||
async consumeItem(itemId: number): Promise<void> {
|
||||
await api.post(`/inventory/items/${itemId}/consume`)
|
||||
async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
|
||||
const body = quantity !== undefined ? { quantity } : undefined
|
||||
const response = await api.post(`/inventory/items/${itemId}/consume`, body)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark item as discarded (not used, spoiled, etc).
|
||||
*/
|
||||
async discardItem(itemId: number, reason?: string): Promise<InventoryItem> {
|
||||
const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue