Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 5m43s
1398 lines
42 KiB
Vue
1398 lines
42 KiB
Vue
<script setup lang="ts">
|
|
import { 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 { 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,
|
|
} from '@/common/api'
|
|
import { eventBus } from '@/common/eventBus'
|
|
import '@/assets/styles.css'
|
|
import type {
|
|
Task,
|
|
Child,
|
|
Event,
|
|
Reward,
|
|
RewardStatus,
|
|
ChildTask,
|
|
ChildTaskTriggeredEventPayload,
|
|
ChildRewardTriggeredEventPayload,
|
|
ChildRewardRequestEventPayload,
|
|
ChildTasksSetEventPayload,
|
|
ChildRewardsSetEventPayload,
|
|
ChildModifiedEventPayload,
|
|
TaskModifiedEventPayload,
|
|
RewardModifiedEventPayload,
|
|
ChildOverrideSetPayload,
|
|
ChildOverrideDeletedPayload,
|
|
ChoreScheduleModifiedPayload,
|
|
ChoreTimeExtendedPayload,
|
|
ChildChoreConfirmationPayload,
|
|
} 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)
|
|
|
|
// 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())
|
|
|
|
// Schedule modal
|
|
const showScheduleModal = ref(false)
|
|
const scheduleTarget = ref<ChildTask | null>(null)
|
|
|
|
// 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
|
|
}
|
|
|
|
function handleChoreItemReady(itemId: string) {
|
|
readyItemId.value = itemId
|
|
selectedChoreId.value = itemId || null
|
|
}
|
|
|
|
function handleTaskTriggered(event: Event) {
|
|
console.log('Task triggered, refreshing rewards list -> ', childRewardListRef.value)
|
|
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)
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
if (activeMenuFor.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')
|
|
)
|
|
})
|
|
if (!inside) {
|
|
activeMenuFor.value = null
|
|
selectedChoreId.value = null
|
|
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
|
|
shouldIgnoreNextCardClick.value = true
|
|
} else {
|
|
// Clicked fully outside any card — reset ready state so chore requires 2 clicks again
|
|
readyItemId.value = null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function openChoreMenu(taskId: string, e: MouseEvent) {
|
|
e.stopPropagation()
|
|
const btn = kebabBtnRefs.value.get(taskId)
|
|
if (btn) {
|
|
const rect = btn.getBoundingClientRect()
|
|
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
|
}
|
|
activeMenuFor.value = taskId
|
|
}
|
|
|
|
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
|
|
}
|
|
childChoreListRef.value?.refresh()
|
|
setTimeout(() => resetExpiryTimers(), 300)
|
|
}
|
|
|
|
// ── 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.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.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.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()
|
|
}
|
|
})
|
|
|
|
async function saveOverride() {
|
|
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
|
|
|
|
const 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 } = 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 {
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
if (idParam !== undefined) {
|
|
const promise = fetchChildData(idParam)
|
|
promise.then((data) => {
|
|
if (data) {
|
|
child.value = data
|
|
tasks.value = data.tasks || []
|
|
rewards.value = data.rewards || []
|
|
}
|
|
loading.value = false
|
|
if (scrollToId) {
|
|
setTimeout(() => {
|
|
if (entityType === 'chore') {
|
|
childChoreListRef.value?.scrollToItem(scrollToId)
|
|
} else if (entityType === 'reward') {
|
|
childRewardListRef.value?.scrollToItem(scrollToId)
|
|
}
|
|
}, 500)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error in onMounted:', err)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
eventBus.off('child_task_triggered', handleTaskTriggered)
|
|
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
|
eventBus.off('child_tasks_set', handleChildTaskSet)
|
|
eventBus.off('child_rewards_set', handleChildRewardSet)
|
|
eventBus.off('child_modified', handleChildModified)
|
|
eventBus.off('child_reward_request', handleRewardRequest)
|
|
eventBus.off('task_modified', handleTaskModified)
|
|
eventBus.off('reward_modified', handleRewardModified)
|
|
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)
|
|
|
|
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) {
|
|
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
|
|
// 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
|
|
}
|
|
selectedReward.value = reward
|
|
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
|
|
}
|
|
}
|
|
|
|
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 },
|
|
})
|
|
}
|
|
}
|
|
</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" @mousedown.stop.prevent @click="editChorePoints(item)">
|
|
Edit Points
|
|
</button>
|
|
<button
|
|
class="menu-item"
|
|
@mousedown.stop.prevent
|
|
@click="openScheduleModal(item, $event)"
|
|
>
|
|
Schedule
|
|
</button>
|
|
<button
|
|
v-if="isChoreExpired(item)"
|
|
class="menu-item"
|
|
@mousedown.stop.prevent
|
|
@click="doExtendTime(item, $event)"
|
|
>
|
|
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" />
|
|
<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="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="goToAssignKindness">
|
|
Assign Kindness Acts
|
|
</button>
|
|
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
|
Assign Penalties
|
|
</button>
|
|
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
|
</div>
|
|
|
|
<!-- Pending Reward Dialog -->
|
|
<PendingRewardDialog
|
|
v-if="showPendingRewardDialog"
|
|
: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
|
|
}
|
|
"
|
|
/>
|
|
|
|
<!-- Schedule Modal -->
|
|
<ScheduleModal
|
|
v-if="showScheduleModal && scheduleTarget && child"
|
|
:task="scheduleTarget"
|
|
:childId="child.id"
|
|
:schedule="scheduleTarget.schedule ?? null"
|
|
@saved="onScheduleSaved"
|
|
@cancelled="showScheduleModal = 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"
|
|
:childName="child?.name"
|
|
@confirm="confirmTriggerReward"
|
|
@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"
|
|
/>
|
|
</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;
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
</style>
|