round 4
This commit is contained in:
198
api/child_api.py
198
api/child_api.py
@@ -1,21 +1,22 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from db.db import child_db, task_db, reward_db
|
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||||
from api.reward_status import RewardStatus
|
from api.reward_status import RewardStatus
|
||||||
from api.child_tasks import ChildTask
|
from api.child_tasks import ChildTask
|
||||||
from api.child_rewards import ChildReward
|
from api.child_rewards import ChildReward
|
||||||
from events.sse import send_to_user, send_event_to_user
|
from events.sse import send_event_to_user
|
||||||
from events.types.child_add import ChildAdd
|
from events.types.child_modified import ChildModified
|
||||||
from events.types.child_delete import ChildDelete
|
from events.types.child_reward_request import ChildRewardRequest
|
||||||
from events.types.child_update import ChildUpdate
|
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||||
|
from events.types.child_rewards_set import ChildRewardsSet
|
||||||
|
from events.types.child_task_triggered import ChildTaskTriggered
|
||||||
|
from events.types.child_tasks_set import ChildTasksSet
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from events.types.reward_set import RewardSet
|
from api.pending_reward import PendingReward as PendingRewardResponse
|
||||||
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.child import Child
|
||||||
|
from models.pending_reward import PendingReward
|
||||||
from models.task import Task
|
from models.task import Task
|
||||||
from models.reward import Reward
|
from models.reward import Reward
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ def get_child(id):
|
|||||||
result = child_db.search(ChildQuery.id == id)
|
result = child_db.search(ChildQuery.id == id)
|
||||||
if not result:
|
if not result:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
return jsonify(result[0]), 200
|
return jsonify(Child.from_dict(result[0]).to_dict()), 200
|
||||||
|
|
||||||
@child_api.route('/child/add', methods=['PUT'])
|
@child_api.route('/child/add', methods=['PUT'])
|
||||||
def add_child():
|
def add_child():
|
||||||
@@ -43,7 +44,7 @@ def add_child():
|
|||||||
|
|
||||||
child = Child(name=name, age=age, image_id=image)
|
child = Child(name=name, age=age, image_id=image)
|
||||||
child_db.insert(child.to_dict())
|
child_db.insert(child.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_ADD.value, ChildAdd(child.id, "set")))
|
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
|
||||||
return jsonify({'message': f'Child {name} added.'}), 201
|
return jsonify({'message': f'Child {name} added.'}), 201
|
||||||
|
|
||||||
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
||||||
@@ -67,7 +68,7 @@ def edit_child(id):
|
|||||||
if image is not None:
|
if image is not None:
|
||||||
child['image_id'] = image
|
child['image_id'] = image
|
||||||
child_db.update(child, ChildQuery.id == id)
|
child_db.update(child, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.CHILD_UPDATE.value, ChildUpdate(id, "set")))
|
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
|
||||||
return jsonify({'message': f'Child {id} updated.'}), 200
|
return jsonify({'message': f'Child {id} updated.'}), 200
|
||||||
|
|
||||||
@child_api.route('/child/list', methods=['GET'])
|
@child_api.route('/child/list', methods=['GET'])
|
||||||
@@ -81,7 +82,7 @@ def delete_child(id):
|
|||||||
ChildQuery = Query()
|
ChildQuery = Query()
|
||||||
if child_db.remove(ChildQuery.id == id):
|
if child_db.remove(ChildQuery.id == id):
|
||||||
send_event_to_user("user123",
|
send_event_to_user("user123",
|
||||||
Event(EventType.CHILD_DELETE.value, ChildDelete(id, "deleted")))
|
Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||||
return jsonify({'message': f'Child {id} deleted.'}), 200
|
return jsonify({'message': f'Child {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ def set_child_tasks(id):
|
|||||||
valid_task_ids.append(tid)
|
valid_task_ids.append(tid)
|
||||||
# Replace tasks with validated IDs
|
# Replace tasks with validated IDs
|
||||||
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.TASK_SET.value, TaskSet(id, "set")))
|
send_event_to_user("user123", Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Tasks set for child {id}.',
|
'message': f'Tasks set for child {id}.',
|
||||||
'task_ids': valid_task_ids,
|
'task_ids': valid_task_ids,
|
||||||
@@ -275,7 +276,7 @@ def trigger_child_task(id):
|
|||||||
child.points = max(child.points, 0)
|
child.points = max(child.points, 0)
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
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)))
|
send_event_to_user("user123", Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
||||||
|
|
||||||
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||||
|
|
||||||
@@ -361,7 +362,7 @@ def set_child_rewards(id):
|
|||||||
|
|
||||||
# Replace rewards with validated IDs
|
# Replace rewards with validated IDs
|
||||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_SET.value, RewardSet(id, "set")))
|
send_event_to_user("user123", Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Rewards set for child {id}.',
|
'message': f'Rewards set for child {id}.',
|
||||||
'reward_ids': valid_reward_ids,
|
'reward_ids': valid_reward_ids,
|
||||||
@@ -454,11 +455,23 @@ def trigger_child_reward(id):
|
|||||||
if not reward_result:
|
if not reward_result:
|
||||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||||
reward: Reward = Reward.from_dict(reward_result[0])
|
reward: Reward = Reward.from_dict(reward_result[0])
|
||||||
|
|
||||||
|
# Remove matching pending reward requests for this child and reward
|
||||||
|
PendingQuery = Query()
|
||||||
|
removed = pending_reward_db.remove(
|
||||||
|
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
|
||||||
|
)
|
||||||
|
if removed:
|
||||||
|
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||||
|
|
||||||
|
|
||||||
# update the child's points based on reward cost
|
# update the child's points based on reward cost
|
||||||
child.points -= reward.cost
|
child.points -= reward.cost
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
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)))
|
send_event_to_user("user123", Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||||
|
|
||||||
|
|
||||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
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'])
|
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||||
@@ -468,13 +481,13 @@ def list_affordable_rewards(id):
|
|||||||
if not result:
|
if not result:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
child = result[0]
|
child = Child.from_dict(result[0])
|
||||||
points = child.get('points', 0)
|
points = child.points
|
||||||
reward_ids = child.get('rewards', [])
|
reward_ids = child.rewards
|
||||||
RewardQuery = Query()
|
RewardQuery = Query()
|
||||||
affordable = [
|
affordable = [
|
||||||
reward for reward_id in reward_ids
|
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
|
||||||
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= reward.get('cost', 0)
|
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= Reward.from_dict(reward).cost
|
||||||
]
|
]
|
||||||
return jsonify({'affordable_rewards': affordable}), 200
|
return jsonify({'affordable_rewards': affordable}), 200
|
||||||
|
|
||||||
@@ -485,9 +498,9 @@ def reward_status(id):
|
|||||||
if not result:
|
if not result:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
child = result[0]
|
child = Child.from_dict(result[0])
|
||||||
points = child.get('points', 0)
|
points = child.points
|
||||||
reward_ids = child.get('rewards', [])
|
reward_ids = child.rewards
|
||||||
|
|
||||||
RewardQuery = Query()
|
RewardQuery = Query()
|
||||||
statuses = []
|
statuses = []
|
||||||
@@ -496,8 +509,137 @@ def reward_status(id):
|
|||||||
if not reward:
|
if not reward:
|
||||||
continue
|
continue
|
||||||
points_needed = max(0, reward.cost - points)
|
points_needed = max(0, reward.cost - points)
|
||||||
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, reward.image_id)
|
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
|
||||||
|
pending_query = Query()
|
||||||
|
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id))
|
||||||
|
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending, reward.image_id)
|
||||||
statuses.append(status.to_dict())
|
statuses.append(status.to_dict())
|
||||||
|
|
||||||
statuses.sort(key=lambda s: s['points_needed'])
|
statuses.sort(key=lambda s: s['cost'])
|
||||||
return jsonify({'reward_status': statuses}), 200
|
return jsonify({'reward_status': statuses}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_api.route('/child/<id>/request-reward', methods=['POST'])
|
||||||
|
def request_reward(id):
|
||||||
|
data = request.get_json()
|
||||||
|
reward_id = data.get('reward_id')
|
||||||
|
if not reward_id:
|
||||||
|
return jsonify({'error': 'reward_id is required'}), 400
|
||||||
|
|
||||||
|
ChildQuery = Query()
|
||||||
|
result = child_db.search(ChildQuery.id == id)
|
||||||
|
if not result:
|
||||||
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
|
child = Child.from_dict(result[0])
|
||||||
|
if reward_id not in child.rewards:
|
||||||
|
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
|
||||||
|
|
||||||
|
RewardQuery = Query()
|
||||||
|
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||||
|
if not reward_result:
|
||||||
|
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||||
|
|
||||||
|
reward = Reward.from_dict(reward_result[0])
|
||||||
|
|
||||||
|
# Check if child has enough points
|
||||||
|
if child.points < reward.cost:
|
||||||
|
points_needed = reward.cost - child.points
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Insufficient points',
|
||||||
|
'points_needed': points_needed,
|
||||||
|
'current_points': child.points,
|
||||||
|
'reward_cost': reward.cost
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
||||||
|
pending_reward_db.insert(pending.to_dict())
|
||||||
|
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||||
|
'reward_id': reward.id,
|
||||||
|
'reward_name': reward.name,
|
||||||
|
'child_id': child.id,
|
||||||
|
'child_name': child.name,
|
||||||
|
'cost': reward.cost
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
|
||||||
|
def cancel_request_reward(id):
|
||||||
|
data = request.get_json()
|
||||||
|
reward_id = data.get('reward_id')
|
||||||
|
if not reward_id:
|
||||||
|
return jsonify({'error': 'reward_id is required'}), 400
|
||||||
|
|
||||||
|
ChildQuery = Query()
|
||||||
|
result = child_db.search(ChildQuery.id == id)
|
||||||
|
if not result:
|
||||||
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
|
child = Child.from_dict(result[0])
|
||||||
|
|
||||||
|
# Remove matching pending reward request
|
||||||
|
PendingQuery = Query()
|
||||||
|
removed = pending_reward_db.remove(
|
||||||
|
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not removed:
|
||||||
|
return jsonify({'error': 'No pending request found for this reward'}), 404
|
||||||
|
|
||||||
|
# Notify user that the request was cancelled
|
||||||
|
send_event_to_user(
|
||||||
|
"user123",
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_REWARD_REQUEST.value,
|
||||||
|
ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Reward request cancelled for {child.name}.',
|
||||||
|
'child_id': child.id,
|
||||||
|
'reward_id': reward_id,
|
||||||
|
'removed_count': len(removed)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@child_api.route('/pending-rewards', methods=['GET'])
|
||||||
|
def list_pending_rewards():
|
||||||
|
pending_rewards = pending_reward_db.all()
|
||||||
|
reward_responses = []
|
||||||
|
|
||||||
|
RewardQuery = Query()
|
||||||
|
ChildQuery = Query()
|
||||||
|
|
||||||
|
for pr in pending_rewards:
|
||||||
|
pending = PendingReward.from_dict(pr)
|
||||||
|
|
||||||
|
# Look up reward details
|
||||||
|
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
|
||||||
|
if not reward_result:
|
||||||
|
continue
|
||||||
|
reward = Reward.from_dict(reward_result)
|
||||||
|
|
||||||
|
# Look up child details
|
||||||
|
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
||||||
|
if not child_result:
|
||||||
|
continue
|
||||||
|
child = Child.from_dict(child_result)
|
||||||
|
|
||||||
|
# Create response object
|
||||||
|
response = PendingRewardResponse(
|
||||||
|
_id=pending.id,
|
||||||
|
child_id=child.id,
|
||||||
|
child_name=child.name,
|
||||||
|
child_image_id=child.image_id,
|
||||||
|
reward_id=reward.id,
|
||||||
|
reward_name=reward.name,
|
||||||
|
reward_image_id=reward.image_id
|
||||||
|
)
|
||||||
|
reward_responses.append(response.to_dict())
|
||||||
|
|
||||||
|
return jsonify({'rewards': reward_responses}), 200
|
||||||
|
|
||||||
|
|||||||
20
api/pending_reward.py
Normal file
20
api/pending_reward.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class PendingReward:
|
||||||
|
def __init__(self, _id, child_id, child_name, child_image_id, reward_id, reward_name, reward_image_id):
|
||||||
|
self.id = _id
|
||||||
|
self.child_id = child_id
|
||||||
|
self.child_name = child_name
|
||||||
|
self.child_image_id = child_image_id
|
||||||
|
self.reward_id = reward_id
|
||||||
|
self.reward_name = reward_name
|
||||||
|
self.reward_image_id = reward_image_id
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'child_name': self.child_name,
|
||||||
|
'child_image_id': self.child_image_id,
|
||||||
|
'reward_id': self.reward_id,
|
||||||
|
'reward_name': self.reward_name,
|
||||||
|
'reward_image_id': self.reward_image_id
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ from tinydb import Query
|
|||||||
from events.sse import send_event_to_user
|
from events.sse import send_event_to_user
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from events.types.reward_created import RewardCreated
|
from events.types.reward_modified import RewardModified
|
||||||
from events.types.reward_deleted import RewardDeleted
|
|
||||||
from events.types.reward_edited import RewardEdited
|
|
||||||
from models.reward import Reward
|
from models.reward import Reward
|
||||||
from db.db import reward_db, child_db
|
from db.db import reward_db, child_db
|
||||||
|
|
||||||
@@ -24,8 +22,8 @@ def add_reward():
|
|||||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||||
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
||||||
reward_db.insert(reward.to_dict())
|
reward_db.insert(reward.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_CREATED.value,
|
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardCreated(reward.id, "created")))
|
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
||||||
|
|
||||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||||
|
|
||||||
@@ -56,8 +54,8 @@ def delete_reward(id):
|
|||||||
if id in rewards:
|
if id in rewards:
|
||||||
rewards.remove(id)
|
rewards.remove(id)
|
||||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_DELETED.value,
|
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardDeleted(id, "created")))
|
RewardModified(id, RewardModified.OPERATION_DELETE)))
|
||||||
|
|
||||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Reward not found'}), 404
|
return jsonify({'error': 'Reward not found'}), 404
|
||||||
@@ -100,7 +98,7 @@ def edit_reward(id):
|
|||||||
|
|
||||||
reward_db.update(updates, RewardQuery.id == id)
|
reward_db.update(updates, RewardQuery.id == id)
|
||||||
updated = reward_db.get(RewardQuery.id == id)
|
updated = reward_db.get(RewardQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.REWARD_EDITED.value,
|
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||||
RewardEdited(id, "created")))
|
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||||
|
|
||||||
return jsonify(updated), 200
|
return jsonify(updated), 200
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
class RewardStatus:
|
class RewardStatus:
|
||||||
def __init__(self, id, name, points_needed, cost, image_id):
|
def __init__(self, id, name, points_needed, cost, redeeming, image_id):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.points_needed = points_needed
|
self.points_needed = points_needed
|
||||||
self.cost = cost
|
self.cost = cost
|
||||||
|
self.redeeming = redeeming
|
||||||
self.image_id = image_id
|
self.image_id = image_id
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -12,5 +13,6 @@ class RewardStatus:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'points_needed': self.points_needed,
|
'points_needed': self.points_needed,
|
||||||
'cost': self.cost,
|
'cost': self.cost,
|
||||||
|
'redeeming': self.redeeming,
|
||||||
'image_id': self.image_id
|
'image_id': self.image_id
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,7 @@ from tinydb import Query
|
|||||||
from events.sse import send_event_to_user
|
from events.sse import send_event_to_user
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from events.types.task_created import TaskCreated
|
from events.types.task_modified import TaskModified
|
||||||
from events.types.task_deleted import TaskDeleted
|
|
||||||
from events.types.task_edited import TaskEdited
|
|
||||||
from models.task import Task
|
from models.task import Task
|
||||||
from db.db import task_db, child_db
|
from db.db import task_db, child_db
|
||||||
|
|
||||||
@@ -24,8 +22,8 @@ def add_task():
|
|||||||
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
||||||
task = Task(name=name, points=points, is_good=is_good, image_id=image)
|
task = Task(name=name, points=points, is_good=is_good, image_id=image)
|
||||||
task_db.insert(task.to_dict())
|
task_db.insert(task.to_dict())
|
||||||
send_event_to_user("user123", Event(EventType.TASK_CREATED.value,
|
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskCreated(task.id, "created")))
|
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||||
return jsonify({'message': f'Task {name} added.'}), 201
|
return jsonify({'message': f'Task {name} added.'}), 201
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['GET'])
|
@task_api.route('/task/<id>', methods=['GET'])
|
||||||
@@ -53,8 +51,8 @@ def delete_task(id):
|
|||||||
if id in tasks:
|
if id in tasks:
|
||||||
tasks.remove(id)
|
tasks.remove(id)
|
||||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
||||||
send_event_to_user("user123", Event(EventType.TASK_DELETED.value,
|
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskDeleted(id, "deleted")))
|
TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||||
|
|
||||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
return jsonify({'error': 'Task not found'}), 404
|
||||||
@@ -97,6 +95,6 @@ def edit_task(id):
|
|||||||
|
|
||||||
task_db.update(updates, TaskQuery.id == id)
|
task_db.update(updates, TaskQuery.id == id)
|
||||||
updated = task_db.get(TaskQuery.id == id)
|
updated = task_db.get(TaskQuery.id == id)
|
||||||
send_event_to_user("user123", Event(EventType.TASK_EDITED.value,
|
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||||
TaskEdited(id, "edited")))
|
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||||
return jsonify(updated), 200
|
return jsonify(updated), 200
|
||||||
|
|||||||
8
db/db.py
8
db/db.py
@@ -70,26 +70,32 @@ if DB_ENV == 'test':
|
|||||||
task_path = os.path.join(base_dir, 'test_tasks.json')
|
task_path = os.path.join(base_dir, 'test_tasks.json')
|
||||||
reward_path = os.path.join(base_dir, 'test_rewards.json')
|
reward_path = os.path.join(base_dir, 'test_rewards.json')
|
||||||
image_path = os.path.join(base_dir, 'test_images.json')
|
image_path = os.path.join(base_dir, 'test_images.json')
|
||||||
|
pending_reward_path = os.path.join(base_dir, 'test_pending_rewards.json')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
child_path = os.path.join(base_dir, 'children.json')
|
child_path = os.path.join(base_dir, 'children.json')
|
||||||
task_path = os.path.join(base_dir, 'tasks.json')
|
task_path = os.path.join(base_dir, 'tasks.json')
|
||||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||||
image_path = os.path.join(base_dir, 'images.json')
|
image_path = os.path.join(base_dir, 'images.json')
|
||||||
|
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||||
|
|
||||||
# Use separate TinyDB instances/files for each collection
|
# Use separate TinyDB instances/files for each collection
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
_child_db = TinyDB(child_path, indent=2)
|
||||||
_task_db = TinyDB(task_path, indent=2)
|
_task_db = TinyDB(task_path, indent=2)
|
||||||
_reward_db = TinyDB(reward_path, indent=2)
|
_reward_db = TinyDB(reward_path, indent=2)
|
||||||
_image_db = TinyDB(image_path, indent=2)
|
_image_db = TinyDB(image_path, indent=2)
|
||||||
|
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||||
|
|
||||||
# Expose table objects wrapped with locking
|
# Expose table objects wrapped with locking
|
||||||
child_db = LockedTable(_child_db)
|
child_db = LockedTable(_child_db)
|
||||||
task_db = LockedTable(_task_db)
|
task_db = LockedTable(_task_db)
|
||||||
reward_db = LockedTable(_reward_db)
|
reward_db = LockedTable(_reward_db)
|
||||||
image_db = LockedTable(_image_db)
|
image_db = LockedTable(_image_db)
|
||||||
|
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||||
|
|
||||||
if DB_ENV == 'test':
|
if DB_ENV == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
task_db.truncate()
|
task_db.truncate()
|
||||||
reward_db.truncate()
|
reward_db.truncate()
|
||||||
image_db.truncate()
|
image_db.truncate()
|
||||||
|
pending_reward_db.truncate()
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
21
events/types/child_modified.py
Normal file
21
events/types/child_modified.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class ChildModified(Payload):
|
||||||
|
OPERATION_ADD = "ADD"
|
||||||
|
OPERATION_EDIT = "EDIT"
|
||||||
|
OPERATION_DELETE = "DELETE"
|
||||||
|
def __init__(self, child_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'operation': operation,
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def child_id(self) -> str:
|
||||||
|
return self.get("child_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation(self) -> str:
|
||||||
|
return self.get("operation")
|
||||||
|
|
||||||
27
events/types/child_reward_request.py
Normal file
27
events/types/child_reward_request.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRewardRequest(Payload):
|
||||||
|
REQUEST_GRANTED = "GRANTED"
|
||||||
|
REQUEST_CREATED = "CREATED"
|
||||||
|
REQUEST_CANCELLED = "CANCELLED"
|
||||||
|
def __init__(self, child_id, reward_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'reward_id': reward_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def child_id(self) -> str:
|
||||||
|
return self.get("child_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reward_id(self) -> str:
|
||||||
|
return self.get("reward_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation(self) -> str:
|
||||||
|
return self.get("operation")
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
from events.types.payload import Payload
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
class RewardUpdate(Payload):
|
class ChildRewardTriggered(Payload):
|
||||||
def __init__(self, reward_id: str, child_id: str, status: str, points: int):
|
def __init__(self, reward_id: str, child_id: str, points: int):
|
||||||
super().__init__({
|
super().__init__({
|
||||||
'reward_id': reward_id,
|
'reward_id': reward_id,
|
||||||
'child_id': child_id,
|
'child_id': child_id,
|
||||||
'status': status,
|
|
||||||
'points': points
|
'points': points
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -18,10 +17,6 @@ class RewardUpdate(Payload):
|
|||||||
def child_id(self) -> str:
|
def child_id(self) -> str:
|
||||||
return self.get("child_id")
|
return self.get("child_id")
|
||||||
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.get("status")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def points(self) -> int:
|
def points(self) -> int:
|
||||||
return self.get("points", 0)
|
return self.get("points", 0)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from events.types.payload import Payload
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
class RewardSet(Payload):
|
class ChildRewardsSet(Payload):
|
||||||
def __init__(self, child_id: str, status: str):
|
def __init__(self, child_id: str, reward_ids: list[str]):
|
||||||
super().__init__({
|
super().__init__({
|
||||||
'child_id': child_id,
|
'child_id': child_id,
|
||||||
'status': status
|
'reward_ids': reward_ids
|
||||||
})
|
})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -13,7 +13,8 @@ class RewardSet(Payload):
|
|||||||
return self.get("child_id")
|
return self.get("child_id")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def reward_ids(self) -> list[str]:
|
||||||
return self.get("status")
|
return self.get("reward_ids", [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
from events.types.payload import Payload
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(Payload):
|
class ChildTaskTriggered(Payload):
|
||||||
def __init__(self, task_id: str, child_id: str, status: str, points: int):
|
def __init__(self, task_id: str, child_id: str, points: int):
|
||||||
super().__init__({
|
super().__init__({
|
||||||
'task_id': task_id,
|
'task_id': task_id,
|
||||||
'child_id': child_id,
|
'child_id': child_id,
|
||||||
'status': status,
|
|
||||||
'points': points
|
'points': points
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -18,10 +17,6 @@ class TaskUpdate(Payload):
|
|||||||
def child_id(self) -> str:
|
def child_id(self) -> str:
|
||||||
return self.get("child_id")
|
return self.get("child_id")
|
||||||
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.get("status")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def points(self) -> int:
|
def points(self) -> int:
|
||||||
return self.get("points", 0)
|
return self.get("points", 0)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from events.types.payload import Payload
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
class TaskSet(Payload):
|
class ChildTasksSet(Payload):
|
||||||
def __init__(self, child_id: str, status: str):
|
def __init__(self, child_id: str, task_ids: list[str]):
|
||||||
super().__init__({
|
super().__init__({
|
||||||
'child_id': child_id,
|
'child_id': child_id,
|
||||||
'status': status
|
'task_ids': task_ids
|
||||||
})
|
})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -13,7 +13,7 @@ class TaskSet(Payload):
|
|||||||
return self.get("child_id")
|
return self.get("child_id")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def task_ids(self) -> list[str]:
|
||||||
return self.get("status")
|
return self.get("task_ids", [])
|
||||||
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
@@ -2,18 +2,14 @@ from enum import Enum
|
|||||||
|
|
||||||
|
|
||||||
class EventType(Enum):
|
class EventType(Enum):
|
||||||
TASK_UPDATE = "task_update"
|
CHILD_TASK_TRIGGERED = "child_task_triggered"
|
||||||
TASK_SET = "task_set"
|
CHILD_TASKS_SET = "child_tasks_set"
|
||||||
TASK_TEST = "task_test"
|
|
||||||
REWARD_UPDATE = "reward_update"
|
TASK_MODIFIED = "task_modified"
|
||||||
REWARD_SET = "reward_set"
|
REWARD_MODIFIED = "reward_modified"
|
||||||
CHILD_UPDATE = "child_update"
|
|
||||||
CHILD_ADD = "child_add"
|
CHILD_REWARD_TRIGGERED = "child_reward_triggered"
|
||||||
CHILD_DELETE = "child_delete"
|
CHILD_REWARDS_SET = "child_rewards_set"
|
||||||
TASK_CREATED = "task_created"
|
CHILD_REWARD_REQUEST = "child_reward_request"
|
||||||
TASK_DELETED = "task_deleted"
|
|
||||||
TASK_EDITED = "task_edited"
|
CHILD_MODIFIED = "child_modified"
|
||||||
REWARD_CREATED = "reward_created"
|
|
||||||
REWARD_DELETED = "reward_deleted"
|
|
||||||
REWARD_EDITED = "reward_edited"
|
|
||||||
# Add more event types here
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
22
events/types/reward_modified.py
Normal file
22
events/types/reward_modified.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RewardModified(Payload):
|
||||||
|
OPERATION_ADD = "ADD"
|
||||||
|
OPERATION_EDIT = "EDIT"
|
||||||
|
OPERATION_DELETE = "DELETE"
|
||||||
|
def __init__(self, reward_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'reward_id': reward_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reward_id(self) -> str:
|
||||||
|
return self.get("reward_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation(self) -> str:
|
||||||
|
return self.get("operation")
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
22
events/types/task_modified.py
Normal file
22
events/types/task_modified.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class TaskModified(Payload):
|
||||||
|
OPERATION_ADD = "ADD"
|
||||||
|
OPERATION_EDIT = "EDIT"
|
||||||
|
OPERATION_DELETE = "DELETE"
|
||||||
|
def __init__(self, task_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'task_id': task_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task_id(self) -> str:
|
||||||
|
return self.get("task_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation(self) -> str:
|
||||||
|
return self.get("operation")
|
||||||
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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")
|
|
||||||
28
models/pending_reward.py
Normal file
28
models/pending_reward.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingReward(BaseModel):
|
||||||
|
child_id: str
|
||||||
|
reward_id: str
|
||||||
|
status: str = "pending" # pending, approved, rejected
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
reward_id=d.get('reward_id'),
|
||||||
|
status=d.get('status', 'pending'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'reward_id': self.reward_id,
|
||||||
|
'status': self.status
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -8,10 +8,12 @@ export interface Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Child {
|
export interface Child {
|
||||||
id: string | number
|
id: string
|
||||||
name: string
|
name: string
|
||||||
age: number
|
age: number
|
||||||
points?: number
|
tasks: string[]
|
||||||
|
rewards: string[]
|
||||||
|
points: number
|
||||||
image_id: string | null
|
image_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
image_url?: string | null // optional, for resolved URLs
|
||||||
}
|
}
|
||||||
@@ -30,91 +32,73 @@ export interface RewardStatus {
|
|||||||
name: string
|
name: string
|
||||||
points_needed: number
|
points_needed: number
|
||||||
cost: number
|
cost: number
|
||||||
|
redeeming: boolean
|
||||||
image_id: string | null
|
image_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
image_url?: string | null // optional, for resolved URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingReward {
|
||||||
|
id: string
|
||||||
|
child_id: string
|
||||||
|
child_name: string
|
||||||
|
child_image_id: string | null
|
||||||
|
child_image_url?: string | null // optional, for resolved URLs
|
||||||
|
reward_id: string
|
||||||
|
reward_name: string
|
||||||
|
reward_image_id: string | null
|
||||||
|
reward_image_url?: string | null // optional, for resolved URLs
|
||||||
|
}
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
type: string
|
type: string
|
||||||
payload:
|
payload:
|
||||||
| TaskUpdateEventPayload
|
| ChildModifiedEventPayload
|
||||||
| RewardUpdateEventPayload
|
| ChildTaskTriggeredEventPayload
|
||||||
| ChildUpdateEventPayload
|
| ChildRewardTriggeredEventPayload
|
||||||
| ChildDeleteEventPayload
|
| ChildRewardRequestEventPayload
|
||||||
| TaskCreatedEventPayload
|
| ChildTasksSetEventPayload
|
||||||
| TaskDeletedEventPayload
|
| ChildRewardsSetEventPayload
|
||||||
| TaskEditedEventPayload
|
| TaskModifiedEventPayload
|
||||||
| RewardCreatedEventPayload
|
| RewardModifiedEventPayload
|
||||||
| RewardDeletedEventPayload
|
|
||||||
| RewardEditedEventPayload
|
|
||||||
| RewardSetEventPayload
|
|
||||||
| TaskSetEventPayload
|
|
||||||
| ChildAddEventPayload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskUpdateEventPayload {
|
export interface ChildModifiedEventPayload {
|
||||||
|
child_id: string
|
||||||
|
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildTaskTriggeredEventPayload {
|
||||||
task_id: string
|
task_id: string
|
||||||
child_id: string
|
child_id: string
|
||||||
status: string
|
|
||||||
points: number
|
points: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskSetEventPayload {
|
export interface ChildRewardTriggeredEventPayload {
|
||||||
|
task_id: string
|
||||||
child_id: string
|
child_id: string
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RewardUpdateEventPayload {
|
|
||||||
reward_id: string
|
|
||||||
child_id: string
|
|
||||||
status: string
|
|
||||||
points: number
|
points: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewardSetEventPayload {
|
export interface ChildRewardRequestEventPayload {
|
||||||
child_id: string
|
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
|
reward_id: string
|
||||||
|
operation: 'GRANTED' | 'CREATED' | 'CANCELLED'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewardDeletedEventPayload {
|
export interface ChildTasksSetEventPayload {
|
||||||
reward_id: string
|
child_id: string
|
||||||
status: string
|
task_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewardEditedEventPayload {
|
export interface ChildRewardsSetEventPayload {
|
||||||
reward_id: string
|
child_id: string
|
||||||
status: string
|
reward_ids: string[]
|
||||||
|
}
|
||||||
|
export interface TaskModifiedEventPayload {
|
||||||
|
task_id: string
|
||||||
|
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||||
|
}
|
||||||
|
export interface RewardModifiedEventPayload {
|
||||||
|
reward_id: string
|
||||||
|
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||||
import { isParentAuthenticated } from '../stores/auth'
|
import { isParentAuthenticated } from '../stores/auth'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import type { Child, Event } from '@/common/models'
|
import type {
|
||||||
|
Child,
|
||||||
|
ChildModifiedEventPayload,
|
||||||
|
ChildTaskTriggeredEventPayload,
|
||||||
|
ChildRewardTriggeredEventPayload,
|
||||||
|
Event,
|
||||||
|
} from '@/common/models'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const children = ref<Child[]>([])
|
const children = ref<Child[]>([])
|
||||||
@@ -25,8 +31,70 @@ const openChildEditor = (child: Child, evt?: Event) => {
|
|||||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServerChange(event: Event) {
|
async function handleChildModified(event: Event) {
|
||||||
fetchChildren()
|
const payload = event.payload as ChildModifiedEventPayload
|
||||||
|
const childId = payload.child_id
|
||||||
|
|
||||||
|
switch (payload.operation) {
|
||||||
|
case 'DELETE':
|
||||||
|
children.value = children.value.filter((c) => c.id !== childId)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ADD':
|
||||||
|
try {
|
||||||
|
const list = await fetchChildren()
|
||||||
|
children.value = list
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch children after ADD operation:', err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EDIT':
|
||||||
|
try {
|
||||||
|
const list = await fetchChildren()
|
||||||
|
const updatedChild = list.find((c) => c.id === childId)
|
||||||
|
if (updatedChild) {
|
||||||
|
const idx = children.value.findIndex((c) => c.id === childId)
|
||||||
|
if (idx !== -1) {
|
||||||
|
children.value[idx] = updatedChild
|
||||||
|
} else {
|
||||||
|
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch children after EDIT operation:', err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown operation: ${payload.operation}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChildTaskTriggered(event: Event) {
|
||||||
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
|
const childId = payload.child_id
|
||||||
|
const child = children.value.find((c) => c.id === childId)
|
||||||
|
if (child) {
|
||||||
|
child.points = payload.points
|
||||||
|
} else {
|
||||||
|
console.warn(`Child with id ${childId} not found when updating points.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChildRewardTriggered(event: Event) {
|
||||||
|
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||||
|
const childId = payload.child_id
|
||||||
|
const child = children.value.find((c) => c.id === childId)
|
||||||
|
if (child) {
|
||||||
|
child.points = payload.points
|
||||||
|
} else {
|
||||||
|
console.warn(`Child with id ${childId} not found when updating points.`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// points update state
|
// points update state
|
||||||
@@ -41,8 +109,7 @@ const fetchImage = async (imageId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extracted fetch so we can refresh after delete / points edit
|
const fetchChildren = async (): Promise<Child[]> => {
|
||||||
const fetchChildren = async () => {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
images.value.clear()
|
images.value.clear()
|
||||||
@@ -53,20 +120,22 @@ const fetchChildren = async () => {
|
|||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
children.value = data.children || []
|
const childList = data.children || []
|
||||||
|
|
||||||
// Fetch images for each child (shared cache util)
|
// Fetch images for each child (shared cache util)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
children.value.map((child) => {
|
childList.map((child) => {
|
||||||
if (child.image_id) {
|
if (child.image_id) {
|
||||||
return fetchImage(child.image_id)
|
return fetchImage(child.image_id)
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
return childList
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||||
console.error('Error fetching children:', err)
|
console.error('Error fetching children:', err)
|
||||||
|
return []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -77,21 +146,22 @@ const createChild = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
eventBus.on('child_update', handleServerChange)
|
eventBus.on('child_modified', handleChildModified)
|
||||||
eventBus.on('task_update', handleServerChange)
|
eventBus.on('child_task_triggered', handleChildTaskTriggered)
|
||||||
eventBus.on('reward_update', handleServerChange)
|
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
||||||
eventBus.on('child_delete', handleServerChange)
|
|
||||||
|
|
||||||
await fetchChildren()
|
const listPromise = fetchChildren()
|
||||||
|
listPromise.then((list) => {
|
||||||
|
children.value = list
|
||||||
|
})
|
||||||
// listen for outside clicks to auto-close any open kebab menu
|
// listen for outside clicks to auto-close any open kebab menu
|
||||||
document.addEventListener('click', onDocClick, true)
|
document.addEventListener('click', onDocClick, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onUnmounted(() => {
|
||||||
eventBus.off('child_update', handleServerChange)
|
eventBus.off('child_modified', handleChildModified)
|
||||||
eventBus.off('task_update', handleServerChange)
|
eventBus.off('child_task_triggered', handleChildTaskTriggered)
|
||||||
eventBus.off('reward_update', handleServerChange)
|
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
|
||||||
eventBus.off('child_delete', handleServerChange)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldIgnoreNextCardClick = ref(false)
|
const shouldIgnoreNextCardClick = ref(false)
|
||||||
@@ -188,8 +258,7 @@ const deletePoints = async (childId: string | number, evt?: Event) => {
|
|||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`Failed to update points: ${resp.status}`)
|
throw new Error(`Failed to update points: ${resp.status}`)
|
||||||
}
|
}
|
||||||
// refresh the list so points reflect the change
|
// no need to refresh since we update optimistically via eventBus
|
||||||
await fetchChildren()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete points for child', childId, err)
|
console.error('Failed to delete points for child', childId, err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -204,13 +273,22 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div>
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="children.length === 0" class="no-children-message">
|
||||||
|
<div>No children</div>
|
||||||
|
<div class="sub-message">
|
||||||
|
<template v-if="!isParentAuthenticated">
|
||||||
|
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
|
||||||
|
create a child
|
||||||
|
</template>
|
||||||
|
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||||
|
|
||||||
<div v-else-if="children.length === 0" class="empty">No children found</div>
|
|
||||||
|
|
||||||
<div v-else class="grid">
|
<div v-else class="grid">
|
||||||
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
|
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
|
||||||
<!-- kebab menu shown only for authenticated parent -->
|
<!-- kebab menu shown only for authenticated parent -->
|
||||||
@@ -547,4 +625,37 @@ h1 {
|
|||||||
.fab:active {
|
.fab:active {
|
||||||
background: #4c51bf;
|
background: #4c51bf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-children-message {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: #fdfdfd;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sub-message {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #b5ccff;
|
||||||
|
}
|
||||||
|
.sign-in-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #2563eb;
|
||||||
|
border: 2px solid #2563eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s,
|
||||||
|
color 0.18s;
|
||||||
|
}
|
||||||
|
.sign-in-btn:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { eventBus } from '@/common/eventBus'
|
||||||
import { authenticateParent, isParentAuthenticated, logout } from '../stores/auth'
|
import { authenticateParent, isParentAuthenticated, logout } from '../stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -39,6 +40,13 @@ const handleLogout = () => {
|
|||||||
logout()
|
logout()
|
||||||
router.push('/child')
|
router.push('/child')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventBus.on('open-login', open)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('open-login', open)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, toRefs, ref, onMounted, onBeforeUnmount } from 'vue'
|
import { defineProps, toRefs, ref, watch, onBeforeUnmount } from 'vue'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
|
|
||||||
interface Child {
|
interface Child {
|
||||||
@@ -28,11 +28,15 @@ const fetchImage = async (imageId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
if (child.value && child.value.image_id) {
|
() => child.value?.image_id,
|
||||||
fetchImage(child.value.image_id)
|
(newImageId) => {
|
||||||
}
|
if (newImageId) {
|
||||||
})
|
fetchImage(newImageId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// Revoke created object URLs when component unmounts to avoid memory leaks
|
// Revoke created object URLs when component unmounts to avoid memory leaks
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const submit = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!resp.ok) throw new Error('Failed to save child')
|
if (!resp.ok) throw new Error('Failed to save child')
|
||||||
await router.push({ name: 'ChildrenListView' })
|
await router.push({ name: 'ParentChildrenListView' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to save child.')
|
alert('Failed to save child.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ import type {
|
|||||||
Event,
|
Event,
|
||||||
Task,
|
Task,
|
||||||
Reward,
|
Reward,
|
||||||
TaskUpdateEventPayload,
|
ChildTaskTriggeredEventPayload,
|
||||||
RewardUpdateEventPayload,
|
ChildRewardTriggeredEventPayload,
|
||||||
ChildUpdateEventPayload,
|
ChildRewardRequestEventPayload,
|
||||||
ChildDeleteEventPayload,
|
ChildTasksSetEventPayload,
|
||||||
TaskCreatedEventPayload,
|
ChildRewardsSetEventPayload,
|
||||||
TaskDeletedEventPayload,
|
TaskModifiedEventPayload,
|
||||||
TaskEditedEventPayload,
|
RewardModifiedEventPayload,
|
||||||
RewardCreatedEventPayload,
|
ChildModifiedEventPayload,
|
||||||
RewardDeletedEventPayload,
|
|
||||||
RewardEditedEventPayload,
|
|
||||||
} from '@/common/models'
|
} from '@/common/models'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -30,73 +28,205 @@ const tasks = ref<string[]>([])
|
|||||||
const rewards = ref<string[]>([])
|
const rewards = ref<string[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const showRewardDialog = ref(false)
|
||||||
|
const showCancelDialog = ref(false)
|
||||||
|
const dialogReward = ref<Reward | null>(null)
|
||||||
|
const childRewardListRef = ref()
|
||||||
|
const childChoreListRef = ref()
|
||||||
|
const childHabitListRef = ref()
|
||||||
|
|
||||||
function handlePointsUpdate(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
child.value.points = payload.points
|
child.value.points = payload.points
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServerChange(event: Event) {
|
function handleRewardTriggered(event: Event) {
|
||||||
const payload = event.payload as
|
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||||
| TaskUpdateEventPayload
|
|
||||||
| RewardUpdateEventPayload
|
|
||||||
| ChildUpdateEventPayload
|
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
fetchChildData(child.value.id)
|
child.value.points = payload.points
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChildDeletion(event: Event) {
|
function handleChildTaskSet(event: Event) {
|
||||||
const payload = event.payload as ChildDeleteEventPayload
|
const payload = event.payload as ChildTasksSetEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
// Navigate away back to children list
|
tasks.value = payload.task_ids
|
||||||
router.push({ name: 'ChildrenListView' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskChanged(event: Event) {
|
function handleChildRewardSet(event: Event) {
|
||||||
const payload = event.payload as
|
const payload = event.payload as ChildRewardsSetEventPayload
|
||||||
| TaskCreatedEventPayload
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
| TaskDeletedEventPayload
|
rewards.value = payload.reward_ids
|
||||||
| TaskEditedEventPayload
|
childRewardListRef.value?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRewardRequest(event: Event) {
|
||||||
|
const payload = event.payload as ChildRewardRequestEventPayload
|
||||||
|
const childId = payload.child_id
|
||||||
|
const rewardId = payload.reward_id
|
||||||
|
if (child.value && childId == child.value.id) {
|
||||||
|
if (rewards.value.find((r) => r === rewardId)) {
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChildModified(event: Event) {
|
||||||
|
const payload = event.payload as ChildModifiedEventPayload
|
||||||
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
|
switch (payload.operation) {
|
||||||
|
case 'DELETE':
|
||||||
|
// Navigate away back to children list
|
||||||
|
router.push({ name: 'ChildrenListView' })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ADD':
|
||||||
|
// A new child was added, this shouldn't affect the current child view
|
||||||
|
console.log('ADD operation received for child_modified, no action taken.')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EDIT':
|
||||||
|
//our child was edited, refetch its data
|
||||||
|
try {
|
||||||
|
const dataPromise = fetchChildData(payload.child_id)
|
||||||
|
dataPromise.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
child.value = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown operation: ${payload.operation}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskModified(event: Event) {
|
||||||
|
const payload = event.payload as TaskModifiedEventPayload
|
||||||
if (child.value) {
|
if (child.value) {
|
||||||
const task_id = payload.task_id
|
const task_id = payload.task_id
|
||||||
if (tasks.value.includes(task_id)) {
|
if (tasks.value.includes(task_id)) {
|
||||||
fetchChildData(child.value.id)
|
try {
|
||||||
|
switch (payload.operation) {
|
||||||
|
case 'DELETE':
|
||||||
|
// Remove the task from the list
|
||||||
|
tasks.value = tasks.value.filter((t) => t !== task_id)
|
||||||
|
return // No need to refetch
|
||||||
|
|
||||||
|
case 'ADD':
|
||||||
|
// A new task was added, this shouldn't affect the current task list
|
||||||
|
console.log('ADD operation received for task_modified, no action taken.')
|
||||||
|
return // No need to refetch
|
||||||
|
|
||||||
|
case 'EDIT':
|
||||||
|
try {
|
||||||
|
const dataPromise = fetchChildData(child.value.id)
|
||||||
|
dataPromise.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
tasks.value = data.tasks || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown operation: ${payload.operation}`)
|
||||||
|
return // No need to refetch
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch child after task modification:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRewardChanged(event: Event) {
|
function handleRewardModified(event: Event) {
|
||||||
const payload = event.payload as
|
const payload = event.payload as RewardModifiedEventPayload
|
||||||
| RewardCreatedEventPayload
|
|
||||||
| RewardDeletedEventPayload
|
|
||||||
| RewardEditedEventPayload
|
|
||||||
if (child.value) {
|
if (child.value) {
|
||||||
const reward_id = payload.reward_id
|
const reward_id = payload.reward_id
|
||||||
if (rewards.value.includes(reward_id)) {
|
if (rewards.value.includes(reward_id)) {
|
||||||
fetchChildData(child.value.id)
|
childRewardListRef.value?.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTriggerTask = (task: Task) => {
|
const triggerTask = (task: Task) => {
|
||||||
if ('speechSynthesis' in window && task.name) {
|
if ('speechSynthesis' in window && task.name) {
|
||||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
|
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
|
||||||
if ('speechSynthesis' in window && reward.name) {
|
if ('speechSynthesis' in window && reward.name) {
|
||||||
console.log('Handle trigger reward:', reward, redeemable)
|
|
||||||
const utterString =
|
const utterString =
|
||||||
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
||||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
}
|
}
|
||||||
|
if (pending) {
|
||||||
|
dialogReward.value = reward
|
||||||
|
showCancelDialog.value = true
|
||||||
|
return // Do not allow redeeming if already pending
|
||||||
|
}
|
||||||
|
if (redeemable) {
|
||||||
|
dialogReward.value = reward
|
||||||
|
showRewardDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPendingReward() {
|
||||||
|
if (!child.value?.id || !dialogReward.value) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cancel pending reward:', err)
|
||||||
|
} finally {
|
||||||
|
showCancelDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRedeemReward() {
|
||||||
|
showRewardDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCancelDialog() {
|
||||||
|
showCancelDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRedeemReward() {
|
||||||
|
if (!child.value?.id || !dialogReward.value) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) return
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to redeem reward:', err)
|
||||||
|
} finally {
|
||||||
|
showRewardDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChildData(id: string | number) {
|
async function fetchChildData(id: string | number) {
|
||||||
@@ -105,13 +235,12 @@ async function fetchChildData(id: string | number) {
|
|||||||
const resp = await fetch(`/api/child/${id}`)
|
const resp = await fetch(`/api/child/${id}`)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
child.value = data.children ? data.children : data
|
|
||||||
tasks.value = data.tasks || []
|
|
||||||
rewards.value = data.rewards || []
|
|
||||||
error.value = null
|
error.value = null
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -119,23 +248,25 @@ async function fetchChildData(id: string | number) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
eventBus.on('task_update', handlePointsUpdate)
|
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||||
eventBus.on('reward_update', handlePointsUpdate)
|
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||||
eventBus.on('task_set', handleServerChange)
|
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||||
eventBus.on('reward_set', handleServerChange)
|
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||||
eventBus.on('child_update', handleServerChange)
|
eventBus.on('task_modified', handleTaskModified)
|
||||||
eventBus.on('child_delete', handleChildDeletion)
|
eventBus.on('reward_modified', handleRewardModified)
|
||||||
eventBus.on('task_created', handleTaskChanged)
|
eventBus.on('child_modified', handleChildModified)
|
||||||
eventBus.on('task_deleted', handleTaskChanged)
|
eventBus.on('child_reward_request', handleRewardRequest)
|
||||||
eventBus.on('task_edited', handleTaskChanged)
|
|
||||||
eventBus.on('reward_created', handleRewardChanged)
|
|
||||||
eventBus.on('reward_deleted', handleRewardChanged)
|
|
||||||
eventBus.on('reward_edited', handleRewardChanged)
|
|
||||||
|
|
||||||
if (route.params.id) {
|
if (route.params.id) {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
if (idParam !== undefined) {
|
if (idParam !== undefined) {
|
||||||
fetchChildData(idParam)
|
const promise = fetchChildData(idParam)
|
||||||
|
promise.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
child.value = data
|
||||||
|
tasks.value = data.tasks || []
|
||||||
|
rewards.value = data.rewards || []
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -144,18 +275,14 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
eventBus.off('task_update', handlePointsUpdate)
|
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||||
eventBus.off('reward_update', handlePointsUpdate)
|
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||||
eventBus.off('task_set', handleServerChange)
|
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||||
eventBus.off('reward_set', handleServerChange)
|
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||||
eventBus.off('child_update', handleServerChange)
|
eventBus.off('task_modified', handleTaskModified)
|
||||||
eventBus.off('child_delete', handleChildDeletion)
|
eventBus.off('reward_modified', handleRewardModified)
|
||||||
eventBus.off('task_created', handleTaskChanged)
|
eventBus.off('child_modified', handleChildModified)
|
||||||
eventBus.off('task_deleted', handleTaskChanged)
|
eventBus.off('child_reward_request', handleRewardRequest)
|
||||||
eventBus.off('task_edited', handleTaskChanged)
|
|
||||||
eventBus.off('reward_created', handleRewardChanged)
|
|
||||||
eventBus.off('reward_deleted', handleRewardChanged)
|
|
||||||
eventBus.off('reward_edited', handleRewardChanged)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -169,33 +296,80 @@ onUnmounted(() => {
|
|||||||
<ChildDetailCard :child="child" />
|
<ChildDetailCard :child="child" />
|
||||||
<ChildTaskList
|
<ChildTaskList
|
||||||
title="Chores"
|
title="Chores"
|
||||||
|
ref="childChoreListRef"
|
||||||
:task-ids="tasks"
|
:task-ids="tasks"
|
||||||
:child-id="child ? child.id : null"
|
:child-id="child ? child.id : null"
|
||||||
:is-parent-authenticated="false"
|
:is-parent-authenticated="false"
|
||||||
:filter-type="1"
|
:filter-type="1"
|
||||||
@trigger-task="handleTriggerTask"
|
@trigger-task="triggerTask"
|
||||||
/>
|
/>
|
||||||
<ChildTaskList
|
<ChildTaskList
|
||||||
title="Bad Habits"
|
title="Bad Habits"
|
||||||
|
ref="childHabitListRef"
|
||||||
:task-ids="tasks"
|
:task-ids="tasks"
|
||||||
:child-id="child ? child.id : null"
|
:child-id="child ? child.id : null"
|
||||||
:is-parent-authenticated="false"
|
:is-parent-authenticated="false"
|
||||||
:filter-type="2"
|
:filter-type="2"
|
||||||
@trigger-task="handleTriggerTask"
|
@trigger-task="triggerTask"
|
||||||
/>
|
/>
|
||||||
<ChildRewardList
|
<ChildRewardList
|
||||||
|
ref="childRewardListRef"
|
||||||
:child-id="child ? child.id : null"
|
:child-id="child ? child.id : null"
|
||||||
:child-points="child?.points ?? 0"
|
:child-points="child?.points ?? 0"
|
||||||
:is-parent-authenticated="false"
|
:is-parent-authenticated="false"
|
||||||
@trigger-reward="handleTriggerReward"
|
@trigger-reward="triggerReward"
|
||||||
@points-updated="
|
|
||||||
({ id, points }) => {
|
|
||||||
if (child && child.id === id) child.points = points
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="reward-info">
|
||||||
|
<img
|
||||||
|
v-if="dialogReward.image_id"
|
||||||
|
:src="dialogReward.image_id"
|
||||||
|
alt="Reward Image"
|
||||||
|
class="reward-image"
|
||||||
|
/>
|
||||||
|
<div class="reward-details">
|
||||||
|
<div class="reward-name">{{ dialogReward.name }}</div>
|
||||||
|
<div class="reward-points">{{ dialogReward.cost }} pts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||||
|
Would you like to redeem this reward?
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="confirmRedeemReward">Yes</button>
|
||||||
|
<button @click="cancelRedeemReward">No</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCancelDialog && dialogReward" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="reward-info">
|
||||||
|
<img
|
||||||
|
v-if="dialogReward.image_id"
|
||||||
|
:src="dialogReward.image_id"
|
||||||
|
alt="Reward Image"
|
||||||
|
class="reward-image"
|
||||||
|
/>
|
||||||
|
<div class="reward-details">
|
||||||
|
<div class="reward-name">{{ dialogReward.name }}</div>
|
||||||
|
<div class="reward-points">{{ dialogReward.cost }} pts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-message" style="margin-bottom: 1.2rem">
|
||||||
|
This reward is pending.<br />
|
||||||
|
Would you like to cancel the pending reward request?
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="cancelPendingReward">Yes</button>
|
||||||
|
<button @click="closeCancelDialog">No</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -266,6 +440,84 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px #667eea22;
|
||||||
|
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.dialog-message {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.18s;
|
||||||
|
}
|
||||||
|
.actions button:first-child {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.actions button:last-child {
|
||||||
|
background: #f3f3f3;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.actions button:last-child:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import type {
|
|||||||
Child,
|
Child,
|
||||||
Event,
|
Event,
|
||||||
Reward,
|
Reward,
|
||||||
TaskUpdateEventPayload,
|
ChildTaskTriggeredEventPayload,
|
||||||
RewardUpdateEventPayload,
|
ChildRewardTriggeredEventPayload,
|
||||||
ChildUpdateEventPayload,
|
ChildRewardRequestEventPayload,
|
||||||
ChildDeleteEventPayload,
|
ChildTasksSetEventPayload,
|
||||||
|
ChildRewardsSetEventPayload,
|
||||||
|
ChildModifiedEventPayload,
|
||||||
|
TaskModifiedEventPayload,
|
||||||
|
RewardModifiedEventPayload,
|
||||||
} from '@/common/models'
|
} from '@/common/models'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -22,36 +26,140 @@ const router = useRouter()
|
|||||||
|
|
||||||
const child = ref<Child | null>(null)
|
const child = ref<Child | null>(null)
|
||||||
const tasks = ref<string[]>([])
|
const tasks = ref<string[]>([])
|
||||||
|
const rewards = ref<string[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const rewardListRef = ref()
|
|
||||||
const showConfirm = ref(false)
|
const showConfirm = ref(false)
|
||||||
const selectedTask = ref<Task | null>(null)
|
const selectedTask = ref<Task | null>(null)
|
||||||
const showRewardConfirm = ref(false)
|
const showRewardConfirm = ref(false)
|
||||||
const selectedReward = ref<Reward | null>(null)
|
const selectedReward = ref<Reward | null>(null)
|
||||||
|
const childRewardListRef = ref()
|
||||||
|
const childChoreListRef = ref()
|
||||||
|
const childHabitListRef = ref()
|
||||||
|
|
||||||
function handlePointsUpdate(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
child.value.points = payload.points
|
child.value.points = payload.points
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServerChange(event: Event) {
|
function handleRewardTriggered(event: Event) {
|
||||||
const payload = event.payload as
|
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||||
| TaskUpdateEventPayload
|
|
||||||
| RewardUpdateEventPayload
|
|
||||||
| ChildUpdateEventPayload
|
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
fetchChildData(child.value.id)
|
child.value.points = payload.points
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChildDeletion(event: Event) {
|
function handleChildTaskSet(event: Event) {
|
||||||
const payload = event.payload as ChildDeleteEventPayload
|
console.log('handleChildTaskSet called')
|
||||||
|
const payload = event.payload as ChildTasksSetEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
// Navigate away back to children list
|
tasks.value = payload.task_ids
|
||||||
router.push({ name: 'ChildrenListView' })
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChildRewardSet(event: Event) {
|
||||||
|
const payload = event.payload as ChildRewardsSetEventPayload
|
||||||
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
|
rewards.value = payload.reward_ids
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRewardRequest(event: Event) {
|
||||||
|
const payload = event.payload as ChildRewardRequestEventPayload
|
||||||
|
const childId = payload.child_id
|
||||||
|
const rewardId = payload.reward_id
|
||||||
|
if (child.value && childId == child.value.id) {
|
||||||
|
if (rewards.value.find((r) => r === rewardId)) {
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskModified(event: Event) {
|
||||||
|
const payload = event.payload as TaskModifiedEventPayload
|
||||||
|
if (child.value) {
|
||||||
|
const task_id = payload.task_id
|
||||||
|
if (tasks.value.includes(task_id)) {
|
||||||
|
try {
|
||||||
|
switch (payload.operation) {
|
||||||
|
case 'DELETE':
|
||||||
|
// Remove the task from the list
|
||||||
|
tasks.value = tasks.value.filter((t) => t !== task_id)
|
||||||
|
return // No need to refetch
|
||||||
|
|
||||||
|
case 'ADD':
|
||||||
|
// A new task was added, this shouldn't affect the current task list
|
||||||
|
console.log('ADD operation received for task_modified, no action taken.')
|
||||||
|
return // No need to refetch
|
||||||
|
|
||||||
|
case 'EDIT':
|
||||||
|
try {
|
||||||
|
const dataPromise = fetchChildData(child.value.id)
|
||||||
|
dataPromise.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
tasks.value = data.tasks || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown operation: ${payload.operation}`)
|
||||||
|
return // No need to refetch
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch child after task modification:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRewardModified(event: Event) {
|
||||||
|
const payload = event.payload as RewardModifiedEventPayload
|
||||||
|
if (child.value) {
|
||||||
|
const reward_id = payload.reward_id
|
||||||
|
if (rewards.value.includes(reward_id)) {
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChildModified(event: Event) {
|
||||||
|
const payload = event.payload as ChildModifiedEventPayload
|
||||||
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
|
switch (payload.operation) {
|
||||||
|
case 'DELETE':
|
||||||
|
// Navigate away back to children list
|
||||||
|
router.push({ name: 'ChildrenListView' })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ADD':
|
||||||
|
// A new child was added, this shouldn't affect the current child view
|
||||||
|
console.log('ADD operation received for child_modified, no action taken.')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EDIT':
|
||||||
|
//our child was edited, refetch its data
|
||||||
|
try {
|
||||||
|
const dataPromise = fetchChildData(payload.child_id)
|
||||||
|
dataPromise.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
child.value = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown operation: ${payload.operation}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +169,12 @@ async function fetchChildData(id: string | number) {
|
|||||||
const resp = await fetch(`/api/child/${id}`)
|
const resp = await fetch(`/api/child/${id}`)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
child.value = data.children ? data.children : data
|
|
||||||
tasks.value = data.tasks || []
|
|
||||||
error.value = null
|
error.value = null
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -74,17 +182,26 @@ async function fetchChildData(id: string | number) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
eventBus.on('task_update', handlePointsUpdate)
|
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||||
eventBus.on('reward_update', handlePointsUpdate)
|
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||||
eventBus.on('task_set', handleServerChange)
|
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||||
eventBus.on('reward_set', handleServerChange)
|
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||||
eventBus.on('child_update', handleServerChange)
|
eventBus.on('task_modified', handleTaskModified)
|
||||||
eventBus.on('child_delete', handleChildDeletion)
|
eventBus.on('reward_modified', handleRewardModified)
|
||||||
|
eventBus.on('child_modified', handleChildModified)
|
||||||
|
eventBus.on('child_reward_request', handleRewardRequest)
|
||||||
|
|
||||||
if (route.params.id) {
|
if (route.params.id) {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
if (idParam !== undefined) {
|
if (idParam !== undefined) {
|
||||||
fetchChildData(idParam)
|
const promise = fetchChildData(idParam)
|
||||||
|
promise.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
child.value = data
|
||||||
|
tasks.value = data.tasks || []
|
||||||
|
rewards.value = data.rewards || []
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -93,19 +210,17 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
eventBus.off('task_update', handlePointsUpdate)
|
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||||
eventBus.off('reward_update', handlePointsUpdate)
|
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||||
eventBus.off('task_set', handleServerChange)
|
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||||
eventBus.off('reward_set', handleServerChange)
|
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||||
eventBus.off('child_update', handleServerChange)
|
eventBus.off('child_modified', handleChildModified)
|
||||||
eventBus.off('child_delete', handleChildDeletion)
|
eventBus.off('child_reward_request', handleRewardRequest)
|
||||||
|
eventBus.off('task_modified', handleTaskModified)
|
||||||
|
eventBus.off('reward_modified', handleRewardModified)
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshRewards = () => {
|
const triggerTask = (task: Task) => {
|
||||||
rewardListRef.value?.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTriggerTask = (task: Task) => {
|
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
showConfirm.value = true
|
showConfirm.value = true
|
||||||
}
|
}
|
||||||
@@ -130,7 +245,7 @@ const confirmTriggerTask = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
|
const triggerReward = (reward: Reward, redeemable: boolean) => {
|
||||||
console.log('Handle trigger reward:', reward, redeemable)
|
console.log('Handle trigger reward:', reward, redeemable)
|
||||||
if (!redeemable) return
|
if (!redeemable) return
|
||||||
selectedReward.value = reward
|
selectedReward.value = reward
|
||||||
@@ -174,14 +289,6 @@ function goToAssignRewards() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
const childId = computed(() => child.value?.id ?? null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -195,29 +302,28 @@ const childId = computed(() => child.value?.id ?? null)
|
|||||||
<ChildDetailCard :child="child" />
|
<ChildDetailCard :child="child" />
|
||||||
<ChildTaskList
|
<ChildTaskList
|
||||||
title="Chores"
|
title="Chores"
|
||||||
|
ref="childChoreListRef"
|
||||||
:task-ids="tasks"
|
:task-ids="tasks"
|
||||||
:child-id="childId"
|
:child-id="childId"
|
||||||
:is-parent-authenticated="isParentAuthenticated"
|
:is-parent-authenticated="isParentAuthenticated"
|
||||||
:filter-type="1"
|
:filter-type="1"
|
||||||
@points-updated="handleTaskPointsUpdated"
|
@trigger-task="triggerTask"
|
||||||
@trigger-task="handleTriggerTask"
|
|
||||||
/>
|
/>
|
||||||
<ChildTaskList
|
<ChildTaskList
|
||||||
title="Bad Habits"
|
title="Bad Habits"
|
||||||
|
ref="childHabitListRef"
|
||||||
:task-ids="tasks"
|
:task-ids="tasks"
|
||||||
:child-id="childId"
|
:child-id="childId"
|
||||||
:is-parent-authenticated="isParentAuthenticated"
|
:is-parent-authenticated="isParentAuthenticated"
|
||||||
:filter-type="2"
|
:filter-type="2"
|
||||||
@points-updated="handleTaskPointsUpdated"
|
@trigger-task="triggerTask"
|
||||||
@trigger-task="handleTriggerTask"
|
|
||||||
/>
|
/>
|
||||||
<ChildRewardList
|
<ChildRewardList
|
||||||
ref="rewardListRef"
|
ref="childRewardListRef"
|
||||||
:child-id="childId"
|
:child-id="childId"
|
||||||
:child-points="child?.points ?? 0"
|
:child-points="child?.points ?? 0"
|
||||||
:is-parent-authenticated="false"
|
:is-parent-authenticated="false"
|
||||||
@points-updated="handleRewardPointsUpdated"
|
@trigger-reward="triggerReward"
|
||||||
@trigger-reward="handleTriggerReward"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
202
web/vue-app/src/components/notification/NotificationList.vue
Normal file
202
web/vue-app/src/components/notification/NotificationList.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { getCachedImageUrl } from '../../common/imageCache'
|
||||||
|
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
|
||||||
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
|
||||||
|
const emit = defineEmits(['item-clicked'])
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const notifications = ref<PendingReward[]>([])
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pending-rewards')
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
const rewards: PendingReward[] = data.rewards || []
|
||||||
|
|
||||||
|
// Fetch images for child and reward
|
||||||
|
await Promise.all(
|
||||||
|
rewards.map(async (item) => {
|
||||||
|
if (item.child_image_id) {
|
||||||
|
try {
|
||||||
|
item.child_image_url = await getCachedImageUrl(item.child_image_id)
|
||||||
|
} catch (e) {
|
||||||
|
item.child_image_url = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.reward_image_id) {
|
||||||
|
try {
|
||||||
|
item.reward_image_url = await getCachedImageUrl(item.reward_image_id)
|
||||||
|
} catch (e) {
|
||||||
|
item.reward_image_url = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
notifications.value = rewards
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to fetch notifications'
|
||||||
|
notifications.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRewardRequest(event: Event) {
|
||||||
|
const payload = event.payload as ChildRewardRequestEventPayload
|
||||||
|
const childId = payload.child_id
|
||||||
|
const rewardId = payload.reward_id
|
||||||
|
// Todo: Have event carry more info to avoid full refresh
|
||||||
|
fetchNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
eventBus.on('child_reward_request', handleRewardRequest)
|
||||||
|
await fetchNotifications()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('child_reward_request', handleRewardRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleItemClick(item: PendingReward) {
|
||||||
|
emit('item-clicked', item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="loading" class="loading">Loading notifications...</div>
|
||||||
|
<div v-else-if="error" class="error">{{ error }}</div>
|
||||||
|
<div v-else-if="notifications.length === 0" class="empty">No Notifications</div>
|
||||||
|
<div v-else class="notification-listbox">
|
||||||
|
<div v-for="(item, idx) in notifications" :key="item.id">
|
||||||
|
<div class="notification-list-item" @click="handleItemClick(item)">
|
||||||
|
<div class="child-info">
|
||||||
|
<img
|
||||||
|
v-if="item.child_image_url"
|
||||||
|
:src="item.child_image_url"
|
||||||
|
alt="Child"
|
||||||
|
class="child-image"
|
||||||
|
/>
|
||||||
|
<span class="child-name">{{ item.child_name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="requested-text">requested</span>
|
||||||
|
<div class="reward-info">
|
||||||
|
<span class="reward-name">{{ item.reward_name }}</span>
|
||||||
|
<img
|
||||||
|
v-if="item.reward_image_url"
|
||||||
|
:src="item.reward_image_url"
|
||||||
|
alt="Reward"
|
||||||
|
class="reward-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="idx < notifications.length - 1" class="notification-separator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-listbox {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: calc(100vh - 4.5rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0.2rem 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
background: #fff5;
|
||||||
|
padding: 0.2rem 0.2rem 0.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.notification-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 2px outset #ef4444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.2rem 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border 0.18s;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||||
|
box-sizing: border-box;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.child-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.child-image {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eee;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.child-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
.reward-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.reward-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.reward-image {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eee;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.empty {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: #fdfdfd;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ef4444; /* Red-500 for errors */
|
||||||
|
background: #fff1f2; /* Red-50 for error background */
|
||||||
|
}
|
||||||
|
.notification-list-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.notification-separator {
|
||||||
|
height: 0px;
|
||||||
|
background: #0000;
|
||||||
|
margin: 0rem 0.2rem;
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
.requested-text {
|
||||||
|
margin: 0 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #444;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
web/vue-app/src/components/notification/NotificationView.vue
Normal file
55
web/vue-app/src/components/notification/NotificationView.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="notification-view">
|
||||||
|
<NotificationList @item-clicked="handleNotificationClick" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import NotificationList from './NotificationList.vue'
|
||||||
|
import type { PendingReward } from '@/common/models'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function handleNotificationClick(item: PendingReward) {
|
||||||
|
if (item.child_id) {
|
||||||
|
router.push({ name: 'ParentView', params: { id: item.child_id } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: Floating Action Button styles if you add one */
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
background: #ef4444;
|
||||||
|
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: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
|
||||||
import { defineProps, defineEmits, defineExpose } from 'vue'
|
import { defineProps, defineEmits, defineExpose } from 'vue'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
import type { RewardStatus } from '@/common/models'
|
import type { RewardStatus } from '@/common/models'
|
||||||
@@ -7,11 +7,11 @@ import type { RewardStatus } from '@/common/models'
|
|||||||
const imageCacheName = 'images-v1'
|
const imageCacheName = 'images-v1'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
childId: string | number | null
|
childId: string | null
|
||||||
childPoints: number
|
childPoints: number
|
||||||
isParentAuthenticated: boolean
|
isParentAuthenticated: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(['points-updated', 'trigger-reward'])
|
const emit = defineEmits(['trigger-reward'])
|
||||||
|
|
||||||
const rewards = ref<RewardStatus[]>([])
|
const rewards = ref<RewardStatus[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -107,21 +107,20 @@ const handleRewardClick = async (rewardId: string) => {
|
|||||||
const triggerReward = (rewardId: string) => {
|
const triggerReward = (rewardId: string) => {
|
||||||
const reward = rewards.value.find((rew) => rew.id === rewardId)
|
const reward = rewards.value.find((rew) => rew.id === rewardId)
|
||||||
if (!reward) return // Don't trigger if not allowed
|
if (!reward) return // Don't trigger if not allowed
|
||||||
emit('trigger-reward', reward, reward.points_needed <= 0)
|
emit('trigger-reward', reward, reward.points_needed <= 0, reward.redeeming)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => fetchRewards(props.childId))
|
|
||||||
watch(
|
watch(
|
||||||
() => props.childId,
|
() => props.childId,
|
||||||
(v) => fetchRewards(v),
|
(newId) => {
|
||||||
|
fetchRewards(newId)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.childPoints,
|
() => 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) => {
|
rewards.value.forEach((reward) => {
|
||||||
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
|
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
|
||||||
})
|
})
|
||||||
@@ -135,6 +134,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
// expose refresh method for parent component
|
// expose refresh method for parent component
|
||||||
defineExpose({ refresh: () => fetchRewards(props.childId) })
|
defineExpose({ refresh: () => fetchRewards(props.childId) })
|
||||||
|
|
||||||
|
const isAnyPending = computed(() => rewards.value.some((r) => r.redeeming))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -153,6 +154,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
|
|||||||
class="reward-card"
|
class="reward-card"
|
||||||
:class="{
|
:class="{
|
||||||
ready: readyRewardId === r.id,
|
ready: readyRewardId === r.id,
|
||||||
|
disabled: isAnyPending && !r.redeeming,
|
||||||
}"
|
}"
|
||||||
:ref="(el) => (rewardRefs[r.id] = el)"
|
:ref="(el) => (rewardRefs[r.id] = el)"
|
||||||
@click="() => handleRewardClick(r.id)"
|
@click="() => handleRewardClick(r.id)"
|
||||||
@@ -163,6 +165,8 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
|
|||||||
<template v-if="r.points_needed === 0"> REWARD READY </template>
|
<template v-if="r.points_needed === 0"> REWARD READY </template>
|
||||||
<template v-else> {{ r.points_needed }} pts needed </template>
|
<template v-else> {{ r.points_needed }} pts needed </template>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- PENDING block if redeeming is true -->
|
||||||
|
<div v-if="r.redeeming" class="pending-block">PENDING</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,6 +236,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reward-card {
|
.reward-card {
|
||||||
|
position: relative; /* Add this for overlay positioning */
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -330,4 +335,26 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
|
|||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-block {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 80%;
|
||||||
|
background: #222b;
|
||||||
|
color: #62ff7a;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.95;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue'
|
import { ref, watch, onMounted, computed } from 'vue'
|
||||||
import { getCachedImageUrl } from '../../common/imageCache'
|
import { getCachedImageUrl } from '../../common/imageCache'
|
||||||
|
import type { Reward } from '@/common/models'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
childId?: string | number
|
childId?: string | number
|
||||||
@@ -8,7 +9,7 @@ const props = defineProps<{
|
|||||||
deletable?: boolean
|
deletable?: boolean
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(['edit-reward', 'delete-reward'])
|
const emit = defineEmits(['edit-reward', 'delete-reward', 'loading-complete'])
|
||||||
|
|
||||||
const rewards = ref<
|
const rewards = ref<
|
||||||
{
|
{
|
||||||
@@ -35,11 +36,11 @@ const fetchRewards = async () => {
|
|||||||
const resp = await fetch(url)
|
const resp = await fetch(url)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
const assigned = (data.assigned_rewards || []).map((reward: any) => ({
|
const assigned = (data.assigned_rewards || []).map((reward: Reward) => ({
|
||||||
...reward,
|
...reward,
|
||||||
assigned: true,
|
assigned: true,
|
||||||
}))
|
}))
|
||||||
const assignable = (data.assignable_rewards || []).map((reward: any) => ({
|
const assignable = (data.assignable_rewards || []).map((reward: Reward) => ({
|
||||||
...reward,
|
...reward,
|
||||||
assigned: false,
|
assigned: false,
|
||||||
}))
|
}))
|
||||||
@@ -69,13 +70,14 @@ const fetchRewards = async () => {
|
|||||||
|
|
||||||
// If selectable, pre-select assigned rewards
|
// If selectable, pre-select assigned rewards
|
||||||
if (props.selectable) {
|
if (props.selectable) {
|
||||||
selectedRewards.value = assigned.map((reward: any) => String(reward.id))
|
selectedRewards.value = assigned.map((reward: Reward) => String(reward.id))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||||
rewards.value = []
|
rewards.value = []
|
||||||
if (props.selectable) selectedRewards.value = []
|
if (props.selectable) selectedRewards.value = []
|
||||||
} finally {
|
} finally {
|
||||||
|
emit('loading-complete', rewards.value.length)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reward-view">
|
<div class="task-view">
|
||||||
<RewardList
|
<div v-if="rewardCountRef === 0" class="no-rewards-message">
|
||||||
ref="rewardListRef"
|
<div>No Rewards</div>
|
||||||
:deletable="true"
|
<div class="sub-message">
|
||||||
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
<button class="create-btn" @click="createReward">Create</button> a reward
|
||||||
@delete-reward="confirmDeleteReward"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div class="reward-view" v-else>
|
||||||
|
<RewardList
|
||||||
|
ref="rewardListRef"
|
||||||
|
:deletable="true"
|
||||||
|
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
|
||||||
|
@delete-reward="confirmDeleteReward"
|
||||||
|
@loading-complete="(count) => (rewardCountRef = count)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Floating Action Button -->
|
<!-- Floating Action Button -->
|
||||||
<button class="fab" @click="createReward" aria-label="Create Reward">
|
<button class="fab" @click="createReward" aria-label="Create Reward">
|
||||||
@@ -37,6 +47,7 @@ const $router = useRouter()
|
|||||||
const showConfirm = ref(false)
|
const showConfirm = ref(false)
|
||||||
const rewardToDelete = ref<string | null>(null)
|
const rewardToDelete = ref<string | null>(null)
|
||||||
const rewardListRef = ref()
|
const rewardListRef = ref()
|
||||||
|
const rewardCountRef = ref<number>(0)
|
||||||
|
|
||||||
function confirmDeleteReward(rewardId: string) {
|
function confirmDeleteReward(rewardId: string) {
|
||||||
rewardToDelete.value = rewardId
|
rewardToDelete.value = rewardId
|
||||||
@@ -142,4 +153,37 @@ const createReward = () => {
|
|||||||
.fab:hover {
|
.fab:hover {
|
||||||
background: #5a67d8;
|
background: #5a67d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-rewards-message {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: #fdfdfd;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sub-message {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #b5ccff;
|
||||||
|
}
|
||||||
|
.create-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #2563eb;
|
||||||
|
border: 2px solid #2563eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s,
|
||||||
|
color 0.18s;
|
||||||
|
}
|
||||||
|
.create-btn:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||||
import { defineProps, defineEmits } from 'vue'
|
import { defineProps, defineEmits } from 'vue'
|
||||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||||
import type { Task } from '@/common/models'
|
import type { Task } from '@/common/models'
|
||||||
@@ -14,7 +14,6 @@ const props = defineProps<{
|
|||||||
filterType?: number | null
|
filterType?: number | null
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'points-updated', payload: { id: string; points: number }): void
|
|
||||||
(e: 'trigger-task', task: Task): void
|
(e: 'trigger-task', task: Task): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -119,7 +118,18 @@ const filteredTasks = computed(() => {
|
|||||||
return tasks.value
|
return tasks.value
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(fetchTasks)
|
watch(
|
||||||
|
() => props.taskIds,
|
||||||
|
(newTaskIds) => {
|
||||||
|
if (newTaskIds && newTaskIds.length > 0) {
|
||||||
|
fetchTasks()
|
||||||
|
} else {
|
||||||
|
tasks.value = []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// revoke all created object URLs when component unmounts
|
// revoke all created object URLs when component unmounts
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue'
|
import { ref, watch, onMounted, computed } from 'vue'
|
||||||
import { getCachedImageUrl } from '../../common/imageCache'
|
import { getCachedImageUrl } from '../../common/imageCache'
|
||||||
|
import type { Task } from '@/common/models'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
childId?: string | number
|
childId?: string | number
|
||||||
@@ -9,7 +10,7 @@ const props = defineProps<{
|
|||||||
deletable?: boolean
|
deletable?: boolean
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(['edit-task', 'delete-task'])
|
const emit = defineEmits(['edit-task', 'delete-task', 'loading-complete'])
|
||||||
|
|
||||||
const tasks = ref<
|
const tasks = ref<
|
||||||
{
|
{
|
||||||
@@ -36,12 +37,15 @@ const fetchTasks = async () => {
|
|||||||
const resp = await fetch(url)
|
const resp = await fetch(url)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
const assigned = (data.assigned_tasks || []).map((task: any) => ({ ...task, assigned: true }))
|
const assigned = (data.assigned_tasks || []).map((task: Task) => ({
|
||||||
const assignable = (data.assignable_tasks || []).map((task: any) => ({
|
...task,
|
||||||
|
assigned: true,
|
||||||
|
}))
|
||||||
|
const assignable = (data.assignable_tasks || []).map((task: Task) => ({
|
||||||
...task,
|
...task,
|
||||||
assigned: false,
|
assigned: false,
|
||||||
}))
|
}))
|
||||||
let taskList: any[] = []
|
let taskList: Task[] = []
|
||||||
if (props.assignFilter === 'assignable') {
|
if (props.assignFilter === 'assignable') {
|
||||||
taskList = assignable
|
taskList = assignable
|
||||||
} else if (props.assignFilter === 'assigned') {
|
} else if (props.assignFilter === 'assigned') {
|
||||||
@@ -53,7 +57,7 @@ const fetchTasks = async () => {
|
|||||||
}
|
}
|
||||||
// Fetch images for each task if image_id is present
|
// Fetch images for each task if image_id is present
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
taskList.map(async (task: any) => {
|
taskList.map(async (task: Task) => {
|
||||||
if (task.image_id) {
|
if (task.image_id) {
|
||||||
try {
|
try {
|
||||||
task.image_url = await getCachedImageUrl(task.image_id)
|
task.image_url = await getCachedImageUrl(task.image_id)
|
||||||
@@ -67,7 +71,7 @@ const fetchTasks = async () => {
|
|||||||
|
|
||||||
// If selectable, pre-select assigned tasks
|
// If selectable, pre-select assigned tasks
|
||||||
if (props.selectable) {
|
if (props.selectable) {
|
||||||
selectedTasks.value = assigned.map((task: any) => String(task.id))
|
selectedTasks.value = assigned.map((task: Task) => String(task.id))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||||
@@ -84,7 +88,7 @@ const fetchTasks = async () => {
|
|||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
const taskList = data.tasks || []
|
const taskList = data.tasks || []
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
taskList.map(async (task: any) => {
|
taskList.map(async (task: Task) => {
|
||||||
if (task.image_id) {
|
if (task.image_id) {
|
||||||
try {
|
try {
|
||||||
task.image_url = await getCachedImageUrl(task.image_id)
|
task.image_url = await getCachedImageUrl(task.image_id)
|
||||||
@@ -101,6 +105,7 @@ const fetchTasks = async () => {
|
|||||||
tasks.value = []
|
tasks.value = []
|
||||||
if (props.selectable) selectedTasks.value = []
|
if (props.selectable) selectedTasks.value = []
|
||||||
} finally {
|
} finally {
|
||||||
|
emit('loading-complete', tasks.value.length)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="task-view">
|
<div class="task-view">
|
||||||
|
<div v-if="taskCountRef === 0" class="no-tasks-message">
|
||||||
|
<div>No Tasks</div>
|
||||||
|
<div class="sub-message">
|
||||||
|
<button class="create-btn" @click="createTask">Create</button> a task
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TaskList
|
<TaskList
|
||||||
|
v-else
|
||||||
ref="taskListRef"
|
ref="taskListRef"
|
||||||
:deletable="true"
|
:deletable="true"
|
||||||
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||||
@delete-task="confirmDeleteTask"
|
@delete-task="confirmDeleteTask"
|
||||||
|
@loading-complete="(count) => (taskCountRef = count)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Floating Action Button -->
|
<!-- Floating Action Button -->
|
||||||
@@ -37,6 +46,7 @@ const $router = useRouter()
|
|||||||
const showConfirm = ref(false)
|
const showConfirm = ref(false)
|
||||||
const taskToDelete = ref<string | null>(null)
|
const taskToDelete = ref<string | null>(null)
|
||||||
const taskListRef = ref()
|
const taskListRef = ref()
|
||||||
|
const taskCountRef = ref<number>(0)
|
||||||
|
|
||||||
function confirmDeleteTask(taskId: string) {
|
function confirmDeleteTask(taskId: string) {
|
||||||
taskToDelete.value = taskId
|
taskToDelete.value = taskId
|
||||||
@@ -145,4 +155,37 @@ const createTask = () => {
|
|||||||
.fab:hover {
|
.fab:hover {
|
||||||
background: #5a67d8;
|
background: #5a67d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-tasks-message {
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: #fdfdfd;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sub-message {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #b5ccff;
|
||||||
|
}
|
||||||
|
.create-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #2563eb;
|
||||||
|
border: 2px solid #2563eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s,
|
||||||
|
color 0.18s;
|
||||||
|
}
|
||||||
|
.create-btn:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showBack = computed(
|
const showBack = computed(
|
||||||
() => !(route.path === '/parent' || route.name === 'TaskView' || route.name === 'RewardView'),
|
() =>
|
||||||
|
!(
|
||||||
|
route.path === '/parent' ||
|
||||||
|
route.name === 'TaskView' ||
|
||||||
|
route.name === 'RewardView' ||
|
||||||
|
route.name === 'NotificationView'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,7 +46,7 @@ const showBack = computed(
|
|||||||
aria-label="Children"
|
aria-label="Children"
|
||||||
title="Children"
|
title="Children"
|
||||||
>
|
>
|
||||||
<!-- Children Icon: Two user portraits -->
|
<!-- Children Icon -->
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
@@ -105,6 +111,29 @@ const showBack = computed(
|
|||||||
<rect x="7" y="2" width="10" height="15" rx="5" />
|
<rect x="7" y="2" width="10" height="15" rx="5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: ['NotificationView'].includes(String(route.name)) }"
|
||||||
|
@click="router.push({ name: 'NotificationView' })"
|
||||||
|
aria-label="Notifications"
|
||||||
|
title="Notifications"
|
||||||
|
>
|
||||||
|
<!-- Notification/Bell Icon -->
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.7"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 16v-5a6 6 0 1 0-12 0v5" />
|
||||||
|
<path d="M2 16h20" />
|
||||||
|
<path d="M8 20a4 4 0 0 0 8 0" />
|
||||||
|
<circle cx="19" cy="7" r="2" fill="#ef4444" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<LoginButton class="login-btn" />
|
<LoginButton class="login-btn" />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import RewardEditView from '@/components/reward/RewardEditView.vue'
|
|||||||
import ChildEditView from '@/components/child/ChildEditView.vue'
|
import ChildEditView from '@/components/child/ChildEditView.vue'
|
||||||
import TaskAssignView from '@/components/child/TaskAssignView.vue'
|
import TaskAssignView from '@/components/child/TaskAssignView.vue'
|
||||||
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
||||||
|
import NotificationView from '@/components/notification/NotificationView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -103,6 +104,12 @@ const routes = [
|
|||||||
component: RewardAssignView,
|
component: RewardAssignView,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
name: 'NotificationView',
|
||||||
|
component: NotificationView,
|
||||||
|
props: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user