Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m56s
2173 lines
68 KiB
Vue
2173 lines
68 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
import ModalDialog from '../shared/ModalDialog.vue'
|
|
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 RoutineConfirmDialog from './RoutineConfirmDialog.vue'
|
|
import RoutineApproveDialog from './RoutineApproveDialog.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,
|
|
approveChore,
|
|
rejectChore,
|
|
resetChore,
|
|
extendRoutineTime,
|
|
setChildRoutineOverride,
|
|
approveRoutine,
|
|
rejectRoutine,
|
|
resetRoutine,
|
|
triggerRoutineAsParent,
|
|
} from '@/common/api'
|
|
import { eventBus } from '@/common/eventBus'
|
|
import {
|
|
maybeShow as tutorialMaybeShow,
|
|
activeStep as tutorialActiveStep,
|
|
modalTutorialStepId,
|
|
setHelpButtonHidden,
|
|
} from '@/tutorial/controller'
|
|
import '@/assets/styles.css'
|
|
import type {
|
|
Task,
|
|
Child,
|
|
Event,
|
|
Reward,
|
|
RewardStatus,
|
|
ChildTask,
|
|
ChildRoutine,
|
|
ChildTaskTriggeredEventPayload,
|
|
ChildRewardTriggeredEventPayload,
|
|
ChildRewardRequestEventPayload,
|
|
ChildTasksSetEventPayload,
|
|
ChildRewardsSetEventPayload,
|
|
ChildModifiedEventPayload,
|
|
TaskModifiedEventPayload,
|
|
RewardModifiedEventPayload,
|
|
ChildOverrideSetPayload,
|
|
ChildOverrideDeletedPayload,
|
|
ChoreScheduleModifiedPayload,
|
|
ChoreTimeExtendedPayload,
|
|
ChildChoreConfirmationPayload,
|
|
RoutineScheduleModifiedPayload,
|
|
RoutineTimeExtendedPayload,
|
|
} from '@/common/models'
|
|
import {
|
|
isScheduledToday,
|
|
isPastTime,
|
|
getDueTimeToday,
|
|
formatDueTimeLabel,
|
|
msUntilExpiry,
|
|
isExtendedToday,
|
|
toLocalISODate,
|
|
} from '@/common/scheduleUtils'
|
|
|
|
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 childChoreListRef = ref()
|
|
const childPenaltyListRef = ref()
|
|
const childRewardListRef = ref()
|
|
const childKindnessListRef = ref()
|
|
const showPendingRewardDialog = ref(false)
|
|
const lastEditedItem = ref<{ id: string; type: 'task' | 'reward' } | null>(null)
|
|
|
|
// Chore approve/reject
|
|
const showChoreApproveDialog = ref(false)
|
|
const approveDialogChore = ref<ChildTask | null>(null)
|
|
|
|
// Routine approve/reject
|
|
const showRoutineApproveDialog = ref(false)
|
|
const approveDialogRoutine = ref<ChildRoutine | null>(null)
|
|
const showRoutineConfirmDialog = ref(false)
|
|
const confirmDialogRoutine = ref<ChildRoutine | null>(null)
|
|
|
|
// Override editing
|
|
const showOverrideModal = ref(false)
|
|
const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(null)
|
|
const overrideCustomValue = ref(0)
|
|
const isOverrideValid = ref(true)
|
|
const readyItemId = ref<string | null>(null)
|
|
const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(
|
|
null,
|
|
)
|
|
|
|
// Kebab menu
|
|
const activeMenuFor = ref<string | null>(null)
|
|
const shouldIgnoreNextCardClick = ref(false)
|
|
const selectedChoreId = ref<string | null>(null)
|
|
const menuPosition = ref({ top: 0, left: 0 })
|
|
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
|
|
|
// Tutorial auto-demo state
|
|
const tutorialHighlightedItemId = ref<string | null>(null)
|
|
|
|
// Schedule modal
|
|
const showScheduleModal = ref(false)
|
|
const scheduleTarget = ref<ChildTask | null>(null)
|
|
|
|
// Routines
|
|
const childRoutineListRef = ref()
|
|
const selectedRoutineId = ref<string | null>(null)
|
|
const activeRoutineMenuFor = ref<string | null>(null)
|
|
const routineKebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
|
const showRoutineScheduleModal = ref(false)
|
|
const routineScheduleTarget = ref<ChildRoutine | null>(null)
|
|
|
|
const pendingDialogReward = computed<Reward | RewardStatus | null>(() => {
|
|
if (pendingEditOverrideTarget.value?.type === 'reward') {
|
|
return pendingEditOverrideTarget.value.entity as Reward
|
|
}
|
|
|
|
return selectedReward.value
|
|
})
|
|
|
|
// Expiry timers
|
|
const expiryTimers = ref<number[]>([])
|
|
|
|
// Last fetch date (for overnight detection)
|
|
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
|
|
|
|
function handleItemReady(itemId: string) {
|
|
readyItemId.value = itemId
|
|
selectedChoreId.value = null
|
|
selectedRoutineId.value = null
|
|
}
|
|
|
|
function handleChoreItemReady(itemId: string) {
|
|
readyItemId.value = itemId
|
|
selectedChoreId.value = itemId || null
|
|
}
|
|
|
|
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}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleOverrideSet(event: Event) {
|
|
const payload = event.payload as ChildOverrideSetPayload
|
|
if (child.value && payload.override.child_id === child.value.id) {
|
|
const editedId = lastEditedItem.value?.id ?? null
|
|
const scrollAfterRefresh = (listRef: typeof childChoreListRef) => {
|
|
listRef.value?.refresh().then(() => {
|
|
if (editedId) listRef.value?.scrollToItem(editedId)
|
|
})
|
|
}
|
|
if (payload.override.entity_type === 'task') {
|
|
scrollAfterRefresh(childChoreListRef)
|
|
scrollAfterRefresh(childKindnessListRef)
|
|
scrollAfterRefresh(childPenaltyListRef)
|
|
} else if (payload.override.entity_type === 'reward') {
|
|
scrollAfterRefresh(childRewardListRef)
|
|
} else if (payload.override.entity_type === 'routine') {
|
|
scrollAfterRefresh(childRoutineListRef)
|
|
}
|
|
lastEditedItem.value = null
|
|
}
|
|
}
|
|
|
|
function handleOverrideDeleted(event: Event) {
|
|
const payload = event.payload as ChildOverrideDeletedPayload
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
// Refresh the appropriate list to remove the override badge
|
|
if (payload.entity_type === 'task') {
|
|
childChoreListRef.value?.refresh()
|
|
childKindnessListRef.value?.refresh()
|
|
childPenaltyListRef.value?.refresh()
|
|
} else if (payload.entity_type === 'reward') {
|
|
childRewardListRef.value?.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleChoreScheduleModified(event: Event) {
|
|
const payload = event.payload as ChoreScheduleModifiedPayload
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childChoreListRef.value?.refresh()
|
|
setTimeout(() => resetExpiryTimers(), 300)
|
|
}
|
|
}
|
|
|
|
function handleChoreTimeExtended(event: Event) {
|
|
const payload = event.payload as ChoreTimeExtendedPayload
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childChoreListRef.value?.refresh()
|
|
setTimeout(() => resetExpiryTimers(), 300)
|
|
}
|
|
}
|
|
|
|
function handleChoreConfirmation(event: Event) {
|
|
const payload = event.payload as ChildChoreConfirmationPayload
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childChoreListRef.value?.refresh()
|
|
}
|
|
}
|
|
|
|
// ── Routine SSE handlers ──────────────────────────────────────────────────────
|
|
|
|
function handleRoutineScheduleModified(event: Event) {
|
|
const payload = event.payload as RoutineScheduleModifiedPayload
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childRoutineListRef.value?.refresh()
|
|
}
|
|
}
|
|
|
|
function handleRoutineTimeExtended(event: Event) {
|
|
const payload = event.payload as RoutineTimeExtendedPayload
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childRoutineListRef.value?.refresh()
|
|
}
|
|
}
|
|
|
|
function handleRoutineModified() {
|
|
childRoutineListRef.value?.refresh()
|
|
}
|
|
|
|
function handleChildRoutinesSet(event: Event) {
|
|
const payload = event.payload as { child_id: string }
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childRoutineListRef.value?.refresh()
|
|
}
|
|
}
|
|
|
|
function handleChildRoutineConfirmation(event: Event) {
|
|
const payload = event.payload as { child_id: string }
|
|
if (child.value && payload.child_id === child.value.id) {
|
|
childRoutineListRef.value?.refresh()
|
|
}
|
|
}
|
|
|
|
// ── Routine helpers ───────────────────────────────────────────────────────────
|
|
|
|
function isRoutineScheduledToday(item: ChildRoutine): boolean {
|
|
if (!item.schedule) return true
|
|
return isScheduledToday(item.schedule as any, new Date())
|
|
}
|
|
|
|
function isRoutineExpired(item: ChildRoutine): boolean {
|
|
if (item.pending_status === 'pending') return false
|
|
if (!item.schedule) return false
|
|
const now = new Date()
|
|
if (!isScheduledToday(item.schedule as any, now)) return false
|
|
const due = getDueTimeToday(item.schedule as any, now)
|
|
if (!due) return false
|
|
if (isExtendedToday(item.extension_date ?? null, now)) return false
|
|
return isPastTime(due.hour, due.minute, now)
|
|
}
|
|
|
|
function isRoutinePending(item: ChildRoutine): boolean {
|
|
return item.pending_status === 'pending'
|
|
}
|
|
|
|
function isRoutineApprovedToday(item: ChildRoutine): boolean {
|
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
|
const approvedDate = new Date(item.approved_at)
|
|
const today = new Date()
|
|
return (
|
|
approvedDate.getFullYear() === today.getFullYear() &&
|
|
approvedDate.getMonth() === today.getMonth() &&
|
|
approvedDate.getDate() === today.getDate()
|
|
)
|
|
}
|
|
|
|
function isRoutineInactive(item: ChildRoutine): boolean {
|
|
return !isRoutineScheduledToday(item) || isRoutineExpired(item)
|
|
}
|
|
|
|
function routineDueLabel(item: ChildRoutine): string | null {
|
|
if (!item.schedule) return null
|
|
const now = new Date()
|
|
if (!isScheduledToday(item.schedule as any, now)) return null
|
|
const due = getDueTimeToday(item.schedule as any, now)
|
|
if (!due) return null
|
|
if (isExtendedToday(item.extension_date ?? null, now)) return null
|
|
if (isPastTime(due.hour, due.minute, now)) return null
|
|
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
|
|
}
|
|
|
|
// ── Routine kebab menu ────────────────────────────────────────────────────────
|
|
|
|
function handleRoutineItemReady(itemId: string) {
|
|
readyItemId.value = itemId
|
|
selectedRoutineId.value = itemId || null
|
|
}
|
|
|
|
function openRoutineMenu(routineId: string, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
const btn = routineKebabBtnRefs.value.get(routineId)
|
|
if (btn) {
|
|
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
|
const rect = btn.getBoundingClientRect()
|
|
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
|
}
|
|
activeRoutineMenuFor.value = routineId
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'routine-kebab-menu',
|
|
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
|
)
|
|
})
|
|
const items: ChildRoutine[] = childRoutineListRef.value?.items ?? []
|
|
const routine = items.find((r) => r.id === routineId)
|
|
if (routine) {
|
|
if (isRoutineExpired(routine)) {
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'routine-extend-time',
|
|
() => document.querySelector('[data-tutorial="routine-extend-time"]') as HTMLElement | null,
|
|
)
|
|
})
|
|
}
|
|
if (isRoutineApprovedToday(routine)) {
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'routine-reset',
|
|
() => document.querySelector('[data-tutorial="routine-reset"]') as HTMLElement | null,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function closeRoutineMenu() {
|
|
activeRoutineMenuFor.value = null
|
|
}
|
|
|
|
function openRoutineScheduleModal(item: ChildRoutine, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
closeRoutineMenu()
|
|
routineScheduleTarget.value = item
|
|
showRoutineScheduleModal.value = true
|
|
}
|
|
|
|
function onRoutineScheduleSaved() {
|
|
showRoutineScheduleModal.value = false
|
|
routineScheduleTarget.value = null
|
|
}
|
|
|
|
function editRoutine(item: ChildRoutine) {
|
|
closeRoutineMenu()
|
|
router.push({ name: 'EditRoutine', params: { id: item.id } })
|
|
}
|
|
|
|
function editRoutinePoints(item: ChildRoutine) {
|
|
closeRoutineMenu()
|
|
overrideEditTarget.value = { entity: item as any, type: 'routine' as any }
|
|
const defaultValue = item.custom_value ?? item.points
|
|
overrideCustomValue.value = defaultValue
|
|
validateOverrideInput()
|
|
showOverrideModal.value = true
|
|
}
|
|
|
|
async function doExtendRoutineTime(item: ChildRoutine, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
closeRoutineMenu()
|
|
if (!child.value) return
|
|
const today = toLocalISODate(new Date())
|
|
const res = await extendRoutineTime(child.value.id, item.id, today)
|
|
if (!res.ok) {
|
|
const { msg } = await parseErrorResponse(res)
|
|
alert(`Error: ${msg}`)
|
|
}
|
|
}
|
|
|
|
function isChoreCompletedToday(item: ChildTask): boolean {
|
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
|
const approvedDate = new Date(item.approved_at)
|
|
const today = new Date()
|
|
const sameDay =
|
|
approvedDate.getFullYear() === today.getFullYear() &&
|
|
approvedDate.getMonth() === today.getMonth() &&
|
|
approvedDate.getDate() === today.getDate()
|
|
if (!sameDay) return false
|
|
// If the task has a schedule and today is not a scheduled day, don't show as completed
|
|
if (item.schedule && !isScheduledToday(item.schedule, new Date())) return false
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── Routine approve/reject ────────────────────────────────────────────────────
|
|
|
|
async function doApproveRoutine() {
|
|
if (!child.value || !approveDialogRoutine.value) return
|
|
try {
|
|
const confirmationId = approveDialogRoutine.value.pending_confirmation_id
|
|
if (!confirmationId) return
|
|
const resp = await approveRoutine(child.value.id, confirmationId)
|
|
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 routine:', err)
|
|
} finally {
|
|
showRoutineApproveDialog.value = false
|
|
approveDialogRoutine.value = null
|
|
}
|
|
}
|
|
|
|
async function doRejectRoutine() {
|
|
if (!child.value || !approveDialogRoutine.value) return
|
|
const confirmationId = approveDialogRoutine.value.pending_confirmation_id
|
|
if (!confirmationId) return
|
|
try {
|
|
const resp = await rejectRoutine(child.value.id, confirmationId)
|
|
if (!resp.ok) {
|
|
const { msg } = await parseErrorResponse(resp)
|
|
alert(`Error: ${msg}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to reject routine:', err)
|
|
} finally {
|
|
showRoutineApproveDialog.value = false
|
|
approveDialogRoutine.value = null
|
|
}
|
|
}
|
|
|
|
function cancelRoutineApproveDialog() {
|
|
showRoutineApproveDialog.value = false
|
|
approveDialogRoutine.value = null
|
|
}
|
|
|
|
async function doConfirmRoutine() {
|
|
if (!child.value || !confirmDialogRoutine.value) return
|
|
try {
|
|
const resp = await triggerRoutineAsParent(child.value.id, confirmDialogRoutine.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 confirm routine:', err)
|
|
} finally {
|
|
showRoutineConfirmDialog.value = false
|
|
confirmDialogRoutine.value = null
|
|
}
|
|
}
|
|
|
|
function cancelRoutineConfirmDialog() {
|
|
showRoutineConfirmDialog.value = false
|
|
confirmDialogRoutine.value = null
|
|
}
|
|
|
|
async function doResetRoutine(item: ChildRoutine, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
closeRoutineMenu()
|
|
if (!child.value || !item.pending_confirmation_id) return
|
|
try {
|
|
const resp = await resetRoutine(child.value.id, item.pending_confirmation_id)
|
|
if (!resp.ok) {
|
|
const { msg } = await parseErrorResponse(resp)
|
|
alert(`Error: ${msg}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to reset routine:', err)
|
|
}
|
|
}
|
|
|
|
function triggerRoutine(item: ChildRoutine) {
|
|
if (shouldIgnoreNextCardClick.value) {
|
|
shouldIgnoreNextCardClick.value = false
|
|
return
|
|
}
|
|
if (isRoutineApprovedToday(item)) return
|
|
if (isRoutineExpired(item)) return
|
|
if (isRoutinePending(item)) {
|
|
approveDialogRoutine.value = item
|
|
setTimeout(() => {
|
|
showRoutineApproveDialog.value = true
|
|
}, 150)
|
|
return
|
|
}
|
|
confirmDialogRoutine.value = item
|
|
setTimeout(() => {
|
|
showRoutineConfirmDialog.value = true
|
|
}, 150)
|
|
}
|
|
|
|
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) => {
|
|
if (activeMenuFor.value !== null || activeRoutineMenuFor.value !== null) {
|
|
const path = (e.composedPath?.() ?? (e as any).path ?? []) as EventTarget[]
|
|
const inside = path.some((node) => {
|
|
if (!(node instanceof HTMLElement)) return false
|
|
return (
|
|
node.classList.contains('chore-kebab-wrap') ||
|
|
node.classList.contains('kebab-btn') ||
|
|
node.classList.contains('kebab-menu')
|
|
)
|
|
})
|
|
const fromTutorial = path.some(
|
|
(n) => n instanceof HTMLElement && n.classList.contains('tutorial-root'),
|
|
)
|
|
if (!inside && !fromTutorial) {
|
|
activeMenuFor.value = null
|
|
activeRoutineMenuFor.value = null
|
|
selectedChoreId.value = null
|
|
selectedRoutineId.value = null
|
|
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
|
|
shouldIgnoreNextCardClick.value = true
|
|
} else {
|
|
readyItemId.value = null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function openChoreMenu(taskId: string, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
const btn = kebabBtnRefs.value.get(taskId)
|
|
if (btn) {
|
|
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
|
const rect = btn.getBoundingClientRect()
|
|
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
|
}
|
|
activeMenuFor.value = taskId
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'chore-kebab-menu',
|
|
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
|
)
|
|
})
|
|
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
|
const task = items.find((t) => t.id === taskId)
|
|
if (task) {
|
|
if (isChoreExpired(task)) {
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'chore-kebab-extend-time',
|
|
() => document.querySelector('[data-tutorial="chore-extend-time"]') as HTMLElement | null,
|
|
)
|
|
})
|
|
}
|
|
if (isChoreCompletedToday(task)) {
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'chore-kebab-reset',
|
|
() => document.querySelector('[data-tutorial="chore-reset"]') as HTMLElement | null,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function closeChoreMenu() {
|
|
activeMenuFor.value = null
|
|
}
|
|
|
|
// ── Schedule modal ────────────────────────────────────────────────────────────
|
|
|
|
function openScheduleModal(item: ChildTask, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
closeChoreMenu()
|
|
scheduleTarget.value = item
|
|
showScheduleModal.value = true
|
|
}
|
|
|
|
function onScheduleSaved() {
|
|
showScheduleModal.value = false
|
|
scheduleTarget.value = null
|
|
setTimeout(() => resetExpiryTimers(), 300)
|
|
}
|
|
|
|
// ── Extend Time ───────────────────────────────────────────────────────────────
|
|
|
|
async function doExtendTime(item: ChildTask, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
closeChoreMenu()
|
|
if (!child.value) return
|
|
const today = toLocalISODate(new Date())
|
|
const res = await extendChoreTime(child.value.id, item.id, today)
|
|
if (!res.ok) {
|
|
const { msg } = await parseErrorResponse(res)
|
|
alert(`Error: ${msg}`)
|
|
return
|
|
}
|
|
// SSE event 'chore_time_extended' triggers handleChoreTimeExtended which refreshes the list
|
|
}
|
|
|
|
// ── Schedule state helpers (for item-slot use) ────────────────────────────────
|
|
|
|
function isChoreScheduledToday(item: ChildTask): boolean {
|
|
if (!item.schedule) return true // no schedule = always active
|
|
return isScheduledToday(item.schedule, new Date())
|
|
}
|
|
|
|
function isChoreExpired(item: ChildTask): boolean {
|
|
if (item.pending_status === 'pending') return false
|
|
if (!item.schedule) return false
|
|
const now = new Date()
|
|
if (!isScheduledToday(item.schedule, now)) return false
|
|
const due = getDueTimeToday(item.schedule, now)
|
|
if (!due) return false
|
|
if (isExtendedToday(item.extension_date, now)) return false
|
|
return isPastTime(due.hour, due.minute, now)
|
|
}
|
|
|
|
function choreDueLabel(item: ChildTask): string | null {
|
|
if (!item.schedule) return null
|
|
const now = new Date()
|
|
if (!isScheduledToday(item.schedule, now)) return null
|
|
const due = getDueTimeToday(item.schedule, now)
|
|
if (!due) return null
|
|
if (isExtendedToday(item.extension_date, now)) return null
|
|
if (isPastTime(due.hour, due.minute, now)) return null
|
|
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
|
|
}
|
|
|
|
function isChoreInactive(item: ChildTask): boolean {
|
|
return !isChoreScheduledToday(item) || isChoreExpired(item)
|
|
}
|
|
|
|
// ── Sorting ───────────────────────────────────────────────────────────────────
|
|
|
|
function parentChoreSortPriority(item: ChildTask): number {
|
|
if (isChorePending(item)) return 0
|
|
if (!isChoreScheduledToday(item)) return 4
|
|
if (isChoreCompletedToday(item)) return 3
|
|
if (isChoreExpired(item)) return 2
|
|
return 1 // general / active
|
|
}
|
|
|
|
function parentChoreSort(a: ChildTask, b: ChildTask): number {
|
|
return parentChoreSortPriority(a) - parentChoreSortPriority(b)
|
|
}
|
|
|
|
function parentRewardSort(a: RewardStatus, b: RewardStatus): number {
|
|
if (a.redeeming !== b.redeeming) return a.redeeming ? -1 : 1
|
|
return 0
|
|
}
|
|
|
|
// ── Expiry timers ─────────────────────────────────────────────────────────────
|
|
|
|
function clearExpiryTimers() {
|
|
expiryTimers.value.forEach(clearTimeout)
|
|
expiryTimers.value = []
|
|
}
|
|
|
|
function resetExpiryTimers() {
|
|
clearExpiryTimers()
|
|
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
|
const now = new Date()
|
|
for (const item of items) {
|
|
if (!item.schedule || item.type !== 'chore') continue
|
|
if (!isScheduledToday(item.schedule, now)) continue
|
|
const due = getDueTimeToday(item.schedule, now)
|
|
if (!due) continue
|
|
if (isExtendedToday(item.extension_date, now)) continue
|
|
if (isPastTime(due.hour, due.minute, now)) continue
|
|
const ms = msUntilExpiry(due.hour, due.minute, now)
|
|
const handle = setTimeout(() => {
|
|
// trigger a reactive update by refreshing the list
|
|
childChoreListRef.value?.refresh()
|
|
}, ms) as unknown as number
|
|
expiryTimers.value.push(handle)
|
|
}
|
|
}
|
|
|
|
// ── Midnight detection (tab left open overnight) ──────────────────────────────
|
|
|
|
function onVisibilityChange() {
|
|
if (document.visibilityState !== 'visible') return
|
|
const today = toLocalISODate(new Date())
|
|
if (today !== lastFetchDate.value) {
|
|
lastFetchDate.value = today
|
|
childChoreListRef.value?.refresh()
|
|
resetExpiryTimers()
|
|
}
|
|
}
|
|
|
|
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
|
// If editing a pending reward, warn first
|
|
if (type === 'reward' && (item as any).redeeming) {
|
|
pendingEditOverrideTarget.value = { entity: item, type }
|
|
showPendingRewardDialog.value = true
|
|
return
|
|
}
|
|
overrideEditTarget.value = { entity: item, type }
|
|
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
|
|
overrideCustomValue.value = (item as any).custom_value ?? defaultValue
|
|
validateOverrideInput()
|
|
showOverrideModal.value = true
|
|
}
|
|
|
|
function editChorePoints(item: Task) {
|
|
handleEditItem(item, 'task')
|
|
closeChoreMenu()
|
|
}
|
|
|
|
async function confirmPendingRewardAndEdit() {
|
|
if (!pendingEditOverrideTarget.value) return
|
|
const item = pendingEditOverrideTarget.value.entity as any
|
|
await cancelRewardById(item.id)
|
|
showPendingRewardDialog.value = false
|
|
const target = pendingEditOverrideTarget.value
|
|
pendingEditOverrideTarget.value = null
|
|
// Open override modal directly, bypassing the redeeming check
|
|
overrideEditTarget.value = target
|
|
const defaultValue =
|
|
target.type === 'task' ? (target.entity as Task).points : (target.entity as Reward).cost
|
|
overrideCustomValue.value = (target.entity as any).custom_value ?? defaultValue
|
|
validateOverrideInput()
|
|
showOverrideModal.value = true
|
|
}
|
|
|
|
function validateOverrideInput() {
|
|
const val = overrideCustomValue.value
|
|
isOverrideValid.value = typeof val === 'number' && val >= 0 && val <= 10000
|
|
}
|
|
|
|
watch(showOverrideModal, async (newVal) => {
|
|
if (newVal) {
|
|
await nextTick()
|
|
document.getElementById('custom-value')?.focus()
|
|
modalTutorialStepId.value = 'point-editor-help'
|
|
tutorialMaybeShow(
|
|
'point-editor-help',
|
|
() => document.querySelector('input#custom-value') as HTMLElement | null,
|
|
)
|
|
} else {
|
|
modalTutorialStepId.value = null
|
|
}
|
|
})
|
|
|
|
watch(showConfirm, (newVal) => setHelpButtonHidden(newVal))
|
|
watch(showRewardConfirm, (newVal) => setHelpButtonHidden(newVal))
|
|
watch(showRoutineConfirmDialog, (newVal) => setHelpButtonHidden(newVal))
|
|
|
|
async function saveOverride() {
|
|
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
|
|
|
|
const type = overrideEditTarget.value.type as string
|
|
let res: Response
|
|
if (type === 'routine') {
|
|
res = await setChildRoutineOverride(
|
|
child.value.id,
|
|
overrideEditTarget.value.entity.id,
|
|
overrideCustomValue.value,
|
|
)
|
|
} else {
|
|
res = await setChildOverride(
|
|
child.value.id,
|
|
overrideEditTarget.value.entity.id,
|
|
overrideEditTarget.value.type,
|
|
overrideCustomValue.value,
|
|
)
|
|
}
|
|
|
|
if (res.ok) {
|
|
lastEditedItem.value = {
|
|
id: overrideEditTarget.value.entity.id,
|
|
type: overrideEditTarget.value.type,
|
|
}
|
|
showOverrideModal.value = false
|
|
} else {
|
|
const { msg } = await parseErrorResponse(res)
|
|
alert(`Error: ${msg}`)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
}
|
|
}
|
|
|
|
function applyHighlightPulse(itemId: string) {
|
|
// Delay slightly so the scroll animation has time to settle
|
|
setTimeout(() => {
|
|
const el = document.querySelector(`[data-item-id="${itemId}"]`)
|
|
if (!el) return
|
|
el.classList.add('highlight-pulse')
|
|
el.addEventListener(
|
|
'animationend',
|
|
() => {
|
|
el.classList.remove('highlight-pulse')
|
|
},
|
|
{ once: true },
|
|
)
|
|
}, 200)
|
|
}
|
|
|
|
// Handle digestToken appearing in route query (e.g. SW navigates to same route with new token)
|
|
watch(
|
|
() => route.query.digestToken,
|
|
async (token) => {
|
|
if (typeof token !== 'string' || !token) return
|
|
try {
|
|
const res = await fetch(`/api/digest-action/${token}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
})
|
|
const data = await res.json().catch(() => ({}))
|
|
console.log('[ParentView] digest-action POST status=', res.status, 'body=', data)
|
|
} catch (e) {
|
|
console.warn('[ParentView] Digest action request failed:', e)
|
|
}
|
|
// Refresh chore and reward lists to reflect the action result
|
|
childChoreListRef.value?.refresh()
|
|
childRewardListRef.value?.refresh()
|
|
if (child.value?.id) {
|
|
const updated = await fetchChildData(child.value.id)
|
|
if (updated) {
|
|
child.value = updated
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
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)
|
|
eventBus.on('child_override_set', handleOverrideSet)
|
|
eventBus.on('child_override_deleted', handleOverrideDeleted)
|
|
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
|
|
eventBus.on('chore_time_extended', handleChoreTimeExtended)
|
|
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
|
|
eventBus.on('routine_schedule_modified', handleRoutineScheduleModified)
|
|
eventBus.on('routine_time_extended', handleRoutineTimeExtended)
|
|
eventBus.on('routine_modified', handleRoutineModified)
|
|
eventBus.on('child_routines_set', handleChildRoutinesSet)
|
|
eventBus.on('child_routine_confirmation', handleChildRoutineConfirmation)
|
|
|
|
document.addEventListener('click', onDocClick, true)
|
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
|
|
if (route.params.id) {
|
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
|
const scrollToId = typeof route.query.scrollTo === 'string' ? route.query.scrollTo : null
|
|
const entityType = typeof route.query.entityType === 'string' ? route.query.entityType : null
|
|
const digestToken =
|
|
typeof route.query.digestToken === 'string' ? route.query.digestToken : null
|
|
|
|
if (digestToken) {
|
|
try {
|
|
const res = await fetch(`/api/digest-action/${digestToken}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
})
|
|
} catch (e) {
|
|
console.warn('[ParentView] onMounted digest action request failed:', e)
|
|
}
|
|
}
|
|
|
|
if (idParam !== undefined) {
|
|
const promise = fetchChildData(idParam)
|
|
promise.then((data) => {
|
|
if (data) {
|
|
child.value = data
|
|
tasks.value = data.tasks || []
|
|
rewards.value = data.rewards || []
|
|
// Fire the per-child overview tour (chains into assign-* steps).
|
|
// No anchor: this is a general overview so the card stays centered.
|
|
nextTick(() => {
|
|
tutorialMaybeShow('select-child')
|
|
})
|
|
}
|
|
loading.value = false
|
|
if (scrollToId) {
|
|
setTimeout(() => {
|
|
if (entityType === 'chore') {
|
|
childChoreListRef.value?.scrollToItem(scrollToId)
|
|
applyHighlightPulse(scrollToId)
|
|
} else if (entityType === 'reward') {
|
|
childRewardListRef.value?.scrollToItem(scrollToId)
|
|
applyHighlightPulse(scrollToId)
|
|
} else if (entityType === 'routine') {
|
|
childRoutineListRef.value?.scrollToItem(scrollToId)
|
|
applyHighlightPulse(scrollToId)
|
|
}
|
|
const { scrollTo: _s, entityType: _e, ...remainingQuery } = route.query
|
|
router.replace({ query: remainingQuery })
|
|
}, 500)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error in onMounted:', err)
|
|
}
|
|
})
|
|
|
|
// Fire status-badge tutorials when those badges first render for any chore.
|
|
watch(
|
|
() => {
|
|
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
|
return items.map((t) => ({ id: t.id, expired: isChoreExpired(t), pending: isChorePending(t) }))
|
|
},
|
|
(list) => {
|
|
if (list.some((t) => t.expired)) {
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'status-too-late',
|
|
() =>
|
|
Array.from(document.querySelectorAll('.chore-stamp')).find(
|
|
(el) => (el.textContent || '').trim() === 'TOO LATE',
|
|
) as HTMLElement | null,
|
|
)
|
|
})
|
|
}
|
|
if (list.some((t) => t.pending)) {
|
|
nextTick(() => {
|
|
tutorialMaybeShow(
|
|
'status-pending',
|
|
() => document.querySelector('.chore-stamp.pending-stamp') as HTMLElement | null,
|
|
)
|
|
})
|
|
}
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
// When the generic kebab-overview step fires, scroll to an assigned item
|
|
// and select it so its kebab button is visible for the user to discover.
|
|
watch(
|
|
() => tutorialActiveStep.value?.def.id,
|
|
async (stepId, prevStepId) => {
|
|
if (stepId === 'item-kebab-overview') {
|
|
const chores: ChildTask[] = childChoreListRef.value?.items ?? []
|
|
const routines: ChildRoutine[] = childRoutineListRef.value?.items ?? []
|
|
if (chores.length > 0 && childChoreListRef.value) {
|
|
const first = chores[0]
|
|
childChoreListRef.value.scrollToItem(first.id)
|
|
selectedChoreId.value = first.id
|
|
tutorialHighlightedItemId.value = first.id
|
|
await nextTick()
|
|
const btn = kebabBtnRefs.value.get(first.id)
|
|
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
|
tutorialActiveStep.value.anchor = () => btn
|
|
}
|
|
} else if (routines.length > 0 && childRoutineListRef.value) {
|
|
const first = routines[0]
|
|
childRoutineListRef.value.scrollToItem(first.id)
|
|
selectedRoutineId.value = first.id
|
|
tutorialHighlightedItemId.value = first.id
|
|
await nextTick()
|
|
const btn = routineKebabBtnRefs.value.get(first.id)
|
|
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
|
tutorialActiveStep.value.anchor = () => btn
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up the highlighted selection when the tutorial leaves kebab steps
|
|
const kebabStepIds = new Set([
|
|
'item-kebab-overview',
|
|
'chore-kebab-menu',
|
|
'chore-edit-points',
|
|
'chore-schedule',
|
|
'chore-kebab-extend-time',
|
|
'chore-kebab-reset',
|
|
'routine-kebab-menu',
|
|
'routine-edit',
|
|
'routine-edit-points',
|
|
'routine-schedule',
|
|
'routine-extend-time',
|
|
'routine-reset',
|
|
'kebab-edit-points-cost',
|
|
])
|
|
if (
|
|
prevStepId &&
|
|
kebabStepIds.has(prevStepId) &&
|
|
!kebabStepIds.has(stepId ?? '') &&
|
|
tutorialHighlightedItemId.value
|
|
) {
|
|
selectedChoreId.value = null
|
|
selectedRoutineId.value = null
|
|
tutorialHighlightedItemId.value = null
|
|
}
|
|
},
|
|
)
|
|
|
|
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)
|
|
eventBus.off('child_override_set', handleOverrideSet)
|
|
eventBus.off('child_override_deleted', handleOverrideDeleted)
|
|
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
|
|
eventBus.off('chore_time_extended', handleChoreTimeExtended)
|
|
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
|
|
eventBus.off('routine_schedule_modified', handleRoutineScheduleModified)
|
|
eventBus.off('routine_time_extended', handleRoutineTimeExtended)
|
|
eventBus.off('routine_modified', handleRoutineModified)
|
|
eventBus.off('child_routines_set', handleChildRoutinesSet)
|
|
eventBus.off('child_routine_confirmation', handleChildRoutineConfirmation)
|
|
|
|
document.removeEventListener('click', onDocClick, true)
|
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
clearExpiryTimers()
|
|
})
|
|
|
|
function getPendingRewardIds(): string[] {
|
|
const items = childRewardListRef.value?.items || []
|
|
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
|
|
}
|
|
|
|
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) {
|
|
selectedReward.value = null
|
|
showPendingRewardDialog.value = true
|
|
return
|
|
}
|
|
setTimeout(() => {
|
|
showConfirm.value = true
|
|
}, 150)
|
|
}
|
|
|
|
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
|
|
// If there is a pending reward and it's not this one, show the pending dialog
|
|
const pendingRewardIds = getPendingRewardIds()
|
|
if (pendingRewardIds.length > 0 && !reward.redeeming) {
|
|
showPendingRewardDialog.value = true
|
|
return
|
|
}
|
|
setTimeout(() => {
|
|
showRewardConfirm.value = true
|
|
}, 150)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
const denyRewardRequest = async () => {
|
|
if (!child.value?.id || !selectedReward.value) return
|
|
try {
|
|
await fetch(`/api/child/${child.value.id}/deny-reward-request`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reward_id: selectedReward.value.id }),
|
|
})
|
|
} catch (err) {
|
|
console.error('Failed to deny reward request:', err)
|
|
} finally {
|
|
showRewardConfirm.value = false
|
|
selectedReward.value = null
|
|
}
|
|
}
|
|
|
|
function goToAssignTasks() {
|
|
if (child.value?.id) {
|
|
router.push({
|
|
name: 'ChoreAssignView',
|
|
params: { id: child.value.id },
|
|
query: { name: child.value.name },
|
|
})
|
|
}
|
|
}
|
|
|
|
function goToAssignBadHabits() {
|
|
if (child.value?.id) {
|
|
router.push({
|
|
name: 'PenaltyAssignView',
|
|
params: { id: child.value.id },
|
|
query: { name: child.value.name },
|
|
})
|
|
}
|
|
}
|
|
|
|
function goToAssignKindness() {
|
|
if (child.value?.id) {
|
|
router.push({
|
|
name: 'KindnessAssignView',
|
|
params: { id: child.value.id },
|
|
query: { name: child.value.name },
|
|
})
|
|
}
|
|
}
|
|
|
|
function goToAssignRewards() {
|
|
if (child.value?.id) {
|
|
router.push({
|
|
name: 'RewardAssignView',
|
|
params: { id: child.value.id },
|
|
query: { name: child.value.name },
|
|
})
|
|
}
|
|
}
|
|
|
|
function goToAssignRoutines() {
|
|
if (child.value?.id) {
|
|
router.push({
|
|
name: 'RoutineAssignView',
|
|
params: { id: child.value.id },
|
|
query: { name: child.value.name },
|
|
})
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<StatusMessage :loading="loading" :error="error" />
|
|
|
|
<div v-if="!loading && !error" class="layout">
|
|
<div class="main">
|
|
<ChildDetailCard :child="child" />
|
|
<ScrollingList
|
|
title="Chores"
|
|
ref="childChoreListRef"
|
|
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
|
|
:ids="tasks"
|
|
itemKey="tasks"
|
|
imageField="image_id"
|
|
:enableEdit="false"
|
|
:childId="child?.id"
|
|
:readyItemId="readyItemId"
|
|
:isParentAuthenticated="true"
|
|
@trigger-item="triggerTask"
|
|
@item-ready="handleChoreItemReady"
|
|
:getItemClass="
|
|
(item) => ({
|
|
bad: item.type === 'penalty',
|
|
good: item.type !== 'penalty',
|
|
'chore-inactive': isChoreInactive(item) || isChoreCompletedToday(item),
|
|
})
|
|
"
|
|
:filter-fn="(item) => item.type === 'chore'"
|
|
:sort-fn="parentChoreSort"
|
|
>
|
|
<template #item="{ item }: { item: ChildTask }">
|
|
<!-- Kebab menu -->
|
|
<div class="chore-kebab-wrap" @click.stop>
|
|
<button
|
|
v-show="selectedChoreId === item.id"
|
|
class="kebab-btn"
|
|
:ref="
|
|
(el) => {
|
|
if (el) kebabBtnRefs.set(item.id, el as HTMLElement)
|
|
else kebabBtnRefs.delete(item.id)
|
|
}
|
|
"
|
|
@mousedown.stop.prevent
|
|
@click="openChoreMenu(item.id, $event)"
|
|
:aria-expanded="activeMenuFor === item.id ? 'true' : 'false'"
|
|
aria-label="Options"
|
|
>
|
|
⋮
|
|
</button>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="activeMenuFor === item.id"
|
|
class="kebab-menu"
|
|
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
|
|
@mousedown.stop.prevent
|
|
@click.stop
|
|
>
|
|
<button class="menu-item" data-tutorial="chore-edit-points kebab-edit-points-cost" @mousedown.stop.prevent @click="editChorePoints(item)">
|
|
Edit Points
|
|
</button>
|
|
<button
|
|
class="menu-item"
|
|
data-tutorial="chore-schedule"
|
|
@mousedown.stop.prevent
|
|
@click="openScheduleModal(item, $event)"
|
|
>
|
|
Schedule
|
|
</button>
|
|
<button
|
|
v-if="isChoreExpired(item)"
|
|
class="menu-item"
|
|
data-tutorial="chore-extend-time"
|
|
@mousedown.stop.prevent
|
|
@click="doExtendTime(item, $event)"
|
|
>
|
|
Extend Time
|
|
</button>
|
|
<button
|
|
v-if="isChoreCompletedToday(item)"
|
|
class="menu-item"
|
|
data-tutorial="chore-reset"
|
|
@mousedown.stop.prevent
|
|
@click="doResetChore(item, $event)"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
|
|
<!-- COMPLETED badge -->
|
|
<span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
|
>COMPLETED</span
|
|
>
|
|
<!-- TOO LATE badge -->
|
|
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
|
<!-- PENDING badge -->
|
|
<span v-else-if="isChorePending(item)" 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 good-points">
|
|
{{
|
|
item.custom_value !== undefined && item.custom_value !== null
|
|
? item.custom_value
|
|
: item.points
|
|
}}
|
|
Points
|
|
</div>
|
|
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
|
|
</template>
|
|
</ScrollingList>
|
|
<ScrollingList
|
|
title="Routines"
|
|
ref="childRoutineListRef"
|
|
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
|
|
itemKey="routines"
|
|
imageField="image_id"
|
|
:enableEdit="false"
|
|
:childId="child?.id"
|
|
:readyItemId="readyItemId"
|
|
:isParentAuthenticated="true"
|
|
@item-ready="handleRoutineItemReady"
|
|
@trigger-item="triggerRoutine"
|
|
:getItemClass="
|
|
(item) => ({
|
|
good: true,
|
|
'chore-inactive': isRoutineInactive(item) || isRoutineApprovedToday(item),
|
|
})
|
|
"
|
|
:sort-fn="
|
|
(a: ChildRoutine, b: ChildRoutine) => {
|
|
if (isRoutinePending(a) !== isRoutinePending(b)) return isRoutinePending(a) ? -1 : 1
|
|
if (!isRoutineScheduledToday(a) !== !isRoutineScheduledToday(b))
|
|
return isRoutineScheduledToday(a) ? -1 : 1
|
|
if (isRoutineApprovedToday(a) !== isRoutineApprovedToday(b))
|
|
return isRoutineApprovedToday(a) ? 1 : -1
|
|
if (isRoutineExpired(a) !== isRoutineExpired(b)) return isRoutineExpired(a) ? 1 : -1
|
|
return 0
|
|
}
|
|
"
|
|
>
|
|
<template #item="{ item }: { item: ChildRoutine }">
|
|
<!-- Routine kebab menu -->
|
|
<div class="chore-kebab-wrap" @click.stop>
|
|
<button
|
|
v-show="selectedRoutineId === item.id"
|
|
class="kebab-btn"
|
|
:ref="
|
|
(el) => {
|
|
if (el) routineKebabBtnRefs.set(item.id, el as HTMLElement)
|
|
else routineKebabBtnRefs.delete(item.id)
|
|
}
|
|
"
|
|
@mousedown.stop.prevent
|
|
@click="openRoutineMenu(item.id, $event)"
|
|
:aria-expanded="activeRoutineMenuFor === item.id ? 'true' : 'false'"
|
|
aria-label="Options"
|
|
>
|
|
⋮
|
|
</button>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="activeRoutineMenuFor === item.id"
|
|
class="kebab-menu"
|
|
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
|
|
@mousedown.stop.prevent
|
|
@click.stop
|
|
>
|
|
<button class="menu-item" data-tutorial="routine-edit" @mousedown.stop.prevent @click="editRoutine(item)">
|
|
Edit Routine
|
|
</button>
|
|
<button
|
|
class="menu-item"
|
|
data-tutorial="routine-edit-points kebab-edit-points-cost"
|
|
@mousedown.stop.prevent
|
|
@click="editRoutinePoints(item)"
|
|
>
|
|
Edit Points
|
|
</button>
|
|
<button
|
|
class="menu-item"
|
|
data-tutorial="routine-schedule"
|
|
@mousedown.stop.prevent
|
|
@click="openRoutineScheduleModal(item, $event)"
|
|
>
|
|
Schedule
|
|
</button>
|
|
<button
|
|
v-if="isRoutineExpired(item)"
|
|
class="menu-item"
|
|
data-tutorial="routine-extend-time"
|
|
@mousedown.stop.prevent
|
|
@click="doExtendRoutineTime(item, $event)"
|
|
>
|
|
Extend Time
|
|
</button>
|
|
<button
|
|
v-if="isRoutineApprovedToday(item)"
|
|
class="menu-item"
|
|
data-tutorial="routine-reset"
|
|
@mousedown.stop.prevent
|
|
@click="doResetRoutine(item, $event)"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
|
|
<!-- Status badges -->
|
|
<span v-if="isRoutineApprovedToday(item)" class="chore-stamp completed-stamp"
|
|
>COMPLETED</span
|
|
>
|
|
<span v-else-if="isRoutineExpired(item)" class="chore-stamp">TOO LATE</span>
|
|
<span v-else-if="isRoutinePending(item)" 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="Routine 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>
|
|
<div v-if="routineDueLabel(item)" class="due-label">{{ routineDueLabel(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"
|
|
: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="
|
|
(item) => ({ bad: item.type === 'penalty', good: item.type !== 'penalty' })
|
|
"
|
|
:filter-fn="
|
|
(item) => {
|
|
return 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.type !== 'penalty',
|
|
'bad-points': item.type === 'penalty',
|
|
}"
|
|
>
|
|
{{
|
|
item.custom_value !== undefined && item.custom_value !== null
|
|
? -item.custom_value
|
|
: -item.points
|
|
}}
|
|
Points
|
|
</div>
|
|
</template>
|
|
</ScrollingList>
|
|
<ScrollingList
|
|
title="Rewards"
|
|
ref="childRewardListRef"
|
|
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
|
itemKey="reward_status"
|
|
imageField="image_id"
|
|
:ids="rewards"
|
|
:enableEdit="true"
|
|
:childId="child?.id"
|
|
:readyItemId="readyItemId"
|
|
:isParentAuthenticated="true"
|
|
@trigger-item="triggerReward"
|
|
@edit-item="(item) => handleEditItem(item, 'reward')"
|
|
@item-ready="handleItemReady"
|
|
:getItemClass="(item) => ({ reward: true })"
|
|
:sort-fn="parentRewardSort"
|
|
>
|
|
<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 Chores</button>
|
|
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
|
|
Assign Routines
|
|
</button>
|
|
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</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>
|
|
</div>
|
|
|
|
<!-- Pending Reward Dialog -->
|
|
<PendingRewardDialog
|
|
v-if="showPendingRewardDialog"
|
|
:reward-name="pendingDialogReward?.name"
|
|
:child-name="pendingDialogReward ? child?.name : undefined"
|
|
:image-url="pendingDialogReward?.image_url ?? null"
|
|
:subtitle="
|
|
pendingDialogReward
|
|
? pendingDialogReward.points_needed === 0
|
|
? 'Reward Ready!'
|
|
: pendingDialogReward.points_needed + ' more points'
|
|
: undefined
|
|
"
|
|
:message="
|
|
pendingEditOverrideTarget
|
|
? 'This reward is currently pending. Changing its cost will cancel the pending request. Would you like to proceed?'
|
|
: 'A reward is currently pending. It will be cancelled when a chore or penalty is triggered. Would you like to proceed?'
|
|
"
|
|
@confirm="pendingEditOverrideTarget ? confirmPendingRewardAndEdit() : cancelPendingReward()"
|
|
@cancel="
|
|
() => {
|
|
showPendingRewardDialog = false
|
|
pendingEditOverrideTarget = null
|
|
selectedReward = null
|
|
}
|
|
"
|
|
/>
|
|
|
|
<!-- Schedule Modal -->
|
|
<ScheduleModal
|
|
v-if="showScheduleModal && scheduleTarget && child"
|
|
:entity="scheduleTarget"
|
|
entityType="task"
|
|
:childId="child.id"
|
|
:schedule="scheduleTarget.schedule ?? null"
|
|
@saved="onScheduleSaved"
|
|
@cancelled="showScheduleModal = false"
|
|
/>
|
|
|
|
<!-- Schedule Modal (Routines) -->
|
|
<ScheduleModal
|
|
v-if="showRoutineScheduleModal && routineScheduleTarget && child"
|
|
:entity="routineScheduleTarget"
|
|
entityType="routine"
|
|
:childId="child.id"
|
|
:schedule="routineScheduleTarget.schedule ?? null"
|
|
@saved="onRoutineScheduleSaved"
|
|
@cancelled="showRoutineScheduleModal = false"
|
|
/>
|
|
|
|
<!-- Override Edit Modal -->
|
|
<ModalDialog
|
|
v-if="showOverrideModal && overrideEditTarget && child"
|
|
:image-url="overrideEditTarget.entity.image_url"
|
|
:title="overrideEditTarget.entity.name"
|
|
:subtitle="`Assign ${overrideEditTarget.type === 'task' ? 'new points' : 'new cost'}`"
|
|
>
|
|
<div class="override-content">
|
|
<div class="input-group">
|
|
<label for="custom-value"
|
|
>{{ overrideEditTarget.type === 'task' ? 'New Points' : 'New Cost' }}:</label
|
|
>
|
|
<input
|
|
id="custom-value"
|
|
v-model.number="overrideCustomValue"
|
|
type="number"
|
|
min="0"
|
|
max="10000"
|
|
:class="{ invalid: !isOverrideValid }"
|
|
@input="validateOverrideInput"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn-secondary" @click="showOverrideModal = false">Cancel</button>
|
|
<button class="btn-primary" :disabled="!isOverrideValid" @click="saveOverride">Save</button>
|
|
</div>
|
|
</ModalDialog>
|
|
|
|
<!-- Task Confirm Dialog -->
|
|
<TaskConfirmDialog
|
|
v-if="showConfirm"
|
|
:task="selectedTask"
|
|
:childName="child?.name"
|
|
@confirm="confirmTriggerTask"
|
|
@cancel="
|
|
() => {
|
|
showConfirm = false
|
|
selectedTask = null
|
|
}
|
|
"
|
|
/>
|
|
|
|
<!-- Reward Confirm Dialog -->
|
|
<RewardConfirmDialog
|
|
v-if="showRewardConfirm"
|
|
:reward="selectedReward as any"
|
|
:childName="child?.name"
|
|
@confirm="confirmTriggerReward"
|
|
@deny="denyRewardRequest"
|
|
@cancel="
|
|
() => {
|
|
showRewardConfirm = false
|
|
selectedReward = null
|
|
}
|
|
"
|
|
/>
|
|
|
|
<!-- 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"
|
|
/>
|
|
|
|
<!-- Routine Approve/Reject Dialog -->
|
|
<RoutineApproveDialog
|
|
v-if="showRoutineApproveDialog && approveDialogRoutine"
|
|
:show="showRoutineApproveDialog"
|
|
:childName="child?.name ?? ''"
|
|
:routineName="approveDialogRoutine.name"
|
|
:points="approveDialogRoutine.custom_value ?? approveDialogRoutine.points"
|
|
:imageUrl="approveDialogRoutine.image_url"
|
|
@approve="doApproveRoutine"
|
|
@reject="doRejectRoutine"
|
|
@cancel="cancelRoutineApproveDialog"
|
|
/>
|
|
|
|
<RoutineConfirmDialog
|
|
:routine="confirmDialogRoutine"
|
|
:childName="child?.name ?? ''"
|
|
@confirm="doConfirmRoutine"
|
|
@cancel="cancelRoutineConfirmDialog"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.layout {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: center;
|
|
margin: 2rem 0;
|
|
}
|
|
|
|
.main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.5rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.assign-buttons {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: center;
|
|
margin: 2rem 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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(.chore-inactive) {
|
|
position: relative;
|
|
}
|
|
:deep(.chore-inactive::before) {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(160, 160, 160, 0.45);
|
|
filter: grayscale(80%);
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
border-radius: inherit;
|
|
}
|
|
:deep(.chore-inactive) .kebab-btn {
|
|
color: rgba(255, 255, 255, 0.85);
|
|
}
|
|
|
|
/* Chore kebab menu (inside item-card which is position:relative in ScrollingList) */
|
|
.chore-kebab-wrap {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
z-index: 20;
|
|
}
|
|
|
|
.kebab-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: 0;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
color: var(--kebab-icon-color, #4a4a6a);
|
|
border-radius: 6px;
|
|
font-size: 1.4rem;
|
|
}
|
|
|
|
.kebab-btn:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
|
|
}
|
|
|
|
.kebab-menu {
|
|
position: fixed;
|
|
min-width: 140px;
|
|
background: var(--kebab-menu-bg, #f7fafc);
|
|
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
|
box-shadow: var(--kebab-menu-shadow);
|
|
backdrop-filter: blur(var(--kebab-menu-blur));
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
z-index: 9999;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.menu-item {
|
|
padding: 0.85rem 0.9rem;
|
|
background: transparent;
|
|
border: 0;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
color: var(--menu-item-color, #333);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.menu-item:hover {
|
|
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
|
|
}
|
|
|
|
/* TOO LATE stamp on expired chores */
|
|
.chore-stamp {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 80%;
|
|
background: rgba(34, 34, 34, 0.65);
|
|
color: var(--text-bad-color, #ef4444);
|
|
text-shadow: var(--item-points-shadow);
|
|
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;
|
|
}
|
|
|
|
.pending-stamp {
|
|
color: #fbbf24;
|
|
}
|
|
|
|
.completed-stamp {
|
|
color: #22c55e;
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.chore-stamp {
|
|
font-size: 0.82rem;
|
|
letter-spacing: 1px;
|
|
padding: 0.3rem 0;
|
|
}
|
|
}
|
|
|
|
/* Due time sub-text */
|
|
.due-label {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-bad-color, #ef4444);
|
|
margin-top: 0.2rem;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
|
|
/* Override modal styles */
|
|
.override-content {
|
|
text-align: left;
|
|
}
|
|
|
|
.input-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin: var(--spacing-md, 1rem) 0;
|
|
}
|
|
|
|
.input-group label {
|
|
color: var(--text-primary);
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.input-group input {
|
|
width: 100%;
|
|
padding: 0.6rem;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
|
font-size: 1rem;
|
|
background: var(--form-input-bg, #fff);
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.input-group input.invalid {
|
|
border-color: var(--error-color, #e53e3e);
|
|
}
|
|
|
|
@keyframes highlight-pulse {
|
|
0% {
|
|
box-shadow: 0 0 0 0 var(--accent, #4a90e2);
|
|
outline: 2px solid var(--accent, #4a90e2);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 16px 4px var(--accent, #4a90e2);
|
|
outline: 2px solid var(--accent, #4a90e2);
|
|
}
|
|
100% {
|
|
box-shadow: none;
|
|
outline: none;
|
|
}
|
|
}
|
|
|
|
:global(.highlight-pulse) {
|
|
animation: highlight-pulse 1.8s ease-out forwards;
|
|
}
|
|
</style>
|