round 3
This commit is contained in:
169
api/child_api.py
169
api/child_api.py
@@ -4,6 +4,16 @@ from db.db import child_db, task_db, reward_db
|
||||
from api.reward_status import RewardStatus
|
||||
from api.child_tasks import ChildTask
|
||||
from api.child_rewards import ChildReward
|
||||
from events.sse import send_to_user, send_event_to_user
|
||||
from events.types.child_add import ChildAdd
|
||||
from events.types.child_delete import ChildDelete
|
||||
from events.types.child_update import ChildUpdate
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.reward_set import RewardSet
|
||||
from events.types.reward_update import RewardUpdate
|
||||
from events.types.task_set import TaskSet
|
||||
from events.types.task_update import TaskUpdate
|
||||
|
||||
from models.child import Child
|
||||
from models.task import Task
|
||||
@@ -31,8 +41,9 @@ def add_child():
|
||||
if not image:
|
||||
image = 'boy01'
|
||||
|
||||
child = Child(name, age, image_id=image)
|
||||
child = Child(name=name, age=age, image_id=image)
|
||||
child_db.insert(child.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.CHILD_ADD.value, ChildAdd(child.id, "set")))
|
||||
return jsonify({'message': f'Child {name} added.'}), 201
|
||||
|
||||
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
||||
@@ -56,6 +67,7 @@ def edit_child(id):
|
||||
if image is not None:
|
||||
child['image_id'] = image
|
||||
child_db.update(child, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_UPDATE.value, ChildUpdate(id, "set")))
|
||||
return jsonify({'message': f'Child {id} updated.'}), 200
|
||||
|
||||
@child_api.route('/child/list', methods=['GET'])
|
||||
@@ -68,6 +80,8 @@ def list_children():
|
||||
def delete_child(id):
|
||||
ChildQuery = Query()
|
||||
if child_db.remove(ChildQuery.id == id):
|
||||
send_event_to_user("user123",
|
||||
Event(EventType.CHILD_DELETE.value, ChildDelete(id, "deleted")))
|
||||
return jsonify({'message': f'Child {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
@@ -89,6 +103,37 @@ def assign_task_to_child(id):
|
||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200
|
||||
|
||||
# python
|
||||
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
|
||||
def set_child_tasks(id):
|
||||
data = request.get_json() or {}
|
||||
task_ids = data.get('task_ids')
|
||||
if not isinstance(task_ids, list):
|
||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||
|
||||
# Deduplicate and drop falsy values
|
||||
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
# 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)
|
||||
# Replace tasks with validated IDs
|
||||
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.TASK_SET.value, TaskSet(id, "set")))
|
||||
return jsonify({
|
||||
'message': f'Tasks set for child {id}.',
|
||||
'task_ids': valid_task_ids,
|
||||
'count': len(valid_task_ids)
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-task', methods=['POST'])
|
||||
@@ -159,6 +204,48 @@ def list_assignable_tasks(id):
|
||||
|
||||
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
|
||||
def list_all_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
|
||||
# Get all tasks from database
|
||||
all_tasks = task_db.all()
|
||||
|
||||
assigned_tasks = []
|
||||
assignable_tasks = []
|
||||
|
||||
for task in all_tasks:
|
||||
if not task or not task.get('id'):
|
||||
continue
|
||||
|
||||
ct = ChildTask(
|
||||
task.get('name'),
|
||||
task.get('is_good'),
|
||||
task.get('points'),
|
||||
task.get('image_id'),
|
||||
task.get('id')
|
||||
)
|
||||
|
||||
if task.get('id') in assigned_ids:
|
||||
assigned_tasks.append(ct.to_dict())
|
||||
else:
|
||||
assignable_tasks.append(ct.to_dict())
|
||||
|
||||
return jsonify({
|
||||
'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'])
|
||||
def trigger_child_task(id):
|
||||
data = request.get_json()
|
||||
@@ -188,6 +275,7 @@ def trigger_child_task(id):
|
||||
child.points = max(child.points, 0)
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.TASK_UPDATE.value, TaskUpdate(task.id, child.id,"complete", child.points)))
|
||||
|
||||
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@@ -209,6 +297,78 @@ def assign_reward_to_child(id):
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
|
||||
def list_all_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('rewards', []))
|
||||
|
||||
# Get all rewards from database
|
||||
all_rewards = reward_db.all()
|
||||
|
||||
assigned_rewards = []
|
||||
assignable_rewards = []
|
||||
|
||||
for reward in all_rewards:
|
||||
if not reward or not reward.get('id'):
|
||||
continue
|
||||
|
||||
cr = ChildReward(
|
||||
reward.get('name'),
|
||||
reward.get('cost'),
|
||||
reward.get('image_id'),
|
||||
reward.get('id')
|
||||
)
|
||||
|
||||
if reward.get('id') in assigned_ids:
|
||||
assigned_rewards.append(cr.to_dict())
|
||||
else:
|
||||
assignable_rewards.append(cr.to_dict())
|
||||
|
||||
return jsonify({
|
||||
'assigned_rewards': assigned_rewards,
|
||||
'assignable_rewards': assignable_rewards,
|
||||
'assigned_count': len(assigned_rewards),
|
||||
'assignable_count': len(assignable_rewards)
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
|
||||
def set_child_rewards(id):
|
||||
data = request.get_json() or {}
|
||||
reward_ids = data.get('reward_ids')
|
||||
if not isinstance(reward_ids, list):
|
||||
return jsonify({'error': 'reward_ids must be a list'}), 400
|
||||
|
||||
# Deduplicate and drop falsy values
|
||||
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
# Optional: validate reward IDs exist in the reward DB
|
||||
RewardQuery = Query()
|
||||
valid_reward_ids = []
|
||||
for rid in new_reward_ids:
|
||||
if reward_db.get(RewardQuery.id == rid):
|
||||
valid_reward_ids.append(rid)
|
||||
|
||||
# Replace rewards with validated IDs
|
||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.REWARD_SET.value, RewardSet(id, "set")))
|
||||
return jsonify({
|
||||
'message': f'Rewards set for child {id}.',
|
||||
'reward_ids': valid_reward_ids,
|
||||
'count': len(valid_reward_ids)
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
|
||||
def remove_reward_from_child(id):
|
||||
data = request.get_json()
|
||||
@@ -298,6 +458,7 @@ def trigger_child_reward(id):
|
||||
child.points -= reward.cost
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.REWARD_UPDATE.value, RewardUpdate(reward.id, child.id, "redeemed", child.points)))
|
||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||
@@ -331,11 +492,11 @@ def reward_status(id):
|
||||
RewardQuery = Query()
|
||||
statuses = []
|
||||
for reward_id in reward_ids:
|
||||
reward = reward_db.get(RewardQuery.id == reward_id)
|
||||
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
|
||||
if not reward:
|
||||
continue
|
||||
points_needed = max(0, reward.get('cost', 0) - points)
|
||||
status = RewardStatus(reward.get('id'), reward.get('name'), points_needed, reward.get('image_id'))
|
||||
points_needed = max(0, reward.cost - points)
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, reward.image_id)
|
||||
statuses.append(status.to_dict())
|
||||
|
||||
statuses.sort(key=lambda s: s['points_needed'])
|
||||
|
||||
@@ -61,8 +61,8 @@ def upload():
|
||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||
extension = format_extension_map.get(original_format, '.png')
|
||||
|
||||
_id = str(uuid.uuid4())
|
||||
filename = _id + extension
|
||||
image_record = Image(extension=extension, permanent=perm, type=image_type)
|
||||
filename = image_record.id + extension
|
||||
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
|
||||
|
||||
try:
|
||||
@@ -76,10 +76,9 @@ def upload():
|
||||
except Exception:
|
||||
return jsonify({'error': 'Failed to save processed image'}), 500
|
||||
|
||||
image_record = Image(image_type, extension, permanent=perm, id=_id)
|
||||
image_db.insert(image_record.to_dict())
|
||||
|
||||
return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': _id}), 200
|
||||
return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': image_record.id}), 200
|
||||
|
||||
@image_api.route('/image/request/<id>', methods=['GET'])
|
||||
def request_image(id):
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.reward_created import RewardCreated
|
||||
from events.types.reward_deleted import RewardDeleted
|
||||
from events.types.reward_edited import RewardEdited
|
||||
from models.reward import Reward
|
||||
from db.db import reward_db, child_db
|
||||
|
||||
@@ -15,8 +22,11 @@ def add_reward():
|
||||
image = data.get('image_id', '')
|
||||
if not name or description is None or cost is None:
|
||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||
reward = Reward(name, description, cost, image_id=image)
|
||||
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
||||
reward_db.insert(reward.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.REWARD_CREATED.value,
|
||||
RewardCreated(reward.id, "created")))
|
||||
|
||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||
|
||||
|
||||
@@ -46,6 +56,9 @@ def delete_reward(id):
|
||||
if id in rewards:
|
||||
rewards.remove(id)
|
||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||
send_event_to_user("user123", Event(EventType.REWARD_DELETED.value,
|
||||
RewardDeleted(id, "created")))
|
||||
|
||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
@@ -87,4 +100,7 @@ def edit_reward(id):
|
||||
|
||||
reward_db.update(updates, RewardQuery.id == id)
|
||||
updated = reward_db.get(RewardQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.REWARD_EDITED.value,
|
||||
RewardEdited(id, "created")))
|
||||
|
||||
return jsonify(updated), 200
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class RewardStatus:
|
||||
def __init__(self, id, name, points_needed, image_id):
|
||||
def __init__(self, id, name, points_needed, cost, image_id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.points_needed = points_needed
|
||||
self.cost = cost
|
||||
self.image_id = image_id
|
||||
|
||||
def to_dict(self):
|
||||
@@ -10,5 +11,6 @@ class RewardStatus:
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'points_needed': self.points_needed,
|
||||
'cost': self.cost,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.task_created import TaskCreated
|
||||
from events.types.task_deleted import TaskDeleted
|
||||
from events.types.task_edited import TaskEdited
|
||||
from models.task import Task
|
||||
from db.db import task_db, child_db
|
||||
|
||||
@@ -15,8 +22,10 @@ def add_task():
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None or is_good is None:
|
||||
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
||||
task = Task(name, points, is_good, image_id=image)
|
||||
task = Task(name=name, points=points, is_good=is_good, image_id=image)
|
||||
task_db.insert(task.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.TASK_CREATED.value,
|
||||
TaskCreated(task.id, "created")))
|
||||
return jsonify({'message': f'Task {name} added.'}), 201
|
||||
|
||||
@task_api.route('/task/<id>', methods=['GET'])
|
||||
@@ -44,6 +53,9 @@ def delete_task(id):
|
||||
if id in tasks:
|
||||
tasks.remove(id)
|
||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
||||
send_event_to_user("user123", Event(EventType.TASK_DELETED.value,
|
||||
TaskDeleted(id, "deleted")))
|
||||
|
||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
@@ -85,4 +97,6 @@ def edit_task(id):
|
||||
|
||||
task_db.update(updates, TaskQuery.id == id)
|
||||
updated = task_db.get(TaskQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.TASK_EDITED.value,
|
||||
TaskEdited(id, "edited")))
|
||||
return jsonify(updated), 200
|
||||
|
||||
10
events/broadcaster.py
Normal file
10
events/broadcaster.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
class Broadcaster(Thread):
|
||||
"""Background thread sending periodic notifications."""
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
#push event to all users
|
||||
time.sleep(5) # Send every 5 seconds
|
||||
74
events/sse.py
Normal file
74
events/sse.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
import queue
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
|
||||
from flask import Response
|
||||
|
||||
from events.types.event import Event
|
||||
|
||||
# Maps user_id → dict of {connection_id: queue}
|
||||
user_queues: Dict[str, Dict[str, queue.Queue]] = {}
|
||||
|
||||
|
||||
def get_queue(user_id: str, connection_id: str) -> queue.Queue:
|
||||
"""Get or create a queue for a specific user connection."""
|
||||
if user_id not in user_queues:
|
||||
user_queues[user_id] = {}
|
||||
|
||||
if connection_id not in user_queues[user_id]:
|
||||
user_queues[user_id][connection_id] = queue.Queue()
|
||||
|
||||
return user_queues[user_id][connection_id]
|
||||
|
||||
|
||||
def send_to_user(user_id: str, data: Dict[str, Any]):
|
||||
"""Send data to all connections for a specific user."""
|
||||
if user_id in user_queues:
|
||||
# Format as SSE message once
|
||||
message = f"data: {json.dumps(data)}\n\n".encode('utf-8')
|
||||
|
||||
# Send to all connections for this user
|
||||
for connection_id, q in user_queues[user_id].items():
|
||||
try:
|
||||
q.put(message, block=False)
|
||||
except queue.Full:
|
||||
# Skip if queue is full (connection might be dead)
|
||||
pass
|
||||
|
||||
|
||||
def send_event_to_user(user_id: str, event: Event):
|
||||
"""Send an Event to all connections for a specific user."""
|
||||
send_to_user(user_id, event.to_dict())
|
||||
|
||||
|
||||
def sse_response_for_user(user_id: str):
|
||||
"""Create SSE response for a user connection."""
|
||||
# Generate unique connection ID
|
||||
connection_id = str(uuid.uuid4())
|
||||
user_queue = get_queue(user_id, connection_id)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
# Get message from queue (blocks until available)
|
||||
message = user_queue.get()
|
||||
yield message
|
||||
except GeneratorExit:
|
||||
# Clean up when client disconnects
|
||||
if user_id in user_queues and connection_id in user_queues[user_id]:
|
||||
del user_queues[user_id][connection_id]
|
||||
|
||||
# Remove user entry if no connections remain
|
||||
if not user_queues[user_id]:
|
||||
del user_queues[user_id]
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
)
|
||||
18
events/types/child_add.py
Normal file
18
events/types/child_add.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChildAdd(Payload):
|
||||
def __init__(self, child_id: str, status: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'status': status,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
18
events/types/child_delete.py
Normal file
18
events/types/child_delete.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChildDelete(Payload):
|
||||
def __init__(self, child_id: str, status: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'status': status,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
18
events/types/child_update.py
Normal file
18
events/types/child_update.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChildUpdate(Payload):
|
||||
def __init__(self, child_id: str, status: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'status': status,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
18
events/types/event.py
Normal file
18
events/types/event.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
type: str
|
||||
payload: Payload
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'type': self.type,
|
||||
'payload': self.payload.data,
|
||||
'timestamp': self.timestamp
|
||||
}
|
||||
19
events/types/event_types.py
Normal file
19
events/types/event_types.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
TASK_UPDATE = "task_update"
|
||||
TASK_SET = "task_set"
|
||||
TASK_TEST = "task_test"
|
||||
REWARD_UPDATE = "reward_update"
|
||||
REWARD_SET = "reward_set"
|
||||
CHILD_UPDATE = "child_update"
|
||||
CHILD_ADD = "child_add"
|
||||
CHILD_DELETE = "child_delete"
|
||||
TASK_CREATED = "task_created"
|
||||
TASK_DELETED = "task_deleted"
|
||||
TASK_EDITED = "task_edited"
|
||||
REWARD_CREATED = "reward_created"
|
||||
REWARD_DELETED = "reward_deleted"
|
||||
REWARD_EDITED = "reward_edited"
|
||||
# Add more event types here
|
||||
6
events/types/payload.py
Normal file
6
events/types/payload.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class Payload:
|
||||
def __init__(self, data: dict):
|
||||
self.data = data
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
return self.data.get(key, default)
|
||||
19
events/types/reward_created.py
Normal file
19
events/types/reward_created.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class RewardCreated(Payload):
|
||||
def __init__(self, reward_id: str, status: str):
|
||||
super().__init__({
|
||||
'reward_id': reward_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def reward_id(self) -> str:
|
||||
return self.get("reward_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
19
events/types/reward_deleted.py
Normal file
19
events/types/reward_deleted.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class RewardDeleted(Payload):
|
||||
def __init__(self, reward_id: str, status: str):
|
||||
super().__init__({
|
||||
'reward_id': reward_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def reward_id(self) -> str:
|
||||
return self.get("reward_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
19
events/types/reward_edited.py
Normal file
19
events/types/reward_edited.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class RewardEdited(Payload):
|
||||
def __init__(self, reward_id: str, status: str):
|
||||
super().__init__({
|
||||
'reward_id': reward_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def reward_id(self) -> str:
|
||||
return self.get("reward_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
19
events/types/reward_set.py
Normal file
19
events/types/reward_set.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class RewardSet(Payload):
|
||||
def __init__(self, child_id: str, status: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
29
events/types/reward_update.py
Normal file
29
events/types/reward_update.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class RewardUpdate(Payload):
|
||||
def __init__(self, reward_id: str, child_id: str, status: str, points: int):
|
||||
super().__init__({
|
||||
'reward_id': reward_id,
|
||||
'child_id': child_id,
|
||||
'status': status,
|
||||
'points': points
|
||||
})
|
||||
|
||||
@property
|
||||
def reward_id(self) -> str:
|
||||
return self.get("reward_id")
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
@property
|
||||
def points(self) -> int:
|
||||
return self.get("points", 0)
|
||||
|
||||
|
||||
19
events/types/task_created.py
Normal file
19
events/types/task_created.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class TaskCreated(Payload):
|
||||
def __init__(self, task_id: str, status: str):
|
||||
super().__init__({
|
||||
'task_id': task_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get("task_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
19
events/types/task_deleted.py
Normal file
19
events/types/task_deleted.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class TaskDeleted(Payload):
|
||||
def __init__(self, task_id: str, status: str):
|
||||
super().__init__({
|
||||
'task_id': task_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get("task_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
19
events/types/task_edited.py
Normal file
19
events/types/task_edited.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class TaskEdited(Payload):
|
||||
def __init__(self, task_id: str, status: str):
|
||||
super().__init__({
|
||||
'task_id': task_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get("task_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
19
events/types/task_set.py
Normal file
19
events/types/task_set.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class TaskSet(Payload):
|
||||
def __init__(self, child_id: str, status: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'status': status
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
|
||||
17
events/types/task_test.py
Normal file
17
events/types/task_test.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class TaskTest(Payload):
|
||||
def __init__(self, task_id: str, message: str):
|
||||
super().__init__({
|
||||
'task_id': task_id,
|
||||
'message': message
|
||||
})
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get("task_id")
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.get("message")
|
||||
29
events/types/task_update.py
Normal file
29
events/types/task_update.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class TaskUpdate(Payload):
|
||||
def __init__(self, task_id: str, child_id: str, status: str, points: int):
|
||||
super().__init__({
|
||||
'task_id': task_id,
|
||||
'child_id': child_id,
|
||||
'status': status,
|
||||
'points': points
|
||||
})
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get("task_id")
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get("status")
|
||||
|
||||
@property
|
||||
def points(self) -> int:
|
||||
return self.get("points", 0)
|
||||
|
||||
|
||||
38
main.py
38
main.py
@@ -1,9 +1,12 @@
|
||||
from flask import Flask
|
||||
from flask import Flask, request
|
||||
from flask_cors import CORS
|
||||
|
||||
from api.child_api import child_api
|
||||
from api.image_api import image_api
|
||||
from api.reward_api import reward_api
|
||||
from api.task_api import task_api
|
||||
from api.image_api import image_api
|
||||
from events.broadcaster import Broadcaster
|
||||
from events.sse import sse_response_for_user, send_to_user
|
||||
|
||||
app = Flask(__name__)
|
||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||
@@ -13,7 +16,36 @@ app.register_blueprint(task_api)
|
||||
app.register_blueprint(image_api)
|
||||
CORS(app)
|
||||
|
||||
@app.route("/events")
|
||||
def events():
|
||||
# Authenticate user or read a token
|
||||
user_id = request.args.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return {"error": "Missing user_id"}, 400
|
||||
|
||||
|
||||
return sse_response_for_user(user_id)
|
||||
|
||||
|
||||
@app.route("/notify/<user_id>")
|
||||
def notify_user(user_id):
|
||||
# Example trigger
|
||||
send_to_user(user_id, {
|
||||
"type": "notification",
|
||||
"message": f"Hello {user_id}, this is a private message!"
|
||||
})
|
||||
|
||||
return {"status": "sent"}
|
||||
|
||||
def start_background_threads():
|
||||
broadcaster = Broadcaster()
|
||||
broadcaster.daemon = True
|
||||
broadcaster.start()
|
||||
|
||||
# Initialize background workers on server start
|
||||
start_background_threads()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, host='0.0.0.0', port=5000)
|
||||
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
||||
@@ -1,18 +1,13 @@
|
||||
# python
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
import time
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class BaseModel:
|
||||
id: str = field(init=False)
|
||||
created_at: float = field(init=False)
|
||||
updated_at: float = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
self.created_at = now
|
||||
self.updated_at = now
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
|
||||
def touch(self):
|
||||
self.updated_at = time.time()
|
||||
@@ -23,10 +18,3 @@ class BaseModel:
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _apply_base_fields(cls, obj, d: dict):
|
||||
obj.id = d.get('id', obj.id)
|
||||
obj.created_at = d.get('created_at', obj.created_at)
|
||||
obj.updated_at = d.get('updated_at', obj.updated_at)
|
||||
return obj
|
||||
|
||||
@@ -12,15 +12,18 @@ class Child(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
obj = cls(
|
||||
return cls(
|
||||
name=d.get('name'),
|
||||
age=d.get('age'),
|
||||
tasks=d.get('tasks', []),
|
||||
rewards=d.get('rewards', []),
|
||||
points=d.get('points', 0),
|
||||
image_id=d.get('image_id')
|
||||
image_id=d.get('image_id'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
|
||||
)
|
||||
return cls._apply_base_fields(obj, d)
|
||||
|
||||
def to_dict(self):
|
||||
base = super().to_dict()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# python
|
||||
from dataclasses import dataclass
|
||||
from models.base import BaseModel
|
||||
|
||||
@@ -9,12 +10,15 @@ class Image(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
obj = cls(
|
||||
# Supports overriding base fields (id, created_at, updated_at) if present
|
||||
return cls(
|
||||
type=d.get('type'),
|
||||
extension=d.get('extension'),
|
||||
permanent=d.get('permanent', False)
|
||||
permanent=d.get('permanent', False),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
)
|
||||
return cls._apply_base_fields(obj, d)
|
||||
|
||||
def to_dict(self):
|
||||
base = super().to_dict()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# python
|
||||
from dataclasses import dataclass
|
||||
from models.base import BaseModel
|
||||
|
||||
@@ -10,13 +11,16 @@ class Reward(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
obj = cls(
|
||||
# Base fields are keyword-only; can be overridden if provided
|
||||
return cls(
|
||||
name=d.get('name'),
|
||||
description=d.get('description'),
|
||||
cost=d.get('cost', 0),
|
||||
image_id=d.get('image_id')
|
||||
image_id=d.get('image_id'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
)
|
||||
return cls._apply_base_fields(obj, d)
|
||||
|
||||
def to_dict(self):
|
||||
base = super().to_dict()
|
||||
|
||||
@@ -10,13 +10,15 @@ class Task(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
obj = cls(
|
||||
return cls(
|
||||
name=d.get('name'),
|
||||
points=d.get('points', 0),
|
||||
is_good=d.get('is_good', True),
|
||||
image_id=d.get('image_id')
|
||||
image_id=d.get('image_id'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
)
|
||||
return cls._apply_base_fields(obj, d)
|
||||
|
||||
def to_dict(self):
|
||||
base = super().to_dict()
|
||||
|
||||
@@ -125,9 +125,9 @@ def test_list_child_tasks_returns_tasks(client):
|
||||
resp = client.get('/child/child_list_1/list-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
returned_ids = {t['id'] for t in data['child_tasks']}
|
||||
returned_ids = {t['id'] for t in data['tasks']}
|
||||
assert returned_ids == {'t_list_1', 't_list_2'}
|
||||
for t in data['child_tasks']:
|
||||
for t in data['tasks']:
|
||||
assert 'name' in t and 'points' in t and 'is_good' in t
|
||||
|
||||
def test_list_assignable_tasks_returns_expected_ids(client):
|
||||
@@ -143,8 +143,8 @@ def test_list_assignable_tasks_returns_expected_ids(client):
|
||||
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data['assignable_tasks']) == 1
|
||||
assert data['assignable_tasks'][0]['id'] == 'tB'
|
||||
assert len(data['tasks']) == 1
|
||||
assert data['tasks'][0]['id'] == 'tB'
|
||||
assert data['count'] == 1
|
||||
|
||||
def test_list_assignable_tasks_when_none_assigned(client):
|
||||
@@ -158,7 +158,7 @@ def test_list_assignable_tasks_when_none_assigned(client):
|
||||
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
returned_ids = {t['id'] for t in data['assignable_tasks']}
|
||||
returned_ids = {t['id'] for t in data['tasks']}
|
||||
assert returned_ids == set(ids)
|
||||
assert data['count'] == len(ids)
|
||||
|
||||
@@ -170,10 +170,60 @@ def test_list_assignable_tasks_empty_task_db(client):
|
||||
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['assignable_tasks'] == []
|
||||
assert data['tasks'] == []
|
||||
assert data['count'] == 0
|
||||
|
||||
def test_list_assignable_tasks_child_not_found(client):
|
||||
resp = client.get('/child/does-not-exist/list-assignable-tasks')
|
||||
assert resp.status_code == 404
|
||||
assert b'Child not found' in resp.data
|
||||
|
||||
def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None):
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
assigned = assigned or []
|
||||
# Seed tasks
|
||||
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True})
|
||||
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False})
|
||||
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True})
|
||||
# Seed child
|
||||
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
|
||||
child['tasks'] = assigned[:]
|
||||
child_db.insert(child)
|
||||
return child['id']
|
||||
|
||||
def test_list_all_tasks_partitions_assigned_and_assignable(client):
|
||||
child_id = setup_child_with_tasks(assigned=['t1', 't3'])
|
||||
resp = client.get(f'/child/{child_id}/list-all-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assigned_ids = {t['id'] for t in data['assigned_tasks']}
|
||||
assignable_ids = {t['id'] for t in data['assignable_tasks']}
|
||||
assert assigned_ids == {'t1', 't3'}
|
||||
assert assignable_ids == {'t2'}
|
||||
assert data['assigned_count'] == 2
|
||||
assert data['assignable_count'] == 1
|
||||
|
||||
def test_set_child_tasks_replaces_existing(client):
|
||||
child_id = setup_child_with_tasks(assigned=['t1', 't2'])
|
||||
# Provide new set including a valid and an invalid id (invalid should be filtered)
|
||||
payload = {'task_ids': ['t3', 'missing', 't3']}
|
||||
resp = client.put(f'/child/{child_id}/set-tasks', json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['task_ids'] == ['t3']
|
||||
assert data['count'] == 1
|
||||
ChildQuery = Query()
|
||||
child = child_db.get(ChildQuery.id == child_id)
|
||||
assert child['tasks'] == ['t3']
|
||||
|
||||
def test_set_child_tasks_requires_list(client):
|
||||
child_id = setup_child_with_tasks(assigned=['t2'])
|
||||
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list'})
|
||||
assert resp.status_code == 400
|
||||
assert b'task_ids must be a list' in resp.data
|
||||
|
||||
def test_set_child_tasks_child_not_found(client):
|
||||
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2']})
|
||||
assert resp.status_code == 404
|
||||
assert b'Child not found' in resp.data
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { useBackendEvents } from './common/backendEvents'
|
||||
useBackendEvents('user123')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
24
web/vue-app/src/common/backendEvents.ts
Normal file
24
web/vue-app/src/common/backendEvents.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import { eventBus } from './eventBus'
|
||||
|
||||
export function useBackendEvents(userId: string) {
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Connecting to backend events for user:', userId)
|
||||
eventSource = new EventSource(`/events?user_id=${userId}`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// Emit globally for any component that cares
|
||||
eventBus.emit(data.type, data)
|
||||
eventBus.emit('sse', data) // optional: catch-all channel
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
console.log('Disconnecting from backend events for user:', userId)
|
||||
eventSource?.close()
|
||||
})
|
||||
}
|
||||
29
web/vue-app/src/common/eventBus.ts
Normal file
29
web/vue-app/src/common/eventBus.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type Callback = (payload: any) => void
|
||||
|
||||
class EventBus {
|
||||
private listeners: Map<string, Callback[]> = new Map()
|
||||
|
||||
on(event: string, callback: Callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, [])
|
||||
}
|
||||
this.listeners.get(event)!.push(callback)
|
||||
}
|
||||
|
||||
off(event: string, callback: Callback) {
|
||||
const list = this.listeners.get(event)
|
||||
if (!list) return
|
||||
|
||||
const index = list.indexOf(callback)
|
||||
if (index !== -1) list.splice(index, 1)
|
||||
}
|
||||
|
||||
emit(event: string, payload?: any) {
|
||||
const list = this.listeners.get(event)
|
||||
if (!list) return
|
||||
|
||||
list.forEach((callback) => callback(payload))
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus()
|
||||
120
web/vue-app/src/common/models.ts
Normal file
120
web/vue-app/src/common/models.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export interface Task {
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
|
||||
export interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
|
||||
export interface Reward {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
points_needed: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
|
||||
export interface RewardStatus {
|
||||
id: string
|
||||
name: string
|
||||
points_needed: number
|
||||
cost: number
|
||||
image_id: string | null
|
||||
image_url?: string | null // optional, for resolved URLs
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
type: string
|
||||
payload:
|
||||
| TaskUpdateEventPayload
|
||||
| RewardUpdateEventPayload
|
||||
| ChildUpdateEventPayload
|
||||
| ChildDeleteEventPayload
|
||||
| TaskCreatedEventPayload
|
||||
| TaskDeletedEventPayload
|
||||
| TaskEditedEventPayload
|
||||
| RewardCreatedEventPayload
|
||||
| RewardDeletedEventPayload
|
||||
| RewardEditedEventPayload
|
||||
| RewardSetEventPayload
|
||||
| TaskSetEventPayload
|
||||
| ChildAddEventPayload
|
||||
}
|
||||
|
||||
export interface TaskUpdateEventPayload {
|
||||
task_id: string
|
||||
child_id: string
|
||||
status: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface TaskSetEventPayload {
|
||||
child_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface RewardUpdateEventPayload {
|
||||
reward_id: string
|
||||
child_id: string
|
||||
status: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface RewardSetEventPayload {
|
||||
child_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ChildAddEventPayload {
|
||||
child_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ChildUpdateEventPayload {
|
||||
child_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ChildDeleteEventPayload {
|
||||
child_id: string
|
||||
status: string
|
||||
}
|
||||
export interface TaskCreatedEventPayload {
|
||||
task_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface TaskDeletedEventPayload {
|
||||
task_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface TaskEditedEventPayload {
|
||||
task_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface RewardCreatedEventPayload {
|
||||
reward_id: string
|
||||
}
|
||||
|
||||
export interface RewardDeletedEventPayload {
|
||||
reward_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface RewardEditedEventPayload {
|
||||
reward_id: string
|
||||
status: string
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||
import { isParentAuthenticated } from '../stores/auth'
|
||||
import ChildForm from './child/ChildForm.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
}
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type { Child, Event } from '@/common/models'
|
||||
|
||||
const router = useRouter()
|
||||
const children = ref<Child[]>([])
|
||||
@@ -27,19 +20,13 @@ const confirmDeleteVisible = ref(false)
|
||||
const deletingChildId = ref<string | number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const showEditDialog = ref(false)
|
||||
const editingChild = ref<Child | null>(null)
|
||||
|
||||
const openEditDialog = (child: Child, evt?: Event) => {
|
||||
const openChildEditor = (child: Child, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
editingChild.value = { ...child } // shallow copy for editing
|
||||
showEditDialog.value = true
|
||||
closeMenu()
|
||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||
}
|
||||
|
||||
const closeEditDialog = () => {
|
||||
showEditDialog.value = false
|
||||
editingChild.value = null
|
||||
function handleServerChange(event: Event) {
|
||||
fetchChildren()
|
||||
}
|
||||
|
||||
// points update state
|
||||
@@ -85,12 +72,28 @@ const fetchChildren = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const createChild = () => {
|
||||
router.push({ name: 'CreateChild' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_update', handleServerChange)
|
||||
eventBus.on('task_update', handleServerChange)
|
||||
eventBus.on('reward_update', handleServerChange)
|
||||
eventBus.on('child_delete', handleServerChange)
|
||||
|
||||
await fetchChildren()
|
||||
// listen for outside clicks to auto-close any open kebab menu
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventBus.off('child_update', handleServerChange)
|
||||
eventBus.off('task_update', handleServerChange)
|
||||
eventBus.off('reward_update', handleServerChange)
|
||||
eventBus.off('child_delete', handleServerChange)
|
||||
})
|
||||
|
||||
const shouldIgnoreNextCardClick = ref(false)
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
@@ -194,11 +197,6 @@ const deletePoints = async (childId: string | number, evt?: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const gridColumns = computed(() => {
|
||||
const n = Math.min(children.value.length, 3)
|
||||
return `repeat(${n || 1}, minmax(var(--card-width, 289px), 1fr))`
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
revokeAllImageUrls()
|
||||
@@ -238,7 +236,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="openEditDialog(child, $event)"
|
||||
@click="openChildEditor(child, $event)"
|
||||
>
|
||||
Edit Child
|
||||
</button>
|
||||
@@ -269,18 +267,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChildForm
|
||||
v-if="showEditDialog"
|
||||
:child="editingChild"
|
||||
@close="closeEditDialog"
|
||||
@updated="
|
||||
async () => {
|
||||
closeEditDialog()
|
||||
await fetchChildren()
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- confirmation modal -->
|
||||
<div
|
||||
v-if="confirmDeleteVisible"
|
||||
@@ -308,6 +294,14 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -525,4 +519,32 @@ h1 {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: #667eea;
|
||||
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: #5a67d8;
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
background: #4c51bf;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,83 +41,98 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="child" class="detail-card">
|
||||
<h1>{{ child.name }}</h1>
|
||||
<div v-if="child" class="detail-card-horizontal">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
|
||||
<div class="info">
|
||||
<div class="info-item">
|
||||
<span class="label">Age:</span>
|
||||
<span class="value">{{ child.age }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Points:</span>
|
||||
<span class="value">{{ child.points ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="main-info">
|
||||
<div class="child-name">{{ child.name }}</div>
|
||||
<div class="child-age">Age: {{ child.age }}</div>
|
||||
</div>
|
||||
<div class="points">
|
||||
<span class="label">Points</span>
|
||||
<span class="value">{{ child.points ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-card {
|
||||
.detail-card-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
padding: 1.2rem 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.13);
|
||||
padding: 0.7rem 1rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.detail-card h1 {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 0.7rem auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
.main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
.child-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.child-age {
|
||||
font-size: 0.97rem;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 7px;
|
||||
font-size: 0.98rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 54px;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
.points .label {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.points .value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* Even more compact on small screens */
|
||||
@media (max-width: 480px) {
|
||||
.detail-card {
|
||||
padding: 0.7rem 0.4rem;
|
||||
.detail-card-horizontal {
|
||||
padding: 0.5rem 0.4rem;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.detail-card h1 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.7rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.info-item {
|
||||
padding: 0.38rem 0.5rem;
|
||||
font-size: 0.93rem;
|
||||
.points {
|
||||
min-width: 38px;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
247
web/vue-app/src/components/child/ChildEditView.vue
Normal file
247
web/vue-app/src/components/child/ChildEditView.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="child-edit-view">
|
||||
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading child...</div>
|
||||
<form v-else @submit.prevent="submit" class="form">
|
||||
<div class="form-group">
|
||||
<label for="child-name">Name</label>
|
||||
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="child-age">Age</label>
|
||||
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
|
||||
</div>
|
||||
<div class="form-group image-picker-group">
|
||||
<label for="child-image">Image</label>
|
||||
<ImagePicker
|
||||
id="child-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn cancel" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn save" :disabled="loading">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ImagePicker from '../ImagePicker.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Accept id as a prop for edit mode
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const isEdit = computed(() => !!props.id)
|
||||
const name = ref('')
|
||||
const age = ref<number | null>(null)
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.id}`)
|
||||
if (!resp.ok) throw new Error('Failed to load child')
|
||||
const data = await resp.json()
|
||||
name.value = data.name ?? ''
|
||||
age.value = Number(data.age) ?? null
|
||||
selectedImageId.value = data.image_id ?? null
|
||||
} catch (e) {
|
||||
error.value = 'Could not load child.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
} else {
|
||||
await nextTick()
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Child name is required.'
|
||||
return
|
||||
}
|
||||
if (age.value === null || age.value < 0) {
|
||||
error.value = 'Age must be a non-negative number.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '1')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
alert('Failed to upload image.')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update or create the child
|
||||
try {
|
||||
let resp
|
||||
if (isEdit.value && props.id) {
|
||||
resp = await fetch(`/api/child/${props.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
resp = await fetch('/api/child/add', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to save child')
|
||||
await router.push({ name: 'ChildrenListView' })
|
||||
} catch (err) {
|
||||
alert('Failed to save child.')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.child-edit-view {
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px #667eea22;
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1.15rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
.form-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
font-size: 1rem;
|
||||
background: #fafbff;
|
||||
color: #222;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border: 1.5px solid #667eea;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.save {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
.btn.save:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
.form-group.image-picker-group {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
.error {
|
||||
color: #e53e3e;
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Child,
|
||||
Event,
|
||||
Task,
|
||||
Reward,
|
||||
TaskUpdateEventPayload,
|
||||
RewardUpdateEventPayload,
|
||||
ChildUpdateEventPayload,
|
||||
ChildDeleteEventPayload,
|
||||
TaskCreatedEventPayload,
|
||||
TaskDeletedEventPayload,
|
||||
TaskEditedEventPayload,
|
||||
RewardCreatedEventPayload,
|
||||
RewardDeletedEventPayload,
|
||||
RewardEditedEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
function handlePointsUpdate(event: Event) {
|
||||
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerChange(event: Event) {
|
||||
const payload = event.payload as
|
||||
| TaskUpdateEventPayload
|
||||
| RewardUpdateEventPayload
|
||||
| ChildUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildDeletion(event: Event) {
|
||||
const payload = event.payload as ChildDeleteEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskChanged(event: Event) {
|
||||
const payload = event.payload as
|
||||
| TaskCreatedEventPayload
|
||||
| TaskDeletedEventPayload
|
||||
| TaskEditedEventPayload
|
||||
if (child.value) {
|
||||
const task_id = payload.task_id
|
||||
if (tasks.value.includes(task_id)) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardChanged(event: Event) {
|
||||
const payload = event.payload as
|
||||
| RewardCreatedEventPayload
|
||||
| RewardDeletedEventPayload
|
||||
| RewardEditedEventPayload
|
||||
if (child.value) {
|
||||
const reward_id = payload.reward_id
|
||||
if (rewards.value.includes(reward_id)) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerTask = (task: Task) => {
|
||||
if ('speechSynthesis' in window && task.name) {
|
||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
console.log('Handle trigger reward:', reward, redeemable)
|
||||
const utterString =
|
||||
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.id}`)
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
child.value = data.children ? data.children : data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
error.value = null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('task_update', handlePointsUpdate)
|
||||
eventBus.on('reward_update', handlePointsUpdate)
|
||||
eventBus.on('task_set', handleServerChange)
|
||||
eventBus.on('reward_set', handleServerChange)
|
||||
eventBus.on('child_update', handleServerChange)
|
||||
eventBus.on('child_delete', handleChildDeletion)
|
||||
eventBus.on('task_created', handleTaskChanged)
|
||||
eventBus.on('task_deleted', handleTaskChanged)
|
||||
eventBus.on('task_edited', handleTaskChanged)
|
||||
eventBus.on('reward_created', handleRewardChanged)
|
||||
eventBus.on('reward_deleted', handleRewardChanged)
|
||||
eventBus.on('reward_edited', handleRewardChanged)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
fetchChildData(idParam)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('task_update', handlePointsUpdate)
|
||||
eventBus.off('reward_update', handlePointsUpdate)
|
||||
eventBus.off('task_set', handleServerChange)
|
||||
eventBus.off('reward_set', handleServerChange)
|
||||
eventBus.off('child_update', handleServerChange)
|
||||
eventBus.off('child_delete', handleChildDeletion)
|
||||
eventBus.off('task_created', handleTaskChanged)
|
||||
eventBus.off('task_deleted', handleTaskChanged)
|
||||
eventBus.off('task_edited', handleTaskChanged)
|
||||
eventBus.off('reward_created', handleRewardChanged)
|
||||
eventBus.off('reward_deleted', handleRewardChanged)
|
||||
eventBus.off('reward_edited', handleRewardChanged)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -44,26 +168,33 @@ onMounted(async () => {
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
title="Chores"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
:filter-type="1"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildTaskList
|
||||
title="Bad Habits"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
:filter-type="2"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildRewardList
|
||||
:child-id="child ? child.id : null"
|
||||
:child-points="child?.points ?? 0"
|
||||
:is-parent-authenticated="false"
|
||||
@trigger-reward="handleTriggerReward"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- removed placeholder -->
|
||||
</div>
|
||||
<!-- Remove this aside block:
|
||||
<aside class="side">
|
||||
<div class="placeholder">Additional components go here</div>
|
||||
</aside>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,7 +205,7 @@ onMounted(async () => {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
padding: 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
import AssignTaskButton from '../AssignTaskButton.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Task,
|
||||
Child,
|
||||
Event,
|
||||
Reward,
|
||||
TaskUpdateEventPayload,
|
||||
RewardUpdateEventPayload,
|
||||
ChildUpdateEventPayload,
|
||||
ChildDeleteEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const rewardListRef = ref()
|
||||
const showConfirm = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const showRewardConfirm = ref(false)
|
||||
const selectedReward = ref<Reward | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
function handlePointsUpdate(event: Event) {
|
||||
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerChange(event: Event) {
|
||||
const payload = event.payload as
|
||||
| TaskUpdateEventPayload
|
||||
| RewardUpdateEventPayload
|
||||
| ChildUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildDeletion(event: Event) {
|
||||
const payload = event.payload as ChildDeleteEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.id}`)
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
child.value = data.children ? data.children : data
|
||||
tasks.value = data.tasks || []
|
||||
error.value = null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('task_update', handlePointsUpdate)
|
||||
eventBus.on('reward_update', handlePointsUpdate)
|
||||
eventBus.on('task_set', handleServerChange)
|
||||
eventBus.on('reward_set', handleServerChange)
|
||||
eventBus.on('child_update', handleServerChange)
|
||||
eventBus.on('child_delete', handleChildDeletion)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
fetchChildData(idParam)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('task_update', handlePointsUpdate)
|
||||
eventBus.off('reward_update', handlePointsUpdate)
|
||||
eventBus.off('task_set', handleServerChange)
|
||||
eventBus.off('reward_set', handleServerChange)
|
||||
eventBus.off('child_update', handleServerChange)
|
||||
eventBus.off('child_delete', handleChildDeletion)
|
||||
})
|
||||
|
||||
const refreshRewards = () => {
|
||||
rewardListRef.value?.refresh()
|
||||
}
|
||||
|
||||
const handleTriggerTask = (task: Task) => {
|
||||
selectedTask.value = task
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmTriggerTask = async () => {
|
||||
if (!child.value?.id || !selectedTask.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: selectedTask.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
console.log('Trigger task response data:', child.value.id, data.id)
|
||||
if (child.value && child.value.id === data.id) child.value.points = data.points
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger task:', err)
|
||||
} finally {
|
||||
showConfirm.value = false
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
|
||||
console.log('Handle trigger reward:', reward, redeemable)
|
||||
if (!redeemable) return
|
||||
selectedReward.value = reward
|
||||
showRewardConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmTriggerReward = async () => {
|
||||
if (!child.value?.id || !selectedReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: selectedReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
if (child.value && child.value.id === data.id) child.value.points = data.points
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger reward:', err)
|
||||
} finally {
|
||||
showRewardConfirm.value = false
|
||||
selectedReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignTasks() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignBadHabits() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToAssignRewards() {
|
||||
if (child.value?.id) {
|
||||
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
|
||||
if (child.value && child.value.id === id) child.value.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
const handleRewardPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
|
||||
if (child.value && child.value.id === id) child.value.points = points
|
||||
}
|
||||
|
||||
const childId = computed(() => child.value?.id ?? null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,31 +194,118 @@ const refreshRewards = () => {
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
title="Chores"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:child-id="childId"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
:filter-type="1"
|
||||
@points-updated="handleTaskPointsUpdated"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildTaskList
|
||||
title="Bad Habits"
|
||||
:task-ids="tasks"
|
||||
:child-id="childId"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
:filter-type="2"
|
||||
@points-updated="handleTaskPointsUpdated"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildRewardList
|
||||
ref="rewardListRef"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
:child-id="childId"
|
||||
:child-points="child?.points ?? 0"
|
||||
:is-parent-authenticated="false"
|
||||
@points-updated="handleRewardPointsUpdated"
|
||||
@trigger-reward="handleTriggerReward"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Place the AssignTaskButton here, outside .main but inside .container -->
|
||||
<AssignTaskButton :child-id="child ? child.id : null" />
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="assign-task-btn" @click="goToAssignTasks">Assign Tasks</button>
|
||||
<button v-if="child" class="assign-bad-btn" @click="goToAssignBadHabits">
|
||||
Assign Bad Habits
|
||||
</button>
|
||||
<button v-if="child" class="assign-reward-btn" @click="goToAssignRewards">
|
||||
Assign Rewards
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showConfirm && selectedTask" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="task-info">
|
||||
<img
|
||||
v-if="selectedTask.image_url"
|
||||
:src="selectedTask.image_url"
|
||||
alt="Task Image"
|
||||
class="task-image"
|
||||
/>
|
||||
<div class="task-details">
|
||||
<div class="task-name">{{ selectedTask.name }}</div>
|
||||
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
|
||||
{{ selectedTask.points }} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
|
||||
{{ selectedTask.is_good ? 'to' : 'from' }}
|
||||
<span class="child-name">{{ child?.name }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmTriggerTask">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showConfirm = false
|
||||
selectedTask = null
|
||||
}
|
||||
"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<div class="reward-info">
|
||||
<img
|
||||
v-if="selectedReward.image_id"
|
||||
:src="selectedReward.image_id"
|
||||
alt="Reward Image"
|
||||
class="reward-image"
|
||||
/>
|
||||
<div class="reward-details">
|
||||
<div class="reward-name">{{ selectedReward.name }}</div>
|
||||
<div class="reward-points">
|
||||
{{
|
||||
selectedReward.points_needed === 0
|
||||
? 'Reward Ready!'
|
||||
: selectedReward.points_needed + ' pts needed'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||
Redeem this reward for <span class="child-name">{{ child?.name }}</span
|
||||
>?
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="confirmTriggerReward">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showRewardConfirm = false
|
||||
selectedReward = null
|
||||
}
|
||||
"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -89,16 +319,6 @@ const refreshRewards = () => {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.back-btn {
|
||||
background: white;
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
color: white;
|
||||
@@ -118,9 +338,6 @@ const refreshRewards = () => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
/* Remove grid styles */
|
||||
/* grid-template-columns: 1fr 320px; */
|
||||
/* gap: 1.5rem; */
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
@@ -130,22 +347,84 @@ const refreshRewards = () => {
|
||||
width: 100%;
|
||||
max-width: 600px; /* or whatever width fits your content best */
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
min-height: 120px;
|
||||
|
||||
/* Modal styles */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 12px;
|
||||
min-width: 240px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
.task-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.task-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #eee;
|
||||
}
|
||||
.task-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.task-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.task-points,
|
||||
.task-points.good,
|
||||
.task-points.bad {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.task-points.good {
|
||||
color: #38c172;
|
||||
}
|
||||
.task-points.bad {
|
||||
color: #ef4444;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 1.2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions button:first-child {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
.actions button:last-child {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.actions button:last-child:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 900px) {
|
||||
@@ -158,17 +437,83 @@ const refreshRewards = () => {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.main {
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
padding: 0.75rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 1.08rem;
|
||||
color: #444;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dialog-message .child-name {
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.reward-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.reward-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #eee;
|
||||
}
|
||||
.reward-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.reward-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.reward-points {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.assign-task-btn,
|
||||
.assign-bad-btn,
|
||||
.assign-reward-btn {
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
|
||||
transition: background 0.18s;
|
||||
color: #fff;
|
||||
background: #667eea;
|
||||
}
|
||||
.assign-task-btn:hover,
|
||||
.assign-bad-btn:hover,
|
||||
.assign-reward-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
.assign-bad-btn {
|
||||
background: #ef4444;
|
||||
}
|
||||
.assign-bad-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.assign-reward-btn {
|
||||
background: #38c172;
|
||||
}
|
||||
.assign-reward-btn:hover {
|
||||
background: #2f855a;
|
||||
}
|
||||
</style>
|
||||
|
||||
94
web/vue-app/src/components/child/RewardAssignView.vue
Normal file
94
web/vue-app/src/components/child/RewardAssignView.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="reward-assign-view">
|
||||
<h2>Assign Rewards</h2>
|
||||
<div class="reward-list-scroll">
|
||||
<RewardList ref="rewardListRef" :child-id="childId" :selectable="true" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn cancel" @click="onCancel">Cancel</button>
|
||||
<button class="btn submit" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import RewardList from '../reward/RewardList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const childId = route.params.id
|
||||
|
||||
const rewardListRef = ref()
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = rewardListRef.value?.selectedRewards ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update rewards')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
alert('Failed to update rewards.')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reward-assign-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.reward-list-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.submit {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.submit:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
</style>
|
||||
105
web/vue-app/src/components/child/TaskAssignView.vue
Normal file
105
web/vue-app/src/components/child/TaskAssignView.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="task-assign-view">
|
||||
<h2>Assign Tasks</h2>
|
||||
<div class="task-list-scroll">
|
||||
<TaskList
|
||||
ref="taskListRef"
|
||||
:child-id="childId"
|
||||
:selectable="true"
|
||||
:type-filter="typeFilter"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn cancel" @click="onCancel">Cancel</button>
|
||||
<button class="btn submit" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import TaskList from '../task/TaskList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const childId = route.params.id
|
||||
|
||||
const taskListRef = ref()
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (route.params.type === 'good') return 'good'
|
||||
if (route.params.type === 'bad') return 'bad'
|
||||
return 'all'
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = taskListRef.value?.selectedTasks ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_ids: selectedIds }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update tasks')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
alert('Failed to update tasks.')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-assign-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.task-list-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.submit {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.submit:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
</style>
|
||||
@@ -2,23 +2,18 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { defineProps, defineEmits, defineExpose } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
|
||||
interface Reward {
|
||||
id: string
|
||||
name: string
|
||||
points_needed: number
|
||||
image_id: string | null
|
||||
}
|
||||
import type { RewardStatus } from '@/common/models'
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
childId: string | number | null
|
||||
childPoints: number
|
||||
isParentAuthenticated: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
const emit = defineEmits(['points-updated', 'trigger-reward'])
|
||||
|
||||
const rewards = ref<Reward[]>([])
|
||||
const rewards = ref<RewardStatus[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -53,7 +48,7 @@ const fetchRewards = async (id: string | number | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (reward: Reward) => {
|
||||
const fetchImage = async (reward: RewardStatus) => {
|
||||
if (!reward.image_id) {
|
||||
console.log(`No image ID for reward: ${reward.id}`)
|
||||
return
|
||||
@@ -86,8 +81,6 @@ const centerReward = async (rewardId: string) => {
|
||||
}
|
||||
|
||||
const handleRewardClick = async (rewardId: string) => {
|
||||
if (!props.isParentAuthenticated) return // Only allow if logged in
|
||||
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
@@ -111,24 +104,10 @@ const handleRewardClick = async (rewardId: string) => {
|
||||
readyRewardId.value = null
|
||||
}
|
||||
|
||||
const triggerReward = async (rewardId: string) => {
|
||||
if (!props.childId) return
|
||||
const triggerReward = (rewardId: string) => {
|
||||
const reward = rewards.value.find((rew) => rew.id === rewardId)
|
||||
if (!reward || reward.points_needed > 0) return // Don't trigger if not allowed
|
||||
if (!props.isParentAuthenticated) return // Only allow if logged in
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/trigger-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: rewardId }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
// Emit the new points so the parent can update the child points
|
||||
emit('points-updated', { id: props.childId, points: data.points })
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger reward:', err)
|
||||
}
|
||||
if (!reward) return // Don't trigger if not allowed
|
||||
emit('trigger-reward', reward, reward.points_needed <= 0)
|
||||
}
|
||||
|
||||
onMounted(() => fetchRewards(props.childId))
|
||||
@@ -136,6 +115,18 @@ watch(
|
||||
() => props.childId,
|
||||
(v) => fetchRewards(v),
|
||||
)
|
||||
watch(
|
||||
() => props.childPoints,
|
||||
() => {
|
||||
// Option 1: If reward eligibility depends on points, recompute eligibility here
|
||||
// Option 2: If you need to refetch from the backend, call fetchRewards(props.childId)
|
||||
// For most cases, just recompute eligibility locally
|
||||
// Example:
|
||||
rewards.value.forEach((reward) => {
|
||||
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
@@ -162,7 +153,6 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
|
||||
class="reward-card"
|
||||
:class="{
|
||||
ready: readyRewardId === r.id,
|
||||
disabled: r.points_needed > 0,
|
||||
}"
|
||||
:ref="(el) => (rewardRefs[r.id] = el)"
|
||||
@click="() => handleRewardClick(r.id)"
|
||||
@@ -191,7 +181,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
|
||||
}
|
||||
|
||||
.reward-list-container h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
childId?: string | number
|
||||
assignable?: boolean
|
||||
assignFilter?: 'assignable' | 'assigned' | 'none'
|
||||
deletable?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['edit-reward', 'delete-reward'])
|
||||
|
||||
@@ -21,49 +23,93 @@ const rewards = ref<
|
||||
|
||||
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) {
|
||||
if (props.assignable) {
|
||||
url = `/api/child/${props.childId}/list-assignable-rewards`
|
||||
} else {
|
||||
url = `/api/child/${props.childId}/list-rewards`
|
||||
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: any) => ({
|
||||
...reward,
|
||||
assigned: true,
|
||||
}))
|
||||
const assignable = (data.assignable_rewards || []).map((reward: any) => ({
|
||||
...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: any) => String(reward.id))
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
rewards.value = []
|
||||
if (props.selectable) selectedRewards.value = []
|
||||
} finally {
|
||||
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 || []
|
||||
// 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
|
||||
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
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
rewards.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}),
|
||||
)
|
||||
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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchRewards)
|
||||
watch(() => [props.childId, props.assignable], fetchRewards)
|
||||
watch(() => [props.childId, props.assignFilter], fetchRewards)
|
||||
|
||||
const handleEdit = (rewardId: string) => {
|
||||
emit('edit-reward', rewardId)
|
||||
@@ -73,11 +119,10 @@ const handleDelete = (rewardId: string) => {
|
||||
emit('delete-reward', rewardId)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetchRewards })
|
||||
defineExpose({ refresh: fetchRewards, selectedRewards })
|
||||
|
||||
const ITEM_HEIGHT = 52 // px, adjust to match your .reward-list-item + margin
|
||||
const listHeight = computed(() => {
|
||||
// Add a little for padding, separators, etc.
|
||||
const n = rewards.value.length
|
||||
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
|
||||
})
|
||||
@@ -94,6 +139,14 @@ const listHeight = computed(() => {
|
||||
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
|
||||
<span class="reward-name">{{ reward.name }}</span>
|
||||
<span class="reward-cost"> {{ reward.cost }} pts </span>
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="reward-checkbox"
|
||||
v-model="selectedRewards"
|
||||
:value="reward.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@@ -121,8 +174,9 @@ const listHeight = computed(() => {
|
||||
<style scoped>
|
||||
.reward-listbox {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: 480px;
|
||||
/* Subtract any header/nav height if needed, e.g. 4.5rem */
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
overflow-y: auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
@@ -209,4 +263,11 @@ const listHeight = computed(() => {
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
.reward-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: #667eea;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id: string | null // Ensure image can be null or hold an object URL
|
||||
}
|
||||
import type { Task } from '@/common/models'
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
taskIds: string[]
|
||||
childId: string | number | null
|
||||
isParentAuthenticated: boolean
|
||||
filterType?: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'points-updated', payload: { id: string; points: number }): void
|
||||
(e: 'trigger-task', task: Task): void
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -27,7 +25,6 @@ const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const taskRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const lastCenteredTaskId = ref<string | null>(null)
|
||||
const lastCenterTime = ref<number>(0)
|
||||
const readyTaskId = ref<string | null>(null)
|
||||
|
||||
const fetchTasks = async () => {
|
||||
@@ -60,7 +57,7 @@ const fetchImage = async (task: Task) => {
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(task.image_id, imageCacheName)
|
||||
task.image_id = url
|
||||
task.image_url = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for task', task.id, err)
|
||||
}
|
||||
@@ -84,20 +81,9 @@ const centerTask = async (taskId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = async (taskId: string) => {
|
||||
if (!props.isParentAuthenticated || !props.childId) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
emit('points-updated', { id: data.id, points: data.points })
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger task:', err)
|
||||
}
|
||||
const triggerTask = (taskId: string) => {
|
||||
const task = tasks.value.find((t) => t.id === taskId)
|
||||
if (task) emit('trigger-task', task)
|
||||
}
|
||||
|
||||
const handleTaskClick = async (taskId: string) => {
|
||||
@@ -115,16 +101,24 @@ const handleTaskClick = async (taskId: string) => {
|
||||
// Center the task, but don't trigger
|
||||
await centerTask(taskId)
|
||||
lastCenteredTaskId.value = taskId
|
||||
lastCenterTime.value = Date.now()
|
||||
readyTaskId.value = taskId // <-- Add this line
|
||||
readyTaskId.value = taskId
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the task
|
||||
// If already centered and visible, emit to parent
|
||||
triggerTask(taskId)
|
||||
readyTaskId.value = null
|
||||
}
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (props.filterType == 1) {
|
||||
return tasks.value.filter((t) => t.is_good)
|
||||
} else if (props.filterType == 2) {
|
||||
return tasks.value.filter((t) => !t.is_good)
|
||||
}
|
||||
return tasks.value
|
||||
})
|
||||
|
||||
onMounted(fetchTasks)
|
||||
|
||||
// revoke all created object URLs when component unmounts
|
||||
@@ -135,16 +129,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="task-list-container">
|
||||
<h3>Tasks</h3>
|
||||
<h3>{{ title }}</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="tasks.length === 0" class="empty">No tasks</div>
|
||||
|
||||
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="task-scroll">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
|
||||
@@ -152,7 +145,7 @@ onBeforeUnmount(() => {
|
||||
@click="() => handleTaskClick(task.id)"
|
||||
>
|
||||
<div class="task-name">{{ task.name }}</div>
|
||||
<img v-if="task.image_id" :src="task.image_id" alt="Task Image" class="task-image" />
|
||||
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
|
||||
<div
|
||||
class="task-points"
|
||||
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
|
||||
@@ -177,7 +170,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.task-list-container h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
childId?: string | number
|
||||
assignable?: boolean
|
||||
assignFilter?: 'assignable' | 'assigned' | 'none'
|
||||
typeFilter?: 'good' | 'bad' | 'all'
|
||||
deletable?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['edit-task', 'delete-task'])
|
||||
|
||||
@@ -16,53 +19,95 @@ const tasks = ref<
|
||||
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) {
|
||||
if (props.assignable) {
|
||||
url = `/api/child/${props.childId}/list-assignable-tasks`
|
||||
} else {
|
||||
url = `/api/child/${props.childId}/list-tasks`
|
||||
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: any) => ({ ...task, assigned: true }))
|
||||
const assignable = (data.assignable_tasks || []).map((task: any) => ({
|
||||
...task,
|
||||
assigned: false,
|
||||
}))
|
||||
let taskList: any[] = []
|
||||
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: any) => {
|
||||
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: any) => 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
|
||||
}
|
||||
} 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 || []
|
||||
// Fetch images for each task if image_id is present
|
||||
await Promise.all(
|
||||
taskList.map(async (task: any) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
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: any) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
tasks.value = taskList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
tasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}),
|
||||
)
|
||||
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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
watch(() => [props.childId, props.assignable], fetchTasks)
|
||||
watch(() => [props.childId, props.assignFilter], fetchTasks)
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
emit('edit-task', taskId)
|
||||
@@ -72,7 +117,7 @@ const handleDelete = (taskId: string) => {
|
||||
emit('delete-task', taskId)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetchTasks })
|
||||
defineExpose({ refresh: fetchTasks, selectedTasks })
|
||||
|
||||
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
|
||||
const listHeight = computed(() => {
|
||||
@@ -80,6 +125,15 @@ const listHeight = computed(() => {
|
||||
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>
|
||||
@@ -88,7 +142,7 @@ const listHeight = computed(() => {
|
||||
<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 tasks" :key="task.id">
|
||||
<div v-for="(task, idx) in filteredTasks" :key="task.id">
|
||||
<div
|
||||
class="task-list-item"
|
||||
:class="{ good: task.is_good, bad: !task.is_good }"
|
||||
@@ -99,6 +153,15 @@ const listHeight = computed(() => {
|
||||
<span class="task-points">
|
||||
{{ task.is_good ? task.points : '-' + task.points }} pts
|
||||
</span>
|
||||
<!-- Add checkbox if selectable -->
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="task-checkbox"
|
||||
v-model="selectedTasks"
|
||||
:value="task.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@@ -126,7 +189,7 @@ const listHeight = computed(() => {
|
||||
<style scoped>
|
||||
.task-listbox {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: 480px;
|
||||
/* Subtract any header/nav height if needed, e.g. 4.5rem */
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
@@ -223,4 +286,11 @@ const listHeight = computed(() => {
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
.task-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: #667eea;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,15 @@ const showBack = computed(
|
||||
</div>
|
||||
<nav class="view-selector">
|
||||
<button
|
||||
:class="{ active: ['ParentChildrenListView', 'ParentView'].includes(String(route.name)) }"
|
||||
:class="{
|
||||
active: [
|
||||
'ParentChildrenListView',
|
||||
'ParentView',
|
||||
'ChildEditView',
|
||||
'CreateChild',
|
||||
'TaskAssignView',
|
||||
].includes(String(route.name)),
|
||||
}"
|
||||
@click="router.push({ name: 'ParentChildrenListView' })"
|
||||
aria-label="Children"
|
||||
title="Children"
|
||||
|
||||
@@ -8,6 +8,9 @@ import TaskView from '../components/task/TaskView.vue'
|
||||
import RewardView from '../components/reward/RewardView.vue'
|
||||
import TaskEditView from '@/components/task/TaskEditView.vue'
|
||||
import RewardEditView from '@/components/reward/RewardEditView.vue'
|
||||
import ChildEditView from '@/components/child/ChildEditView.vue'
|
||||
import TaskAssignView from '@/components/child/TaskAssignView.vue'
|
||||
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -43,6 +46,17 @@ const routes = [
|
||||
component: ParentView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'children/create',
|
||||
name: 'CreateChild',
|
||||
component: ChildEditView,
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'ChildEditView',
|
||||
component: ChildEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'TaskView',
|
||||
@@ -77,6 +91,18 @@ const routes = [
|
||||
component: RewardEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-tasks/:type?',
|
||||
name: 'TaskAssignView',
|
||||
component: TaskAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-rewards',
|
||||
name: 'RewardAssignView',
|
||||
component: RewardAssignView,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
'/events': {
|
||||
target: 'http://192.168.1.102:5000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user