This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from time import sleep
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
@@ -137,6 +139,12 @@ def assign_task_to_child(id):
|
||||
def set_child_tasks(id):
|
||||
data = request.get_json() or {}
|
||||
task_ids = data.get('task_ids')
|
||||
if 'type' not in data:
|
||||
return jsonify({'error': 'type is required (good or bad)'}), 400
|
||||
task_type = data.get('type', 'good')
|
||||
if task_type not in ['good', 'bad']:
|
||||
return jsonify({'error': 'type must be either good or bad'}), 400
|
||||
is_good = task_type == 'good'
|
||||
if not isinstance(task_ids, list):
|
||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||
|
||||
@@ -147,22 +155,25 @@ def set_child_tasks(id):
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
child = Child.from_dict(result[0])
|
||||
new_task_ids = set(task_ids)
|
||||
|
||||
# Optional: validate task IDs exist in the task DB
|
||||
TaskQuery = Query()
|
||||
valid_task_ids = []
|
||||
for tid in new_task_ids:
|
||||
if task_db.get(TaskQuery.id == tid):
|
||||
valid_task_ids.append(tid)
|
||||
# Add all existing child tasks of the opposite type
|
||||
for task in task_db.all():
|
||||
if task['id'] in child.tasks and task['is_good'] != is_good:
|
||||
new_task_ids.add(task['id'])
|
||||
|
||||
# Convert back to list if needed
|
||||
new_tasks = list(new_task_ids)
|
||||
# Replace tasks with validated IDs
|
||||
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
||||
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({
|
||||
'message': f'Tasks set for child {id}.',
|
||||
'task_ids': valid_task_ids,
|
||||
'count': len(valid_task_ids)
|
||||
'task_ids': new_tasks,
|
||||
'count': len(new_tasks)
|
||||
}), 200
|
||||
|
||||
|
||||
@@ -242,6 +253,10 @@ def list_all_tasks(id):
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
has_type = "type" in request.args
|
||||
if has_type and request.args.get('type') not in ['good', 'bad']:
|
||||
return jsonify({'error': 'type must be either good or bad'}), 400
|
||||
good = request.args.get('type', False) == 'good'
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
@@ -249,8 +264,7 @@ def list_all_tasks(id):
|
||||
# Get all tasks from database
|
||||
all_tasks = task_db.all()
|
||||
|
||||
assigned_tasks = []
|
||||
assignable_tasks = []
|
||||
tasks = []
|
||||
|
||||
for task in all_tasks:
|
||||
if not task or not task.get('id'):
|
||||
@@ -263,18 +277,15 @@ def list_all_tasks(id):
|
||||
task.get('image_id'),
|
||||
task.get('id')
|
||||
)
|
||||
task_dict = ct.to_dict()
|
||||
if has_type and task.get('is_good') != good:
|
||||
continue
|
||||
|
||||
if task.get('id') in assigned_ids:
|
||||
assigned_tasks.append(ct.to_dict())
|
||||
else:
|
||||
assignable_tasks.append(ct.to_dict())
|
||||
task_dict.update({'assigned': task.get('id') in assigned_ids})
|
||||
tasks.append(task_dict)
|
||||
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||
|
||||
return jsonify({
|
||||
'assigned_tasks': assigned_tasks,
|
||||
'assignable_tasks': assignable_tasks,
|
||||
'assigned_count': len(assigned_tasks),
|
||||
'assignable_count': len(assignable_tasks)
|
||||
}), 200
|
||||
return jsonify({ 'tasks': tasks }), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||
@@ -341,9 +352,7 @@ def list_all_rewards(id):
|
||||
|
||||
# Get all rewards from database
|
||||
all_rewards = reward_db.all()
|
||||
|
||||
assigned_rewards = []
|
||||
assignable_rewards = []
|
||||
rewards = []
|
||||
|
||||
for reward in all_rewards:
|
||||
if not reward or not reward.get('id'):
|
||||
@@ -356,16 +365,15 @@ def list_all_rewards(id):
|
||||
reward.get('id')
|
||||
)
|
||||
|
||||
if reward.get('id') in assigned_ids:
|
||||
assigned_rewards.append(cr.to_dict())
|
||||
else:
|
||||
assignable_rewards.append(cr.to_dict())
|
||||
reward_dict = cr.to_dict()
|
||||
|
||||
reward_dict.update({'assigned': reward.get('id') in assigned_ids})
|
||||
rewards.append(reward_dict)
|
||||
rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||
|
||||
return jsonify({
|
||||
'assigned_rewards': assigned_rewards,
|
||||
'assignable_rewards': assignable_rewards,
|
||||
'assigned_count': len(assigned_rewards),
|
||||
'assignable_count': len(assignable_rewards)
|
||||
'rewards': rewards,
|
||||
'rewards_count': len(rewards)
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
@@ -119,31 +119,3 @@
|
||||
background: var(--sign-in-btn-hover-bg);
|
||||
color: var(--sign-in-btn-hover-color);
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--fab-bg);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--fab-hover-bg);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
background: var(--fab-active-bg);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,12 @@
|
||||
|
||||
--list-bg: #fff5;
|
||||
--list-item-bg: #f8fafc;
|
||||
--list-item-border-good: #38c172;
|
||||
--list-item-border-reward: #38c172;
|
||||
--list-item-border-bad: #e53e3e;
|
||||
--list-item-bg-good: #f0fff4;
|
||||
--list-item-bg-bad: #fff5f5;
|
||||
--list-item-border-good: #00e1ff;
|
||||
--list-item-bg-reward: #94ffb1;
|
||||
--list-item-bg-bad: #ffc5c5;
|
||||
--list-item-bg-good: #8dabfd;
|
||||
--list-image-bg: #eee;
|
||||
--delete-btn-hover-bg: #ffeaea;
|
||||
--delete-btn-hover-shadow: #ef444422;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/* List container */
|
||||
.listbox {
|
||||
flex: 1 1 auto;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
overflow-y: auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
background: var(--list-bg);
|
||||
padding: 0.2rem 0.2rem 0.2rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Delete button */
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0.15rem;
|
||||
margin-left: 0.7rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: var(--delete-btn-hover-bg);
|
||||
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
|
||||
opacity: 1;
|
||||
}
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.list-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: var(--checkbox-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Separator (if needed) */
|
||||
.list-separator {
|
||||
height: 0px;
|
||||
background: #0000;
|
||||
margin: 0rem 0.2rem;
|
||||
border-radius: 0px;
|
||||
}
|
||||
@@ -94,34 +94,6 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--fab-bg);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--fab-hover-bg);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
background: var(--fab-active-bg);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, toRefs, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { toRefs, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
|
||||
interface Child {
|
||||
|
||||
@@ -370,7 +370,7 @@ const childId = computed(() => child.value?.id ?? null)
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
|
||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||
Assign Bad Habits
|
||||
Assign Penalties
|
||||
</button>
|
||||
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
||||
</div>
|
||||
|
||||
@@ -2,22 +2,20 @@
|
||||
<div class="reward-assign-view">
|
||||
<h2>Assign Rewards</h2>
|
||||
<div class="reward-view">
|
||||
<div v-if="rewardCountRef == 0" class="no-rewards-message">
|
||||
<div>No rewards available</div>
|
||||
<div class="sub-message">
|
||||
<button class="create-btn" @click="goToCreateReward">Create</button> a reward
|
||||
</div>
|
||||
</div>
|
||||
<div class="reward-list-scroll">
|
||||
<RewardList
|
||||
v-if="rewardCountRef != 0"
|
||||
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
ref="rewardListRef"
|
||||
:child-id="childId"
|
||||
:selectable="true"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
||||
itemKey="rewards"
|
||||
:itemFields="['id', 'name', 'cost', 'image_id']"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
@loading-complete="(count) => (rewardCountRef = count)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" v-if="rewardCountRef != 0">
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
@@ -28,7 +26,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import RewardList from '../reward/RewardList.vue'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/actions-shared.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -42,7 +42,7 @@ function goToCreateReward() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = rewardListRef.value?.selectedRewards ?? []
|
||||
const selectedIds = rewardListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
|
||||
method: 'PUT',
|
||||
@@ -72,23 +72,13 @@ function onCancel() {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
h2 {
|
||||
.reward-assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.reward-list-scroll {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.reward-view {
|
||||
display: flex;
|
||||
@@ -100,37 +90,4 @@ h2 {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.no-rewards-message {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--assign-no-items-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sub-message {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--assign-sub-message-color);
|
||||
}
|
||||
.create-btn {
|
||||
background: var(--assign-create-btn-bg);
|
||||
color: var(--assign-create-btn-color);
|
||||
border: 2px solid var(--assign-create-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.create-btn:hover {
|
||||
background: var(--assign-create-btn-hover-bg);
|
||||
color: var(--assign-create-btn-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,23 +2,20 @@
|
||||
<div class="task-assign-view">
|
||||
<h2>Assign Tasks</h2>
|
||||
<div class="task-view">
|
||||
<div v-if="taskCountRef == 0" class="no-tasks-message">
|
||||
<div>No tasks available</div>
|
||||
<div class="sub-message">
|
||||
<button class="create-btn" @click="goToCreateTask">Create</button> a task
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-list-scroll">
|
||||
<TaskList
|
||||
v-if="taskCountRef != 0"
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
ref="taskListRef"
|
||||
:child-id="childId"
|
||||
:selectable="true"
|
||||
:type-filter="typeFilter"
|
||||
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
||||
itemKey="tasks"
|
||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" v-if="taskCountRef > 0">
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
@@ -29,7 +26,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import TaskList from '../task/TaskList.vue'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import '@/assets/actions-shared.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -49,12 +48,12 @@ function goToCreateTask() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = taskListRef.value?.selectedTasks ?? []
|
||||
const selectedIds = taskListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_ids: selectedIds }),
|
||||
body: JSON.stringify({ type: typeFilter.value, task_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update tasks')
|
||||
router.back()
|
||||
@@ -79,23 +78,13 @@ function onCancel() {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
h2 {
|
||||
.task-assign-view h2 {
|
||||
font-size: 1.15rem;
|
||||
color: var(--assign-heading-color);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.task-list-scroll {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.task-view {
|
||||
display: flex;
|
||||
@@ -107,37 +96,4 @@ h2 {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.no-tasks-message {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--assign-no-items-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sub-message {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--assign-sub-message-color);
|
||||
}
|
||||
.create-btn {
|
||||
background: var(--assign-create-btn-bg);
|
||||
color: var(--assign-create-btn-color);
|
||||
border: 2px solid var(--assign-create-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.create-btn:hover {
|
||||
background: var(--assign-create-btn-hover-bg);
|
||||
color: var(--assign-create-btn-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/list-shared.css'
|
||||
|
||||
const emit = defineEmits(['item-clicked'])
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
|
||||
import { defineProps, defineEmits, defineExpose } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import type { RewardStatus } from '@/common/models'
|
||||
import '@/assets/child-list-shared.css'
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
import type { Reward } from '@/common/models'
|
||||
import '@/assets/list-shared.css'
|
||||
|
||||
const props = defineProps<{
|
||||
childId?: string | number
|
||||
assignFilter?: 'assignable' | 'assigned' | 'none'
|
||||
deletable?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['edit-reward', 'delete-reward', 'loading-complete'])
|
||||
|
||||
const rewards = ref<
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
cost: number
|
||||
image_id?: string | null
|
||||
image_url?: string | null
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedRewards = ref<string[]>([])
|
||||
|
||||
const fetchRewards = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
let url = ''
|
||||
if (props.childId) {
|
||||
url = `/api/child/${props.childId}/list-all-rewards`
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const assigned = (data.assigned_rewards || []).map((reward: Reward) => ({
|
||||
...reward,
|
||||
assigned: true,
|
||||
}))
|
||||
const assignable = (data.assignable_rewards || []).map((reward: Reward) => ({
|
||||
...reward,
|
||||
assigned: false,
|
||||
}))
|
||||
let rewardList: any[] = []
|
||||
if (props.assignFilter === 'assignable') {
|
||||
rewardList = assignable
|
||||
} else if (props.assignFilter === 'assigned') {
|
||||
rewardList = assigned
|
||||
} else if (props.assignFilter === 'none') {
|
||||
rewardList = []
|
||||
} else {
|
||||
rewardList = [...assigned, ...assignable]
|
||||
}
|
||||
// Fetch images for each reward if image_id is present
|
||||
await Promise.all(
|
||||
rewardList.map(async (reward: any) => {
|
||||
if (reward.image_id) {
|
||||
try {
|
||||
reward.image_url = await getCachedImageUrl(reward.image_id)
|
||||
} catch (e) {
|
||||
reward.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
rewards.value = rewardList
|
||||
|
||||
// If selectable, pre-select assigned rewards
|
||||
if (props.selectable) {
|
||||
selectedRewards.value = assigned.map((reward: Reward) => String(reward.id))
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
rewards.value = []
|
||||
if (props.selectable) selectedRewards.value = []
|
||||
} finally {
|
||||
emit('loading-complete', rewards.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
url = '/api/reward/list'
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const rewardList = data.rewards || []
|
||||
await Promise.all(
|
||||
rewardList.map(async (reward: any) => {
|
||||
if (reward.image_id) {
|
||||
try {
|
||||
reward.image_url = await getCachedImageUrl(reward.image_id)
|
||||
} catch (e) {
|
||||
reward.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
rewards.value = rewardList
|
||||
if (props.selectable) selectedRewards.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
rewards.value = []
|
||||
if (props.selectable) selectedRewards.value = []
|
||||
} finally {
|
||||
emit('loading-complete', rewards.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchRewards)
|
||||
watch(() => [props.childId, props.assignFilter], fetchRewards)
|
||||
|
||||
const handleEdit = (rewardId: string) => {
|
||||
emit('edit-reward', rewardId)
|
||||
}
|
||||
|
||||
const handleDelete = (rewardId: string) => {
|
||||
emit('delete-reward', rewardId)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetchRewards, selectedRewards })
|
||||
|
||||
const ITEM_HEIGHT = 52 // px, adjust to match your .reward-list-item + margin
|
||||
const listHeight = computed(() => {
|
||||
const n = rewards.value.length
|
||||
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
|
||||
<div v-if="loading" class="loading">Loading rewards...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="rewards.length === 0" class="empty">No rewards found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(reward, idx) in rewards" :key="reward.id">
|
||||
<div class="list-item" @click="handleEdit(reward.id)">
|
||||
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="list-image" />
|
||||
<span class="list-name">{{ reward.name }}</span>
|
||||
<span class="list-cost"> {{ reward.cost }} pts </span>
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedRewards"
|
||||
:value="reward.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@click.stop="handleDelete(reward.id)"
|
||||
aria-label="Delete reward"
|
||||
type="button"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||
<path
|
||||
d="M7 7l6 6M13 7l-6 6"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="idx < rewards.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,46 +1,42 @@
|
||||
<template>
|
||||
<div class="reward-view">
|
||||
<div v-if="rewardCountRef == 0" class="no-message">
|
||||
<div>No rewards</div>
|
||||
<div class="sub-message">
|
||||
<button class="create-btn" @click="createReward">Create</button> a reward
|
||||
</div>
|
||||
</div>
|
||||
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||
<span> <button class="round-btn" @click="createReward">Create</button> a reward </span>
|
||||
</MessageBlock>
|
||||
|
||||
<div class="reward-view" v-else>
|
||||
<RewardList
|
||||
ref="rewardListRef"
|
||||
:deletable="true"
|
||||
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
||||
@delete-reward="confirmDeleteReward"
|
||||
<ItemList
|
||||
v-else
|
||||
fetchUrl="/api/reward/list"
|
||||
itemKey="rewards"
|
||||
:itemFields="['id', 'name', 'cost', 'description', 'image_id']"
|
||||
imageField="image_id"
|
||||
deletable
|
||||
@edit="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
||||
@delete="confirmDeleteReward"
|
||||
@loading-complete="(count) => (rewardCountRef = count)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<button class="fab" @click="createReward" aria-label="Create Reward">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
|
||||
|
||||
<div v-if="showConfirm" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<p>Are you sure you want to delete this reward?</p>
|
||||
<div class="actions">
|
||||
<button @click="deleteReward" class="btn btn-danger">Delete</button>
|
||||
<button @click="showConfirm = false" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteModal
|
||||
:show="showConfirm"
|
||||
message="Are you sure you want to delete this reward?"
|
||||
@confirm="deleteReward"
|
||||
@cancel="showConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import RewardList from './RewardList.vue'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
|
||||
import '@/assets/view-shared.css'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
@@ -87,23 +83,4 @@ const createReward = () => {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: var(--create-btn-bg);
|
||||
color: var(--create-btn-color);
|
||||
border: 2px solid var(--create-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.create-btn:hover {
|
||||
background: var(--create-btn-hover-bg);
|
||||
color: var(--create-btn-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,10 +12,11 @@ import type {
|
||||
Event,
|
||||
} from '@/common/models'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/common.css'
|
||||
import '@/assets/modal.css'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const children = ref<Child[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -345,41 +346,18 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- confirmation modal -->
|
||||
<div
|
||||
v-if="confirmDeleteVisible"
|
||||
class="modal-backdrop"
|
||||
@click.self="confirmDeleteVisible = false"
|
||||
>
|
||||
<div class="modal">
|
||||
<h3>Delete child?</h3>
|
||||
<p>Are you sure you want to permanently delete this child?</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="
|
||||
() => {
|
||||
confirmDeleteVisible = false
|
||||
deletingChildId = null
|
||||
}
|
||||
"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="performDelete" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting…' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteModal
|
||||
:show="confirmDeleteVisible"
|
||||
message="Are you sure you want to delete this child?"
|
||||
@confirm="performDelete"
|
||||
@cancel="confirmDeleteVisible = false"
|
||||
/>
|
||||
|
||||
<!-- Add Child button (FAB) -->
|
||||
<button v-if="isParentAuthenticated" class="fab" @click="createChild" aria-label="Add Child">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<FloatingActionButton
|
||||
v-if="isParentAuthenticated"
|
||||
aria-label="Add Child"
|
||||
@click="createChild"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -526,4 +504,27 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
48
web/vue-app/src/components/shared/DeleteModal.vue
Normal file
48
web/vue-app/src/components/shared/DeleteModal.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="modal-backdrop" v-if="show">
|
||||
<div class="modal">
|
||||
<p>{{ message }}</p>
|
||||
<div class="actions">
|
||||
<button @click="handleDelete" class="btn btn-danger" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting' : 'Delete' }}
|
||||
</button>
|
||||
<button @click="handleCancel" class="btn btn-secondary" :disabled="deleting">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const deleting = ref(false)
|
||||
|
||||
function handleDelete() {
|
||||
deleting.value = true
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!deleting.value) emit('cancel')
|
||||
}
|
||||
|
||||
// Reset deleting state when modal is closed
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (!val) deleting.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '@/assets/modal.css';
|
||||
@import '@/assets/actions-shared.css';
|
||||
</style>
|
||||
45
web/vue-app/src/components/shared/FloatingActionButton.vue
Normal file
45
web/vue-app/src/components/shared/FloatingActionButton.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@/assets/global.css'
|
||||
defineProps<{ ariaLabel?: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--fab-bg);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--fab-hover-bg);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
background: var(--fab-active-bg);
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -21,13 +21,21 @@ const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedItems = ref<string[]>([])
|
||||
|
||||
defineExpose({
|
||||
items,
|
||||
selectedItems,
|
||||
})
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
console.log(`Fetching items from: ${props.fetchUrl}`)
|
||||
try {
|
||||
const resp = await fetch(props.fetchUrl)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
//console log all data
|
||||
console.log('Fetched data:', data)
|
||||
let itemList = data[props.itemKey] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
await Promise.all(
|
||||
@@ -38,11 +46,14 @@ const fetchItems = async () => {
|
||||
} catch {
|
||||
item.image_url = null
|
||||
}
|
||||
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
|
||||
if (props.selectable && item.assigned === true) {
|
||||
selectedItems.value.push(item.id)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
items.value = itemList
|
||||
if (props.selectable) selectedItems.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
@@ -53,8 +64,17 @@ const fetchItems = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getItemType = (itemList) => {
|
||||
if (itemList.length === 0) return 'reward'
|
||||
if (items.value[0].points !== undefined) {
|
||||
return items.value[0].is_good ? 'good' : 'bad'
|
||||
}
|
||||
return 'reward'
|
||||
}
|
||||
|
||||
onMounted(fetchItems)
|
||||
watch(() => props.fetchUrl, fetchItems)
|
||||
const itemType = computed(() => getItemType(items))
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
emit('edit', id)
|
||||
@@ -73,11 +93,11 @@ const handleDelete = (id: string) => {
|
||||
<div v-else-if="items.length === 0" class="empty">No items found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(item, idx) in items" :key="item.id">
|
||||
<div class="list-item" @click="handleEdit(item.id)">
|
||||
<div class="list-item" :class="['list-item', itemType]" @click="handleEdit(item.id)">
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
|
||||
<span class="list-name">{{ item.name }}</span>
|
||||
<span v-if="item.points !== undefined" class="list-points">{{ item.points }} pts</span>
|
||||
<span v-if="item.cost !== undefined" class="list-cost">{{ item.cost }} pts</span>
|
||||
<span v-if="item.points !== undefined" class="value">{{ item.points }} pts</span>
|
||||
<span v-if="item.cost !== undefined" class="value">{{ item.cost }} pts</span>
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
@@ -111,10 +131,24 @@ const handleDelete = (id: string) => {
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.listbox {
|
||||
flex: 0 1 auto;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
overflow-y: auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
background: var(--list-bg);
|
||||
padding: 0.2rem 0.2rem 0.2rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px outset var(--list-item-border-good);
|
||||
border: 2px outset var(--list-item-border-reward);
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 1rem;
|
||||
background: var(--list-item-bg);
|
||||
@@ -131,6 +165,10 @@ const handleDelete = (id: string) => {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
.list-item.reward {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
.list-item.good {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
@@ -160,4 +198,67 @@ const handleDelete = (id: string) => {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0.15rem;
|
||||
margin-left: 0.7rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: var(--delete-btn-hover-bg);
|
||||
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
|
||||
opacity: 1;
|
||||
}
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.list-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: var(--checkbox-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
/* Separator (if needed) */
|
||||
.list-separator {
|
||||
height: 0px;
|
||||
background: #0000;
|
||||
margin: 0rem 0.2rem;
|
||||
border-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import type { Task } from '@/common/models'
|
||||
import '@/assets/child-list-shared.css'
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
import type { Task } from '@/common/models'
|
||||
import '@/assets/list-shared.css'
|
||||
|
||||
const props = defineProps<{
|
||||
childId?: string | number
|
||||
assignFilter?: 'assignable' | 'assigned' | 'none'
|
||||
typeFilter?: 'good' | 'bad' | 'all'
|
||||
deletable?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['edit-task', 'delete-task', 'loading-complete'])
|
||||
|
||||
const tasks = ref<
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id?: string | null
|
||||
image_url?: string | null
|
||||
assigned?: boolean
|
||||
}[]
|
||||
>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedTasks = ref<string[]>([])
|
||||
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
let url = ''
|
||||
if (props.childId) {
|
||||
url = `/api/child/${props.childId}/list-all-tasks`
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const assigned = (data.assigned_tasks || []).map((task: Task) => ({
|
||||
...task,
|
||||
assigned: true,
|
||||
}))
|
||||
const assignable = (data.assignable_tasks || []).map((task: Task) => ({
|
||||
...task,
|
||||
assigned: false,
|
||||
}))
|
||||
let taskList: Task[] = []
|
||||
if (props.assignFilter === 'assignable') {
|
||||
taskList = assignable
|
||||
} else if (props.assignFilter === 'assigned') {
|
||||
taskList = assigned
|
||||
} else if (props.assignFilter === 'none') {
|
||||
taskList = []
|
||||
} else {
|
||||
taskList = [...assigned, ...assignable]
|
||||
}
|
||||
// Fetch images for each task if image_id is present
|
||||
await Promise.all(
|
||||
taskList.map(async (task: Task) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
tasks.value = taskList
|
||||
|
||||
// If selectable, pre-select assigned tasks
|
||||
if (props.selectable) {
|
||||
selectedTasks.value = assigned.map((task: Task) => String(task.id))
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
tasks.value = []
|
||||
if (props.selectable) selectedTasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
emit('loading-complete', filteredTasks.value.length)
|
||||
}
|
||||
} else {
|
||||
url = '/api/task/list'
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const taskList = data.tasks || []
|
||||
await Promise.all(
|
||||
taskList.map(async (task: Task) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
tasks.value = taskList
|
||||
if (props.selectable) selectedTasks.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
tasks.value = []
|
||||
if (props.selectable) selectedTasks.value = []
|
||||
} finally {
|
||||
emit('loading-complete', filteredTasks.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
watch(() => [props.childId, props.assignFilter], fetchTasks)
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
emit('edit-task', taskId)
|
||||
}
|
||||
|
||||
const handleDelete = (taskId: string) => {
|
||||
emit('delete-task', taskId)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetchTasks, selectedTasks })
|
||||
|
||||
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
|
||||
const listHeight = computed(() => {
|
||||
// Add a little for padding, separators, etc.
|
||||
const n = tasks.value.length
|
||||
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (props.typeFilter === 'good') {
|
||||
return tasks.value.filter((t) => t.is_good)
|
||||
} else if (props.typeFilter === 'bad') {
|
||||
return tasks.value.filter((t) => !t.is_good)
|
||||
}
|
||||
return tasks.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
|
||||
<div v-if="loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(task, idx) in filteredTasks" :key="task.id">
|
||||
<div
|
||||
class="list-item"
|
||||
:class="{ good: task.is_good, bad: !task.is_good }"
|
||||
@click="handleEdit(task.id)"
|
||||
>
|
||||
<img v-if="task.image_url" :src="task.image_url" alt="Task" class="list-image" />
|
||||
<span class="list-name">{{ task.name }}</span>
|
||||
<span class="list-points">
|
||||
{{ task.is_good ? task.points : '-' + task.points }} pts
|
||||
</span>
|
||||
<!-- Add checkbox if selectable -->
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedTasks"
|
||||
:value="task.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
@click.stop="handleDelete(task.id)"
|
||||
aria-label="Delete task"
|
||||
type="button"
|
||||
class="delete-btn"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||
<path
|
||||
d="M7 7l6 6M13 7l-6 6"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="idx < tasks.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,11 +1,8 @@
|
||||
<template>
|
||||
<div class="task-view">
|
||||
<div v-if="taskCountRef === 0" class="no-message">
|
||||
<div>No tasks</div>
|
||||
<div class="sub-message">
|
||||
<button class="create-btn" @click="createTask">Create</button> a task
|
||||
</div>
|
||||
</div>
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||
<span> <button class="round-btn" @click="createTask">Create</button> a task </span>
|
||||
</MessageBlock>
|
||||
|
||||
<ItemList
|
||||
v-else
|
||||
@@ -13,38 +10,33 @@
|
||||
itemKey="tasks"
|
||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
deletable
|
||||
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||
@delete="confirmDeleteTask"
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<button class="fab" @click="createTask" aria-label="Create Task">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<FloatingActionButton aria-label="Create Task" @click="createTask" />
|
||||
|
||||
<div v-if="showConfirm" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<p>Are you sure you want to delete this task?</p>
|
||||
<div class="actions">
|
||||
<button @click="deleteTask" class="btn btn-danger">Delete</button>
|
||||
<button @click="showConfirm = false" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteModal
|
||||
:show="showConfirm"
|
||||
message="Are you sure you want to delete this task?"
|
||||
@confirm="deleteTask"
|
||||
@cancel="showConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TaskList from './TaskList.vue'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
|
||||
import '@/assets/view-shared.css'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
@@ -94,23 +86,4 @@ const createTask = () => {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: var(--create-btn-bg);
|
||||
color: var(--create-btn-color);
|
||||
border: 2px solid var(--create-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.create-btn:hover {
|
||||
background: var(--create-btn-hover-bg);
|
||||
color: var(--create-btn-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user