Compare commits

..

3 Commits

Author SHA1 Message Date
Pantry Lead Agent
080d2424c8 feat: integrate TagBadge and TagPicker in inventory (#28 #29)
Some checks failed
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 Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (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
Issue #28 - Tag assignment in AddItemForm:
- Replace custom tag selection with TagPicker component
- Simplified code (removed manual tag state management)
- Cleaner UI with reusable component

Issue #29 - Display tags in InventoryList:
- Replace UBadge with TagBadge in InventoryCard
- Automatic contrast color for readability
- Consistent tag display across app

Closes #28, #29
2026-02-24 00:05:44 +00:00
Pantry Lead Agent
6b1c34ceff feat: create TagBadge and TagPicker components (#26 #27)
TagBadge:
- Display tag with icon, name, color
- Automatic contrast text color (light/dark)
- Optional removable with X button
- Configurable size (sm/md/lg)

TagPicker:
- Select multiple tags by category
- Visual feedback for selected tags
- Category-based organization
- Position category prioritized
- Two-way binding with v-model

Closes #26, #27
2026-02-24 00:05:12 +00:00
231f594004 Merge pull request 'feat: implement scan-to-add flow (#25)' (#51) from feature/issue-25-scan-to-add-flow into develop
Some checks failed
Deploy to Coolify / Deploy to Test (push) Has been cancelled
Deploy to Coolify / Code Quality (push) Has been cancelled
Deploy to Coolify / Run Tests (push) Has been cancelled
Deploy to Coolify / Deploy to Development (push) Has been cancelled
Deploy to Coolify / Deploy to Production (push) Has been cancelled
2026-02-24 00:04:23 +00:00
4 changed files with 204 additions and 81 deletions

View File

@@ -68,37 +68,7 @@
<!-- Tags -->
<UFormGroup label="Tags" hint="Optional">
<div class="space-y-2">
<!-- Selected Tags -->
<div v-if="selectedTags.length > 0" class="flex flex-wrap gap-1 mb-2">
<UBadge
v-for="tag in selectedTags"
:key="tag.id"
:style="{ backgroundColor: tag.color }"
class="text-white cursor-pointer"
@click="removeTag(tag.id)"
>
{{ tag.icon }} {{ tag.name }}
</UBadge>
</div>
<!-- Tag Selection by Category -->
<div v-for="category in tagCategories" :key="category.name" class="space-y-1">
<p class="text-xs font-medium text-gray-500 uppercase">{{ category.name }}</p>
<div class="flex flex-wrap gap-1">
<UButton
v-for="tag in category.tags"
:key="tag.id"
size="xs"
:color="isTagSelected(tag.id) ? 'primary' : 'gray'"
:variant="isTagSelected(tag.id) ? 'solid' : 'outline'"
@click="toggleTag(tag)"
>
{{ tag.icon }} {{ tag.name }}
</UButton>
</div>
</div>
</div>
<TagsTagPicker v-model="selectedTags" />
</UFormGroup>
<!-- Submit -->
@@ -129,7 +99,6 @@
<script setup lang="ts">
const { addInventoryItem, addItemTags } = useInventory()
const { getUnits } = useUnits()
const { getTags } = useTags()
const props = defineProps<{
initialData?: {
@@ -158,18 +127,12 @@ const form = reactive({
const submitting = ref(false)
const selectedTags = ref<any[]>([])
// Load units and tags
// Load units
const units = ref<any[]>([])
const tags = ref<any[]>([])
onMounted(async () => {
const [unitsResult, tagsResult] = await Promise.all([
getUnits(),
getTags()
])
const unitsResult = await getUnits()
units.value = unitsResult.data || []
tags.value = tagsResult.data || []
// Set default unit (Piece)
const defaultUnit = units.value.find(u => u.abbreviation === 'pc')
@@ -231,39 +194,6 @@ const unitOptions = computed(() => {
])
})
// Tag categories for display
const tagCategories = computed(() => {
const categories: Record<string, any[]> = {}
for (const tag of tags.value) {
const cat = tag.category
if (!categories[cat]) categories[cat] = []
categories[cat].push(tag)
}
return Object.entries(categories).map(([name, tags]) => ({
name,
tags
}))
})
// Tag selection helpers
const isTagSelected = (tagId: string) => {
return selectedTags.value.some(t => t.id === tagId)
}
const toggleTag = (tag: any) => {
if (isTagSelected(tag.id)) {
removeTag(tag.id)
} else {
selectedTags.value.push(tag)
}
}
const removeTag = (tagId: string) => {
selectedTags.value = selectedTags.value.filter(t => t.id !== tagId)
}
// Validation
const isValid = computed(() => {
return form.name.trim().length > 0 && form.quantity > 0 && form.unit_id

View File

@@ -50,15 +50,12 @@
<!-- Tags -->
<div v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-1">
<UBadge
v-for="tagItem in item.tags.slice(0, 3)"
<TagsTagBadge
v-for="tagItem in item.tags.slice(0, 3)"
:key="tagItem.tag.id"
:style="{ backgroundColor: tagItem.tag.color }"
size="xs"
class="text-white"
>
{{ tagItem.tag.icon }} {{ tagItem.tag.name }}
</UBadge>
:tag="tagItem.tag"
size="sm"
/>
<UBadge v-if="item.tags.length > 3" size="xs" color="gray">
+{{ item.tags.length - 3 }}
</UBadge>

View File

@@ -0,0 +1,71 @@
<template>
<UBadge
:style="badgeStyle"
:class="badgeClasses"
v-bind="$attrs"
>
<span v-if="tag.icon" class="mr-1">{{ tag.icon }}</span>
<span>{{ tag.name }}</span>
<UButton
v-if="removable"
icon="i-heroicons-x-mark"
size="2xs"
color="white"
variant="link"
class="ml-1 -mr-1"
@click.stop="$emit('remove', tag.id)"
/>
</UBadge>
</template>
<script setup lang="ts">
interface Tag {
id: string
name: string
color: string
icon?: string
category: string
}
const props = withDefaults(defineProps<{
tag: Tag
removable?: boolean
size?: 'sm' | 'md' | 'lg'
}>(), {
removable: false,
size: 'md'
})
defineEmits<{
remove: [tagId: string]
}>()
const badgeStyle = computed(() => ({
backgroundColor: props.tag.color,
color: getContrastColor(props.tag.color)
}))
const badgeClasses = computed(() => ({
'cursor-pointer': props.removable,
'text-xs px-2 py-1': props.size === 'sm',
'text-sm px-2.5 py-1': props.size === 'md',
'text-base px-3 py-1.5': props.size === 'lg'
}))
// Calculate contrast color for text (black or white)
function getContrastColor(hexColor: string): string {
// Remove # if present
const hex = hexColor.replace('#', '')
// Convert to RGB
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Return white for dark colors, black for light colors
return luminance > 0.5 ? '#000000' : '#FFFFFF'
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div class="space-y-4">
<!-- Selected Tags -->
<div v-if="selectedTags.length > 0" class="flex flex-wrap gap-2">
<TagsTagBadge
v-for="tag in selectedTags"
:key="tag.id"
:tag="tag"
:removable="true"
@remove="removeTag"
/>
</div>
<!-- Empty State -->
<div v-else class="text-sm text-gray-500 italic">
No tags selected
</div>
<!-- Tag Selection by Category -->
<div v-for="category in tagsByCategory" :key="category.name" class="space-y-2">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{{ category.name }}
</h4>
<div class="flex flex-wrap gap-2">
<UButton
v-for="tag in category.tags"
:key="tag.id"
size="sm"
:color="isSelected(tag.id) ? 'primary' : 'gray'"
:variant="isSelected(tag.id) ? 'solid' : 'outline'"
@click="toggleTag(tag)"
>
<span v-if="tag.icon" class="mr-1">{{ tag.icon }}</span>
{{ tag.name }}
</UButton>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-4">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500"></div>
<p class="text-sm text-gray-500 mt-2">Loading tags...</p>
</div>
<!-- Empty State (no tags available) -->
<div v-if="!loading && availableTags.length === 0" class="text-center py-4">
<p class="text-gray-500">No tags available</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Tag {
id: string
name: string
color: string
icon?: string
category: string
}
const props = defineProps<{
modelValue: Tag[]
}>()
const emit = defineEmits<{
'update:modelValue': [tags: Tag[]]
}>()
const { getTags } = useTags()
const availableTags = ref<Tag[]>([])
const loading = ref(true)
// Load tags on mount
onMounted(async () => {
const { data, error } = await getTags()
if (data) {
availableTags.value = data
}
loading.value = false
})
// Computed
const selectedTags = computed(() => props.modelValue)
const tagsByCategory = computed(() => {
const grouped: Record<string, Tag[]> = {}
for (const tag of availableTags.value) {
if (!grouped[tag.category]) {
grouped[tag.category] = []
}
grouped[tag.category].push(tag)
}
return Object.entries(grouped).map(([name, tags]) => ({
name: name.charAt(0).toUpperCase() + name.slice(1),
tags: tags.sort((a, b) => a.name.localeCompare(b.name))
})).sort((a, b) => {
// Position category first, then others alphabetically
if (a.name === 'Position') return -1
if (b.name === 'Position') return 1
return a.name.localeCompare(b.name)
})
})
// Methods
const isSelected = (tagId: string) => {
return selectedTags.value.some(t => t.id === tagId)
}
const toggleTag = (tag: Tag) => {
const isCurrentlySelected = isSelected(tag.id)
if (isCurrentlySelected) {
removeTag(tag.id)
} else {
emit('update:modelValue', [...selectedTags.value, tag])
}
}
const removeTag = (tagId: string) => {
emit('update:modelValue', selectedTags.value.filter(t => t.id !== tagId))
}
</script>