This commit is contained in:
142
frontend/vue-app/src/components/child/ChildDetailCard.vue
Normal file
142
frontend/vue-app/src/components/child/ChildDetailCard.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
child: Child | null
|
||||
}>()
|
||||
|
||||
const { child } = toRefs(props)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
imageUrl.value = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching child image:', err)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => child.value?.image_id,
|
||||
(newImageId) => {
|
||||
if (newImageId) {
|
||||
fetchImage(newImageId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="child" class="detail-card-horizontal">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
|
||||
<div class="main-info">
|
||||
<div class="child-name">{{ child.name }}</div>
|
||||
<div class="child-age">Age: {{ child.age }}</div>
|
||||
</div>
|
||||
<div class="points">
|
||||
<span class="label">Points</span>
|
||||
<span class="value">{{ child.points ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-card-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--detail-card-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--detail-card-shadow);
|
||||
padding: 0.7rem 1rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.child-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: var(--child-name-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.child-age {
|
||||
font-size: 0.97rem;
|
||||
color: var(--child-age-color);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 54px;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
.points .label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--points-label-color);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.points .value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: var(--points-value-color);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.detail-card-horizontal {
|
||||
padding: 0.5rem 0.4rem;
|
||||
max-width: 98vw;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.points {
|
||||
min-width: 38px;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
frontend/vue-app/src/components/child/ChildEditView.vue
Normal file
157
frontend/vue-app/src/components/child/ChildEditView.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="child-edit-view">
|
||||
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading child...</div>
|
||||
<form v-else @submit.prevent="submit" class="child-edit-form">
|
||||
<div class="group">
|
||||
<label for="child-name">Name</label>
|
||||
<input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="child-age">Age</label>
|
||||
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="child-image">Image</label>
|
||||
<ImagePicker
|
||||
id="child-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import '@/assets/edit-forms.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Accept id as a prop for edit mode
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const isEdit = computed(() => !!props.id)
|
||||
const name = ref('')
|
||||
const age = ref<number | null>(null)
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.id}`)
|
||||
if (!resp.ok) throw new Error('Failed to load child')
|
||||
const data = await resp.json()
|
||||
name.value = data.name ?? ''
|
||||
age.value = Number(data.age) ?? null
|
||||
selectedImageId.value = data.image_id ?? null
|
||||
} catch (e) {
|
||||
error.value = 'Could not load child.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
} else {
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Child name is required.'
|
||||
return
|
||||
}
|
||||
if (age.value === null || age.value < 0) {
|
||||
error.value = 'Age must be a non-negative number.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '1')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
alert('Failed to upload image.')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update or create the child
|
||||
try {
|
||||
let resp
|
||||
if (isEdit.value && props.id) {
|
||||
resp = await fetch(`/api/child/${props.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
resp = await fetch('/api/child/add', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to save child')
|
||||
await router.push({ name: 'ParentChildrenListView' })
|
||||
} catch (err) {
|
||||
alert('Failed to save child.')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
533
frontend/vue-app/src/components/child/ChildView.vue
Normal file
533
frontend/vue-app/src/components/child/ChildView.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
import type {
|
||||
Child,
|
||||
Event,
|
||||
Task,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
ChildTasksSetEventPayload,
|
||||
ChildRewardsSetEventPayload,
|
||||
TaskModifiedEventPayload,
|
||||
RewardModifiedEventPayload,
|
||||
ChildModifiedEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showRewardDialog = ref(false)
|
||||
const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildTaskSet(event: Event) {
|
||||
const payload = event.payload as ChildTasksSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
tasks.value = payload.task_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildRewardSet(event: Event) {
|
||||
const payload = event.payload as ChildRewardsSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
rewards.value = payload.reward_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardRequest(event: Event) {
|
||||
const payload = event.payload as ChildRewardRequestEventPayload
|
||||
const childId = payload.child_id
|
||||
const rewardId = payload.reward_id
|
||||
if (child.value && childId == child.value.id) {
|
||||
if (rewards.value.find((r) => r === rewardId)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildModified(event: Event) {
|
||||
const payload = event.payload as ChildModifiedEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
break
|
||||
|
||||
case 'ADD':
|
||||
// A new child was added, this shouldn't affect the current child view
|
||||
console.log('ADD operation received for child_modified, no action taken.')
|
||||
break
|
||||
|
||||
case 'EDIT':
|
||||
//our child was edited, refetch its data
|
||||
try {
|
||||
const dataPromise = fetchChildData(payload.child_id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskModified(event: Event) {
|
||||
const payload = event.payload as TaskModifiedEventPayload
|
||||
if (child.value) {
|
||||
const task_id = payload.task_id
|
||||
if (tasks.value.includes(task_id)) {
|
||||
try {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Remove the task from the list
|
||||
tasks.value = tasks.value.filter((t) => t !== task_id)
|
||||
return // No need to refetch
|
||||
|
||||
case 'ADD':
|
||||
// A new task was added, this shouldn't affect the current task list
|
||||
console.log('ADD operation received for task_modified, no action taken.')
|
||||
return // No need to refetch
|
||||
|
||||
case 'EDIT':
|
||||
try {
|
||||
const dataPromise = fetchChildData(child.value.id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
tasks.value = data.tasks || []
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
return // No need to refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after task modification:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardModified(event: Event) {
|
||||
const payload = event.payload as RewardModifiedEventPayload
|
||||
if (child.value) {
|
||||
const reward_id = payload.reward_id
|
||||
if (rewards.value.includes(reward_id)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
if ('speechSynthesis' in window && task.name) {
|
||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
const utterString =
|
||||
reward.name +
|
||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
window.speechSynthesis.speak(utter)
|
||||
console.log('Reward data is:', reward)
|
||||
if (reward.redeeming) {
|
||||
dialogReward.value = reward
|
||||
showCancelDialog.value = true
|
||||
return // Do not allow redeeming if already pending
|
||||
}
|
||||
if (reward.points_needed <= 0) {
|
||||
dialogReward.value = reward
|
||||
showRewardDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel pending reward:', err)
|
||||
} finally {
|
||||
showCancelDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRedeemReward() {
|
||||
showRewardDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
|
||||
function closeCancelDialog() {
|
||||
showCancelDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
|
||||
async function confirmRedeemReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
} catch (err) {
|
||||
console.error('Failed to redeem reward:', err)
|
||||
} finally {
|
||||
showRewardDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
error.value = null
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
return null
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function resetInactivityTimer() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
inactivityTimer = setTimeout(() => {
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}, 60000) // 60 seconds
|
||||
}
|
||||
|
||||
function setupInactivityListeners() {
|
||||
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
|
||||
events.forEach((evt) => window.addEventListener(evt, resetInactivityTimer))
|
||||
}
|
||||
|
||||
function removeInactivityListeners() {
|
||||
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
|
||||
events.forEach((evt) => window.removeEventListener(evt, resetInactivityTimer))
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
}
|
||||
|
||||
const hasPendingRewards = computed(() =>
|
||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.on('task_modified', handleTaskModified)
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
const promise = fetchChildData(idParam)
|
||||
promise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
setupInactivityListeners()
|
||||
resetInactivityTimer()
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.off('task_modified', handleTaskModified)
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
removeInactivityListeners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ScrollingList
|
||||
title="Chores"
|
||||
ref="childChoreListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="
|
||||
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
||||
"
|
||||
>
|
||||
<template #item="{ item }: { item: RewardStatus }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div class="item-points">
|
||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||
<span v-else>{{ item.points_needed }} more points</span>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="dialogReward.image_url"
|
||||
:src="dialogReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ dialogReward.name }}</div>
|
||||
<div class="reward-points">{{ dialogReward.cost }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
Would you like to redeem this reward?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCancelDialog && dialogReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="dialogReward.image_url"
|
||||
:src="dialogReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ dialogReward.name }}</div>
|
||||
<div class="reward-points">{{ dialogReward.cost }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
This reward is pending.<br />
|
||||
Would you like to cancel the pending reward request?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--back-btn-bg);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--back-btn-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
.ready {
|
||||
color: var(--item-points-ready-color, #38c172);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pending {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
:deep(.disabled) {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
</style>
|
||||
600
frontend/vue-app/src/components/child/ParentView.vue
Normal file
600
frontend/vue-app/src/components/child/ParentView.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
import type {
|
||||
Task,
|
||||
Child,
|
||||
Event,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
ChildTasksSetEventPayload,
|
||||
ChildRewardsSetEventPayload,
|
||||
ChildModifiedEventPayload,
|
||||
TaskModifiedEventPayload,
|
||||
RewardModifiedEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showConfirm = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const showRewardConfirm = ref(false)
|
||||
const selectedReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
const showPendingRewardDialog = ref(false)
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildTaskSet(event: Event) {
|
||||
const payload = event.payload as ChildTasksSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
tasks.value = payload.task_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildRewardSet(event: Event) {
|
||||
const payload = event.payload as ChildRewardsSetEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
rewards.value = payload.reward_ids
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardRequest(event: Event) {
|
||||
const payload = event.payload as ChildRewardRequestEventPayload
|
||||
const childId = payload.child_id
|
||||
const rewardId = payload.reward_id
|
||||
if (child.value && childId == child.value.id) {
|
||||
if (rewards.value.find((r) => r === rewardId)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskModified(event: Event) {
|
||||
const payload = event.payload as TaskModifiedEventPayload
|
||||
if (child.value) {
|
||||
const task_id = payload.task_id
|
||||
if (tasks.value.includes(task_id)) {
|
||||
try {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Remove the task from the list
|
||||
tasks.value = tasks.value.filter((t) => t !== task_id)
|
||||
return // No need to refetch
|
||||
|
||||
case 'ADD':
|
||||
// A new task was added, this shouldn't affect the current task list
|
||||
console.log('ADD operation received for task_modified, no action taken.')
|
||||
return // No need to refetch
|
||||
|
||||
case 'EDIT':
|
||||
try {
|
||||
const dataPromise = fetchChildData(child.value.id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
tasks.value = data.tasks || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
return // No need to refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after task modification:', err)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardModified(event: Event) {
|
||||
const payload = event.payload as RewardModifiedEventPayload
|
||||
if (child.value) {
|
||||
const reward_id = payload.reward_id
|
||||
if (rewards.value.includes(reward_id)) {
|
||||
childRewardListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildModified(event: Event) {
|
||||
const payload = event.payload as ChildModifiedEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
break
|
||||
|
||||
case 'ADD':
|
||||
// A new child was added, this shouldn't affect the current child view
|
||||
console.log('ADD operation received for child_modified, no action taken.')
|
||||
break
|
||||
|
||||
case 'EDIT':
|
||||
//our child was edited, refetch its data
|
||||
try {
|
||||
const dataPromise = fetchChildData(payload.child_id)
|
||||
dataPromise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
loading.value = false
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
error.value = null
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
return null
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.on('task_modified', handleTaskModified)
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
const promise = fetchChildData(idParam)
|
||||
promise.then((data) => {
|
||||
if (data) {
|
||||
child.value = data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
eventBus.off('task_modified', handleTaskModified)
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
})
|
||||
|
||||
function getPendingRewardIds(): string[] {
|
||||
const items = childRewardListRef.value?.items || []
|
||||
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
selectedTask.value = task
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
console.log('Pending reward IDs:', pendingRewardIds)
|
||||
if (pendingRewardIds.length > 0) {
|
||||
showPendingRewardDialog.value = true
|
||||
return
|
||||
}
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function cancelRewardById(rewardId: string) {
|
||||
if (!child.value?.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: rewardId }),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to cancel reward ID ${rewardId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id) {
|
||||
showPendingRewardDialog.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
|
||||
childRewardListRef.value?.refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel pending reward:', err)
|
||||
} finally {
|
||||
showPendingRewardDialog.value = false
|
||||
// After cancelling, proceed to trigger the task if one was selected
|
||||
if (selectedTask.value) {
|
||||
showConfirm.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmTriggerTask = async () => {
|
||||
if (!child.value?.id || !selectedTask.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: selectedTask.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
if (child.value && child.value.id === data.id) child.value.points = data.points
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger task:', err)
|
||||
} finally {
|
||||
showConfirm.value = false
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if (reward.points_needed > 0) return
|
||||
selectedReward.value = reward
|
||||
showRewardConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmTriggerReward = async () => {
|
||||
if (!child.value?.id || !selectedReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: selectedReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
if (child.value && child.value.id === data.id) child.value.points = data.points
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger reward:', err)
|
||||
} finally {
|
||||
showRewardConfirm.value = false
|
||||
selectedReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignTasks() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignBadHabits() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignRewards() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ScrollingList
|
||||
title="Chores"
|
||||
ref="childChoreListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="(item) => ({ reward: true })"
|
||||
>
|
||||
<template #item="{ item }: { item: RewardStatus }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div class="item-points">
|
||||
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||
<span v-else>{{ item.points_needed }} more points</span>
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
|
||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||
Assign Penalties
|
||||
</button>
|
||||
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Reward Dialog -->
|
||||
<div v-if="showPendingRewardDialog" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
There is a pending reward request. The reward must be cancelled before triggering a new
|
||||
task.<br />
|
||||
Would you like to cancel the pending reward?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="showPendingRewardDialog = false" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showConfirm && selectedTask" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="task-info">
|
||||
<img
|
||||
v-if="selectedTask.image_url"
|
||||
:src="selectedTask.image_url"
|
||||
alt="Task Image"
|
||||
class="task-image"
|
||||
/>
|
||||
<div class="task-details">
|
||||
<div class="task-name">{{ selectedTask.name }}</div>
|
||||
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
|
||||
{{ selectedTask.points }} points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
|
||||
{{ selectedTask.is_good ? 'to' : 'from' }}
|
||||
<span class="child-name">{{ child?.name }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmTriggerTask" class="btn btn-primary">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showConfirm = false
|
||||
selectedTask = null
|
||||
}
|
||||
"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="selectedReward.image_url"
|
||||
:src="selectedReward.image_url"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ selectedReward.name }}</div>
|
||||
<div class="reward-points">
|
||||
{{
|
||||
selectedReward.points_needed === 0
|
||||
? 'Reward Ready!'
|
||||
: selectedReward.points_needed + ' more points'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
Redeem this reward for <span class="child-name">{{ child?.name }}</span
|
||||
>?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmTriggerReward" class="btn btn-primary">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showRewardConfirm = false
|
||||
selectedReward = null
|
||||
}
|
||||
"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--back-btn-bg);
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--back-btn-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--item-points-ready-color, #38c172);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pending {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: var(--pending-block-bg, #222b);
|
||||
color: var(--pending-block-color, #62ff7a);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
:deep(.reward) {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
</style>
|
||||
118
frontend/vue-app/src/components/child/RewardAssignView.vue
Normal file
118
frontend/vue-app/src/components/child/RewardAssignView.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="reward-assign-view">
|
||||
<h2>Assign Rewards</h2>
|
||||
<div class="reward-view">
|
||||
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
ref="rewardListRef"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
||||
itemKey="rewards"
|
||||
: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>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
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()
|
||||
const childId = route.params.id
|
||||
|
||||
const rewardListRef = ref()
|
||||
const rewardCountRef = ref(-1)
|
||||
|
||||
function goToCreateReward() {
|
||||
router.push({ name: 'CreateReward' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = rewardListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update rewards')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
alert('Failed to update rewards.')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reward-assign-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.reward-assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.reward-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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>
|
||||
129
frontend/vue-app/src/components/child/TaskAssignView.vue
Normal file
129
frontend/vue-app/src/components/child/TaskAssignView.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="task-assign-view">
|
||||
<h2>Assign Tasks</h2>
|
||||
<div class="task-view">
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
ref="taskListRef"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
||||
itemKey="tasks"
|
||||
: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>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
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()
|
||||
const childId = route.params.id
|
||||
|
||||
const taskListRef = ref()
|
||||
const taskCountRef = ref(-1)
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (route.params.type === 'good') return 'good'
|
||||
if (route.params.type === 'bad') return 'bad'
|
||||
return 'all'
|
||||
})
|
||||
|
||||
function goToCreateTask() {
|
||||
router.push({ name: 'CreateTask' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = taskListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
console.log('selectedIds:', selectedIds)
|
||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: typeFilter.value, task_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update tasks')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
alert('Failed to update tasks.')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-assign-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.task-assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.task-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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>
|
||||
Reference in New Issue
Block a user