This commit is contained in:
@@ -285,7 +285,7 @@ def list_all_tasks(id):
|
||||
tasks.append(task_dict)
|
||||
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||
|
||||
return jsonify({ 'tasks': tasks }), 200
|
||||
return jsonify({ 'tasks': tasks, 'count': len(tasks), 'list_type': 'task' }), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||
@@ -373,7 +373,8 @@ def list_all_rewards(id):
|
||||
|
||||
return jsonify({
|
||||
'rewards': rewards,
|
||||
'rewards_count': len(rewards)
|
||||
'rewards_count': len(rewards),
|
||||
'list_type': 'reward'
|
||||
}), 200
|
||||
|
||||
|
||||
@@ -680,5 +681,5 @@ def list_pending_rewards():
|
||||
)
|
||||
reward_responses.append(response.to_dict())
|
||||
|
||||
return jsonify({'rewards': reward_responses}), 200
|
||||
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Task {
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
|
||||
|
||||
export interface Child {
|
||||
id: string
|
||||
@@ -17,6 +18,7 @@ export interface Child {
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'rewards', 'points', 'image_id'] as const
|
||||
|
||||
export interface Reward {
|
||||
id: string
|
||||
@@ -26,6 +28,7 @@ export interface Reward {
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const REWARD_FIELDS = ['id', 'name', 'cost', 'points_needed', 'image_id'] as const
|
||||
|
||||
export interface RewardStatus {
|
||||
id: string
|
||||
@@ -36,6 +39,14 @@ export interface RewardStatus {
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const REWARD_STATUS_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'points_needed',
|
||||
'cost',
|
||||
'redeeming',
|
||||
'image_id',
|
||||
] as const
|
||||
|
||||
export interface PendingReward {
|
||||
id: string
|
||||
@@ -48,6 +59,15 @@ export interface PendingReward {
|
||||
reward_image_id: string | null
|
||||
reward_image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
export const PENDING_REWARD_FIELDS = [
|
||||
'id',
|
||||
'child_id',
|
||||
'child_name',
|
||||
'child_image_id',
|
||||
'reward_id',
|
||||
'reward_name',
|
||||
'reward_image_id',
|
||||
] as const
|
||||
|
||||
export interface Event {
|
||||
type: string
|
||||
|
||||
@@ -10,11 +10,18 @@
|
||||
ref="rewardListRef"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
||||
itemKey="rewards"
|
||||
:itemFields="['id', 'name', 'cost', 'image_id']"
|
||||
:itemFields="REWARD_FIELDS"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
@loading-complete="(count) => (rewardCountRef = count)"
|
||||
/>
|
||||
:getItemClass="(item) => `reward`"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.cost }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
</div>
|
||||
<div class="actions" v-if="rewardCountRef != 0">
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
@@ -29,6 +36,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/actions-shared.css'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -90,4 +98,21 @@ function onCancel() {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,11 +10,18 @@
|
||||
ref="taskListRef"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
||||
itemKey="tasks"
|
||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||
:itemFields="TASK_FIELDS"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
/>
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.points }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
</div>
|
||||
<div class="actions" v-if="taskCountRef > 0">
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
@@ -29,6 +36,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/actions-shared.css'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -96,4 +104,25 @@ function onCancel() {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
|
||||
const emit = defineEmits(['item-clicked'])
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const notifications = ref<PendingReward[]>([])
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch('/api/pending-rewards')
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const rewards: PendingReward[] = data.rewards || []
|
||||
|
||||
// Fetch images for child and reward
|
||||
await Promise.all(
|
||||
rewards.map(async (item) => {
|
||||
if (item.child_image_id) {
|
||||
try {
|
||||
item.child_image_url = await getCachedImageUrl(item.child_image_id)
|
||||
} catch (e) {
|
||||
item.child_image_url = null
|
||||
}
|
||||
}
|
||||
if (item.reward_image_id) {
|
||||
try {
|
||||
item.reward_image_url = await getCachedImageUrl(item.reward_image_id)
|
||||
} catch (e) {
|
||||
item.reward_image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
notifications.value = rewards
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch notifications'
|
||||
notifications.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardRequest(event: Event) {
|
||||
const payload = event.payload as ChildRewardRequestEventPayload
|
||||
const childId = payload.child_id
|
||||
const rewardId = payload.reward_id
|
||||
// Todo: Have event carry more info to avoid full refresh
|
||||
fetchNotifications()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
await fetchNotifications()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
})
|
||||
|
||||
function handleItemClick(item: PendingReward) {
|
||||
emit('item-clicked', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="centered-list-container">
|
||||
<div v-if="loading" class="loading">Loading notifications...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="notifications.length === 0" class="empty">No notifications</div>
|
||||
<div v-else class="listbox">
|
||||
<div v-for="(item, idx) in notifications" :key="item.id">
|
||||
<div class="list-item notification-centered" @click="handleItemClick(item)">
|
||||
<div class="child-info">
|
||||
<img
|
||||
v-if="item.child_image_url"
|
||||
:src="item.child_image_url"
|
||||
alt="Child"
|
||||
class="list-image"
|
||||
/>
|
||||
<span class="child-name">{{ item.child_name }}</span>
|
||||
</div>
|
||||
<span class="requested-text">requested</span>
|
||||
<div class="reward-info">
|
||||
<span class="reward-name">{{ item.reward_name }}</span>
|
||||
<img
|
||||
v-if="item.reward_image_url"
|
||||
:src="item.reward_image_url"
|
||||
alt="Reward"
|
||||
class="list-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="idx < notifications.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.centered-list-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.notification-centered {
|
||||
justify-content: center;
|
||||
}
|
||||
.child-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
color: var(--dialog-child-name);
|
||||
}
|
||||
.reward-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0rem;
|
||||
}
|
||||
.reward-name {
|
||||
font-weight: 600;
|
||||
color: var(--notification-reward-name);
|
||||
}
|
||||
.requested-text {
|
||||
margin: 0 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--dialog-message);
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,43 @@
|
||||
<template>
|
||||
<div class="notification-view">
|
||||
<NotificationList @item-clicked="handleNotificationClick" />
|
||||
<ItemList
|
||||
:fetchUrl="`/api/pending-rewards`"
|
||||
itemKey="rewards"
|
||||
:itemFields="PENDING_REWARD_FIELDS"
|
||||
:imageFields="['child_image_id', 'reward_image_id']"
|
||||
@clicked="handleNotificationClick"
|
||||
@loading-complete="(count) => (notificationListCountRef = count)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="notification-centered">
|
||||
<div class="child-info">
|
||||
<img v-if="item.child_image_url" :src="item.child_image_url" alt="Child" />
|
||||
<span>{{ item.child_name }}</span>
|
||||
</div>
|
||||
<span class="requested-text">requested</span>
|
||||
<div class="reward-info">
|
||||
<span>{{ item.reward_name }}</span>
|
||||
<img v-if="item.reward_image_url" :src="item.reward_image_url" alt="Reward" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ItemList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NotificationList from './NotificationList.vue'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import type { PendingReward } from '@/common/models'
|
||||
import { PENDING_REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const notificationListCountRef = ref(-1)
|
||||
|
||||
function handleNotificationClick(item: PendingReward) {
|
||||
if (item.child_id) {
|
||||
router.push({ name: 'ParentView', params: { id: item.child_id } })
|
||||
}
|
||||
router.push({ name: 'ParentView', params: { id: item.child_id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,4 +52,34 @@ function handleNotificationClick(item: PendingReward) {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.notification-centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-inline: auto;
|
||||
align-items: center;
|
||||
}
|
||||
.child-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--dialog-child-name);
|
||||
}
|
||||
|
||||
.reward-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0rem;
|
||||
font-weight: 600;
|
||||
color: var(--notification-reward-name);
|
||||
}
|
||||
|
||||
.requested-text {
|
||||
margin: 0 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--dialog-message);
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,13 +8,20 @@
|
||||
v-else
|
||||
fetchUrl="/api/reward/list"
|
||||
itemKey="rewards"
|
||||
:itemFields="['id', 'name', 'cost', 'description', 'image_id']"
|
||||
:itemFields="REWARD_FIELDS"
|
||||
imageField="image_id"
|
||||
deletable
|
||||
@edit="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
||||
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
|
||||
@delete="confirmDeleteReward"
|
||||
@loading-complete="(count) => (rewardCountRef = count)"
|
||||
/>
|
||||
:getItemClass="(item) => `reward`"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.cost }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
|
||||
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
|
||||
|
||||
@@ -35,6 +42,8 @@ import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
import type { Reward } from '@/common/models'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
import '@/assets/view-shared.css'
|
||||
|
||||
@@ -83,4 +92,21 @@ const createReward = () => {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
fetchUrl: string
|
||||
itemKey: string
|
||||
itemFields: string[]
|
||||
imageField?: string
|
||||
itemFields: readonly string[]
|
||||
imageFields?: readonly string[]
|
||||
selectable?: boolean
|
||||
deletable?: boolean
|
||||
onEdit?: (id: string) => void
|
||||
onClicked?: (item: any) => void
|
||||
onDelete?: (id: string) => void
|
||||
filterFn?: (item: any) => boolean
|
||||
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'loading-complete'])
|
||||
const emit = defineEmits(['clicked', 'delete', 'loading-complete'])
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -32,24 +33,35 @@ const fetchItems = async () => {
|
||||
console.log(`Fetching items from: ${props.fetchUrl}`)
|
||||
try {
|
||||
const resp = await fetch(props.fetchUrl)
|
||||
console.log(`Fetch response status: ${resp.status}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
//console log all data
|
||||
console.log('Fetched data:', data)
|
||||
let itemList = data[props.itemKey] || []
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
await Promise.all(
|
||||
itemList.map(async (item: any) => {
|
||||
if (props.imageField && item[props.imageField]) {
|
||||
if (props.imageFields) {
|
||||
for (const field of props.imageFields) {
|
||||
if (item[field]) {
|
||||
try {
|
||||
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
|
||||
} catch {
|
||||
item[`${field.replace('_id', '_url')}`] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.image_id) {
|
||||
try {
|
||||
item.image_url = await getCachedImageUrl(item[props.imageField])
|
||||
item.image_url = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
item.image_url = null
|
||||
}
|
||||
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
|
||||
if (props.selectable && item.assigned === true) {
|
||||
selectedItems.value.push(item.id)
|
||||
}
|
||||
}
|
||||
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
|
||||
if (props.selectable && item.assigned === true) {
|
||||
selectedItems.value.push(item.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -64,25 +76,16 @@ const fetchItems = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getItemType = (itemList) => {
|
||||
if (itemList.length === 0) return 'reward'
|
||||
if (items.value[0].points !== undefined) {
|
||||
return items.value[0].is_good ? 'good' : 'bad'
|
||||
}
|
||||
return 'reward'
|
||||
}
|
||||
|
||||
onMounted(fetchItems)
|
||||
watch(() => props.fetchUrl, fetchItems)
|
||||
const itemType = computed(() => getItemType(items))
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
emit('edit', id)
|
||||
props.onEdit?.(id)
|
||||
const handleClicked = (item: any) => {
|
||||
emit('clicked', item)
|
||||
props.onClicked?.(item)
|
||||
}
|
||||
const handleDelete = (id: string) => {
|
||||
emit('delete', id)
|
||||
props.onDelete?.(id)
|
||||
const handleDelete = (item: any) => {
|
||||
emit('delete', item)
|
||||
props.onDelete?.(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -92,44 +95,49 @@ const handleDelete = (id: string) => {
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="items.length === 0" class="empty">No items found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(item, idx) in items" :key="item.id">
|
||||
<div class="list-item" :class="['list-item', itemType]" @click="handleEdit(item.id)">
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
|
||||
<span class="list-name">{{ item.name }}</span>
|
||||
<span v-if="item.points !== undefined" class="value">{{ item.points }} pts</span>
|
||||
<span v-if="item.cost !== undefined" class="value">{{ item.cost }} pts</span>
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedItems"
|
||||
:value="item.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
aria-label="Delete item"
|
||||
type="button"
|
||||
>
|
||||
<!-- SVG icon here -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||
<path
|
||||
d="M7 7l6 6M13 7l-6 6"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
|
||||
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
|
||||
<slot name="item" :item="item">
|
||||
<!-- Default rendering if no slot is provided -->
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="list-name">List Item</span>
|
||||
<span class="list-value">1</span>
|
||||
</slot>
|
||||
<div v-if="props.selectable || props.deletable" class="interact">
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedItems"
|
||||
:value="item"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@click.stop="handleDelete(item)"
|
||||
aria-label="Delete item"
|
||||
type="button"
|
||||
>
|
||||
<!-- SVG icon here -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||
<path
|
||||
d="M7 7l6 6M13 7l-6 6"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="idx < items.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
<div v-if="idx < items.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.listbox {
|
||||
flex: 0 1 auto;
|
||||
@@ -160,22 +168,17 @@ const handleDelete = (id: string) => {
|
||||
margin-right: 0.2rem;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item.bad {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
.list-item.reward {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
.list-item.good {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
.list-item .interact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Image styles */
|
||||
.list-image {
|
||||
:deep(.list-item img) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
@@ -202,7 +205,6 @@ const handleDelete = (id: string) => {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0.15rem;
|
||||
margin-left: 0.7rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -250,10 +252,6 @@ const handleDelete = (id: string) => {
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
/* Separator (if needed) */
|
||||
.list-separator {
|
||||
height: 0px;
|
||||
|
||||
@@ -8,13 +8,20 @@
|
||||
v-else
|
||||
fetchUrl="/api/task/list"
|
||||
itemKey="tasks"
|
||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||
:itemFields="TASK_FIELDS"
|
||||
imageField="image_id"
|
||||
deletable
|
||||
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||
@clicked="(task: Task) => $router.push({ name: 'EditTask', params: { id: task.id } })"
|
||||
@delete="confirmDeleteTask"
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
/>
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="value">{{ item.points }} pts</span>
|
||||
</template>
|
||||
</ItemList>
|
||||
|
||||
<FloatingActionButton aria-label="Create Task" @click="createTask" />
|
||||
|
||||
@@ -35,8 +42,8 @@ import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
|
||||
import '@/assets/view-shared.css'
|
||||
import type { Task } from '@/common/models'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
@@ -86,4 +93,24 @@ const createTask = () => {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user