feat: add chore, kindness, and penalty management components
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
- Implemented ChoreAssignView for assigning chores to children. - Created ChoreConfirmDialog for confirming chore completion. - Developed KindnessAssignView for assigning kindness acts. - Added PenaltyAssignView for assigning penalties. - Introduced ChoreEditView and ChoreView for editing and viewing chores. - Created KindnessEditView and KindnessView for managing kindness acts. - Developed PenaltyEditView and PenaltyView for managing penalties. - Added TaskSubNav for navigation between chores, kindness acts, and penalties.
This commit is contained in:
@@ -5,6 +5,7 @@ import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import StatusMessage from '../shared/StatusMessage.vue'
|
||||
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
//import '@/assets/view-shared.css'
|
||||
@@ -25,7 +26,9 @@ import type {
|
||||
ChildModifiedEventPayload,
|
||||
ChoreScheduleModifiedPayload,
|
||||
ChoreTimeExtendedPayload,
|
||||
ChildChoreConfirmationPayload,
|
||||
} from '@/common/models'
|
||||
import { confirmChore, cancelConfirmChore } from '@/common/api'
|
||||
import {
|
||||
isScheduledToday,
|
||||
isPastTime,
|
||||
@@ -48,6 +51,9 @@ const childRewardListRef = ref()
|
||||
const showRewardDialog = ref(false)
|
||||
const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<RewardStatus | null>(null)
|
||||
const showChoreConfirmDialog = ref(false)
|
||||
const showChoreCancelDialog = ref(false)
|
||||
const dialogChore = ref<ChildTask | null>(null)
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
@@ -177,7 +183,7 @@ function handleRewardModified(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = async (task: Task) => {
|
||||
const triggerTask = async (task: ChildTask) => {
|
||||
// Cancel any pending speech to avoid conflicts
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel()
|
||||
@@ -191,7 +197,74 @@ const triggerTask = async (task: Task) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Child mode is speech-only; point changes are handled in parent mode.
|
||||
// For chores, handle confirmation flow
|
||||
if (task.type === 'chore') {
|
||||
if (isChoreExpired(task)) return // Expired — no action
|
||||
if (isChoreCompletedToday(task)) return // Already completed — no action
|
||||
if (task.pending_status === 'pending') {
|
||||
// Show cancel dialog
|
||||
dialogChore.value = task
|
||||
setTimeout(() => {
|
||||
showChoreCancelDialog.value = true
|
||||
}, 150)
|
||||
return
|
||||
}
|
||||
// Available — show confirm dialog
|
||||
dialogChore.value = task
|
||||
setTimeout(() => {
|
||||
showChoreConfirmDialog.value = true
|
||||
}, 150)
|
||||
return
|
||||
}
|
||||
|
||||
// Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode.
|
||||
}
|
||||
|
||||
function isChoreCompletedToday(item: ChildTask): boolean {
|
||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||
const approvedDate = item.approved_at.substring(0, 10)
|
||||
const today = toLocalISODate(new Date())
|
||||
return approvedDate === today
|
||||
}
|
||||
|
||||
async function doConfirmChore() {
|
||||
if (!child.value?.id || !dialogChore.value) return
|
||||
try {
|
||||
const resp = await confirmChore(child.value.id, dialogChore.value.id)
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to confirm chore')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to confirm chore:', err)
|
||||
} finally {
|
||||
showChoreConfirmDialog.value = false
|
||||
dialogChore.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelChoreConfirmDialog() {
|
||||
showChoreConfirmDialog.value = false
|
||||
dialogChore.value = null
|
||||
}
|
||||
|
||||
async function doCancelConfirmChore() {
|
||||
if (!child.value?.id || !dialogChore.value) return
|
||||
try {
|
||||
const resp = await cancelConfirmChore(child.value.id, dialogChore.value.id)
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to cancel chore confirmation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel chore confirmation:', err)
|
||||
} finally {
|
||||
showChoreCancelDialog.value = false
|
||||
dialogChore.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function closeChoreCancelDialog() {
|
||||
showChoreCancelDialog.value = false
|
||||
dialogChore.value = null
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
@@ -331,6 +404,13 @@ function handleChoreTimeExtended(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleChoreConfirmation(event: Event) {
|
||||
const payload = event.payload as ChildChoreConfirmationPayload
|
||||
if (child.value && payload.child_id === child.value.id) {
|
||||
childChoreListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function isChoreScheduledToday(item: ChildTask): boolean {
|
||||
if (!item.schedule) return true
|
||||
const today = new Date()
|
||||
@@ -406,6 +486,7 @@ onMounted(async () => {
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.on('chore_time_extended', handleChoreTimeExtended)
|
||||
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
@@ -440,6 +521,7 @@ onUnmounted(() => {
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.off('chore_time_extended', handleChoreTimeExtended)
|
||||
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
clearExpiryTimers()
|
||||
removeInactivityListeners()
|
||||
@@ -466,21 +548,25 @@ onUnmounted(() => {
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="
|
||||
(item: ChildTask) => ({
|
||||
bad: !item.is_good,
|
||||
good: item.is_good,
|
||||
'chore-inactive': isChoreExpired(item),
|
||||
bad: item.type === 'penalty',
|
||||
good: item.type !== 'penalty',
|
||||
'chore-inactive':
|
||||
item.type === 'chore' && (isChoreExpired(item) || isChoreCompletedToday(item)),
|
||||
})
|
||||
"
|
||||
:filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)"
|
||||
:filter-fn="(item: ChildTask) => item.type === 'chore' && isChoreScheduledToday(item)"
|
||||
>
|
||||
<template #item="{ item }: { item: ChildTask }">
|
||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||
>COMPLETED</span
|
||||
>
|
||||
<span v-else-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
||||
>PENDING</span
|
||||
>
|
||||
<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 }"
|
||||
>
|
||||
<div class="item-points good-points">
|
||||
{{
|
||||
item.custom_value !== undefined && item.custom_value !== null
|
||||
? item.custom_value
|
||||
@@ -491,6 +577,33 @@ onUnmounted(() => {
|
||||
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Kindness Acts"
|
||||
ref="childKindnessListRef"
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
:isParentAuthenticated="false"
|
||||
:readyItemId="readyItemId"
|
||||
@item-ready="handleItemReady"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="() => ({ good: true })"
|
||||
:filter-fn="(item: ChildTask) => item.type === 'kindness'"
|
||||
>
|
||||
<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 good-points">
|
||||
{{
|
||||
item.custom_value !== undefined && item.custom_value !== null
|
||||
? item.custom_value
|
||||
: item.points
|
||||
}}
|
||||
Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
@@ -502,20 +615,13 @@ onUnmounted(() => {
|
||||
:readyItemId="readyItemId"
|
||||
@item-ready="handleItemReady"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
:getItemClass="() => ({ bad: true })"
|
||||
:filter-fn="(item: ChildTask) => item.type === 'penalty'"
|
||||
>
|
||||
<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 }"
|
||||
>
|
||||
<div class="item-points bad-points">
|
||||
{{
|
||||
item.custom_value !== undefined && item.custom_value !== null
|
||||
? -item.custom_value
|
||||
@@ -583,6 +689,31 @@ onUnmounted(() => {
|
||||
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Chore confirm dialog -->
|
||||
<ChoreConfirmDialog
|
||||
:show="showChoreConfirmDialog"
|
||||
:choreName="dialogChore?.name ?? ''"
|
||||
:imageUrl="dialogChore?.image_url"
|
||||
@confirm="doConfirmChore"
|
||||
@cancel="cancelChoreConfirmDialog"
|
||||
/>
|
||||
|
||||
<!-- Cancel chore confirmation dialog -->
|
||||
<ModalDialog
|
||||
v-if="showChoreCancelDialog && dialogChore"
|
||||
:title="dialogChore.name"
|
||||
subtitle="Chore Pending"
|
||||
@backdrop-click="closeChoreCancelDialog"
|
||||
>
|
||||
<div class="modal-message">
|
||||
This chore is pending confirmation.<br />Would you like to cancel?
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="doCancelConfirmChore" class="btn btn-primary">Yes</button>
|
||||
<button @click="closeChoreCancelDialog" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -708,6 +839,14 @@ onUnmounted(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pending-stamp {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.completed-stamp {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.due-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user