diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 864a2cb..1a2d4e8 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -7,7 +7,9 @@ use tokio::time::{timeout, Duration}; use crate::db::connection::{create_postgres_connection, create_sqlite_connection}; use crate::db::connection::DatabaseType; -use crate::db::entities::{reasons, score_events, student_tags, students, tags}; +use crate::db::entities::{ + reasons, reward_redemptions, reward_settings, score_events, student_tags, students, tags, +}; use crate::db::migration::run_migration; use crate::services::permission::PermissionLevel; use crate::services::settings::{SettingsKey, SettingsValue}; @@ -93,6 +95,7 @@ pub enum ConflictStrategy { struct StudentNormalized { name: String, score: i32, + reward_points: i32, tags: String, extra_json: Option, created_at: String, @@ -126,6 +129,24 @@ struct EventNormalized { event_time: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RewardSettingNormalized { + name: String, + cost_points: i32, + created_at: String, + updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RewardRedemptionNormalized { + uuid: String, + student_name: String, + reward_id: i32, + reward_name: String, + cost_points: i32, + redeemed_at: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct StudentTagPair { student_name: String, @@ -177,6 +198,7 @@ async fn load_students( StudentNormalized { name: row.name, score: row.score, + reward_points: row.reward_points, tags: normalize_tags(&row.tags), extra_json: row.extra_json, created_at: row.created_at, @@ -256,6 +278,52 @@ async fn load_events( Ok(map) } +async fn load_reward_settings( + conn: &sea_orm::DatabaseConnection, +) -> Result, String> { + let rows = reward_settings::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string())?; + let mut map = std::collections::HashMap::new(); + for row in rows { + map.insert( + row.name.clone(), + RewardSettingNormalized { + name: row.name, + cost_points: row.cost_points, + created_at: row.created_at, + updated_at: row.updated_at, + }, + ); + } + Ok(map) +} + +async fn load_reward_redemptions( + conn: &sea_orm::DatabaseConnection, +) -> Result, String> { + let rows = reward_redemptions::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string())?; + let mut map = std::collections::HashMap::new(); + for row in rows { + map.insert( + row.uuid.clone(), + RewardRedemptionNormalized { + uuid: row.uuid, + student_name: row.student_name, + reward_id: row.reward_id, + reward_name: row.reward_name, + cost_points: row.cost_points, + redeemed_at: row.redeemed_at, + }, + ); + } + Ok(map) +} + async fn load_student_tag_pairs( conn: &sea_orm::DatabaseConnection, ) -> Result, String> { @@ -334,6 +402,7 @@ async fn upsert_student( let normalized_current = StudentNormalized { name: row.name.clone(), score: row.score, + reward_points: row.reward_points, tags: normalize_tags(&row.tags), extra_json: row.extra_json.clone(), created_at: row.created_at.clone(), @@ -344,6 +413,7 @@ async fn upsert_student( } let mut active: students::ActiveModel = row.into(); active.score = Set(data.score); + active.reward_points = Set(data.reward_points); active.tags = Set(data.tags.clone()); active.extra_json = Set(data.extra_json.clone()); active.created_at = Set(data.created_at.clone()); @@ -356,6 +426,7 @@ async fn upsert_student( id: sea_orm::ActiveValue::NotSet, name: Set(data.name.clone()), score: Set(data.score), + reward_points: Set(data.reward_points), tags: Set(data.tags.clone()), extra_json: Set(data.extra_json.clone()), created_at: Set(data.created_at.clone()), @@ -505,6 +576,98 @@ async fn upsert_event( } } +async fn upsert_reward_setting( + conn: &sea_orm::DatabaseConnection, + data: &RewardSettingNormalized, +) -> Result { + let existing = reward_settings::Entity::find() + .filter(reward_settings::Column::Name.eq(&data.name)) + .one(conn) + .await + .map_err(|e| e.to_string())?; + match existing { + Some(row) => { + let normalized_current = RewardSettingNormalized { + name: row.name.clone(), + cost_points: row.cost_points, + created_at: row.created_at.clone(), + updated_at: row.updated_at.clone(), + }; + if normalized_current == *data { + return Ok(false); + } + let mut active: reward_settings::ActiveModel = row.into(); + active.cost_points = Set(data.cost_points); + active.created_at = Set(data.created_at.clone()); + active.updated_at = Set(data.updated_at.clone()); + active.update(conn).await.map_err(|e| e.to_string())?; + Ok(true) + } + None => { + reward_settings::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + name: Set(data.name.clone()), + cost_points: Set(data.cost_points), + created_at: Set(data.created_at.clone()), + updated_at: Set(data.updated_at.clone()), + } + .insert(conn) + .await + .map_err(|e| e.to_string())?; + Ok(true) + } + } +} + +async fn upsert_reward_redemption( + conn: &sea_orm::DatabaseConnection, + data: &RewardRedemptionNormalized, +) -> Result { + let existing = reward_redemptions::Entity::find() + .filter(reward_redemptions::Column::Uuid.eq(&data.uuid)) + .one(conn) + .await + .map_err(|e| e.to_string())?; + match existing { + Some(row) => { + let normalized_current = RewardRedemptionNormalized { + uuid: row.uuid.clone(), + student_name: row.student_name.clone(), + reward_id: row.reward_id, + reward_name: row.reward_name.clone(), + cost_points: row.cost_points, + redeemed_at: row.redeemed_at.clone(), + }; + if normalized_current == *data { + return Ok(false); + } + let mut active: reward_redemptions::ActiveModel = row.into(); + active.student_name = Set(data.student_name.clone()); + active.reward_id = Set(data.reward_id); + active.reward_name = Set(data.reward_name.clone()); + active.cost_points = Set(data.cost_points); + active.redeemed_at = Set(data.redeemed_at.clone()); + active.update(conn).await.map_err(|e| e.to_string())?; + Ok(true) + } + None => { + reward_redemptions::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(data.uuid.clone()), + student_name: Set(data.student_name.clone()), + reward_id: Set(data.reward_id), + reward_name: Set(data.reward_name.clone()), + cost_points: Set(data.cost_points), + redeemed_at: Set(data.redeemed_at.clone()), + } + .insert(conn) + .await + .map_err(|e| e.to_string())?; + Ok(true) + } + } +} + async fn ensure_student_tag_pair( conn: &sea_orm::DatabaseConnection, pair: &StudentTagPair, @@ -625,6 +788,16 @@ pub async fn realtime_dual_write_sync(app_state: &Arc>) -> Resu let _ = upsert_event(&local_conn, event).await?; } + let remote_reward_settings = load_reward_settings(&remote_conn).await?; + for reward in remote_reward_settings.values() { + let _ = upsert_reward_setting(&local_conn, reward).await?; + } + + let remote_redemptions = load_reward_redemptions(&remote_conn).await?; + for redemption in remote_redemptions.values() { + let _ = upsert_reward_redemption(&local_conn, redemption).await?; + } + let remote_pairs = load_student_tag_pairs(&remote_conn).await?; for pair in &remote_pairs { let _ = ensure_student_tag_pair(&local_conn, pair).await?; @@ -653,6 +826,8 @@ async fn db_sync_apply_internal( let local_reasons = load_reasons(&local_conn).await?; let local_tags = load_tags(&local_conn).await?; let local_events = load_events(&local_conn).await?; + let local_reward_settings = load_reward_settings(&local_conn).await?; + let local_reward_redemptions = load_reward_redemptions(&local_conn).await?; let local_pairs = load_student_tag_pairs(&local_conn).await?; let remote_pairs = load_student_tag_pairs(&remote_conn).await?; @@ -709,6 +884,28 @@ async fn db_sync_apply_internal( synced_records += 1; } } + for reward in local_reward_settings.values() { + if upsert_reward_setting(&remote_conn, reward).await? { + synced_records += 1; + } + } + let remote_reward_settings_after = load_reward_settings(&remote_conn).await?; + for reward in remote_reward_settings_after.values() { + if upsert_reward_setting(&local_conn, reward).await? { + synced_records += 1; + } + } + for redemption in local_reward_redemptions.values() { + if upsert_reward_redemption(&remote_conn, redemption).await? { + synced_records += 1; + } + } + let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?; + for redemption in remote_reward_redemptions_after.values() { + if upsert_reward_redemption(&local_conn, redemption).await? { + synced_records += 1; + } + } for pair in local_pairs.union(&remote_pairs) { if ensure_student_tag_pair(&local_conn, pair).await? { @@ -743,6 +940,18 @@ async fn db_sync_apply_internal( resolved_conflicts += 1; } } + let preferred_reward_settings = load_reward_settings(preferred).await?; + for reward in preferred_reward_settings.values() { + if upsert_reward_setting(target, reward).await? { + resolved_conflicts += 1; + } + } + let preferred_reward_redemptions = load_reward_redemptions(preferred).await?; + for redemption in preferred_reward_redemptions.values() { + if upsert_reward_redemption(target, redemption).await? { + resolved_conflicts += 1; + } + } let preferred_pairs = load_student_tag_pairs(preferred).await?; for pair in &preferred_pairs { if ensure_student_tag_pair(target, pair).await? { @@ -1005,6 +1214,10 @@ pub async fn db_sync_preview( let remote_tags = load_tags(&remote_conn).await?; let local_events = load_events(&local_conn).await?; let remote_events = load_events(&remote_conn).await?; + let local_reward_settings = load_reward_settings(&local_conn).await?; + let remote_reward_settings = load_reward_settings(&remote_conn).await?; + let local_reward_redemptions = load_reward_redemptions(&local_conn).await?; + let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?; let local_pairs = load_student_tag_pairs(&local_conn).await?; let remote_pairs = load_student_tag_pairs(&remote_conn).await?; @@ -1016,9 +1229,29 @@ pub async fn db_sync_preview( compare_maps("tags", &local_tags, &remote_tags); let (evt_local_only, evt_remote_only, evt_conflicts) = compare_maps("score_events", &local_events, &remote_events); + let (reward_local_only, reward_remote_only, reward_conflicts) = compare_maps( + "reward_settings", + &local_reward_settings, + &remote_reward_settings, + ); + let (redeem_local_only, redeem_remote_only, redeem_conflicts) = compare_maps( + "reward_redemptions", + &local_reward_redemptions, + &remote_reward_redemptions, + ); - let mut local_only = stu_local_only + rea_local_only + tag_local_only + evt_local_only; - let mut remote_only = stu_remote_only + rea_remote_only + tag_remote_only + evt_remote_only; + let mut local_only = stu_local_only + + rea_local_only + + tag_local_only + + evt_local_only + + reward_local_only + + redeem_local_only; + let mut remote_only = stu_remote_only + + rea_remote_only + + tag_remote_only + + evt_remote_only + + reward_remote_only + + redeem_remote_only; for pair in &local_pairs { if !remote_pairs.contains(pair) { @@ -1038,8 +1271,14 @@ pub async fn db_sync_preview( conflicts.push(DbSyncConflict { table, key, - local_summary: format!("score={}, updated_at={}", local.score, local.updated_at), - remote_summary: format!("score={}, updated_at={}", remote.score, remote.updated_at), + local_summary: format!( + "score={}, reward_points={}, updated_at={}", + local.score, local.reward_points, local.updated_at + ), + remote_summary: format!( + "score={}, reward_points={}, updated_at={}", + remote.score, remote.reward_points, remote.updated_at + ), }); } for (table, key) in rea_conflicts { @@ -1078,6 +1317,46 @@ pub async fn db_sync_preview( ), }); } + for (table, key) in reward_conflicts { + let local = local_reward_settings + .get(&key) + .expect("local reward setting exists"); + let remote = remote_reward_settings + .get(&key) + .expect("remote reward setting exists"); + conflicts.push(DbSyncConflict { + table, + key, + local_summary: format!( + "cost_points={}, updated_at={}", + local.cost_points, local.updated_at + ), + remote_summary: format!( + "cost_points={}, updated_at={}", + remote.cost_points, remote.updated_at + ), + }); + } + for (table, key) in redeem_conflicts { + let local = local_reward_redemptions + .get(&key) + .expect("local redemption exists"); + let remote = remote_reward_redemptions + .get(&key) + .expect("remote redemption exists"); + conflicts.push(DbSyncConflict { + table, + key, + local_summary: format!( + "student={}, reward={}, cost={}, redeemed_at={}", + local.student_name, local.reward_name, local.cost_points, local.redeemed_at + ), + remote_summary: format!( + "student={}, reward={}, cost={}, redeemed_at={}", + remote.student_name, remote.reward_name, remote.cost_points, remote.redeemed_at + ), + }); + } let need_sync = local_only > 0 || remote_only > 0 || !conflicts.is_empty(); Ok(IpcResponse::success(DbSyncPreviewResult { diff --git a/src-tauri/src/commands/event.rs b/src-tauri/src/commands/event.rs index a181fd6..ff2a8d6 100644 --- a/src-tauri/src/commands/event.rs +++ b/src-tauri/src/commands/event.rs @@ -147,6 +147,7 @@ pub async fn event_create( Ok(Some(student)) => { let val_prev = student.score; let val_curr = val_prev + data.delta; + let reward_points_next = student.reward_points + data.delta; let now = chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") .to_string(); @@ -170,6 +171,7 @@ pub async fn event_create( let mut active: students::ActiveModel = student.into(); active.score = Set(val_curr); + active.reward_points = Set(reward_points_next); active.updated_at = Set(now); active.update(&txn).await.map_err(|e| e.to_string())?; @@ -244,12 +246,14 @@ pub async fn event_delete( if let Some(student) = student { let new_score = student.score - event.delta; + let new_reward_points = student.reward_points - event.delta; let now = chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") .to_string(); let mut active: students::ActiveModel = student.into(); active.score = Set(new_score); + active.reward_points = Set(new_reward_points); active.updated_at = Set(now); active.update(&txn).await.map_err(|e| e.to_string())?; } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c920352..f7fdd5b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod filesystem; pub mod http_server; pub mod log; pub mod reason; +pub mod reward; pub mod response; pub mod settings; pub mod settlement; @@ -26,6 +27,7 @@ pub use filesystem::*; pub use http_server::*; pub use log::*; pub use reason::*; +pub use reward::*; pub use response::*; pub use settings::*; pub use settlement::*; diff --git a/src-tauri/src/commands/reward.rs b/src-tauri/src/commands/reward.rs new file mode 100644 index 0000000..4b319c1 --- /dev/null +++ b/src-tauri/src/commands/reward.rs @@ -0,0 +1,372 @@ +use parking_lot::RwLock; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, + TransactionTrait, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::State; +use uuid::Uuid; + +use crate::db::entities::{reward_redemptions, reward_settings, students}; +use crate::services::PermissionLevel; +use crate::state::AppState; + +use super::database::realtime_dual_write_sync; +use super::response::IpcResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewardSettingDto { + pub id: i32, + pub name: String, + pub cost_points: i32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RewardRedemptionDto { + pub id: i32, + pub uuid: String, + pub student_name: String, + pub reward_id: i32, + pub reward_name: String, + pub cost_points: i32, + pub redeemed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRewardSettingData { + pub name: String, + pub cost_points: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRewardSettingData { + pub name: Option, + pub cost_points: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedeemRewardData { + pub student_name: String, + pub reward_id: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedeemRewardResult { + pub redemption_id: i32, + pub remaining_reward_points: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryRewardRedemptionsParams { + pub limit: Option, +} + +fn require_permission( + state: &Arc>, + level: PermissionLevel, +) -> Result<(), IpcResponse<()>> { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if permissions.require_permission(0, level) { + Ok(()) + } else { + Err(IpcResponse::error("Permission denied")) + } +} + +#[tauri::command] +pub async fn reward_setting_query( + state: State<'_, Arc>>, +) -> Result>, String> { + if require_permission(&state, PermissionLevel::View).is_err() { + return Ok(IpcResponse::error("Permission denied: view required")); + } + + let state_guard = state.read(); + let db_guard = state_guard.db.read(); + let Some(conn) = db_guard.as_ref() else { + return Ok(IpcResponse::error("Database not connected")); + }; + + let rows = reward_settings::Entity::find() + .order_by_asc(reward_settings::Column::CostPoints) + .order_by_asc(reward_settings::Column::Name) + .all(conn) + .await + .map_err(|e| e.to_string())?; + + let data = rows + .into_iter() + .map(|row| RewardSettingDto { + id: row.id, + name: row.name, + cost_points: row.cost_points, + created_at: row.created_at, + updated_at: row.updated_at, + }) + .collect(); + + Ok(IpcResponse::success(data)) +} + +#[tauri::command] +pub async fn reward_setting_create( + state: State<'_, Arc>>, + data: CreateRewardSettingData, +) -> Result, String> { + if require_permission(&state, PermissionLevel::Admin).is_err() { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + let name = data.name.trim(); + if name.is_empty() { + return Ok(IpcResponse::error("Reward name cannot be empty")); + } + if data.cost_points <= 0 { + return Ok(IpcResponse::error("Reward cost points must be positive")); + } + + let state_guard = state.read(); + let db_guard = state_guard.db.read(); + let Some(conn) = db_guard.as_ref() else { + return Ok(IpcResponse::error("Database not connected")); + }; + + let existing = reward_settings::Entity::find() + .filter(reward_settings::Column::Name.eq(name)) + .one(conn) + .await + .map_err(|e| e.to_string())?; + + if existing.is_some() { + return Ok(IpcResponse::error("Reward with this name already exists")); + } + + let now = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + + let inserted = reward_settings::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + name: Set(name.to_string()), + cost_points: Set(data.cost_points), + created_at: Set(now.clone()), + updated_at: Set(now), + } + .insert(conn) + .await + .map_err(|e| e.to_string())?; + + realtime_dual_write_sync(state.inner()).await?; + Ok(IpcResponse::success(inserted.id)) +} + +#[tauri::command] +pub async fn reward_setting_update( + state: State<'_, Arc>>, + id: i32, + data: UpdateRewardSettingData, +) -> Result, String> { + if require_permission(&state, PermissionLevel::Admin).is_err() { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + let state_guard = state.read(); + let db_guard = state_guard.db.read(); + let Some(conn) = db_guard.as_ref() else { + return Ok(IpcResponse::error("Database not connected")); + }; + + let Some(row) = reward_settings::Entity::find_by_id(id) + .one(conn) + .await + .map_err(|e| e.to_string())? + else { + return Ok(IpcResponse::error("Reward not found")); + }; + + let mut active: reward_settings::ActiveModel = row.into(); + + if let Some(name) = data.name { + let name = name.trim().to_string(); + if name.is_empty() { + return Ok(IpcResponse::error("Reward name cannot be empty")); + } + let existing = reward_settings::Entity::find() + .filter(reward_settings::Column::Name.eq(&name)) + .filter(reward_settings::Column::Id.ne(id)) + .one(conn) + .await + .map_err(|e| e.to_string())?; + if existing.is_some() { + return Ok(IpcResponse::error("Reward with this name already exists")); + } + active.name = Set(name); + } + + if let Some(cost_points) = data.cost_points { + if cost_points <= 0 { + return Ok(IpcResponse::error("Reward cost points must be positive")); + } + active.cost_points = Set(cost_points); + } + + let now = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + active.updated_at = Set(now); + + active.update(conn).await.map_err(|e| e.to_string())?; + realtime_dual_write_sync(state.inner()).await?; + Ok(IpcResponse::success_empty()) +} + +#[tauri::command] +pub async fn reward_setting_delete( + state: State<'_, Arc>>, + id: i32, +) -> Result, String> { + if require_permission(&state, PermissionLevel::Admin).is_err() { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + let state_guard = state.read(); + let db_guard = state_guard.db.read(); + let Some(conn) = db_guard.as_ref() else { + return Ok(IpcResponse::error("Database not connected")); + }; + + let deleted = reward_settings::Entity::delete_by_id(id) + .exec(conn) + .await + .map_err(|e| e.to_string())?; + + if deleted.rows_affected == 0 { + return Ok(IpcResponse::error("Reward not found")); + } + + realtime_dual_write_sync(state.inner()).await?; + Ok(IpcResponse::success_empty()) +} + +#[tauri::command] +pub async fn reward_redeem( + state: State<'_, Arc>>, + data: RedeemRewardData, +) -> Result, String> { + if require_permission(&state, PermissionLevel::Points).is_err() { + return Ok(IpcResponse::error("Permission denied: points required")); + } + + let student_name = data.student_name.trim(); + if student_name.is_empty() { + return Ok(IpcResponse::error("Student name cannot be empty")); + } + + let state_guard = state.read(); + let db_guard = state_guard.db.read(); + let Some(conn) = db_guard.as_ref() else { + return Ok(IpcResponse::error("Database not connected")); + }; + + let txn = conn.begin().await.map_err(|e| e.to_string())?; + + let Some(student) = students::Entity::find() + .filter(students::Column::Name.eq(student_name)) + .one(&txn) + .await + .map_err(|e| e.to_string())? + else { + return Ok(IpcResponse::error("Student not found")); + }; + + let Some(reward) = reward_settings::Entity::find_by_id(data.reward_id) + .one(&txn) + .await + .map_err(|e| e.to_string())? + else { + return Ok(IpcResponse::error("Reward not found")); + }; + + if student.reward_points < reward.cost_points { + return Ok(IpcResponse::error("Insufficient reward points")); + } + + let remaining = student.reward_points - reward.cost_points; + let now = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + let uuid = Uuid::new_v4().to_string(); + + let redemption = reward_redemptions::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(uuid), + student_name: Set(student_name.to_string()), + reward_id: Set(reward.id), + reward_name: Set(reward.name.clone()), + cost_points: Set(reward.cost_points), + redeemed_at: Set(now.clone()), + } + .insert(&txn) + .await + .map_err(|e| e.to_string())?; + + let mut active_student: students::ActiveModel = student.into(); + active_student.reward_points = Set(remaining); + active_student.updated_at = Set(now); + active_student + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + txn.commit().await.map_err(|e| e.to_string())?; + realtime_dual_write_sync(state.inner()).await?; + + Ok(IpcResponse::success(RedeemRewardResult { + redemption_id: redemption.id, + remaining_reward_points: remaining, + })) +} + +#[tauri::command] +pub async fn reward_redemption_query( + state: State<'_, Arc>>, + params: Option, +) -> Result>, String> { + if require_permission(&state, PermissionLevel::View).is_err() { + return Ok(IpcResponse::error("Permission denied: view required")); + } + + let state_guard = state.read(); + let db_guard = state_guard.db.read(); + let Some(conn) = db_guard.as_ref() else { + return Ok(IpcResponse::error("Database not connected")); + }; + + let limit = params.and_then(|p| p.limit).unwrap_or(100); + + let rows = reward_redemptions::Entity::find() + .order_by_desc(reward_redemptions::Column::RedeemedAt) + .limit(limit) + .all(conn) + .await + .map_err(|e| e.to_string())?; + + let data = rows + .into_iter() + .map(|row| RewardRedemptionDto { + id: row.id, + uuid: row.uuid, + student_name: row.student_name, + reward_id: row.reward_id, + reward_name: row.reward_name, + cost_points: row.cost_points, + redeemed_at: row.redeemed_at, + }) + .collect(); + + Ok(IpcResponse::success(data)) +} diff --git a/src-tauri/src/commands/student.rs b/src-tauri/src/commands/student.rs index 8222d3d..b7c340c 100644 --- a/src-tauri/src/commands/student.rs +++ b/src-tauri/src/commands/student.rs @@ -65,6 +65,7 @@ pub async fn student_query( id: s.id, name: s.name, score: s.score, + reward_points: s.reward_points, tags: serde_json::from_str(&s.tags).unwrap_or_default(), extra_json: s.extra_json, }) @@ -120,6 +121,7 @@ pub async fn student_create( id: sea_orm::ActiveValue::NotSet, name: Set(name.to_string()), score: Set(0), + reward_points: Set(0), tags: Set("[]".to_string()), extra_json: Set(None), created_at: Set(now.clone()), @@ -176,6 +178,9 @@ pub async fn student_update( if let Some(score) = data.score { active.score = Set(score); } + if let Some(reward_points) = data.reward_points { + active.reward_points = Set(reward_points); + } if let Some(tags) = data.tags { active.tags = Set(serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string())); @@ -227,6 +232,7 @@ pub async fn student_delete( id: sea_orm::ActiveValue::Set(student.id), name: sea_orm::ActiveValue::Unchanged(student.name), score: sea_orm::ActiveValue::Unchanged(student.score), + reward_points: sea_orm::ActiveValue::Unchanged(student.reward_points), tags: sea_orm::ActiveValue::Unchanged(student.tags), extra_json: sea_orm::ActiveValue::Unchanged(student.extra_json), created_at: sea_orm::ActiveValue::Unchanged(student.created_at), @@ -312,6 +318,7 @@ pub async fn student_import_from_xlsx( id: sea_orm::ActiveValue::NotSet, name: Set(name.to_string()), score: Set(0), + reward_points: Set(0), tags: Set("[]".to_string()), extra_json: Set(None), created_at: Set(now.clone()), diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 3d12a1a..950ffdc 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -1,10 +1,14 @@ pub mod reasons; +pub mod reward_redemptions; +pub mod reward_settings; pub mod score_events; pub mod student_tags; pub mod students; pub mod tags; pub use reasons::Entity as Reasons; +pub use reward_redemptions::Entity as RewardRedemptions; +pub use reward_settings::Entity as RewardSettings; pub use score_events::Entity as ScoreEvents; pub use student_tags::Entity as StudentTags; pub use students::Entity as Students; diff --git a/src-tauri/src/db/entities/reward_redemptions.rs b/src-tauri/src/db/entities/reward_redemptions.rs new file mode 100644 index 0000000..723b765 --- /dev/null +++ b/src-tauri/src/db/entities/reward_redemptions.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "reward_redemptions")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub uuid: String, + pub student_name: String, + pub reward_id: i32, + pub reward_name: String, + pub cost_points: i32, + pub redeemed_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/reward_settings.rs b/src-tauri/src/db/entities/reward_settings.rs new file mode 100644 index 0000000..688e3bc --- /dev/null +++ b/src-tauri/src/db/entities/reward_settings.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "reward_settings")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub cost_points: i32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/students.rs b/src-tauri/src/db/entities/students.rs index 0869c31..6a0498a 100644 --- a/src-tauri/src/db/entities/students.rs +++ b/src-tauri/src/db/entities/students.rs @@ -8,6 +8,7 @@ pub struct Model { pub id: i32, pub name: String, pub score: i32, + pub reward_points: i32, pub tags: String, pub extra_json: Option, pub created_at: String, diff --git a/src-tauri/src/db/migration.rs b/src-tauri/src/db/migration.rs index 42d7552..33f504a 100644 --- a/src-tauri/src/db/migration.rs +++ b/src-tauri/src/db/migration.rs @@ -19,6 +19,9 @@ impl Migration { Self::create_settings_table(conn, is_sqlite).await?; Self::create_tags_table(conn, is_sqlite).await?; Self::create_student_tags_table(conn, is_sqlite).await?; + Self::create_reward_settings_table(conn, is_sqlite).await?; + Self::create_reward_redemptions_table(conn, is_sqlite).await?; + Self::ensure_students_reward_points_column(conn, is_sqlite).await?; Self::create_indexes(conn, is_sqlite).await?; @@ -93,11 +96,64 @@ impl Migration { Ok(()) } + async fn create_reward_settings_table( + conn: &impl ConnectionTrait, + sqlite: bool, + ) -> Result<(), DbErr> { + let sql = get_create_reward_settings_table_sql(sqlite); + conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql)) + .await?; + info!("Created reward_settings table"); + Ok(()) + } + + async fn create_reward_redemptions_table( + conn: &impl ConnectionTrait, + sqlite: bool, + ) -> Result<(), DbErr> { + let sql = get_create_reward_redemptions_table_sql(sqlite); + conn.execute(Statement::from_string(Self::get_db_backend(sqlite), sql)) + .await?; + info!("Created reward_redemptions table"); + Ok(()) + } + + async fn ensure_students_reward_points_column( + conn: &impl ConnectionTrait, + sqlite: bool, + ) -> Result<(), DbErr> { + let db_backend = Self::get_db_backend(sqlite); + let exists_sql = if sqlite { + "SELECT 1 AS exists FROM pragma_table_info('students') WHERE name = 'reward_points' LIMIT 1" + .to_string() + } else { + "SELECT 1 AS exists FROM information_schema.columns WHERE table_name = 'students' AND column_name = 'reward_points' LIMIT 1" + .to_string() + }; + + let exists = conn + .query_one(Statement::from_string(db_backend.clone(), exists_sql)) + .await? + .is_some(); + + if !exists { + let alter_sql = "ALTER TABLE students ADD COLUMN reward_points INTEGER DEFAULT 0"; + conn.execute(Statement::from_string(db_backend.clone(), alter_sql.to_string())) + .await?; + info!("Added students.reward_points column"); + } + + Ok(()) + } + async fn create_indexes(conn: &impl ConnectionTrait, sqlite: bool) -> Result<(), DbErr> { let indexes = vec![ get_create_index_score_events_settlement_id_sql(sqlite), get_create_index_score_events_student_name_sql(sqlite), get_create_index_reasons_content_sql(sqlite), + get_create_index_reward_settings_name_sql(sqlite), + get_create_index_reward_redemptions_student_name_sql(sqlite), + get_create_index_reward_redemptions_reward_id_sql(sqlite), ]; for index_sql in indexes { @@ -251,6 +307,8 @@ impl Migration { TABLE_REASONS, TABLE_TAGS, TABLE_STUDENTS, + TABLE_REWARD_REDEMPTIONS, + TABLE_REWARD_SETTINGS, TABLE_SETTINGS, ]; diff --git a/src-tauri/src/db/repositories/student_repo.rs b/src-tauri/src/db/repositories/student_repo.rs index 46670a6..f7e633a 100644 --- a/src-tauri/src/db/repositories/student_repo.rs +++ b/src-tauri/src/db/repositories/student_repo.rs @@ -30,7 +30,7 @@ impl StudentRepository { pub async fn find_all(&self) -> Result, sqlx::Error> { if let Some(pool) = &self.sqlite_pool { let students = sqlx::query_as::( - r#"SELECT id, name, score, tags, extra_json, created_at, updated_at + r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students ORDER BY score DESC, name ASC"# ) @@ -40,7 +40,7 @@ impl StudentRepository { Ok(students.into_iter().map(StudentWithTags::from).collect()) } else if let Some(pool) = &self.postgres_pool { let students = sqlx::query_as::( - r#"SELECT id, name, score, tags, extra_json, created_at, updated_at + r#"SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students ORDER BY score DESC, name ASC"# ) @@ -59,8 +59,8 @@ impl StudentRepository { if let Some(pool) = &self.sqlite_pool { let result = sqlx::query( - r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) - VALUES (?, 0, '[]', NULL, ?, ?)"# + r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) + VALUES (?, 0, 0, '[]', NULL, ?, ?)"# ) .bind(name) .bind(&now) @@ -71,8 +71,8 @@ impl StudentRepository { Ok(result.last_insert_rowid() as i32) } else if let Some(pool) = &self.postgres_pool { let result = sqlx::query_scalar::( - r#"INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) - VALUES ($1, 0, '[]', NULL, $2, $3) + r#"INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) + VALUES ($1, 0, 0, '[]', NULL, $2, $3) RETURNING id"# ) .bind(name) @@ -102,6 +102,10 @@ impl StudentRepository { query.push(", score = "); query.push_bind(score); } + if let Some(reward_points) = data.reward_points { + query.push(", reward_points = "); + query.push_bind(reward_points); + } if let Some(tags) = &data.tags { query.push(", tags = "); query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())); @@ -127,6 +131,10 @@ impl StudentRepository { query.push(", score = "); query.push_bind(score); } + if let Some(reward_points) = data.reward_points { + query.push(", reward_points = "); + query.push_bind(reward_points); + } if let Some(tags) = &data.tags { query.push(", tags = "); query.push_bind(serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())); @@ -148,7 +156,7 @@ impl StudentRepository { pub async fn delete(&self, id: i32) -> Result<(), sqlx::Error> { if let Some(pool) = &self.sqlite_pool { let student = sqlx::query_as::( - "SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = ?" + "SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = ?" ) .bind(id) .fetch_optional(pool) @@ -171,7 +179,7 @@ impl StudentRepository { } } else if let Some(pool) = &self.postgres_pool { let student = sqlx::query_as::( - "SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE id = $1" + "SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE id = $1" ) .bind(id) .fetch_optional(pool) @@ -239,7 +247,7 @@ impl StudentRepository { for name in &to_insert { sqlx::query( - "INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES (?, 0, '[]', NULL, ?, ?)" + "INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES (?, 0, 0, '[]', NULL, ?, ?)" ) .bind(name) .bind(&now) @@ -274,7 +282,7 @@ impl StudentRepository { for name in &to_insert { sqlx::query( - "INSERT INTO students (name, score, tags, extra_json, created_at, updated_at) VALUES ($1, 0, '[]', NULL, $2, $3)" + "INSERT INTO students (name, score, reward_points, tags, extra_json, created_at, updated_at) VALUES ($1, 0, 0, '[]', NULL, $2, $3)" ) .bind(name) .bind(&now) @@ -298,14 +306,14 @@ impl StudentRepository { pub async fn find_by_name(&self, name: &str) -> Result, sqlx::Error> { if let Some(pool) = &self.sqlite_pool { sqlx::query_as::( - "SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = ?" + "SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = ?" ) .bind(name) .fetch_optional(pool) .await } else if let Some(pool) = &self.postgres_pool { sqlx::query_as::( - "SELECT id, name, score, tags, extra_json, created_at, updated_at FROM students WHERE name = $1" + "SELECT id, name, score, reward_points, tags, extra_json, created_at, updated_at FROM students WHERE name = $1" ) .bind(name) .fetch_optional(pool) diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 75ec7f9..28441b4 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -5,6 +5,8 @@ pub const TABLE_SETTLEMENTS: &str = "settlements"; pub const TABLE_SETTINGS: &str = "settings"; pub const TABLE_TAGS: &str = "tags"; pub const TABLE_STUDENT_TAGS: &str = "student_tags"; +pub const TABLE_REWARD_SETTINGS: &str = "reward_settings"; +pub const TABLE_REWARD_REDEMPTIONS: &str = "reward_redemptions"; pub mod students { pub const TABLE: &str = "students"; @@ -12,6 +14,7 @@ pub mod students { pub const NAME: &str = "name"; pub const TAGS: &str = "tags"; pub const SCORE: &str = "score"; + pub const REWARD_POINTS: &str = "reward_points"; pub const EXTRA_JSON: &str = "extra_json"; pub const CREATED_AT: &str = "created_at"; pub const UPDATED_AT: &str = "updated_at"; @@ -70,6 +73,26 @@ pub mod student_tags { pub const CREATED_AT: &str = "created_at"; } +pub mod reward_settings { + pub const TABLE: &str = "reward_settings"; + pub const ID: &str = "id"; + pub const NAME: &str = "name"; + pub const COST_POINTS: &str = "cost_points"; + pub const CREATED_AT: &str = "created_at"; + pub const UPDATED_AT: &str = "updated_at"; +} + +pub mod reward_redemptions { + pub const TABLE: &str = "reward_redemptions"; + pub const ID: &str = "id"; + pub const UUID: &str = "uuid"; + pub const STUDENT_NAME: &str = "student_name"; + pub const REWARD_ID: &str = "reward_id"; + pub const REWARD_NAME: &str = "reward_name"; + pub const COST_POINTS: &str = "cost_points"; + pub const REDEEMED_AT: &str = "redeemed_at"; +} + pub fn get_create_students_table_sql(sqlite: bool) -> String { if sqlite { r#" @@ -78,6 +101,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String { name TEXT NOT NULL, tags TEXT DEFAULT '[]', score INTEGER DEFAULT 0, + reward_points INTEGER DEFAULT 0, extra_json TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')), updated_at TEXT DEFAULT (datetime('now', 'localtime')) @@ -91,6 +115,7 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String { name TEXT NOT NULL, tags TEXT DEFAULT '[]', score INTEGER DEFAULT 0, + reward_points INTEGER DEFAULT 0, extra_json TEXT, created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')), updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) @@ -100,6 +125,62 @@ pub fn get_create_students_table_sql(sqlite: bool) -> String { } } +pub fn get_create_reward_settings_table_sql(sqlite: bool) -> String { + if sqlite { + r#" + CREATE TABLE IF NOT EXISTS reward_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + cost_points INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')) + ) + "# + .to_string() + } else { + r#" + CREATE TABLE IF NOT EXISTS reward_settings ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + cost_points INTEGER NOT NULL, + created_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')), + updated_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) + ) + "# + .to_string() + } +} + +pub fn get_create_reward_redemptions_table_sql(sqlite: bool) -> String { + if sqlite { + r#" + CREATE TABLE IF NOT EXISTS reward_redemptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + student_name TEXT NOT NULL, + reward_id INTEGER NOT NULL, + reward_name TEXT NOT NULL, + cost_points INTEGER NOT NULL, + redeemed_at TEXT DEFAULT (datetime('now', 'localtime')) + ) + "# + .to_string() + } else { + r#" + CREATE TABLE IF NOT EXISTS reward_redemptions ( + id SERIAL PRIMARY KEY, + uuid TEXT NOT NULL UNIQUE, + student_name TEXT NOT NULL, + reward_id INTEGER NOT NULL, + reward_name TEXT NOT NULL, + cost_points INTEGER NOT NULL, + redeemed_at TEXT DEFAULT (to_char(CURRENT_TIMESTAMP, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) + ) + "# + .to_string() + } +} + pub fn get_create_reasons_table_sql(sqlite: bool) -> String { if sqlite { r#" @@ -260,6 +341,20 @@ pub fn get_create_student_tags_table_sql(sqlite: bool) -> String { } } +pub fn get_create_index_reward_settings_name_sql(_sqlite: bool) -> String { + "CREATE INDEX IF NOT EXISTS idx_reward_settings_name ON reward_settings(name)".to_string() +} + +pub fn get_create_index_reward_redemptions_student_name_sql(_sqlite: bool) -> String { + "CREATE INDEX IF NOT EXISTS idx_reward_redemptions_student_name ON reward_redemptions(student_name)" + .to_string() +} + +pub fn get_create_index_reward_redemptions_reward_id_sql(_sqlite: bool) -> String { + "CREATE INDEX IF NOT EXISTS idx_reward_redemptions_reward_id ON reward_redemptions(reward_id)" + .to_string() +} + pub fn get_create_index_score_events_settlement_id_sql(sqlite: bool) -> String { if sqlite { "CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90d185f..8e084bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,6 +47,12 @@ pub fn run() { reason_create, reason_update, reason_delete, + reward_setting_query, + reward_setting_create, + reward_setting_update, + reward_setting_delete, + reward_redeem, + reward_redemption_query, event_query, event_create, event_delete, diff --git a/src-tauri/src/models/student.rs b/src-tauri/src/models/student.rs index 6f1c0f3..bb41a58 100644 --- a/src-tauri/src/models/student.rs +++ b/src-tauri/src/models/student.rs @@ -5,6 +5,7 @@ pub struct Student { pub id: i32, pub name: String, pub score: i32, + pub reward_points: i32, pub tags: String, pub extra_json: Option, pub created_at: String, @@ -15,6 +16,7 @@ pub struct Student { pub struct StudentUpdate { pub name: Option, pub score: Option, + pub reward_points: Option, pub tags: Option>, pub extra_json: Option, } @@ -24,6 +26,7 @@ pub struct StudentWithTags { pub id: i32, pub name: String, pub score: i32, + pub reward_points: i32, pub tags: Vec, pub extra_json: Option, } @@ -35,6 +38,7 @@ impl From for StudentWithTags { id: student.id, name: student.name, score: student.score, + reward_points: student.reward_points, tags, extra_json: student.extra_json, } diff --git a/src/App.tsx b/src/App.tsx index 2149edf..e90f30d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,8 @@ function MainContent(): React.JSX.Element { if (p.startsWith("/settlements")) return "settlements" if (p.startsWith("/reasons")) return "reasons" if (p.startsWith("/auto-score")) return "auto-score" + if (p.startsWith("/reward-exchange")) return "reward-exchange" + if (p.startsWith("/reward-settings")) return "reward-settings" if (p.startsWith("/settings")) return "settings" return "home" }, [location.pathname]) @@ -272,6 +274,8 @@ function MainContent(): React.JSX.Element { if (key === "settlements") navigate("/settlements") if (key === "reasons") navigate("/reasons") if (key === "auto-score") navigate("/auto-score") + if (key === "reward-exchange") navigate("/reward-exchange") + if (key === "reward-settings") navigate("/reward-settings") if (key === "settings") navigate("/settings") } diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index b09f5f0..2dd8b36 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -13,6 +13,8 @@ const loadScoreManager = () => import("./ScoreManager") const loadLeaderboard = () => import("./Leaderboard") const loadSettlementHistory = () => import("./SettlementHistory") const loadAutoScoreManager = () => import("./AutoScoreManager") +const loadRewardExchange = () => import("./RewardExchange") +const loadRewardSettings = () => import("./RewardSettings") const Home = lazy(() => loadHome().then((m) => ({ default: m.Home }))) const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager }))) @@ -26,6 +28,12 @@ const SettlementHistory = lazy(() => const AutoScoreManager = lazy(() => loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager })) ) +const RewardExchange = lazy(() => + loadRewardExchange().then((m) => ({ default: m.RewardExchange })) +) +const RewardSettings = lazy(() => + loadRewardSettings().then((m) => ({ default: m.RewardSettings })) +) const warmupRouteChunks = () => Promise.allSettled([ @@ -37,6 +45,8 @@ const warmupRouteChunks = () => loadLeaderboard(), loadSettlementHistory(), loadAutoScoreManager(), + loadRewardExchange(), + loadRewardSettings(), ]) const { Content } = Layout @@ -235,6 +245,14 @@ export function ContentArea({ } /> } /> } /> + } + /> + } + /> } /> } /> diff --git a/src/components/RewardExchange.tsx b/src/components/RewardExchange.tsx new file mode 100644 index 0000000..48f3343 --- /dev/null +++ b/src/components/RewardExchange.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react" +import { Button, Card, Modal, Table, Tag, message } from "antd" +import type { ColumnsType } from "antd/es/table" +import { useTranslation } from "react-i18next" + +interface StudentItem { + id: number + name: string + score: number + reward_points: number +} + +interface RewardItem { + id: number + name: string + cost_points: number +} + +interface RedemptionItem { + id: number + uuid: string + student_name: string + reward_id: number + reward_name: string + cost_points: number + redeemed_at: string +} + +export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const { t } = useTranslation() + const [students, setStudents] = useState([]) + const [rewards, setRewards] = useState([]) + const [records, setRecords] = useState([]) + const [loading, setLoading] = useState(false) + const [exchangeMode, setExchangeMode] = useState(false) + const [selectedStudent, setSelectedStudent] = useState(null) + const [chooseRewardVisible, setChooseRewardVisible] = useState(false) + const [messageApi, contextHolder] = message.useMessage() + + const emitDataUpdated = () => { + window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } })) + } + + const fetchData = useCallback(async () => { + if (!(window as any).api) return + setLoading(true) + try { + const [stuRes, rewardRes, recordRes] = await Promise.all([ + (window as any).api.queryStudents({}), + (window as any).api.rewardSettingQuery(), + (window as any).api.rewardRedemptionQuery({ limit: 100 }), + ]) + + if (stuRes.success && Array.isArray(stuRes.data)) { + setStudents(stuRes.data) + } + if (rewardRes.success && Array.isArray(rewardRes.data)) { + setRewards(rewardRes.data) + } + if (recordRes.success && Array.isArray(recordRes.data)) { + setRecords(recordRes.data) + } + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + const onDataUpdated = (e: any) => { + const category = e?.detail?.category + if (category === "all" || category === "students" || category === "events") { + fetchData() + } + } + window.addEventListener("ss:data-updated", onDataUpdated as any) + return () => window.removeEventListener("ss:data-updated", onDataUpdated as any) + }, [fetchData]) + + const sortedStudents = useMemo( + () => [...students].sort((a, b) => b.reward_points - a.reward_points || a.name.localeCompare(b.name, "zh-CN")), + [students] + ) + + const affordableRewards = useMemo(() => { + if (!selectedStudent) return [] + return rewards.filter((r) => r.cost_points <= selectedStudent.reward_points) + }, [rewards, selectedStudent]) + + const handleStudentClick = (student: StudentItem) => { + if (!exchangeMode) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setSelectedStudent(student) + setChooseRewardVisible(true) + } + + const handleRedeem = async (reward: RewardItem) => { + if (!(window as any).api || !selectedStudent) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + + const res = await (window as any).api.rewardRedeem({ + student_name: selectedStudent.name, + reward_id: reward.id, + }) + + if (res.success) { + messageApi.success( + t("rewardExchange.redeemSuccess", { + student: selectedStudent.name, + reward: reward.name, + points: reward.cost_points, + }) + ) + setChooseRewardVisible(false) + setSelectedStudent(null) + setExchangeMode(false) + fetchData() + emitDataUpdated() + } else { + messageApi.error(res.message || t("rewardExchange.redeemFailed")) + } + } + + const columns: ColumnsType = [ + { title: t("rewardExchange.recordStudent"), dataIndex: "student_name", key: "student_name", width: 120 }, + { title: t("rewardExchange.recordReward"), dataIndex: "reward_name", key: "reward_name" }, + { + title: t("rewardExchange.recordCost"), + dataIndex: "cost_points", + key: "cost_points", + width: 120, + render: (v: number) => -{v}, + }, + { + title: t("rewardExchange.recordTime"), + dataIndex: "redeemed_at", + key: "redeemed_at", + width: 180, + render: (time: string) => new Date(time).toLocaleString(), + }, + ] + + return ( +
+ {contextHolder} +
+

{t("rewardExchange.title")}

+ +
+ +
+ {exchangeMode ? t("rewardExchange.modeHint") : t("rewardExchange.normalHint")} +
+ +
+ {sortedStudents.map((student) => { + const points = exchangeMode ? student.reward_points : student.score + return ( + handleStudentClick(student)} + style={{ + cursor: exchangeMode ? "pointer" : "default", + border: exchangeMode ? "1px solid var(--ant-color-primary, #1677ff)" : "1px solid var(--ss-border-color)", + }} + bodyStyle={{ padding: "12px" }} + > +
{student.name}
+ 0 ? "success" : points < 0 ? "error" : "default"}> + {points > 0 ? `+${points}` : points} + +
+ {exchangeMode ? t("rewardExchange.remainingRewardPoints") : t("rewardExchange.currentScore")} +
+
+ ) + })} +
+ + + + + + { + setChooseRewardVisible(false) + setSelectedStudent(null) + }} + footer={null} + destroyOnHidden + > + {!selectedStudent ? null : ( + <> +
+ {t("rewardExchange.currentRewardPoints", { points: selectedStudent.reward_points })} +
+ {affordableRewards.length === 0 ? ( +
{t("rewardExchange.noAffordableRewards")}
+ ) : ( +
+ {affordableRewards + .sort((a, b) => a.cost_points - b.cost_points || a.name.localeCompare(b.name, "zh-CN")) + .map((reward) => ( +
+
+
{reward.name}
+
+ {t("rewardExchange.costLabel", { points: reward.cost_points })} +
+
+ +
+ ))} +
+ )} + + )} +
+ + ) +} diff --git a/src/components/RewardSettings.tsx b/src/components/RewardSettings.tsx new file mode 100644 index 0000000..76acf88 --- /dev/null +++ b/src/components/RewardSettings.tsx @@ -0,0 +1,205 @@ +import React, { useCallback, useEffect, useState } from "react" +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Table, message } from "antd" +import type { ColumnsType } from "antd/es/table" +import { useTranslation } from "react-i18next" + +interface RewardItem { + id: number + name: string + cost_points: number + created_at: string + updated_at: string +} + +export const RewardSettings: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const { t } = useTranslation() + const [data, setData] = useState([]) + const [loading, setLoading] = useState(false) + const [visible, setVisible] = useState(false) + const [editing, setEditing] = useState(null) + const [form] = Form.useForm() + const [messageApi, contextHolder] = message.useMessage() + + const emitDataUpdated = () => { + window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } })) + } + + const fetchRewards = useCallback(async () => { + if (!(window as any).api) return + setLoading(true) + try { + const res = await (window as any).api.rewardSettingQuery() + if (res.success && Array.isArray(res.data)) { + setData(res.data) + } + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchRewards() + }, [fetchRewards]) + + const openCreate = () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setEditing(null) + form.setFieldsValue({ name: "", cost_points: 1 }) + setVisible(true) + } + + const openEdit = (item: RewardItem) => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + setEditing(item) + form.setFieldsValue({ name: item.name, cost_points: item.cost_points }) + setVisible(true) + } + + const handleDelete = async (item: RewardItem) => { + if (!(window as any).api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + + const res = await (window as any).api.rewardSettingDelete(item.id) + if (res.success) { + messageApi.success(t("rewardSettings.deleteSuccess")) + fetchRewards() + emitDataUpdated() + } else { + messageApi.error(res.message || t("rewardSettings.deleteFailed")) + } + } + + const handleSave = async () => { + if (!(window as any).api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + + const values = await form.validateFields() + const payload = { + name: String(values.name || "").trim(), + cost_points: Number(values.cost_points), + } + + if (!payload.name) { + messageApi.warning(t("rewardSettings.nameRequired")) + return + } + if (!Number.isFinite(payload.cost_points) || payload.cost_points <= 0) { + messageApi.warning(t("rewardSettings.costRequired")) + return + } + + const res = editing + ? await (window as any).api.rewardSettingUpdate(editing.id, payload) + : await (window as any).api.rewardSettingCreate(payload) + + if (res.success) { + messageApi.success(editing ? t("rewardSettings.updateSuccess") : t("rewardSettings.createSuccess")) + setVisible(false) + form.resetFields() + setEditing(null) + fetchRewards() + emitDataUpdated() + } else { + messageApi.error(res.message || (editing ? t("rewardSettings.updateFailed") : t("rewardSettings.createFailed"))) + } + } + + const columns: ColumnsType = [ + { + title: t("rewardSettings.rewardName"), + dataIndex: "name", + key: "name", + }, + { + title: t("rewardSettings.costPoints"), + dataIndex: "cost_points", + key: "cost_points", + width: 140, + render: (v: number) => {v}, + }, + { + title: t("common.operation"), + key: "operation", + width: 180, + render: (_, row) => ( + <> + + handleDelete(row)} + disabled={!canEdit} + > + + + + ), + }, + ] + + return ( +
+ {contextHolder} +
+

{t("rewardSettings.title")}

+ +
+ +
+ + { + setVisible(false) + setEditing(null) + }} + okText={t("common.save")} + cancelText={t("common.cancel")} + destroyOnHidden + > +
+ + + + + + + +
+ + ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e1c8a9d..a5973d5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,6 +9,8 @@ import { FileTextOutlined, CloudOutlined, UploadOutlined, + GiftOutlined, + AppstoreAddOutlined, } from "@ant-design/icons" import { useState, useEffect } from "react" import { useTranslation } from "react-i18next" @@ -166,6 +168,17 @@ export function Sidebar({ icon: , label: t("sidebar.autoScore"), }, + { + key: "reward-exchange", + icon: , + label: t("sidebar.rewardExchange"), + }, + { + key: "reward-settings", + icon: , + label: t("sidebar.rewardSettings"), + disabled: permission !== "admin", + }, { key: "leaderboard", icon: , diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 0f8ee27..3dea3da 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -310,6 +310,8 @@ "settlements": "Settlements", "reasons": "Reasons", "autoScore": "Auto Score", + "rewardExchange": "Reward Exchange", + "rewardSettings": "Reward Settings", "settings": "Settings", "remoteDb": "Remote Database", "refreshStatus": "Refresh Status", @@ -516,6 +518,46 @@ "deleteFailed": "Delete failed", "others": "Others" }, + "rewardExchange": { + "title": "Reward Exchange", + "enterMode": "Enter Exchange Mode", + "exitMode": "Exit Exchange Mode", + "modeHint": "Reward exchange mode is active. Click a student card to choose an available reward.", + "normalHint": "Current cards show original score. Enter mode to display redeemable reward points.", + "remainingRewardPoints": "Remaining Reward Points", + "currentScore": "Current Score", + "chooseRewardTitle": "Choose Reward for {{name}}", + "currentRewardPoints": "Current reward points: {{points}}", + "costLabel": "Cost points: {{points}}", + "redeemNow": "Redeem", + "noAffordableRewards": "No rewards can be redeemed now. Earn more points first.", + "redeemSuccess": "{{student}} redeemed \"{{reward}}\" and spent {{points}} points", + "redeemFailed": "Redeem failed", + "recordsTitle": "Redemption Records", + "recordStudent": "Student", + "recordReward": "Reward", + "recordCost": "Cost", + "recordTime": "Redeemed At" + }, + "rewardSettings": { + "title": "Reward Settings", + "addReward": "Add Reward", + "addTitle": "Add Reward", + "editTitle": "Edit Reward", + "rewardName": "Reward Name", + "costPoints": "Cost Points", + "namePlaceholder": "e.g. Homework-Free Card", + "costPlaceholder": "Enter cost points", + "nameRequired": "Please enter reward name", + "costRequired": "Please enter positive integer points", + "createSuccess": "Reward created", + "createFailed": "Create reward failed", + "updateSuccess": "Reward updated", + "updateFailed": "Update reward failed", + "deleteSuccess": "Reward deleted", + "deleteFailed": "Delete reward failed", + "deleteConfirm": "Delete reward \"{{name}}\"?" + }, "leaderboard": { "title": "Points Leaderboard", "rank": "Rank", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3113af4..b700ce6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -310,6 +310,8 @@ "settlements": "结算历史", "reasons": "理由管理", "autoScore": "自动加分", + "rewardExchange": "奖励兑换", + "rewardSettings": "奖励设置", "settings": "系统设置", "remoteDb": "远程数据库", "refreshStatus": "刷新状态", @@ -516,6 +518,46 @@ "deleteFailed": "删除失败", "others": "其他" }, + "rewardExchange": { + "title": "奖励兑换", + "enterMode": "进入奖励兑换模式", + "exitMode": "退出奖励兑换模式", + "modeHint": "当前为奖励兑换模式,点击学生卡可选择可兑换奖励。", + "normalHint": "当前显示学生原始积分,点击按钮后将显示可兑换积分。", + "remainingRewardPoints": "剩余兑换积分", + "currentScore": "当前积分", + "chooseRewardTitle": "为 {{name}} 选择奖励", + "currentRewardPoints": "当前可兑换积分:{{points}}", + "costLabel": "消耗积分:{{points}}", + "redeemNow": "立即兑换", + "noAffordableRewards": "当前没有可兑换的奖励,请先积累积分。", + "redeemSuccess": "{{student}} 成功兑换「{{reward}}」,消耗 {{points}} 分", + "redeemFailed": "兑换失败", + "recordsTitle": "兑换记录", + "recordStudent": "学生", + "recordReward": "奖励", + "recordCost": "消耗积分", + "recordTime": "兑换时间" + }, + "rewardSettings": { + "title": "奖励设置", + "addReward": "添加奖励", + "addTitle": "添加奖励", + "editTitle": "编辑奖励", + "rewardName": "奖励名称", + "costPoints": "消耗积分", + "namePlaceholder": "例如:免作业券", + "costPlaceholder": "请输入消耗积分", + "nameRequired": "请输入奖励名称", + "costRequired": "请输入正整数积分", + "createSuccess": "奖励创建成功", + "createFailed": "奖励创建失败", + "updateSuccess": "奖励更新成功", + "updateFailed": "奖励更新失败", + "deleteSuccess": "奖励删除成功", + "deleteFailed": "奖励删除失败", + "deleteConfirm": "确认删除奖励「{{name}}」?" + }, "leaderboard": { "title": "积分排行榜", "rank": "排名", diff --git a/src/preload/types.ts b/src/preload/types.ts index 8f1d8ea..50d4594 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -100,6 +100,28 @@ const api = { invoke("reason_update", { id, data }), deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }), + // DB - Reward + rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> => invoke("reward_setting_query"), + rewardSettingCreate: (data: { + name: string + cost_points: number + }): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("reward_setting_create", { data }), + rewardSettingUpdate: (id: number, data: any): Promise<{ success: boolean; message?: string }> => + invoke("reward_setting_update", { id, data }), + rewardSettingDelete: (id: number): Promise<{ success: boolean; message?: string }> => + invoke("reward_setting_delete", { id }), + rewardRedeem: (data: { + student_name: string + reward_id: number + }): Promise<{ + success: boolean + data?: { redemption_id: number; remaining_reward_points: number } + message?: string + }> => invoke("reward_redeem", { data }), + rewardRedemptionQuery: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => + invoke("reward_redemption_query", { params }), + // DB - Event queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => invoke("event_query", { params }),