This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
from time import sleep
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
@@ -137,6 +139,12 @@ def assign_task_to_child(id):
|
|||||||
def set_child_tasks(id):
|
def set_child_tasks(id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
task_ids = data.get('task_ids')
|
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):
|
if not isinstance(task_ids, list):
|
||||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
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)
|
result = child_db.search(ChildQuery.id == id)
|
||||||
if not result:
|
if not result:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
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
|
# Add all existing child tasks of the opposite type
|
||||||
TaskQuery = Query()
|
for task in task_db.all():
|
||||||
valid_task_ids = []
|
if task['id'] in child.tasks and task['is_good'] != is_good:
|
||||||
for tid in new_task_ids:
|
new_task_ids.add(task['id'])
|
||||||
if task_db.get(TaskQuery.id == tid):
|
|
||||||
valid_task_ids.append(tid)
|
# Convert back to list if needed
|
||||||
|
new_tasks = list(new_task_ids)
|
||||||
# Replace tasks with validated IDs
|
# Replace tasks with validated IDs
|
||||||
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
|
||||||
if resp:
|
if resp:
|
||||||
return resp
|
return resp
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Tasks set for child {id}.',
|
'message': f'Tasks set for child {id}.',
|
||||||
'task_ids': valid_task_ids,
|
'task_ids': new_tasks,
|
||||||
'count': len(valid_task_ids)
|
'count': len(new_tasks)
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +253,10 @@ def list_all_tasks(id):
|
|||||||
result = child_db.search(ChildQuery.id == id)
|
result = child_db.search(ChildQuery.id == id)
|
||||||
if not result:
|
if not result:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
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]
|
child = result[0]
|
||||||
assigned_ids = set(child.get('tasks', []))
|
assigned_ids = set(child.get('tasks', []))
|
||||||
@@ -249,8 +264,7 @@ def list_all_tasks(id):
|
|||||||
# Get all tasks from database
|
# Get all tasks from database
|
||||||
all_tasks = task_db.all()
|
all_tasks = task_db.all()
|
||||||
|
|
||||||
assigned_tasks = []
|
tasks = []
|
||||||
assignable_tasks = []
|
|
||||||
|
|
||||||
for task in all_tasks:
|
for task in all_tasks:
|
||||||
if not task or not task.get('id'):
|
if not task or not task.get('id'):
|
||||||
@@ -263,18 +277,15 @@ def list_all_tasks(id):
|
|||||||
task.get('image_id'),
|
task.get('image_id'),
|
||||||
task.get('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:
|
task_dict.update({'assigned': task.get('id') in assigned_ids})
|
||||||
assigned_tasks.append(ct.to_dict())
|
tasks.append(task_dict)
|
||||||
else:
|
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||||
assignable_tasks.append(ct.to_dict())
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({ 'tasks': tasks }), 200
|
||||||
'assigned_tasks': assigned_tasks,
|
|
||||||
'assignable_tasks': assignable_tasks,
|
|
||||||
'assigned_count': len(assigned_tasks),
|
|
||||||
'assignable_count': len(assignable_tasks)
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||||
@@ -341,9 +352,7 @@ def list_all_rewards(id):
|
|||||||
|
|
||||||
# Get all rewards from database
|
# Get all rewards from database
|
||||||
all_rewards = reward_db.all()
|
all_rewards = reward_db.all()
|
||||||
|
rewards = []
|
||||||
assigned_rewards = []
|
|
||||||
assignable_rewards = []
|
|
||||||
|
|
||||||
for reward in all_rewards:
|
for reward in all_rewards:
|
||||||
if not reward or not reward.get('id'):
|
if not reward or not reward.get('id'):
|
||||||
@@ -356,16 +365,15 @@ def list_all_rewards(id):
|
|||||||
reward.get('id')
|
reward.get('id')
|
||||||
)
|
)
|
||||||
|
|
||||||
if reward.get('id') in assigned_ids:
|
reward_dict = cr.to_dict()
|
||||||
assigned_rewards.append(cr.to_dict())
|
|
||||||
else:
|
reward_dict.update({'assigned': reward.get('id') in assigned_ids})
|
||||||
assignable_rewards.append(cr.to_dict())
|
rewards.append(reward_dict)
|
||||||
|
rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'assigned_rewards': assigned_rewards,
|
'rewards': rewards,
|
||||||
'assignable_rewards': assignable_rewards,
|
'rewards_count': len(rewards)
|
||||||
'assigned_count': len(assigned_rewards),
|
|
||||||
'assignable_count': len(assignable_rewards)
|
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,31 +119,3 @@
|
|||||||
background: var(--sign-in-btn-hover-bg);
|
background: var(--sign-in-btn-hover-bg);
|
||||||
color: var(--sign-in-btn-hover-color);
|
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-bg: #fff5;
|
||||||
--list-item-bg: #f8fafc;
|
--list-item-bg: #f8fafc;
|
||||||
--list-item-border-good: #38c172;
|
--list-item-border-reward: #38c172;
|
||||||
--list-item-border-bad: #e53e3e;
|
--list-item-border-bad: #e53e3e;
|
||||||
--list-item-bg-good: #f0fff4;
|
--list-item-border-good: #00e1ff;
|
||||||
--list-item-bg-bad: #fff5f5;
|
--list-item-bg-reward: #94ffb1;
|
||||||
|
--list-item-bg-bad: #ffc5c5;
|
||||||
|
--list-item-bg-good: #8dabfd;
|
||||||
--list-image-bg: #eee;
|
--list-image-bg: #eee;
|
||||||
--delete-btn-hover-bg: #ffeaea;
|
--delete-btn-hover-bg: #ffeaea;
|
||||||
--delete-btn-hover-shadow: #ef444422;
|
--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;
|
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 */
|
/* Responsive Adjustments */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout {
|
.layout {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
|
|
||||||
interface Child {
|
interface Child {
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ const childId = computed(() => child.value?.id ?? null)
|
|||||||
<div class="assign-buttons">
|
<div class="assign-buttons">
|
||||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
|
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
|
||||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||||
Assign Bad Habits
|
Assign Penalties
|
||||||
</button>
|
</button>
|
||||||
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,21 +2,19 @@
|
|||||||
<div class="reward-assign-view">
|
<div class="reward-assign-view">
|
||||||
<h2>Assign Rewards</h2>
|
<h2>Assign Rewards</h2>
|
||||||
<div class="reward-view">
|
<div class="reward-view">
|
||||||
<div v-if="rewardCountRef == 0" class="no-rewards-message">
|
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||||
<div>No rewards available</div>
|
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
|
||||||
<div class="sub-message">
|
</MessageBlock>
|
||||||
<button class="create-btn" @click="goToCreateReward">Create</button> a reward
|
<ItemList
|
||||||
</div>
|
v-else
|
||||||
</div>
|
ref="rewardListRef"
|
||||||
<div class="reward-list-scroll">
|
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
|
||||||
<RewardList
|
itemKey="rewards"
|
||||||
v-if="rewardCountRef != 0"
|
:itemFields="['id', 'name', 'cost', 'image_id']"
|
||||||
ref="rewardListRef"
|
imageField="image_id"
|
||||||
:child-id="childId"
|
selectable
|
||||||
:selectable="true"
|
@loading-complete="(count) => (rewardCountRef = count)"
|
||||||
@loading-complete="(count) => (rewardCountRef = count)"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" v-if="rewardCountRef != 0">
|
<div class="actions" v-if="rewardCountRef != 0">
|
||||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||||
@@ -28,7 +26,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -42,7 +42,7 @@ function goToCreateReward() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const selectedIds = rewardListRef.value?.selectedRewards ?? []
|
const selectedIds = rewardListRef.value?.selectedItems ?? []
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
|
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -72,23 +72,13 @@ function onCancel() {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
h2 {
|
.reward-assign-view h2 {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
color: var(--assign-heading-color);
|
color: var(--assign-heading-color);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0.2rem;
|
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 {
|
.reward-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -100,37 +90,4 @@ h2 {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -2,22 +2,19 @@
|
|||||||
<div class="task-assign-view">
|
<div class="task-assign-view">
|
||||||
<h2>Assign Tasks</h2>
|
<h2>Assign Tasks</h2>
|
||||||
<div class="task-view">
|
<div class="task-view">
|
||||||
<div v-if="taskCountRef == 0" class="no-tasks-message">
|
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||||
<div>No tasks available</div>
|
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
|
||||||
<div class="sub-message">
|
</MessageBlock>
|
||||||
<button class="create-btn" @click="goToCreateTask">Create</button> a task
|
<ItemList
|
||||||
</div>
|
v-else
|
||||||
</div>
|
ref="taskListRef"
|
||||||
<div class="task-list-scroll">
|
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
|
||||||
<TaskList
|
itemKey="tasks"
|
||||||
v-if="taskCountRef != 0"
|
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||||
ref="taskListRef"
|
imageField="image_id"
|
||||||
:child-id="childId"
|
selectable
|
||||||
:selectable="true"
|
@loading-complete="(count) => (taskCountRef = count)"
|
||||||
:type-filter="typeFilter"
|
/>
|
||||||
@loading-complete="(count) => (taskCountRef = count)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" v-if="taskCountRef > 0">
|
<div class="actions" v-if="taskCountRef > 0">
|
||||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||||
@@ -29,7 +26,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -49,12 +48,12 @@ function goToCreateTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const selectedIds = taskListRef.value?.selectedTasks ?? []
|
const selectedIds = taskListRef.value?.selectedItems ?? []
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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')
|
if (!resp.ok) throw new Error('Failed to update tasks')
|
||||||
router.back()
|
router.back()
|
||||||
@@ -79,23 +78,13 @@ function onCancel() {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
h2 {
|
.task-assign-view h2 {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
color: var(--assign-heading-color);
|
color: var(--assign-heading-color);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0.2rem;
|
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 {
|
.task-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -107,37 +96,4 @@ h2 {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
|||||||
import { getCachedImageUrl } from '../../common/imageCache'
|
import { getCachedImageUrl } from '../../common/imageCache'
|
||||||
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
|
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import '@/assets/list-shared.css'
|
|
||||||
|
|
||||||
const emit = defineEmits(['item-clicked'])
|
const emit = defineEmits(['item-clicked'])
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
|
|||||||
import { defineProps, defineEmits, defineExpose } from 'vue'
|
import { defineProps, defineEmits, defineExpose } from 'vue'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
import type { RewardStatus } from '@/common/models'
|
import type { RewardStatus } from '@/common/models'
|
||||||
import '@/assets/child-list-shared.css'
|
|
||||||
|
|
||||||
const imageCacheName = 'images-v1'
|
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>
|
<template>
|
||||||
<div class="reward-view">
|
<div class="reward-view">
|
||||||
<div v-if="rewardCountRef == 0" class="no-message">
|
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
|
||||||
<div>No rewards</div>
|
<span> <button class="round-btn" @click="createReward">Create</button> a reward </span>
|
||||||
<div class="sub-message">
|
</MessageBlock>
|
||||||
<button class="create-btn" @click="createReward">Create</button> a reward
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="reward-view" v-else>
|
<ItemList
|
||||||
<RewardList
|
v-else
|
||||||
ref="rewardListRef"
|
fetchUrl="/api/reward/list"
|
||||||
:deletable="true"
|
itemKey="rewards"
|
||||||
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
:itemFields="['id', 'name', 'cost', 'description', 'image_id']"
|
||||||
@delete-reward="confirmDeleteReward"
|
imageField="image_id"
|
||||||
@loading-complete="(count) => (rewardCountRef = count)"
|
deletable
|
||||||
/>
|
@edit="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
||||||
</div>
|
@delete="confirmDeleteReward"
|
||||||
|
@loading-complete="(count) => (rewardCountRef = count)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Floating Action Button -->
|
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="showConfirm" class="modal-backdrop">
|
<DeleteModal
|
||||||
<div class="modal">
|
:show="showConfirm"
|
||||||
<p>Are you sure you want to delete this reward?</p>
|
message="Are you sure you want to delete this reward?"
|
||||||
<div class="actions">
|
@confirm="deleteReward"
|
||||||
<button @click="deleteReward" class="btn btn-danger">Delete</button>
|
@cancel="showConfirm = false"
|
||||||
<button @click="showConfirm = false" class="btn btn-secondary">Cancel</button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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()
|
const $router = useRouter()
|
||||||
|
|
||||||
@@ -87,23 +83,4 @@ const createReward = () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import type {
|
|||||||
Event,
|
Event,
|
||||||
} from '@/common/models'
|
} from '@/common/models'
|
||||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||||
import '@/assets/common.css'
|
|
||||||
import '@/assets/modal.css'
|
|
||||||
import '@/assets/button-shared.css'
|
import '@/assets/button-shared.css'
|
||||||
|
|
||||||
|
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||||
|
import DeleteModal from '../shared/DeleteModal.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const children = ref<Child[]>([])
|
const children = ref<Child[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -345,41 +346,18 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- confirmation modal -->
|
<DeleteModal
|
||||||
<div
|
:show="confirmDeleteVisible"
|
||||||
v-if="confirmDeleteVisible"
|
message="Are you sure you want to delete this child?"
|
||||||
class="modal-backdrop"
|
@confirm="performDelete"
|
||||||
@click.self="confirmDeleteVisible = false"
|
@cancel="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>
|
|
||||||
|
|
||||||
<!-- Add Child button (FAB) -->
|
<FloatingActionButton
|
||||||
<button v-if="isParentAuthenticated" class="fab" @click="createChild" aria-label="Add Child">
|
v-if="isParentAuthenticated"
|
||||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
aria-label="Add Child"
|
||||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
@click="createChild"
|
||||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
/>
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -526,4 +504,27 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
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>
|
</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">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted, computed } from 'vue'
|
||||||
import { getCachedImageUrl } from '@/common/imageCache'
|
import { getCachedImageUrl } from '@/common/imageCache'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -21,13 +21,21 @@ const loading = ref(true)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const selectedItems = ref<string[]>([])
|
const selectedItems = ref<string[]>([])
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
items,
|
||||||
|
selectedItems,
|
||||||
|
})
|
||||||
|
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
console.log(`Fetching items from: ${props.fetchUrl}`)
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(props.fetchUrl)
|
const resp = await fetch(props.fetchUrl)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
//console log all data
|
||||||
|
console.log('Fetched data:', data)
|
||||||
let itemList = data[props.itemKey] || []
|
let itemList = data[props.itemKey] || []
|
||||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -38,11 +46,14 @@ const fetchItems = async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
item.image_url = null
|
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
|
items.value = itemList
|
||||||
if (props.selectable) selectedItems.value = []
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||||
items.value = []
|
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)
|
onMounted(fetchItems)
|
||||||
watch(() => props.fetchUrl, fetchItems)
|
watch(() => props.fetchUrl, fetchItems)
|
||||||
|
const itemType = computed(() => getItemType(items))
|
||||||
|
|
||||||
const handleEdit = (id: string) => {
|
const handleEdit = (id: string) => {
|
||||||
emit('edit', id)
|
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-if="items.length === 0" class="empty">No items found.</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(item, idx) in items" :key="item.id">
|
<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" />
|
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
|
||||||
<span class="list-name">{{ item.name }}</span>
|
<span class="list-name">{{ item.name }}</span>
|
||||||
<span v-if="item.points !== undefined" class="list-points">{{ item.points }} pts</span>
|
<span v-if="item.points !== undefined" class="value">{{ item.points }} pts</span>
|
||||||
<span v-if="item.cost !== undefined" class="list-cost">{{ item.cost }} pts</span>
|
<span v-if="item.cost !== undefined" class="value">{{ item.cost }} pts</span>
|
||||||
<input
|
<input
|
||||||
v-if="props.selectable"
|
v-if="props.selectable"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -111,10 +131,24 @@ const handleDelete = (id: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<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 {
|
.list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 2px outset var(--list-item-border-good);
|
border: 2px outset var(--list-item-border-reward);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.2rem 1rem;
|
padding: 0.2rem 1rem;
|
||||||
background: var(--list-item-bg);
|
background: var(--list-item-bg);
|
||||||
@@ -131,6 +165,10 @@ const handleDelete = (id: string) => {
|
|||||||
border-color: var(--list-item-border-bad);
|
border-color: var(--list-item-border-bad);
|
||||||
background: var(--list-item-bg-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 {
|
.list-item.good {
|
||||||
border-color: var(--list-item-border-good);
|
border-color: var(--list-item-border-good);
|
||||||
background: var(--list-item-bg-good);
|
background: var(--list-item-bg-good);
|
||||||
@@ -160,4 +198,67 @@ const handleDelete = (id: string) => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
|||||||
import { defineProps, defineEmits } from 'vue'
|
import { defineProps, defineEmits } from 'vue'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
import type { Task } from '@/common/models'
|
import type { Task } from '@/common/models'
|
||||||
import '@/assets/child-list-shared.css'
|
|
||||||
|
|
||||||
const imageCacheName = 'images-v1'
|
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>
|
<template>
|
||||||
<div class="task-view">
|
<div class="task-view">
|
||||||
<div v-if="taskCountRef === 0" class="no-message">
|
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||||
<div>No tasks</div>
|
<span> <button class="round-btn" @click="createTask">Create</button> a task </span>
|
||||||
<div class="sub-message">
|
</MessageBlock>
|
||||||
<button class="create-btn" @click="createTask">Create</button> a task
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ItemList
|
<ItemList
|
||||||
v-else
|
v-else
|
||||||
@@ -13,38 +10,33 @@
|
|||||||
itemKey="tasks"
|
itemKey="tasks"
|
||||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
selectable
|
|
||||||
deletable
|
deletable
|
||||||
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||||
@delete="confirmDeleteTask"
|
@delete="confirmDeleteTask"
|
||||||
@loading-complete="(count) => (taskCountRef = count)"
|
@loading-complete="(count) => (taskCountRef = count)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Floating Action Button -->
|
<FloatingActionButton aria-label="Create Task" @click="createTask" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="showConfirm" class="modal-backdrop">
|
<DeleteModal
|
||||||
<div class="modal">
|
:show="showConfirm"
|
||||||
<p>Are you sure you want to delete this task?</p>
|
message="Are you sure you want to delete this task?"
|
||||||
<div class="actions">
|
@confirm="deleteTask"
|
||||||
<button @click="deleteTask" class="btn btn-danger">Delete</button>
|
@cancel="showConfirm = false"
|
||||||
<button @click="showConfirm = false" class="btn btn-secondary">Cancel</button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import TaskList from './TaskList.vue'
|
|
||||||
import ItemList from '../shared/ItemList.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()
|
const $router = useRouter()
|
||||||
|
|
||||||
@@ -94,23 +86,4 @@ const createTask = () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user