This commit is contained in:
@@ -285,7 +285,7 @@ def list_all_tasks(id):
|
|||||||
tasks.append(task_dict)
|
tasks.append(task_dict)
|
||||||
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
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'])
|
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||||
@@ -373,7 +373,8 @@ def list_all_rewards(id):
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'rewards': rewards,
|
'rewards': rewards,
|
||||||
'rewards_count': len(rewards)
|
'rewards_count': len(rewards),
|
||||||
|
'list_type': 'reward'
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -680,5 +681,5 @@ def list_pending_rewards():
|
|||||||
)
|
)
|
||||||
reward_responses.append(response.to_dict())
|
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_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
image_url?: string | null // optional, for resolved URLs
|
||||||
}
|
}
|
||||||
|
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
|
||||||
|
|
||||||
export interface Child {
|
export interface Child {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,6 +18,7 @@ export interface Child {
|
|||||||
image_id: string | null
|
image_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
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 {
|
export interface Reward {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,6 +28,7 @@ export interface Reward {
|
|||||||
image_id: string | null
|
image_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
image_url?: string | null // optional, for resolved URLs
|
||||||
}
|
}
|
||||||
|
export const REWARD_FIELDS = ['id', 'name', 'cost', 'points_needed', 'image_id'] as const
|
||||||
|
|
||||||
export interface RewardStatus {
|
export interface RewardStatus {
|
||||||
id: string
|
id: string
|
||||||
@@ -36,6 +39,14 @@ export interface RewardStatus {
|
|||||||
image_id: string | null
|
image_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
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 {
|
export interface PendingReward {
|
||||||
id: string
|
id: string
|
||||||
@@ -48,6 +59,15 @@ export interface PendingReward {
|
|||||||
reward_image_id: string | null
|
reward_image_id: string | null
|
||||||
reward_image_url?: string | null // optional, for resolved URLs
|
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 {
|
export interface Event {
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -10,11 +10,18 @@
|
|||||||
ref="rewardListRef"
|
ref="rewardListRef"
|
||||||
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
||||||
itemKey="rewards"
|
itemKey="rewards"
|
||||||
:itemFields="['id', 'name', 'cost', 'image_id']"
|
:itemFields="REWARD_FIELDS"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
selectable
|
selectable
|
||||||
@loading-complete="(count) => (rewardCountRef = count)"
|
@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>
|
||||||
<div class="actions" v-if="rewardCountRef != 0">
|
<div class="actions" v-if="rewardCountRef != 0">
|
||||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
<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 ItemList from '../shared/ItemList.vue'
|
||||||
import MessageBlock from '../shared/MessageBlock.vue'
|
import MessageBlock from '../shared/MessageBlock.vue'
|
||||||
import '@/assets/actions-shared.css'
|
import '@/assets/actions-shared.css'
|
||||||
|
import { REWARD_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -90,4 +98,21 @@ function onCancel() {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -10,11 +10,18 @@
|
|||||||
ref="taskListRef"
|
ref="taskListRef"
|
||||||
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
||||||
itemKey="tasks"
|
itemKey="tasks"
|
||||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
:itemFields="TASK_FIELDS"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
selectable
|
selectable
|
||||||
@loading-complete="(count) => (taskCountRef = count)"
|
@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>
|
||||||
<div class="actions" v-if="taskCountRef > 0">
|
<div class="actions" v-if="taskCountRef > 0">
|
||||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
<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 ItemList from '../shared/ItemList.vue'
|
||||||
import MessageBlock from '../shared/MessageBlock.vue'
|
import MessageBlock from '../shared/MessageBlock.vue'
|
||||||
import '@/assets/actions-shared.css'
|
import '@/assets/actions-shared.css'
|
||||||
|
import { TASK_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -96,4 +104,25 @@ function onCancel() {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</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>
|
<template>
|
||||||
<div class="notification-view">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import NotificationList from './NotificationList.vue'
|
import ItemList from '../shared/ItemList.vue'
|
||||||
import type { PendingReward } from '@/common/models'
|
import type { PendingReward } from '@/common/models'
|
||||||
|
import { PENDING_REWARD_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const notificationListCountRef = ref(-1)
|
||||||
|
|
||||||
function handleNotificationClick(item: PendingReward) {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -29,4 +52,34 @@ function handleNotificationClick(item: PendingReward) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -8,13 +8,20 @@
|
|||||||
v-else
|
v-else
|
||||||
fetchUrl="/api/reward/list"
|
fetchUrl="/api/reward/list"
|
||||||
itemKey="rewards"
|
itemKey="rewards"
|
||||||
:itemFields="['id', 'name', 'cost', 'description', 'image_id']"
|
:itemFields="REWARD_FIELDS"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
deletable
|
deletable
|
||||||
@edit="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
|
||||||
@delete="confirmDeleteReward"
|
@delete="confirmDeleteReward"
|
||||||
@loading-complete="(count) => (rewardCountRef = count)"
|
@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" />
|
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
|
||||||
|
|
||||||
@@ -35,6 +42,8 @@ import MessageBlock from '@/components/shared/MessageBlock.vue'
|
|||||||
import '@/assets/button-shared.css'
|
import '@/assets/button-shared.css'
|
||||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||||
import DeleteModal from '../shared/DeleteModal.vue'
|
import DeleteModal from '../shared/DeleteModal.vue'
|
||||||
|
import type { Reward } from '@/common/models'
|
||||||
|
import { REWARD_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
import '@/assets/view-shared.css'
|
import '@/assets/view-shared.css'
|
||||||
|
|
||||||
@@ -83,4 +92,21 @@ const createReward = () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { getCachedImageUrl } from '@/common/imageCache'
|
import { getCachedImageUrl } from '@/common/imageCache'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fetchUrl: string
|
fetchUrl: string
|
||||||
itemKey: string
|
itemKey: string
|
||||||
itemFields: string[]
|
itemFields: readonly string[]
|
||||||
imageField?: string
|
imageFields?: readonly string[]
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
deletable?: boolean
|
deletable?: boolean
|
||||||
onEdit?: (id: string) => void
|
onClicked?: (item: any) => void
|
||||||
onDelete?: (id: string) => void
|
onDelete?: (id: string) => void
|
||||||
filterFn?: (item: any) => boolean
|
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 items = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -32,25 +33,36 @@ const fetchItems = async () => {
|
|||||||
console.log(`Fetching items from: ${props.fetchUrl}`)
|
console.log(`Fetching items from: ${props.fetchUrl}`)
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(props.fetchUrl)
|
const resp = await fetch(props.fetchUrl)
|
||||||
|
console.log(`Fetch response status: ${resp.status}`)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
//console log all data
|
//console log all data
|
||||||
console.log('Fetched data:', 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)
|
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
itemList.map(async (item: any) => {
|
itemList.map(async (item: any) => {
|
||||||
if (props.imageField && item[props.imageField]) {
|
if (props.imageFields) {
|
||||||
|
for (const field of props.imageFields) {
|
||||||
|
if (item[field]) {
|
||||||
try {
|
try {
|
||||||
item.image_url = await getCachedImageUrl(item[props.imageField])
|
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.image_id)
|
||||||
} catch {
|
} catch {
|
||||||
item.image_url = null
|
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
|
//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) {
|
if (props.selectable && item.assigned === true) {
|
||||||
selectedItems.value.push(item.id)
|
selectedItems.value.push(item.id)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
items.value = itemList
|
items.value = itemList
|
||||||
@@ -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)
|
onMounted(fetchItems)
|
||||||
watch(() => props.fetchUrl, fetchItems)
|
watch(() => props.fetchUrl, fetchItems)
|
||||||
const itemType = computed(() => getItemType(items))
|
|
||||||
|
|
||||||
const handleEdit = (id: string) => {
|
const handleClicked = (item: any) => {
|
||||||
emit('edit', id)
|
emit('clicked', item)
|
||||||
props.onEdit?.(id)
|
props.onClicked?.(item)
|
||||||
}
|
}
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (item: any) => {
|
||||||
emit('delete', id)
|
emit('delete', item)
|
||||||
props.onDelete?.(id)
|
props.onDelete?.(item)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -92,24 +95,27 @@ const handleDelete = (id: string) => {
|
|||||||
<div v-else-if="error" class="error">{{ error }}</div>
|
<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-if="items.length === 0" class="empty">No items found.</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(item, idx) in items" :key="item.id">
|
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
|
||||||
<div class="list-item" :class="['list-item', itemType]" @click="handleEdit(item.id)">
|
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
|
||||||
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
|
<slot name="item" :item="item">
|
||||||
<span class="list-name">{{ item.name }}</span>
|
<!-- Default rendering if no slot is provided -->
|
||||||
<span v-if="item.points !== undefined" class="value">{{ item.points }} pts</span>
|
<img v-if="item.image_url" :src="item.image_url" />
|
||||||
<span v-if="item.cost !== undefined" class="value">{{ item.cost }} pts</span>
|
<span class="list-name">List Item</span>
|
||||||
|
<span class="list-value">1</span>
|
||||||
|
</slot>
|
||||||
|
<div v-if="props.selectable || props.deletable" class="interact">
|
||||||
<input
|
<input
|
||||||
v-if="props.selectable"
|
v-if="props.selectable"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="list-checkbox"
|
class="list-checkbox"
|
||||||
v-model="selectedItems"
|
v-model="selectedItems"
|
||||||
:value="item.id"
|
:value="item"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="props.deletable"
|
v-if="props.deletable"
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
@click.stop="handleDelete(item.id)"
|
@click.stop="handleDelete(item)"
|
||||||
aria-label="Delete item"
|
aria-label="Delete item"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -129,7 +135,9 @@ const handleDelete = (id: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.listbox {
|
.listbox {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
@@ -160,22 +168,17 @@ const handleDelete = (id: string) => {
|
|||||||
margin-right: 0.2rem;
|
margin-right: 0.2rem;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.list-item.bad {
|
.list-item .interact {
|
||||||
border-color: var(--list-item-border-bad);
|
display: flex;
|
||||||
background: var(--list-item-bg-bad);
|
align-items: center;
|
||||||
}
|
gap: 0.5rem;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image styles */
|
/* Image styles */
|
||||||
.list-image {
|
:deep(.list-item img) {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -202,7 +205,6 @@ const handleDelete = (id: string) => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 0.15rem;
|
|
||||||
margin-left: 0.7rem;
|
margin-left: 0.7rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -250,10 +252,6 @@ const handleDelete = (id: string) => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.value {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
/* Separator (if needed) */
|
/* Separator (if needed) */
|
||||||
.list-separator {
|
.list-separator {
|
||||||
height: 0px;
|
height: 0px;
|
||||||
|
|||||||
@@ -8,13 +8,20 @@
|
|||||||
v-else
|
v-else
|
||||||
fetchUrl="/api/task/list"
|
fetchUrl="/api/task/list"
|
||||||
itemKey="tasks"
|
itemKey="tasks"
|
||||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
:itemFields="TASK_FIELDS"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
deletable
|
deletable
|
||||||
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
@clicked="(task: Task) => $router.push({ name: 'EditTask', params: { id: task.id } })"
|
||||||
@delete="confirmDeleteTask"
|
@delete="confirmDeleteTask"
|
||||||
@loading-complete="(count) => (taskCountRef = count)"
|
@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" />
|
<FloatingActionButton aria-label="Create Task" @click="createTask" />
|
||||||
|
|
||||||
@@ -35,8 +42,8 @@ import MessageBlock from '@/components/shared/MessageBlock.vue'
|
|||||||
import '@/assets/button-shared.css'
|
import '@/assets/button-shared.css'
|
||||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||||
import DeleteModal from '../shared/DeleteModal.vue'
|
import DeleteModal from '../shared/DeleteModal.vue'
|
||||||
|
import type { Task } from '@/common/models'
|
||||||
import '@/assets/view-shared.css'
|
import { TASK_FIELDS } from '@/common/models'
|
||||||
|
|
||||||
const $router = useRouter()
|
const $router = useRouter()
|
||||||
|
|
||||||
@@ -86,4 +93,24 @@ const createTask = () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user