419 lines
13 KiB
Vue
419 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import ChildDetailCard from './ChildDetailCard.vue'
|
|
import ChildTaskList from '../task/ChildTaskList.vue'
|
|
import ChildRewardList from '../reward/ChildRewardList.vue'
|
|
import { eventBus } from '@/common/eventBus'
|
|
import '@/assets/view-shared.css'
|
|
import type {
|
|
Child,
|
|
Event,
|
|
Task,
|
|
Reward,
|
|
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()
|
|
const childChoreListRef = ref()
|
|
const childHabitListRef = 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
|
|
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
|
|
childRewardListRef.value?.refresh()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
})
|
|
} 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)
|
|
}
|
|
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: Reward, redeemable: boolean, pending: boolean) => {
|
|
if ('speechSynthesis' in window && reward.name) {
|
|
const utterString =
|
|
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
|
const utter = new window.SpeechSynthesisUtterance(utterString)
|
|
window.speechSynthesis.speak(utter)
|
|
}
|
|
if (pending) {
|
|
dialogReward.value = reward
|
|
showCancelDialog.value = true
|
|
return // Do not allow redeeming if already pending
|
|
}
|
|
if (redeemable) {
|
|
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 {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 || []
|
|
}
|
|
})
|
|
}
|
|
}
|
|
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" />
|
|
<ChildTaskList
|
|
title="Chores"
|
|
ref="childChoreListRef"
|
|
:task-ids="tasks"
|
|
:child-id="child ? child.id : null"
|
|
:is-parent-authenticated="false"
|
|
:filter-type="1"
|
|
@trigger-task="triggerTask"
|
|
/>
|
|
<ChildTaskList
|
|
title="Bad Habits"
|
|
ref="childHabitListRef"
|
|
:task-ids="tasks"
|
|
:child-id="child ? child.id : null"
|
|
:is-parent-authenticated="false"
|
|
:filter-type="2"
|
|
@trigger-task="triggerTask"
|
|
/>
|
|
<ChildRewardList
|
|
ref="childRewardListRef"
|
|
:child-id="child ? child.id : null"
|
|
:child-points="child?.points ?? 0"
|
|
:is-parent-authenticated="false"
|
|
@trigger-reward="triggerReward"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop">
|
|
<div class="modal">
|
|
<div class="reward-info">
|
|
<img
|
|
v-if="dialogReward.image_id"
|
|
:src="dialogReward.image_id"
|
|
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_id"
|
|
:src="dialogReward.image_id"
|
|
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>
|
|
.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;
|
|
}
|
|
|
|
.assign-buttons {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: center;
|
|
margin: 2rem 0;
|
|
}
|
|
</style>
|