feat: implement inventory CRUD UI components (#18 #19 #20 #21)
Some checks failed
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Some checks failed
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Week 2 core inventory management: **Composables:** - useInventory: Full CRUD operations for inventory items - useUnits: Unit fetching and conversion helpers - useTags: Tag fetching and category filtering **Components:** - InventoryList (#18): Grid view with loading/empty/error states - InventoryCard: Item card with image, quantity controls, tags, expiry - AddItemForm (#19): Form with tag picker, unit selector, validation - EditItemModal (#20): Modal form for editing existing items - Delete functionality (#21): Confirm dialog + cascade tag cleanup **Features:** - Quantity quick-actions (+/- buttons on cards) - Auto-delete when quantity reaches zero - Expiry date tracking with color-coded warnings - Tag selection by category in add form - Responsive grid layout (1-4 columns) - Product image display from barcode cache - Form validation and loading states Closes #18, #19, #20, #21
This commit is contained in:
201
app/composables/useInventory.ts
Normal file
201
app/composables/useInventory.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { Database } from '~/types/database.types'
|
||||
|
||||
type InventoryItem = Database['public']['Tables']['inventory_items']['Row']
|
||||
type InventoryItemInsert = Database['public']['Tables']['inventory_items']['Insert']
|
||||
type InventoryItemUpdate = Database['public']['Tables']['inventory_items']['Update']
|
||||
|
||||
export const useInventory = () => {
|
||||
const supabase = useSupabase()
|
||||
const { user } = useSupabaseAuth()
|
||||
|
||||
/**
|
||||
* Get all inventory items with denormalized data
|
||||
*/
|
||||
const getInventory = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.select(`
|
||||
*,
|
||||
product:products(*),
|
||||
unit:units(*),
|
||||
tags:item_tags(tag:tags(*))
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching inventory:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single inventory item by ID
|
||||
*/
|
||||
const getInventoryItem = async (id: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.select(`
|
||||
*,
|
||||
product:products(*),
|
||||
unit:units(*),
|
||||
tags:item_tags(tag:tags(*))
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching item:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new inventory item
|
||||
*/
|
||||
const addInventoryItem = async (item: Omit<InventoryItemInsert, 'added_by'>) => {
|
||||
if (!user.value) {
|
||||
return { data: null, error: { message: 'User not authenticated' } }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.insert({
|
||||
...item,
|
||||
added_by: user.value.id
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
product:products(*),
|
||||
unit:units(*),
|
||||
tags:item_tags(tag:tags(*))
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding item:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inventory item
|
||||
*/
|
||||
const updateInventoryItem = async (id: string, updates: InventoryItemUpdate) => {
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select(`
|
||||
*,
|
||||
product:products(*),
|
||||
unit:units(*),
|
||||
tags:item_tags(tag:tags(*))
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating item:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete inventory item
|
||||
*/
|
||||
const deleteInventoryItem = async (id: string) => {
|
||||
// First delete associated tags
|
||||
await supabase
|
||||
.from('item_tags')
|
||||
.delete()
|
||||
.eq('item_id', id)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('inventory_items')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting item:', error)
|
||||
return { error }
|
||||
}
|
||||
|
||||
return { error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item quantity (consume or restock)
|
||||
*/
|
||||
const updateQuantity = async (id: string, change: number) => {
|
||||
const { data: item, error: fetchError } = await getInventoryItem(id)
|
||||
if (fetchError || !item) {
|
||||
return { data: null, error: fetchError }
|
||||
}
|
||||
|
||||
const newQuantity = Number(item.quantity) + change
|
||||
|
||||
if (newQuantity <= 0) {
|
||||
// Auto-delete when quantity reaches zero
|
||||
return await deleteInventoryItem(id)
|
||||
}
|
||||
|
||||
return await updateInventoryItem(id, { quantity: newQuantity })
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags to item
|
||||
*/
|
||||
const addItemTags = async (itemId: string, tagIds: string[]) => {
|
||||
const items = tagIds.map(tagId => ({
|
||||
item_id: itemId,
|
||||
tag_id: tagId
|
||||
}))
|
||||
|
||||
const { error } = await supabase
|
||||
.from('item_tags')
|
||||
.insert(items)
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding tags:', error)
|
||||
return { error }
|
||||
}
|
||||
|
||||
return { error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tag from item
|
||||
*/
|
||||
const removeItemTag = async (itemId: string, tagId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('item_tags')
|
||||
.delete()
|
||||
.eq('item_id', itemId)
|
||||
.eq('tag_id', tagId)
|
||||
|
||||
if (error) {
|
||||
console.error('Error removing tag:', error)
|
||||
return { error }
|
||||
}
|
||||
|
||||
return { error: null }
|
||||
}
|
||||
|
||||
return {
|
||||
getInventory,
|
||||
getInventoryItem,
|
||||
addInventoryItem,
|
||||
updateInventoryItem,
|
||||
deleteInventoryItem,
|
||||
updateQuantity,
|
||||
addItemTags,
|
||||
removeItemTag
|
||||
}
|
||||
}
|
||||
44
app/composables/useTags.ts
Normal file
44
app/composables/useTags.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const useTags = () => {
|
||||
const supabase = useSupabase()
|
||||
|
||||
/**
|
||||
* Get all tags
|
||||
*/
|
||||
const getTags = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.order('category', { ascending: true })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching tags:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags by category
|
||||
*/
|
||||
const getTagsByCategory = async (category: 'position' | 'type' | 'dietary' | 'custom') => {
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.eq('category', category)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching tags by category:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
return {
|
||||
getTags,
|
||||
getTagsByCategory
|
||||
}
|
||||
}
|
||||
53
app/composables/useUnits.ts
Normal file
53
app/composables/useUnits.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const useUnits = () => {
|
||||
const supabase = useSupabase()
|
||||
|
||||
/**
|
||||
* Get all units
|
||||
*/
|
||||
const getUnits = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('units')
|
||||
.select('*')
|
||||
.order('unit_type', { ascending: true })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching units:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default unit for a type
|
||||
*/
|
||||
const getDefaultUnit = async (unitType: 'weight' | 'volume' | 'count' | 'custom') => {
|
||||
const { data, error } = await supabase
|
||||
.from('units')
|
||||
.select('*')
|
||||
.eq('unit_type', unitType)
|
||||
.eq('is_default', true)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching default unit:', error)
|
||||
return { data: null, error }
|
||||
}
|
||||
|
||||
return { data, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quantity between units
|
||||
*/
|
||||
const convertUnit = (quantity: number, fromFactor: number, toFactor: number): number => {
|
||||
return (quantity * fromFactor) / toFactor
|
||||
}
|
||||
|
||||
return {
|
||||
getUnits,
|
||||
getDefaultUnit,
|
||||
convertUnit
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user