Add routine management features for child and parent views
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion. - Created notification tests for parent view to verify routine completion notifications for children. - Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions. - Added RoutineApproveDialog component for approving or rejecting completed routines. - Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering. - Enhanced RoutineEditView with proper handling of task addition and form submission. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
362
frontend/src/components/child/ChildRoutineOverlay.vue
Normal file
362
frontend/src/components/child/ChildRoutineOverlay.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div v-if="show && routine" class="overlay-backdrop" @click.self="$emit('close')">
|
||||
<section class="overlay-panel" aria-modal="true" role="dialog">
|
||||
<header class="overlay-header">
|
||||
<img
|
||||
v-if="routine.image_url"
|
||||
:src="routine.image_url"
|
||||
:alt="routine.name"
|
||||
class="routine-image"
|
||||
/>
|
||||
<p class="routine-question">
|
||||
{{ isPending ? 'This routine is pending' : 'Did you complete' }}
|
||||
</p>
|
||||
<h3 class="routine-name">{{ routine.name }}{{ isPending ? '' : '?' }}</h3>
|
||||
</header>
|
||||
|
||||
<!-- Pending-cancel view -->
|
||||
<template v-if="isPending">
|
||||
<div class="overlay-body pending-body">
|
||||
<p class="pending-message">
|
||||
This routine is pending confirmation.<br />Would you like to cancel?
|
||||
</p>
|
||||
</div>
|
||||
<footer class="overlay-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">No</button>
|
||||
<button type="button" class="btn btn-primary" @click="$emit('cancel-pending')">
|
||||
Yes
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<!-- Normal complete view -->
|
||||
<template v-else>
|
||||
<div class="overlay-body">
|
||||
<div v-if="loading" class="empty-state">Loading tasks...</div>
|
||||
<div v-else-if="routine.items.length === 0" class="empty-state">
|
||||
No routine tasks found.
|
||||
</div>
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="task-scroll">
|
||||
<button
|
||||
v-for="item in resolvedItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="task-card"
|
||||
:title="item.name"
|
||||
@click="$emit('task-click', item)"
|
||||
>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="task-image"
|
||||
/>
|
||||
<span class="task-name">{{ item.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="overlay-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">No</button>
|
||||
<button
|
||||
v-if="canComplete"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="$emit('complete')"
|
||||
>
|
||||
Yes!
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { ChildRoutine, RoutineItem } from '@/common/models'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
routine: ChildRoutine | null
|
||||
canComplete: boolean
|
||||
isPending?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
complete: []
|
||||
'cancel-pending': []
|
||||
'task-click': [item: RoutineItem]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const resolvedItems = ref<(RoutineItem & { image_url?: string })[]>([])
|
||||
|
||||
// Resolve images when routine changes
|
||||
watch(
|
||||
() => props.routine,
|
||||
async (newRoutine) => {
|
||||
if (!newRoutine?.items.length) {
|
||||
resolvedItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const itemsWithImages = await Promise.all(
|
||||
newRoutine.items.map(async (item) => {
|
||||
let image_url: string | undefined
|
||||
if (item.image_id) {
|
||||
try {
|
||||
image_url = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
image_url = undefined
|
||||
}
|
||||
}
|
||||
return { ...item, image_url }
|
||||
}),
|
||||
)
|
||||
resolvedItems.value = itemsWithImages
|
||||
loading.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.overlay-panel {
|
||||
width: min(92vw, 600px);
|
||||
max-height: min(85vh, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--modal-bg, #fff);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--form-input-border, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.routine-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
background: var(--info-image-bg);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.routine-question {
|
||||
margin: 0;
|
||||
color: var(--modal-message-color, #555);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.routine-name {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overlay-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.pending-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pending-message {
|
||||
text-align: center;
|
||||
color: var(--modal-message-color, #333);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tasks-heading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--loading-color, #888);
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.task-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
position: relative;
|
||||
background: var(--list-item-bg-good, rgba(200, 230, 201, 0.3));
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 120px;
|
||||
max-width: 160px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: 1px solid var(--list-item-border-good, rgba(100, 180, 100, 0.3));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.task-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.task-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.task-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
display: block;
|
||||
color: var(--form-label, #222);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
border-top: 1px solid var(--form-input-border, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary, #f0f0f0);
|
||||
color: var(--btn-secondary-text, #333);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.overlay-panel {
|
||||
width: min(94vw, 600px);
|
||||
max-height: 88vh;
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
min-width: 100px;
|
||||
max-width: 140px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.task-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@ import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import StatusMessage from '../shared/StatusMessage.vue'
|
||||
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
|
||||
import RoutineConfirmDialog from './RoutineConfirmDialog.vue'
|
||||
import ChildRoutineOverlay from './ChildRoutineOverlay.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
//import '@/assets/view-shared.css'
|
||||
@@ -14,10 +14,11 @@ import '@/assets/styles.css'
|
||||
import type {
|
||||
Child,
|
||||
Event,
|
||||
Task,
|
||||
ChoreSchedule,
|
||||
RewardStatus,
|
||||
ChildTask,
|
||||
ChildRoutine,
|
||||
RoutineItem,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
@@ -26,17 +27,19 @@ import type {
|
||||
ChildRoutinesSetEventPayload,
|
||||
TaskModifiedEventPayload,
|
||||
RewardModifiedEventPayload,
|
||||
RoutineModifiedEventPayload,
|
||||
ChildModifiedEventPayload,
|
||||
ChoreScheduleModifiedPayload,
|
||||
ChoreTimeExtendedPayload,
|
||||
RoutineScheduleModifiedPayload,
|
||||
ChildChoreConfirmationPayload,
|
||||
ChildRoutineConfirmationPayload,
|
||||
ChildOverrideSetPayload,
|
||||
ChildOverrideDeletedPayload,
|
||||
} from '@/common/models'
|
||||
import { confirmChore, cancelConfirmChore } from '@/common/api'
|
||||
import {
|
||||
confirmChore,
|
||||
cancelConfirmChore,
|
||||
confirmRoutine,
|
||||
cancelRoutineConfirmation,
|
||||
} from '@/common/api'
|
||||
import {
|
||||
isScheduledToday,
|
||||
isPastTime,
|
||||
@@ -63,7 +66,7 @@ const dialogReward = ref<RewardStatus | null>(null)
|
||||
const showChoreConfirmDialog = ref(false)
|
||||
const showChoreCancelDialog = ref(false)
|
||||
const dialogChore = ref<ChildTask | null>(null)
|
||||
const showRoutineConfirmDialog = ref(false)
|
||||
const showRoutineOverlay = ref(false)
|
||||
const dialogRoutine = ref<ChildRoutine | null>(null)
|
||||
const childRoutineListRef = ref()
|
||||
|
||||
@@ -240,13 +243,27 @@ const triggerTask = async (task: ChildTask) => {
|
||||
}
|
||||
|
||||
const handleRoutineClick = (routine: ChildRoutine) => {
|
||||
// Show routine confirmation dialog
|
||||
dialogRoutine.value = routine
|
||||
setTimeout(() => {
|
||||
showRoutineConfirmDialog.value = true
|
||||
showRoutineOverlay.value = true
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function speakText(text: string) {
|
||||
if (!('speechSynthesis' in window)) return
|
||||
window.speechSynthesis.cancel()
|
||||
if (!text) return
|
||||
const utter = new window.SpeechSynthesisUtterance(text)
|
||||
utter.rate = 1.0
|
||||
utter.pitch = 1.0
|
||||
utter.volume = 1.0
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
|
||||
function handleRoutineTaskClick(item: RoutineItem) {
|
||||
speakText(item.name)
|
||||
}
|
||||
|
||||
function isChoreCompletedToday(item: ChildTask): boolean {
|
||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||
const approvedDate = new Date(item.approved_at)
|
||||
@@ -301,27 +318,45 @@ function closeChoreCancelDialog() {
|
||||
async function doConfirmRoutine() {
|
||||
if (!child.value?.id || !dialogRoutine.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/confirm-routine`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ routine_id: dialogRoutine.value.id }),
|
||||
})
|
||||
const resp = await confirmRoutine(child.value.id, dialogRoutine.value.id)
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to confirm routine')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to confirm routine:', err)
|
||||
} finally {
|
||||
showRoutineConfirmDialog.value = false
|
||||
showRoutineOverlay.value = false
|
||||
dialogRoutine.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function closeRoutineConfirmDialog() {
|
||||
showRoutineConfirmDialog.value = false
|
||||
function handleChildRoutineConfirmation(event: Event) {
|
||||
const payload = event.payload as { child_id: string }
|
||||
if (child.value && payload.child_id === child.value.id) {
|
||||
childRoutineListRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function closeRoutineOverlay() {
|
||||
showRoutineOverlay.value = false
|
||||
dialogRoutine.value = null
|
||||
}
|
||||
|
||||
async function doCancelConfirmRoutine() {
|
||||
if (!child.value?.id || !dialogRoutine.value) return
|
||||
try {
|
||||
const resp = await cancelRoutineConfirmation(child.value.id, dialogRoutine.value.id)
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to cancel routine confirmation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel routine confirmation:', err)
|
||||
} finally {
|
||||
showRoutineOverlay.value = false
|
||||
dialogRoutine.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
// Cancel any pending speech to avoid conflicts
|
||||
if ('speechSynthesis' in window) {
|
||||
@@ -510,6 +545,22 @@ function isChoreExpired(item: ChildTask): boolean {
|
||||
return isPastTime(due.hour, due.minute, today)
|
||||
}
|
||||
|
||||
function isRoutineExpired(item: ChildRoutine): boolean {
|
||||
if (item.pending_status === 'pending') return false
|
||||
if (!item.schedule) return false
|
||||
const today = new Date()
|
||||
const due = getDueTimeToday(item.schedule as unknown as ChoreSchedule, today)
|
||||
if (!due) return false
|
||||
if (item.extension_date && isExtendedToday(item.extension_date, today)) return false
|
||||
return isPastTime(due.hour, due.minute, today)
|
||||
}
|
||||
|
||||
const canCompleteRoutine = computed(() => {
|
||||
const routine = dialogRoutine.value
|
||||
if (!routine) return false
|
||||
return routine.pending_status == null && !isRoutineExpired(routine)
|
||||
})
|
||||
|
||||
function choreDueLabel(item: ChildTask): string | null {
|
||||
if (!item.schedule) return null
|
||||
const today = new Date()
|
||||
@@ -602,6 +653,7 @@ onMounted(async () => {
|
||||
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.on('chore_time_extended', handleChoreTimeExtended)
|
||||
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
|
||||
eventBus.on('child_routine_confirmation', handleChildRoutineConfirmation)
|
||||
eventBus.on('child_override_set', handleOverrideSet)
|
||||
eventBus.on('child_override_deleted', handleOverrideDeleted)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
@@ -641,6 +693,7 @@ onUnmounted(() => {
|
||||
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.off('chore_time_extended', handleChoreTimeExtended)
|
||||
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
|
||||
eventBus.off('child_routine_confirmation', handleChildRoutineConfirmation)
|
||||
eventBus.off('child_override_set', handleOverrideSet)
|
||||
eventBus.off('child_override_deleted', handleOverrideDeleted)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
@@ -882,13 +935,15 @@ onUnmounted(() => {
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Routine confirm dialog -->
|
||||
<RoutineConfirmDialog
|
||||
:show="showRoutineConfirmDialog"
|
||||
:routineName="dialogRoutine?.name ?? ''"
|
||||
:imageUrl="dialogRoutine?.image_url"
|
||||
:items="dialogRoutine?.items"
|
||||
@confirm="doConfirmRoutine"
|
||||
@cancel="closeRoutineConfirmDialog"
|
||||
<ChildRoutineOverlay
|
||||
:show="showRoutineOverlay"
|
||||
:routine="dialogRoutine"
|
||||
:canComplete="canCompleteRoutine"
|
||||
:isPending="dialogRoutine?.pending_status === 'pending'"
|
||||
@task-click="handleRoutineTaskClick"
|
||||
@complete="doConfirmRoutine"
|
||||
@cancel-pending="doCancelConfirmRoutine"
|
||||
@close="closeRoutineOverlay"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ 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'
|
||||
@@ -17,6 +19,12 @@ import {
|
||||
approveChore,
|
||||
rejectChore,
|
||||
resetChore,
|
||||
extendRoutineTime,
|
||||
setChildRoutineOverride,
|
||||
approveRoutine,
|
||||
rejectRoutine,
|
||||
resetRoutine,
|
||||
triggerRoutineAsParent,
|
||||
} from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/styles.css'
|
||||
@@ -27,6 +35,7 @@ import type {
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTask,
|
||||
ChildRoutine,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
@@ -40,6 +49,8 @@ import type {
|
||||
ChoreScheduleModifiedPayload,
|
||||
ChoreTimeExtendedPayload,
|
||||
ChildChoreConfirmationPayload,
|
||||
RoutineScheduleModifiedPayload,
|
||||
RoutineTimeExtendedPayload,
|
||||
} from '@/common/models'
|
||||
import {
|
||||
isScheduledToday,
|
||||
@@ -74,6 +85,12 @@ const lastEditedItem = ref<{ id: string; type: 'task' | 'reward' } | null>(null)
|
||||
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)
|
||||
@@ -95,6 +112,14 @@ const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
||||
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
|
||||
@@ -112,6 +137,7 @@ const lastFetchDate = ref<string>(toLocalISODate(new Date()))
|
||||
function handleItemReady(itemId: string) {
|
||||
readyItemId.value = itemId
|
||||
selectedChoreId.value = null
|
||||
selectedRoutineId.value = null
|
||||
}
|
||||
|
||||
function handleChoreItemReady(itemId: string) {
|
||||
@@ -263,6 +289,8 @@ function handleOverrideSet(event: Event) {
|
||||
scrollAfterRefresh(childPenaltyListRef)
|
||||
} else if (payload.override.entity_type === 'reward') {
|
||||
scrollAfterRefresh(childRewardListRef)
|
||||
} else if (payload.override.entity_type === 'routine') {
|
||||
scrollAfterRefresh(childRoutineListRef)
|
||||
}
|
||||
lastEditedItem.value = null
|
||||
}
|
||||
@@ -305,6 +333,147 @@ function handleChoreConfirmation(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeRoutineMenuFor.value = routineId
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -363,6 +532,111 @@ function cancelChoreApproveDialog() {
|
||||
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()
|
||||
@@ -381,7 +655,7 @@ async function doResetChore(item: ChildTask, e: MouseEvent) {
|
||||
// ── Kebab menu ───────────────────────────────────────────────────────────────
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (activeMenuFor.value !== null) {
|
||||
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
|
||||
@@ -393,11 +667,12 @@ const onDocClick = (e: MouseEvent) => {
|
||||
})
|
||||
if (!inside) {
|
||||
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 {
|
||||
// Clicked fully outside any card — reset ready state so chore requires 2 clicks again
|
||||
readyItemId.value = null
|
||||
}
|
||||
}
|
||||
@@ -549,7 +824,7 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
||||
}
|
||||
overrideEditTarget.value = { entity: item, type }
|
||||
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
|
||||
overrideCustomValue.value = item.custom_value ?? defaultValue
|
||||
overrideCustomValue.value = (item as any).custom_value ?? defaultValue
|
||||
validateOverrideInput()
|
||||
showOverrideModal.value = true
|
||||
}
|
||||
@@ -570,7 +845,7 @@ async function confirmPendingRewardAndEdit() {
|
||||
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
|
||||
overrideCustomValue.value = (target.entity as any).custom_value ?? defaultValue
|
||||
validateOverrideInput()
|
||||
showOverrideModal.value = true
|
||||
}
|
||||
@@ -590,12 +865,22 @@ watch(showOverrideModal, async (newVal) => {
|
||||
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,
|
||||
)
|
||||
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 = {
|
||||
@@ -604,7 +889,7 @@ async function saveOverride() {
|
||||
}
|
||||
showOverrideModal.value = false
|
||||
} else {
|
||||
const { msg } = parseErrorResponse(res)
|
||||
const { msg } = await parseErrorResponse(res)
|
||||
alert(`Error: ${msg}`)
|
||||
}
|
||||
}
|
||||
@@ -683,6 +968,11 @@ onMounted(async () => {
|
||||
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)
|
||||
@@ -722,6 +1012,9 @@ onMounted(async () => {
|
||||
} 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 })
|
||||
@@ -749,6 +1042,11 @@ onUnmounted(() => {
|
||||
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)
|
||||
@@ -1058,6 +1356,127 @@ function goToAssignRoutines() {
|
||||
<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" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
Edit Routine
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="editRoutinePoints(item)"
|
||||
>
|
||||
Edit Points
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="openRoutineScheduleModal(item, $event)"
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
v-if="isRoutineExpired(item)"
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendRoutineTime(item, $event)"
|
||||
>
|
||||
Extend Time
|
||||
</button>
|
||||
<button
|
||||
v-if="isRoutineApprovedToday(item)"
|
||||
class="menu-item"
|
||||
@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"
|
||||
@@ -1217,6 +1636,17 @@ function goToAssignRoutines() {
|
||||
@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"
|
||||
@@ -1263,7 +1693,7 @@ function goToAssignRoutines() {
|
||||
<!-- Reward Confirm Dialog -->
|
||||
<RewardConfirmDialog
|
||||
v-if="showRewardConfirm"
|
||||
:reward="selectedReward"
|
||||
:reward="selectedReward as any"
|
||||
:childName="child?.name"
|
||||
@confirm="confirmTriggerReward"
|
||||
@deny="denyRewardRequest"
|
||||
@@ -1287,6 +1717,26 @@ function goToAssignRoutines() {
|
||||
@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>
|
||||
|
||||
|
||||
82
frontend/src/components/child/RoutineApproveDialog.vue
Normal file
82
frontend/src/components/child/RoutineApproveDialog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
|
||||
<template #default>
|
||||
<div class="approve-dialog">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Routine" class="routine-image" />
|
||||
<p class="child-label">{{ childName }}</p>
|
||||
<p class="message">
|
||||
completed <strong>{{ routineName }}</strong>
|
||||
</p>
|
||||
<p class="message">
|
||||
Will be awarded <strong>{{ points }} points</strong>
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="$emit('approve')">Approve</button>
|
||||
<button class="btn btn-secondary" @click="$emit('reject')">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
childName: string
|
||||
routineName: string
|
||||
points: number
|
||||
imageUrl?: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
approve: []
|
||||
reject: []
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approve-dialog {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.routine-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--info-image-bg);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.child-label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--dialog-child-name);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
color: var(--dialog-message);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.message:last-of-type {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.7rem 1.8rem;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
transition: background 0.18s;
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -36,6 +36,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { setChildRoutines } from '@/common/api'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/styles.css'
|
||||
import type { Routine } from '@/common/models'
|
||||
@@ -86,11 +87,7 @@ function goToCreate() {
|
||||
async function onSubmit() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-routines`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ routine_ids: selectedIds.value }),
|
||||
})
|
||||
const resp = await setChildRoutines(childId, selectedIds.value)
|
||||
if (!resp.ok) throw new Error('Failed to update routines')
|
||||
router.back()
|
||||
} catch (error) {
|
||||
@@ -114,27 +111,35 @@ function onCancel() {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
.assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routine-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.routine-item {
|
||||
@@ -171,42 +176,32 @@ h2 {
|
||||
|
||||
.routine-item .name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.routine-item .value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
justify-content: flex-end;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
.actions button {
|
||||
padding: 1rem 2.2rem;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
transition: background 0.18s;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
@@ -214,15 +209,6 @@ h2 {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary);
|
||||
color: var(--btn-secondary-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.round-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
<template>
|
||||
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
|
||||
<template #default>
|
||||
<div class="confirm-dialog">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Routine" class="routine-image" />
|
||||
<p class="message">Confirm routine</p>
|
||||
<p class="routine-name">{{ routineName }}?</p>
|
||||
<div v-if="items && items.length > 0" class="items-preview">
|
||||
<p class="items-label">Items:</p>
|
||||
<ul class="items-list">
|
||||
<li v-for="item in items.slice(0, 3)" :key="item.id">{{ item.name }}</li>
|
||||
<li v-if="items.length > 3" class="more-items">+ {{ items.length - 3 }} more</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="$emit('confirm')">Confirm</button>
|
||||
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ModalDialog
|
||||
v-if="routine"
|
||||
title="Confirm Routine"
|
||||
:subtitle="routine.name"
|
||||
:imageUrl="routine.image_url"
|
||||
@backdrop-click="$emit('cancel')"
|
||||
>
|
||||
<div class="modal-message">
|
||||
Add these points to
|
||||
<span class="child-name">{{ childName }}</span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="$emit('confirm')">Yes</button>
|
||||
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import type { RoutineItem } from '@/common/models'
|
||||
import type { ChildRoutine } from '@/common/models'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
routineName: string
|
||||
imageUrl?: string | null
|
||||
items?: RoutineItem[]
|
||||
routine: ChildRoutine | null
|
||||
childName?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -39,88 +33,14 @@ defineEmits<{
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confirm-dialog {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
.modal-message {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1rem;
|
||||
color: var(--modal-message-color, #333);
|
||||
}
|
||||
.routine-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--info-image-bg);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.message {
|
||||
font-size: 1.1rem;
|
||||
color: var(--dialog-message);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.routine-name {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--dialog-child-name);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.items-preview {
|
||||
margin-bottom: 1rem;
|
||||
text-align: left;
|
||||
background: var(--form-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.items-label {
|
||||
font-size: 0.9rem;
|
||||
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.items-list {
|
||||
list-style: none;
|
||||
padding-left: 1rem;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.items-list li {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.items-list li.more-items {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.7rem 1.8rem;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
transition: background 0.18s;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary);
|
||||
color: var(--btn-secondary-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.8;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ChildRoutineOverlay from '../ChildRoutineOverlay.vue'
|
||||
|
||||
// Mock getCachedImageUrl to return a simple data URL
|
||||
vi.mock('@/common/imageCache', () => ({
|
||||
getCachedImageUrl: vi.fn(() => Promise.resolve('data:image/svg+xml,%3Csvg%3E%3C/svg%3E')),
|
||||
}))
|
||||
|
||||
describe('ChildRoutineOverlay', () => {
|
||||
const routine = {
|
||||
id: 'routine-1',
|
||||
name: 'Morning Routine',
|
||||
points: 12,
|
||||
image_id: 'routine-image',
|
||||
image_url: '/images/routine.png',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
routine_id: 'routine-1',
|
||||
name: 'Brush Teeth',
|
||||
image_id: null,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
routine_id: 'routine-1',
|
||||
name: 'Make Bed',
|
||||
image_id: null,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('emits close when the backdrop is clicked', async () => {
|
||||
const wrapper = mount(ChildRoutineOverlay, {
|
||||
props: {
|
||||
show: true,
|
||||
routine,
|
||||
canComplete: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.overlay-backdrop').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits task-click and complete from the appropriate controls', async () => {
|
||||
const wrapper = mount(ChildRoutineOverlay, {
|
||||
props: {
|
||||
show: true,
|
||||
routine,
|
||||
canComplete: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for async image resolution to complete
|
||||
await wrapper.vm.$nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
await wrapper.find('.task-card').trigger('click')
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('task-click')).toEqual([[routine.items[0]]])
|
||||
expect(wrapper.emitted('complete')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,23 @@ describe('ChildView', () => {
|
||||
custom_value: 8,
|
||||
}
|
||||
|
||||
const mockRoutine = {
|
||||
id: 'routine-1',
|
||||
name: 'Morning Routine',
|
||||
points: 12,
|
||||
image_id: 'routine-image',
|
||||
image_url: '/images/routine.png',
|
||||
items: [
|
||||
{
|
||||
id: 'routine-item-1',
|
||||
routine_id: 'routine-1',
|
||||
name: 'Make Bed',
|
||||
image_id: null,
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -212,6 +229,34 @@ describe('ChildView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Routine Overlay', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ChildView)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('opens the routine overlay when a routine is clicked', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
wrapper.vm.handleRoutineClick(mockRoutine)
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.showRoutineOverlay).toBe(true)
|
||||
expect(wrapper.vm.dialogRoutine?.id).toBe('routine-1')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('speaks the routine task name when a routine task is clicked', () => {
|
||||
wrapper.vm.handleRoutineTaskClick(mockRoutine.items[0])
|
||||
|
||||
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
|
||||
expect(window.speechSynthesis.speak).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reward Triggering', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ChildView)
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
<span>{{ item.child_name }}</span>
|
||||
</div>
|
||||
<span class="requested-text">{{
|
||||
item.entity_type === 'chore' ? 'completed' : 'requested'
|
||||
item.entity_type === 'chore' || item.entity_type === 'routine'
|
||||
? 'completed'
|
||||
: 'requested'
|
||||
}}</span>
|
||||
<div class="reward-info">
|
||||
<span>{{ item.entity_name }}</span>
|
||||
|
||||
@@ -7,33 +7,149 @@
|
||||
:isEdit="isEdit"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:requireDirty="false"
|
||||
:submitDisabled="items.length === 0"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
@add-image="handleAddImage"
|
||||
/>
|
||||
@add-image="handleAddMainImage"
|
||||
>
|
||||
<template #before-actions>
|
||||
<section class="items-panel">
|
||||
<h3>Routine Tasks</h3>
|
||||
|
||||
<div v-if="items.length === 0" class="items-empty">
|
||||
Add at least one task to save this routine.
|
||||
</div>
|
||||
|
||||
<div v-else class="items-list">
|
||||
<div v-for="(item, idx) in items" :key="item.id || `new-${idx}`" class="item-row">
|
||||
<div class="item-left">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="item-thumb"
|
||||
/>
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary small-btn"
|
||||
@click="startEditItem(idx)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary small-btn" @click="removeItem(idx)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="!addFormOpen" type="button" class="add-task-trigger" @click="openAddForm">
|
||||
<span class="add-task-plus">+</span> Add Task
|
||||
</button>
|
||||
|
||||
<div v-else class="add-item-form">
|
||||
<div class="add-form-header">
|
||||
<h4>{{ editingItemIdx !== null ? 'Edit Task' : 'New Task' }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="close-form-btn"
|
||||
@click="cancelEditItem"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<input
|
||||
id="item-name"
|
||||
v-model="newItem.name"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="Task name, e.g. Make bed"
|
||||
@input="error = null"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showImagePicker" class="group">
|
||||
<ImagePicker
|
||||
id="item-image"
|
||||
v-model="newItem.image_id"
|
||||
:image-type="2"
|
||||
@add-image="handleAddItemImage"
|
||||
/>
|
||||
</div>
|
||||
<button v-else type="button" class="toggle-image-btn" @click="showImagePicker = true">
|
||||
+ Add image (optional)
|
||||
</button>
|
||||
|
||||
<div class="item-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="addOrUpdateItem"
|
||||
:disabled="!newItem.name.trim()"
|
||||
>
|
||||
{{ editingItemIdx !== null ? 'Update' : 'Add' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="cancelEditItem">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</EntityEditForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import EntityEditForm from '@/components/shared/EntityEditForm.vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import type { RoutineItem } from '@/common/models'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!props.id)
|
||||
|
||||
type EditableRoutineItem = RoutineItem & {
|
||||
image_url?: string | null
|
||||
local_file?: File | null
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ name: 'name', label: 'Routine Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'points', label: 'Points', type: 'number' as const, required: true, min: 1, max: 1000 },
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 4 },
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 2 },
|
||||
]
|
||||
|
||||
const initialData = ref({ name: '', points: 1, image_id: null })
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const initialData = ref({
|
||||
name: '',
|
||||
points: 1,
|
||||
image_id: null as string | null,
|
||||
})
|
||||
const items = ref<EditableRoutineItem[]>([])
|
||||
const newItem = ref({
|
||||
name: '',
|
||||
image_id: null as string | null,
|
||||
})
|
||||
const newItemLocalFile = ref<File | null>(null)
|
||||
const editingItemIdx = ref<number | null>(null)
|
||||
const addFormOpen = ref(false)
|
||||
const showImagePicker = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const removedItemIds = ref<string[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && props.id) {
|
||||
@@ -47,21 +163,182 @@ onMounted(async () => {
|
||||
points: Number(data.points) || 1,
|
||||
image_id: data.image_id ?? null,
|
||||
}
|
||||
|
||||
const itemsResp = await fetch(`/api/routine/${props.id}/items`)
|
||||
if (itemsResp.ok) {
|
||||
const itemsData = await itemsResp.json()
|
||||
const loadedItems = (itemsData.items || []) as RoutineItem[]
|
||||
items.value = await Promise.all(
|
||||
loadedItems.map(async (item, idx) => {
|
||||
let imageUrl: string | null = null
|
||||
if (item.image_id) {
|
||||
try {
|
||||
imageUrl = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
imageUrl = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
order: idx,
|
||||
image_url: imageUrl,
|
||||
local_file: null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Could not load routine.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') localImageFile.value = file
|
||||
function handleAddMainImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddItemImage({ id, file }: { id: string; file: File }) {
|
||||
if (id !== 'local-upload') {
|
||||
newItemLocalFile.value = null
|
||||
return
|
||||
}
|
||||
|
||||
newItemLocalFile.value = file
|
||||
try {
|
||||
const imageUrl = await getCachedImageUrl(id)
|
||||
if (editingItemIdx.value !== null) {
|
||||
const editingItem = items.value[editingItemIdx.value]
|
||||
if (editingItem) {
|
||||
editingItem.image_url = imageUrl
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No-op: preview still works through ImagePicker.
|
||||
}
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
editingItemIdx.value = null
|
||||
newItem.value = { name: '', image_id: null }
|
||||
newItemLocalFile.value = null
|
||||
showImagePicker.value = false
|
||||
addFormOpen.value = true
|
||||
}
|
||||
|
||||
function startEditItem(idx: number) {
|
||||
const item = items.value[idx]
|
||||
if (!item) return
|
||||
|
||||
editingItemIdx.value = idx
|
||||
newItem.value = {
|
||||
name: item.name,
|
||||
image_id: item.image_id,
|
||||
}
|
||||
newItemLocalFile.value = null
|
||||
showImagePicker.value = !!item.image_id
|
||||
addFormOpen.value = true
|
||||
}
|
||||
|
||||
function normalizeItemOrder() {
|
||||
items.value = items.value.map((item, idx) => ({
|
||||
...item,
|
||||
order: idx,
|
||||
}))
|
||||
}
|
||||
|
||||
function addOrUpdateItem() {
|
||||
if (!newItem.value.name.trim()) {
|
||||
error.value = 'Item name is required.'
|
||||
return
|
||||
}
|
||||
|
||||
if (editingItemIdx.value !== null) {
|
||||
const current = items.value[editingItemIdx.value]
|
||||
if (!current) {
|
||||
cancelEditItem()
|
||||
return
|
||||
}
|
||||
|
||||
items.value[editingItemIdx.value] = {
|
||||
id: current.id || '',
|
||||
routine_id: props.id || '',
|
||||
name: newItem.value.name,
|
||||
image_id: newItem.value.image_id,
|
||||
image_url: current.image_url,
|
||||
local_file: newItemLocalFile.value,
|
||||
order: editingItemIdx.value,
|
||||
}
|
||||
} else {
|
||||
items.value.push({
|
||||
id: `temp-${Date.now()}`,
|
||||
routine_id: props.id || '',
|
||||
name: newItem.value.name,
|
||||
image_id: newItem.value.image_id,
|
||||
image_url: null,
|
||||
local_file: newItemLocalFile.value,
|
||||
order: items.value.length,
|
||||
})
|
||||
}
|
||||
|
||||
normalizeItemOrder()
|
||||
editingItemIdx.value = null
|
||||
addFormOpen.value = false
|
||||
showImagePicker.value = false
|
||||
newItem.value = { name: '', image_id: null }
|
||||
newItemLocalFile.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
const item = items.value[idx]
|
||||
if (!item) return
|
||||
|
||||
if (item.id && !item.id.startsWith('temp-')) {
|
||||
removedItemIds.value.push(item.id)
|
||||
}
|
||||
items.value.splice(idx, 1)
|
||||
normalizeItemOrder()
|
||||
|
||||
if (editingItemIdx.value === idx) {
|
||||
cancelEditItem()
|
||||
} else if (editingItemIdx.value !== null && editingItemIdx.value > idx) {
|
||||
editingItemIdx.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditItem() {
|
||||
editingItemIdx.value = null
|
||||
addFormOpen.value = false
|
||||
showImagePicker.value = false
|
||||
newItem.value = { name: '', image_id: null }
|
||||
newItemLocalFile.value = null
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', '4')
|
||||
formData.append('permanent', 'false')
|
||||
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
async function resolveItemImageId(item: EditableRoutineItem): Promise<string | null> {
|
||||
if (item.image_id === 'local-upload' && item.local_file) {
|
||||
return uploadImage(item.local_file)
|
||||
}
|
||||
return item.image_id ?? null
|
||||
}
|
||||
|
||||
async function handleSubmit(form: { name: string; points: number; image_id: string | null }) {
|
||||
let imageId = form.image_id
|
||||
error.value = null
|
||||
if (!form.name.trim()) {
|
||||
error.value = 'Routine name is required.'
|
||||
@@ -71,26 +348,22 @@ async function handleSubmit(form: { name: string; points: number; image_id: stri
|
||||
error.value = 'Points must be at least 1.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '4')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch {
|
||||
error.value = 'Failed to upload image.'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (items.value.length === 0) {
|
||||
error.value = 'Routine must have at least one item.'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
let imageId = form.image_id
|
||||
if (imageId === null && localImageFile.value) {
|
||||
imageId = await uploadImage(localImageFile.value)
|
||||
}
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
imageId = await uploadImage(localImageFile.value)
|
||||
}
|
||||
|
||||
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
@@ -98,11 +371,41 @@ async function handleSubmit(form: { name: string; points: number; image_id: stri
|
||||
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to save routine')
|
||||
const routineData = await resp.json()
|
||||
const routineId = routineData.id || props.id
|
||||
|
||||
for (const itemId of removedItemIds.value) {
|
||||
await fetch(`/api/routine/${routineId}/item/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
const resolvedImageId = await resolveItemImageId(item)
|
||||
const payload = { name: item.name, image_id: resolvedImageId, order: item.order }
|
||||
|
||||
if (item.id?.startsWith('temp-') || !item.id) {
|
||||
await fetch(`/api/routine/${routineId}/item/add`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} else {
|
||||
await fetch(`/api/routine/${routineId}/item/${item.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await router.push({ name: 'RoutineView' })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
error.value = 'Failed to save routine.'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
@@ -119,4 +422,188 @@ function handleCancel() {
|
||||
box-shadow: 0 4px 24px var(--form-shadow);
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
|
||||
.items-panel {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--form-input-border, #e6e6e6);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.items-panel h3 {
|
||||
color: var(--form-label, #444);
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.items-empty {
|
||||
color: var(--loading-color, #888);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--form-input-bg, #f8fafc);
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.item-thumb {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #333);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.add-task-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: none;
|
||||
border: 1.5px dashed var(--form-input-border, #c8d0db);
|
||||
border-radius: 10px;
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.add-task-trigger:hover {
|
||||
background: var(--form-input-bg, #f0f4ff);
|
||||
border-color: var(--btn-primary, #667eea);
|
||||
}
|
||||
|
||||
.add-task-plus {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-item-form {
|
||||
margin-top: 0.25rem;
|
||||
background: var(--form-input-bg, #f8fafc);
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
border-radius: 10px;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.add-form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.add-form-header h4 {
|
||||
color: var(--form-label, #444);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-form-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--form-label, #888);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close-form-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.toggle-image-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-image-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.group input[type='text'] {
|
||||
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;
|
||||
}
|
||||
|
||||
.item-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.view {
|
||||
padding: 1.5rem 1.2rem;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Unit tests for routine components.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import RoutineEditView from '@/components/routine/RoutineEditView.vue'
|
||||
import RoutineConfirmDialog from '@/components/child/RoutineConfirmDialog.vue'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('RoutineEditView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(global.fetch as ReturnType<typeof vi.fn>).mockClear()
|
||||
})
|
||||
|
||||
it('renders create form heading when no id prop provided', async () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Create Routine')
|
||||
})
|
||||
|
||||
it('renders name and points inputs', () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
// EntityEditForm renders inputs with id matching field.name
|
||||
expect(wrapper.find('input[id="name"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[id="points"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows Routine Tasks section', () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Routine Tasks')
|
||||
})
|
||||
|
||||
it('shows empty state message when no tasks added', () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Add at least one task')
|
||||
})
|
||||
|
||||
it('shows Add Task button by default', () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Add Task')
|
||||
})
|
||||
|
||||
it('opens item form when Add Task button clicked', async () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
const addBtn = wrapper.find('.add-task-trigger')
|
||||
expect(addBtn.exists()).toBe(true)
|
||||
await addBtn.trigger('click')
|
||||
// Item form should appear with a task name input
|
||||
expect(wrapper.find('input[id="item-name"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('allows typing a task name in the add form', async () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
await wrapper.find('.add-task-trigger').trigger('click')
|
||||
const itemInput = wrapper.find('input[id="item-name"]')
|
||||
await itemInput.setValue('Make Bed')
|
||||
expect((itemInput.element as HTMLInputElement).value).toBe('Make Bed')
|
||||
})
|
||||
|
||||
it('adds item to list when Add button clicked with valid name', async () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
await wrapper.find('.add-task-trigger').trigger('click')
|
||||
await wrapper.find('input[id="item-name"]').setValue('Make Bed')
|
||||
await wrapper.find('.item-form-actions .btn-primary').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
// The item name should now appear in the list
|
||||
expect(wrapper.text()).toContain('Make Bed')
|
||||
})
|
||||
|
||||
it('renders Cancel and Create buttons', () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: undefined },
|
||||
})
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Cancel')
|
||||
expect(text).toContain('Create')
|
||||
})
|
||||
|
||||
it('renders edit heading when id prop provided', async () => {
|
||||
;(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'r1',
|
||||
name: 'Morning Routine',
|
||||
points: 50,
|
||||
image_id: null,
|
||||
image_url: null,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ items: [] }),
|
||||
})
|
||||
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: { id: 'r1' },
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('Edit Routine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoutineConfirmDialog.vue', () => {
|
||||
const routine = {
|
||||
id: 'r1',
|
||||
name: 'Morning Routine',
|
||||
points: 20,
|
||||
custom_value: null,
|
||||
image_id: null,
|
||||
image_url: null,
|
||||
pending_status: null,
|
||||
pending_confirmation_id: null,
|
||||
schedule: null,
|
||||
items: [],
|
||||
}
|
||||
|
||||
it('displays routine name', () => {
|
||||
const wrapper = mount(RoutineConfirmDialog, {
|
||||
props: { routine, childName: 'Timmy' },
|
||||
global: { components: { ModalDialog } },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Morning Routine')
|
||||
})
|
||||
|
||||
it('displays child name', () => {
|
||||
const wrapper = mount(RoutineConfirmDialog, {
|
||||
props: { routine, childName: 'Timmy' },
|
||||
global: { components: { ModalDialog } },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Timmy')
|
||||
})
|
||||
|
||||
it('emits confirm event when Yes button clicked', async () => {
|
||||
const wrapper = mount(RoutineConfirmDialog, {
|
||||
props: { routine, childName: 'Timmy' },
|
||||
global: { components: { ModalDialog } },
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const confirmBtn = buttons.find((b) => b.text().toLowerCase().includes('yes'))
|
||||
if (confirmBtn) {
|
||||
await confirmBtn.trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits cancel event when Cancel button clicked', async () => {
|
||||
const wrapper = mount(RoutineConfirmDialog, {
|
||||
props: { routine, childName: 'Timmy' },
|
||||
global: { components: { ModalDialog } },
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const cancelBtn = buttons.find((b) => b.text().toLowerCase().includes('cancel'))
|
||||
if (cancelBtn) {
|
||||
await cancelBtn.trigger('click')
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import RoutineEditView from '../RoutineEditView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn(() => ({
|
||||
params: {},
|
||||
query: { name: 'Timmy' },
|
||||
})),
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/common/imageCache', () => ({
|
||||
getCachedImageUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/assets/styles.css', () => ({}))
|
||||
|
||||
const EntityEditFormStub = defineComponent({
|
||||
name: 'EntityEditForm',
|
||||
props: [
|
||||
'entityLabel',
|
||||
'fields',
|
||||
'initialData',
|
||||
'isEdit',
|
||||
'loading',
|
||||
'error',
|
||||
'requireDirty',
|
||||
'submitDisabled',
|
||||
],
|
||||
emits: ['submit', 'cancel', 'add-image'],
|
||||
template: `
|
||||
<div class="entity-edit-form-stub">
|
||||
<button type="submit" class="submit-btn" :disabled="submitDisabled">Submit</button>
|
||||
<slot name="before-actions" />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
const ImagePickerStub = defineComponent({
|
||||
name: 'ImagePicker',
|
||||
template: '<div class="image-picker-stub" />',
|
||||
})
|
||||
|
||||
describe('RoutineEditView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('disables Create when there are no routine tasks', async () => {
|
||||
const wrapper = mount(RoutineEditView, {
|
||||
props: {},
|
||||
global: {
|
||||
stubs: {
|
||||
EntityEditForm: EntityEditFormStub,
|
||||
ImagePicker: ImagePickerStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const submitButton = wrapper.find('.submit-btn')
|
||||
expect(submitButton.exists()).toBe(true)
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(wrapper.findComponent(EntityEditFormStub).props('submitDisabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<slot name="before-actions" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
@@ -68,7 +69,7 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
|
||||
:disabled="loading || !isValid || (props.requireDirty && !isDirty) || props.submitDisabled"
|
||||
>
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
@@ -107,9 +108,11 @@ const props = withDefaults(
|
||||
title?: string
|
||||
requireDirty?: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
submitDisabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
requireDirty: true,
|
||||
submitDisabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user