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,11 +5,19 @@ import ScheduleModal from '../shared/ScheduleModal.vue'
|
||||
import PendingRewardDialog from './PendingRewardDialog.vue'
|
||||
import TaskConfirmDialog from './TaskConfirmDialog.vue'
|
||||
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||
import ChoreApproveDialog from './ChoreApproveDialog.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import StatusMessage from '../shared/StatusMessage.vue'
|
||||
import { setChildOverride, parseErrorResponse, extendChoreTime } from '@/common/api'
|
||||
import {
|
||||
setChildOverride,
|
||||
parseErrorResponse,
|
||||
extendChoreTime,
|
||||
approveChore,
|
||||
rejectChore,
|
||||
resetChore,
|
||||
} from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
@@ -31,6 +39,7 @@ import type {
|
||||
ChildOverrideDeletedPayload,
|
||||
ChoreScheduleModifiedPayload,
|
||||
ChoreTimeExtendedPayload,
|
||||
ChildChoreConfirmationPayload,
|
||||
} from '@/common/models'
|
||||
import {
|
||||
isScheduledToday,
|
||||
@@ -57,8 +66,13 @@ const selectedReward = ref<Reward | null>(null)
|
||||
const childChoreListRef = ref()
|
||||
const childPenaltyListRef = ref()
|
||||
const childRewardListRef = ref()
|
||||
const childKindnessListRef = ref()
|
||||
const showPendingRewardDialog = ref(false)
|
||||
|
||||
// Chore approve/reject
|
||||
const showChoreApproveDialog = ref(false)
|
||||
const approveDialogChore = ref<ChildTask | null>(null)
|
||||
|
||||
// Override editing
|
||||
const showOverrideModal = ref(false)
|
||||
const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(null)
|
||||
@@ -268,6 +282,78 @@ 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 isChoreCompletedToday(item: ChildTask): boolean {
|
||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return item.approved_at.slice(0, 10) === today
|
||||
}
|
||||
|
||||
function isChorePending(item: ChildTask): boolean {
|
||||
return item.pending_status === 'pending'
|
||||
}
|
||||
|
||||
async function doApproveChore() {
|
||||
if (!child.value || !approveDialogChore.value) return
|
||||
try {
|
||||
const resp = await approveChore(child.value.id, approveDialogChore.value.id)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
if (child.value) child.value.points = data.points
|
||||
} else {
|
||||
const { msg } = await parseErrorResponse(resp)
|
||||
alert(`Error: ${msg}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to approve chore:', err)
|
||||
} finally {
|
||||
showChoreApproveDialog.value = false
|
||||
approveDialogChore.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function doRejectChore() {
|
||||
if (!child.value || !approveDialogChore.value) return
|
||||
try {
|
||||
const resp = await rejectChore(child.value.id, approveDialogChore.value.id)
|
||||
if (!resp.ok) {
|
||||
const { msg } = await parseErrorResponse(resp)
|
||||
alert(`Error: ${msg}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reject chore:', err)
|
||||
} finally {
|
||||
showChoreApproveDialog.value = false
|
||||
approveDialogChore.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelChoreApproveDialog() {
|
||||
showChoreApproveDialog.value = false
|
||||
approveDialogChore.value = null
|
||||
}
|
||||
|
||||
async function doResetChore(item: ChildTask, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
closeChoreMenu()
|
||||
if (!child.value) return
|
||||
try {
|
||||
const resp = await resetChore(child.value.id, item.id)
|
||||
if (!resp.ok) {
|
||||
const { msg } = await parseErrorResponse(resp)
|
||||
alert(`Error: ${msg}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reset chore:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kebab menu ───────────────────────────────────────────────────────────────
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
@@ -383,7 +469,7 @@ function resetExpiryTimers() {
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const now = new Date()
|
||||
for (const item of items) {
|
||||
if (!item.schedule || !item.is_good) continue
|
||||
if (!item.schedule || item.type !== 'chore') continue
|
||||
if (!isScheduledToday(item.schedule, now)) continue
|
||||
const due = getDueTimeToday(item.schedule, now)
|
||||
if (!due) continue
|
||||
@@ -505,6 +591,7 @@ onMounted(async () => {
|
||||
eventBus.on('child_override_deleted', handleOverrideDeleted)
|
||||
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.on('chore_time_extended', handleChoreTimeExtended)
|
||||
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
|
||||
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
@@ -541,6 +628,7 @@ onUnmounted(() => {
|
||||
eventBus.off('child_override_deleted', handleOverrideDeleted)
|
||||
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.off('chore_time_extended', handleChoreTimeExtended)
|
||||
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
|
||||
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
@@ -552,11 +640,29 @@ function getPendingRewardIds(): string[] {
|
||||
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
const triggerTask = (task: ChildTask) => {
|
||||
if (shouldIgnoreNextCardClick.value) {
|
||||
shouldIgnoreNextCardClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// For chores, handle pending/completed states
|
||||
if (task.type === 'chore') {
|
||||
// Completed chore — no tap action (use kebab menu "Reset" instead)
|
||||
if (isChoreCompletedToday(task)) return
|
||||
// Expired chore — no tap action
|
||||
if (isChoreExpired(task)) return
|
||||
// Pending chore — open approve/reject dialog
|
||||
if (isChorePending(task)) {
|
||||
approveDialogChore.value = task
|
||||
setTimeout(() => {
|
||||
showChoreApproveDialog.value = true
|
||||
}, 150)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Available chore / kindness / penalty — existing trigger flow
|
||||
selectedTask.value = task
|
||||
const pendingRewardIds = getPendingRewardIds()
|
||||
if (pendingRewardIds.length > 0) {
|
||||
@@ -657,13 +763,19 @@ const confirmTriggerReward = async () => {
|
||||
|
||||
function goToAssignTasks() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
|
||||
router.push({ name: 'ChoreAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignBadHabits() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
|
||||
router.push({ name: 'PenaltyAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignKindness() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'KindnessAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,12 +808,12 @@ function goToAssignRewards() {
|
||||
@item-ready="handleChoreItemReady"
|
||||
:getItemClass="
|
||||
(item) => ({
|
||||
bad: !item.is_good,
|
||||
good: item.is_good,
|
||||
'chore-inactive': isChoreInactive(item),
|
||||
bad: item.type === 'penalty',
|
||||
good: item.type !== 'penalty',
|
||||
'chore-inactive': isChoreInactive(item) || isChoreCompletedToday(item),
|
||||
})
|
||||
"
|
||||
:filter-fn="(item) => item.is_good"
|
||||
:filter-fn="(item) => item.type === 'chore'"
|
||||
>
|
||||
<template #item="{ item }: { item: ChildTask }">
|
||||
<!-- Kebab menu -->
|
||||
@@ -748,12 +860,26 @@ function goToAssignRewards() {
|
||||
>
|
||||
Extend Time
|
||||
</button>
|
||||
<button
|
||||
v-if="isChoreCompletedToday(item)"
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetChore(item, $event)"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- TOO LATE badge -->
|
||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
<!-- PENDING badge -->
|
||||
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
|
||||
<!-- COMPLETED badge -->
|
||||
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||
>COMPLETED</span
|
||||
>
|
||||
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
@@ -768,6 +894,36 @@ function goToAssignRewards() {
|
||||
<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"
|
||||
:enableEdit="true"
|
||||
:childId="child?.id"
|
||||
:readyItemId="readyItemId"
|
||||
:isParentAuthenticated="true"
|
||||
@trigger-item="triggerTask"
|
||||
@edit-item="(item) => handleEditItem(item, 'task')"
|
||||
@item-ready="handleItemReady"
|
||||
:getItemClass="() => ({ good: true })"
|
||||
:filter-fn="(item) => 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"
|
||||
@@ -782,10 +938,12 @@ function goToAssignRewards() {
|
||||
@trigger-item="triggerTask"
|
||||
@edit-item="(item) => handleEditItem(item, 'task')"
|
||||
@item-ready="handleItemReady"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:getItemClass="
|
||||
(item) => ({ bad: item.type === 'penalty', good: item.type !== 'penalty' })
|
||||
"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
return item.type === 'penalty'
|
||||
}
|
||||
"
|
||||
>
|
||||
@@ -794,7 +952,10 @@ function goToAssignRewards() {
|
||||
<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 }"
|
||||
:class="{
|
||||
'good-points': item.type !== 'penalty',
|
||||
'bad-points': item.type === 'penalty',
|
||||
}"
|
||||
>
|
||||
{{
|
||||
item.custom_value !== undefined && item.custom_value !== null
|
||||
@@ -840,6 +1001,9 @@ function goToAssignRewards() {
|
||||
</div>
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignKindness">
|
||||
Assign Kindness Acts
|
||||
</button>
|
||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||
Assign Penalties
|
||||
</button>
|
||||
@@ -929,6 +1093,19 @@ function goToAssignRewards() {
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Chore Approve/Reject Dialog -->
|
||||
<ChoreApproveDialog
|
||||
v-if="showChoreApproveDialog && approveDialogChore"
|
||||
:show="showChoreApproveDialog"
|
||||
:childName="child?.name ?? ''"
|
||||
:choreName="approveDialogChore.name"
|
||||
:points="approveDialogChore.custom_value ?? approveDialogChore.points"
|
||||
:imageUrl="approveDialogChore.image_url"
|
||||
@approve="doApproveChore"
|
||||
@reject="doRejectChore"
|
||||
@cancel="cancelChoreApproveDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1106,6 +1283,14 @@ function goToAssignRewards() {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pending-stamp {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.completed-stamp {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Due time sub-text */
|
||||
.due-label {
|
||||
font-size: 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user