diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index a78fa5b..cecd4af 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -10,7 +10,9 @@ use tauri::{AppHandle, Emitter, Manager}; use tokio::time::{interval, Duration}; use uuid::Uuid; -use crate::db::entities::{score_events, student_tags, students, tags}; +use crate::db::entities::{ + reward_redemptions, reward_settings, score_events, student_tags, students, tags, +}; use crate::services::settings::{SettingsKey, SettingsValue}; use crate::state::SafeAppState; @@ -32,6 +34,19 @@ pub struct AutoScoreAction { pub value: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct RewardExchangeActionValue { + #[serde(alias = "reward_id")] + reward_id: i32, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "reward_name" + )] + reward_name: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct AutoScoreExecutionConfig { @@ -63,6 +78,8 @@ pub struct AutoScoreExecutionBatch { pub affected_student_names: Vec, pub created_event_ids: Vec, pub added_student_tag_ids: Vec, + #[serde(default)] + pub reward_redemption_ids: Vec, pub score_delta_total: i64, pub settled: bool, pub rolled_back: bool, @@ -123,6 +140,7 @@ pub struct AutoScoreRule { enum PlannedAction { AddScore(i32), AddTags(Vec), + RewardExchange(RewardExchangeActionValue), SettleScore, } @@ -147,6 +165,7 @@ struct RuleExecutionStats { score_delta_total: i64, created_event_ids: Vec, added_student_tag_ids: Vec, + reward_redemption_ids: Vec, settled: bool, } @@ -390,6 +409,7 @@ impl AutoScoreService { affected_student_names: stats.affected_student_names.clone(), created_event_ids: stats.created_event_ids.clone(), added_student_tag_ids: stats.added_student_tag_ids.clone(), + reward_redemption_ids: stats.reward_redemption_ids.clone(), score_delta_total: stats.score_delta_total, settled: stats.settled, rolled_back: false, @@ -941,11 +961,70 @@ fn normalize_action(action: AutoScoreAction) -> Result value: Some(value), }) } + "reward_exchange" => { + let value = parse_reward_exchange_action_value(action.value.as_deref()) + .ok_or_else(|| "Reward exchange action requires a valid reward".to_string())?; + + Ok(AutoScoreAction { + event, + value: Some(serialize_reward_exchange_action_value(&value)?), + }) + } "settle_score" => Ok(AutoScoreAction { event, value: None }), _ => Err(format!("Unsupported action event: {}", event)), } } +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn parse_reward_exchange_action_value(raw_value: Option<&str>) -> Option { + let raw_value = raw_value.map(str::trim)?; + if raw_value.is_empty() { + return None; + } + + if raw_value.starts_with('{') { + let parsed = serde_json::from_str::(raw_value).ok()?; + if parsed.reward_id <= 0 { + return None; + } + return Some(RewardExchangeActionValue { + reward_id: parsed.reward_id, + reward_name: normalize_optional_string(parsed.reward_name), + }); + } + + let reward_id = raw_value.parse::().ok().filter(|value| *value > 0)?; + Some(RewardExchangeActionValue { + reward_id, + reward_name: None, + }) +} + +fn serialize_reward_exchange_action_value( + value: &RewardExchangeActionValue, +) -> Result { + serde_json::to_string(&RewardExchangeActionValue { + reward_id: value.reward_id, + reward_name: normalize_optional_string(value.reward_name.clone()), + }) + .map_err(|error| { + format!( + "Failed to serialize reward exchange action value: {}", + error + ) + }) +} + fn normalize_required_string(value: String, field_name: &str) -> Result { let normalized = value.trim().to_string(); if normalized.is_empty() { @@ -1473,6 +1552,36 @@ pub async fn rollback_execution_batch( .map_err(|e| e.to_string())?; } + for redemption_id in &batch.reward_redemption_ids { + let redemption = reward_redemptions::Entity::find_by_id(*redemption_id) + .one(&txn) + .await + .map_err(|e| e.to_string())?; + let Some(redemption) = redemption else { + continue; + }; + + reward_redemptions::Entity::delete_by_id(*redemption_id) + .exec(&txn) + .await + .map_err(|e| e.to_string())?; + + if let Some(student) = students::Entity::find() + .filter(students::Column::Name.eq(redemption.student_name.clone())) + .one(&txn) + .await + .map_err(|e| e.to_string())? + { + let mut student_active: students::ActiveModel = student.clone().into(); + student_active.reward_points = Set(student.reward_points + redemption.cost_points); + student_active.updated_at = Set(now_iso()); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + } + } + txn.commit().await.map_err(|e| e.to_string())?; batch.rolled_back = true; @@ -1632,6 +1741,7 @@ pub async fn apply_offline_backfill( affected_student_names: stats.affected_student_names.clone(), created_event_ids: stats.created_event_ids.clone(), added_student_tag_ids: stats.added_student_tag_ids.clone(), + reward_redemption_ids: stats.reward_redemption_ids.clone(), score_delta_total: stats.score_delta_total, settled: stats.settled, rolled_back: false, @@ -1728,6 +1838,23 @@ async fn execute_rule( tag_name_to_id.insert(tag_name, tag_id); } + let mut reward_settings_by_id = HashMap::new(); + for action in &planned_actions { + if let PlannedAction::RewardExchange(reward_action) = action { + if reward_settings_by_id.contains_key(&reward_action.reward_id) { + continue; + } + + let reward = reward_settings::Entity::find_by_id(reward_action.reward_id) + .one(&txn) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Reward not found: {}", reward_action.reward_id))?; + + reward_settings_by_id.insert(reward.id, reward); + } + } + for mut student in target_students { if mode == ExecutionMode::Normal { if !is_student_pass_cooldown( @@ -1803,6 +1930,40 @@ async fn execute_rule( } } } + PlannedAction::RewardExchange(reward_action) => { + let Some(reward) = reward_settings_by_id.get(&reward_action.reward_id) else { + return Err(format!("Reward not found: {}", reward_action.reward_id)); + }; + if student.reward_points < reward.cost_points { + continue; + } + + let now = now_iso(); + let redemption = reward_redemptions::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(Uuid::new_v4().to_string()), + student_name: Set(student.name.clone()), + reward_id: Set(reward.id), + reward_name: Set(reward.name.clone()), + cost_points: Set(reward.cost_points), + redeemed_at: Set(now.clone()), + }; + let inserted_redemption = + redemption.insert(&txn).await.map_err(|e| e.to_string())?; + stats.reward_redemption_ids.push(inserted_redemption.id); + + let remaining_reward_points = student.reward_points - reward.cost_points; + let mut student_active: students::ActiveModel = student.clone().into(); + student_active.reward_points = Set(remaining_reward_points); + student_active.updated_at = Set(now); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + student.reward_points = remaining_reward_points; + touched = true; + } PlannedAction::SettleScore => {} } } @@ -1840,6 +2001,11 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result, Strin } planned.push(PlannedAction::AddTags(tags)); } + "reward_exchange" => { + let reward = parse_reward_exchange_action_value(action.value.as_deref()) + .ok_or_else(|| "Invalid reward_exchange value".to_string())?; + planned.push(PlannedAction::RewardExchange(reward)); + } "settle_score" => { planned.push(PlannedAction::SettleScore); } diff --git a/src/assets/main.css b/src/assets/main.css index fba91c1..d85344d 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -400,6 +400,31 @@ html.platform-macos #root { overflow: hidden; } +.ss-content-scroll-container { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--ss-text-secondary) 55%, transparent) transparent; +} + +.ss-content-scroll-container::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.ss-content-scroll-container::-webkit-scrollbar-track { + background: transparent; +} + +.ss-content-scroll-container::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--ss-text-secondary) 55%, transparent); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.ss-content-scroll-container::-webkit-scrollbar-corner { + background: transparent; +} + .ss-route-page.is-subpage-enter { animation: ss-subpage-enter 240ms cubic-bezier(0.22, 1, 0.36, 1); } diff --git a/src/components/AutoScore/ActionEditor.tsx b/src/components/AutoScore/ActionEditor.tsx index c1dd700..d563f2e 100644 --- a/src/components/AutoScore/ActionEditor.tsx +++ b/src/components/AutoScore/ActionEditor.tsx @@ -2,12 +2,22 @@ import React, { useEffect, useMemo } from "react" import { Button, Card, InputNumber, Select, Space } from "antd" import { DeleteOutlined, PlusOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" -import type { ActionDraft, ActionEvent, AutoScoreTagOption } from "./AutoScoreUtils" -import { createDefaultActionDraft } from "./AutoScoreUtils" +import type { + ActionDraft, + ActionEvent, + AutoScoreRewardOption, + AutoScoreTagOption, +} from "./AutoScoreUtils" +import { + createDefaultActionDraft, + parseRewardActionValue, + stringifyRewardActionValue, +} from "./AutoScoreUtils" interface ActionEditorProps { value: ActionDraft[] tagOptions: AutoScoreTagOption[] + rewardOptions: AutoScoreRewardOption[] canEdit: boolean onChange: (nextDrafts: ActionDraft[]) => void } @@ -15,6 +25,7 @@ interface ActionEditorProps { export const ActionEditor: React.FC = ({ value, tagOptions, + rewardOptions, canEdit, onChange, }) => { @@ -33,6 +44,7 @@ export const ActionEditor: React.FC = ({ { value: "add_score", label: t("autoScore.actionAddScore") }, { value: "add_tag", label: t("autoScore.actionAddTag") }, { value: "settle_score", label: t("autoScore.actionSettleScore") }, + { value: "reward_exchange", label: t("rewardExchange.title") }, ], [t] ) @@ -58,6 +70,31 @@ export const ActionEditor: React.FC = ({ return Array.from(optionMap.values()) }, [safeValue, tagOptions]) + const mergedRewardOptions = useMemo(() => { + const optionMap = new Map(rewardOptions.map((option) => [option.value, option])) + + for (const action of safeValue) { + if (action.event !== "reward_exchange") continue + const rewardValue = parseRewardActionValue(action.value) + if (!rewardValue) continue + + const optionValue = String(rewardValue.rewardId) + if (!optionMap.has(optionValue)) { + optionMap.set(optionValue, { + label: rewardValue.rewardName || optionValue, + value: optionValue, + }) + } + } + + return Array.from(optionMap.values()) + }, [rewardOptions, safeValue]) + + const buildRewardDraftValue = (rewardId: string): string => { + const rewardOption = mergedRewardOptions.find((option) => option.value === rewardId) + return stringifyRewardActionValue(Number(rewardId), rewardOption?.label) || "" + } + const updateAction = (id: string, patch: Partial>) => { onChange(safeValue.map((item) => (item.id === id ? { ...item, ...patch } : item))) } @@ -87,7 +124,14 @@ export const ActionEditor: React.FC = ({ onChange={(event: ActionEvent) => updateAction(action.id, { event, - value: event === "add_score" ? "1" : event === "add_tag" ? [] : "", + value: + event === "add_score" + ? "1" + : event === "add_tag" + ? [] + : event === "reward_exchange" && mergedRewardOptions.length > 0 + ? buildRewardDraftValue(mergedRewardOptions[0].value) + : "", }) } /> @@ -115,6 +159,22 @@ export const ActionEditor: React.FC = ({ options={mergedTagOptions} onChange={(nextValue) => updateAction(action.id, { value: nextValue })} /> + ) : action.event === "reward_exchange" ? ( +