From 47cf00b22c5a50f98656474d42dd5e1f7c29e68c Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Mon, 23 Feb 2026 14:52:21 +0200 Subject: [PATCH 01/16] WIP course designer frame --- ...d_course_designer_planning_tables.down.sql | 9 + ...add_course_designer_planning_tables.up.sql | 206 +++++ ...ba0067b035a45be213eb80e7301361d1d4429.json | 103 +++ ...7610c998036b5359ddf9f5ac311862967823d.json | 107 +++ ...033004eca24a7b62694f2a138a38a9e4cc519.json | 22 + ...2e250309efb4539de0b36e081e153fb936877.json | 95 +++ ...9a400118ab5eb91e327f4ab293134e86621c1.json | 40 + ...b3aa05b6ba9345a44ceed558692513654702d.json | 41 + ...b2622486ef4c22bec890635b623a2fa21c37b.json | 92 ++ ...b0d3e7469e388a07a31ef6a1b919e24acd7ce.json | 94 +++ ...5b03e3fe4d697687f1438eb516441b2f053c1.json | 31 + ...c01ddbe00c5aa81bdf66d390f4d923d48ec82.json | 92 ++ ...8a3c6b0237f1b87a6e3761d7dbddf3c53b0ca.json | 106 +++ ...e84714eeb8653571856fb8a013a87cff93980.json | 15 + ...fbb409d90598e2d0e465cc99eacfce14711e1.json | 92 ++ ...431b6e5b27544883dc99441f9cfbc810fe887.json | 30 + .../models/src/course_designer_plans.rs | 797 ++++++++++++++++++ services/headless-lms/models/src/lib.rs | 1 + .../main_frontend/course_designer.rs | 154 ++++ .../src/controllers/main_frontend/mod.rs | 2 + .../server/src/ts_binding_generator.rs | 14 + .../course-plans/[id]/schedule/page.tsx | 384 +++++++++ .../src/app/manage/course-plans/page.tsx | 140 +++ .../src/services/backend/courseDesigner.ts | 128 +++ .../common/src/locales/en/main-frontend.json | 33 + 25 files changed, 2828 insertions(+) create mode 100644 services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.down.sql create mode 100644 services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.up.sql create mode 100644 services/headless-lms/models/.sqlx/query-0627e79ff4710afe3c10d4375b1ba0067b035a45be213eb80e7301361d1d4429.json create mode 100644 services/headless-lms/models/.sqlx/query-0ea8cc60a82bfb68f0dfc1717c77610c998036b5359ddf9f5ac311862967823d.json create mode 100644 services/headless-lms/models/.sqlx/query-2194bc23a7b6ed52a4205be56b8033004eca24a7b62694f2a138a38a9e4cc519.json create mode 100644 services/headless-lms/models/.sqlx/query-2dadd4e83ccd5f82e12d01dfe372e250309efb4539de0b36e081e153fb936877.json create mode 100644 services/headless-lms/models/.sqlx/query-413005008c2b7eb57fbb4cbe35f9a400118ab5eb91e327f4ab293134e86621c1.json create mode 100644 services/headless-lms/models/.sqlx/query-48335dfa0348b619bc7f835e4a3b3aa05b6ba9345a44ceed558692513654702d.json create mode 100644 services/headless-lms/models/.sqlx/query-6ac247dd9f8f71bf96a105b23ffb2622486ef4c22bec890635b623a2fa21c37b.json create mode 100644 services/headless-lms/models/.sqlx/query-92a1fcd6a7140887b2349bc5112b0d3e7469e388a07a31ef6a1b919e24acd7ce.json create mode 100644 services/headless-lms/models/.sqlx/query-a01d1b5421f9a4767af3e65457a5b03e3fe4d697687f1438eb516441b2f053c1.json create mode 100644 services/headless-lms/models/.sqlx/query-a5c698b800871fe0093d58d2d6fc01ddbe00c5aa81bdf66d390f4d923d48ec82.json create mode 100644 services/headless-lms/models/.sqlx/query-bf61544e2c1844e3001b89360a78a3c6b0237f1b87a6e3761d7dbddf3c53b0ca.json create mode 100644 services/headless-lms/models/.sqlx/query-c8d0042fd40cd20e963f96eb070e84714eeb8653571856fb8a013a87cff93980.json create mode 100644 services/headless-lms/models/.sqlx/query-d108ba195f2512ce64f4300dacdfbb409d90598e2d0e465cc99eacfce14711e1.json create mode 100644 services/headless-lms/models/.sqlx/query-d1615df3085611c8a68aa4669bb431b6e5b27544883dc99441f9cfbc810fe887.json create mode 100644 services/headless-lms/models/src/course_designer_plans.rs create mode 100644 services/headless-lms/server/src/controllers/main_frontend/course_designer.rs create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/page.tsx create mode 100644 services/main-frontend/src/services/backend/courseDesigner.ts diff --git a/services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.down.sql b/services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.down.sql new file mode 100644 index 00000000000..253341044a5 --- /dev/null +++ b/services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.down.sql @@ -0,0 +1,9 @@ +DROP TABLE course_designer_plan_events; +DROP TABLE course_designer_plan_stage_tasks; +DROP TABLE course_designer_plan_members; +DROP TABLE course_designer_plan_stages; +DROP TABLE course_designer_plans; + +DROP TYPE course_designer_plan_stage_status; +DROP TYPE course_designer_plan_status; +DROP TYPE course_designer_stage; diff --git a/services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.up.sql b/services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.up.sql new file mode 100644 index 00000000000..157fc83a5f3 --- /dev/null +++ b/services/headless-lms/migrations/20260223110000_add_course_designer_planning_tables.up.sql @@ -0,0 +1,206 @@ +CREATE TYPE course_designer_stage AS ENUM ( + 'analysis', + 'design', + 'development', + 'implementation', + 'evaluation' +); +COMMENT ON TYPE course_designer_stage IS 'Fixed stage identifiers for the hardcoded MOOC course design workflow.'; + +CREATE TYPE course_designer_plan_status AS ENUM ( + 'draft', + 'scheduling', + 'ready_to_start', + 'in_progress', + 'completed', + 'archived' +); +COMMENT ON TYPE course_designer_plan_status IS 'Overall lifecycle status for a MOOC course design plan.'; + +CREATE TYPE course_designer_plan_stage_status AS ENUM ( + 'not_started', + 'in_progress', + 'completed' +); +COMMENT ON TYPE course_designer_plan_stage_status IS 'Manual progress status for a single stage within a MOOC course design plan.'; + +CREATE TABLE course_designer_plans ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + created_by_user_id UUID NOT NULL REFERENCES users(id), + name VARCHAR(255), + STATUS course_designer_plan_status NOT NULL DEFAULT 'draft', + active_stage course_designer_stage, + last_weekly_stage_email_sent_at TIMESTAMP WITH TIME ZONE, + CHECK ( + name IS NULL + OR TRIM(name) <> '' + ), + CHECK ( + STATUS <> 'in_progress' + OR active_stage IS NOT NULL + ) +); +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON course_designer_plans FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +COMMENT ON TABLE course_designer_plans IS 'A collaborative plan used to design a MOOC course over multiple months.'; +COMMENT ON COLUMN course_designer_plans.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN course_designer_plans.created_at IS 'Timestamp when the record was created.'; +COMMENT ON COLUMN course_designer_plans.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN course_designer_plans.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN course_designer_plans.created_by_user_id IS 'User who created the plan.'; +COMMENT ON COLUMN course_designer_plans.name IS 'Optional user-provided name for the plan.'; +COMMENT ON COLUMN course_designer_plans.status IS 'Overall workflow status of the plan.'; +COMMENT ON COLUMN course_designer_plans.active_stage IS 'Stage currently active in the UI. This is manually transitioned by the user.'; +COMMENT ON COLUMN course_designer_plans.last_weekly_stage_email_sent_at IS 'Timestamp when the latest weekly stage reminder email was sent to plan members.'; + +CREATE TABLE course_designer_plan_members ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + course_designer_plan_id UUID NOT NULL REFERENCES course_designer_plans(id), + user_id UUID NOT NULL REFERENCES users(id), + CONSTRAINT course_designer_plan_members_plan_user_unique UNIQUE NULLS NOT DISTINCT ( + course_designer_plan_id, + user_id, + deleted_at + ) +); +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON course_designer_plan_members FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +CREATE INDEX course_designer_plan_members_user_id_idx ON course_designer_plan_members (user_id, deleted_at); + +COMMENT ON TABLE course_designer_plan_members IS 'Users who can access and collaborate on a MOOC course design plan.'; +COMMENT ON COLUMN course_designer_plan_members.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN course_designer_plan_members.created_at IS 'Timestamp when the record was created.'; +COMMENT ON COLUMN course_designer_plan_members.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN course_designer_plan_members.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN course_designer_plan_members.course_designer_plan_id IS 'The plan the member belongs to.'; +COMMENT ON COLUMN course_designer_plan_members.user_id IS 'A user with access to the plan. All active members receive weekly stage reminder emails.'; + +CREATE TABLE course_designer_plan_stages ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + course_designer_plan_id UUID NOT NULL REFERENCES course_designer_plans(id), + stage course_designer_stage NOT NULL, + STATUS course_designer_plan_stage_status NOT NULL DEFAULT 'not_started', + planned_starts_on DATE NOT NULL, + planned_ends_on DATE NOT NULL, + actual_started_at TIMESTAMP WITH TIME ZONE, + actual_completed_at TIMESTAMP WITH TIME ZONE, + CHECK (planned_starts_on <= planned_ends_on), + CHECK ( + actual_started_at IS NULL + OR actual_completed_at IS NULL + OR actual_started_at <= actual_completed_at + ), + CONSTRAINT course_designer_plan_stages_plan_stage_unique UNIQUE NULLS NOT DISTINCT ( + course_designer_plan_id, + stage, + deleted_at + ) +); +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON course_designer_plan_stages FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +COMMENT ON TABLE course_designer_plan_stages IS 'Per-plan stage schedule and actual progress data for the fixed MOOC course design stages.'; +COMMENT ON COLUMN course_designer_plan_stages.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN course_designer_plan_stages.created_at IS 'Timestamp when the record was created.'; +COMMENT ON COLUMN course_designer_plan_stages.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN course_designer_plan_stages.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN course_designer_plan_stages.course_designer_plan_id IS 'The plan this stage schedule row belongs to.'; +COMMENT ON COLUMN course_designer_plan_stages.stage IS 'Which fixed workflow stage this row represents.'; +COMMENT ON COLUMN course_designer_plan_stages.status IS 'Manual progress status for the stage.'; +COMMENT ON COLUMN course_designer_plan_stages.planned_starts_on IS 'Planned start date for this stage.'; +COMMENT ON COLUMN course_designer_plan_stages.planned_ends_on IS 'Planned end date for this stage.'; +COMMENT ON COLUMN course_designer_plan_stages.actual_started_at IS 'Timestamp when work on this stage actually started.'; +COMMENT ON COLUMN course_designer_plan_stages.actual_completed_at IS 'Timestamp when this stage was marked completed.'; + +CREATE TABLE course_designer_plan_stage_tasks ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + course_designer_plan_stage_id UUID NOT NULL REFERENCES course_designer_plan_stages(id), + title VARCHAR(255) NOT NULL, + description TEXT, + order_number INTEGER NOT NULL, + is_completed BOOLEAN NOT NULL DEFAULT FALSE, + completed_at TIMESTAMP WITH TIME ZONE, + completed_by_user_id UUID REFERENCES users(id), + is_auto_generated BOOLEAN NOT NULL DEFAULT FALSE, + created_by_user_id UUID REFERENCES users(id), + CHECK (TRIM(title) <> ''), + CHECK (order_number > 0), + CHECK ( + completed_at IS NULL + OR is_completed = TRUE + ), + CHECK ( + completed_by_user_id IS NULL + OR is_completed = TRUE + ), + CONSTRAINT course_designer_plan_stage_tasks_stage_order_unique UNIQUE NULLS NOT DISTINCT ( + course_designer_plan_stage_id, + order_number, + deleted_at + ) +); +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON course_designer_plan_stage_tasks FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +COMMENT ON TABLE course_designer_plan_stage_tasks IS 'Tasks belonging to a specific stage in a MOOC course design plan.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.created_at IS 'Timestamp when the record was created.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.course_designer_plan_stage_id IS 'The stage row this task belongs to.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.title IS 'Task title shown in the stage task list.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.description IS 'Optional task description.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.order_number IS 'Display order of the task within the stage.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.is_completed IS 'Whether the task has been marked completed.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.completed_at IS 'Timestamp when the task was marked completed.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.completed_by_user_id IS 'User who marked the task completed.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.is_auto_generated IS 'Whether the task was generated by the system instead of created manually.'; +COMMENT ON COLUMN course_designer_plan_stage_tasks.created_by_user_id IS 'User who created the task, or NULL for automatically generated tasks.'; + +CREATE TABLE course_designer_plan_events ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + course_designer_plan_id UUID NOT NULL REFERENCES course_designer_plans(id), + actor_user_id UUID REFERENCES users(id), + event_type VARCHAR(255) NOT NULL, + stage course_designer_stage, + payload JSONB NOT NULL DEFAULT '{}'::JSONB, + occurred_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CHECK (TRIM(event_type) <> '') +); +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON course_designer_plan_events FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +CREATE INDEX course_designer_plan_events_timeline_idx ON course_designer_plan_events ( + course_designer_plan_id, + deleted_at, + occurred_at DESC +); + +COMMENT ON TABLE course_designer_plan_events IS 'Append-only event log for reconstructing a design plan timeline and auditing changes.'; +COMMENT ON COLUMN course_designer_plan_events.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN course_designer_plan_events.created_at IS 'Timestamp when the record was created.'; +COMMENT ON COLUMN course_designer_plan_events.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN course_designer_plan_events.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN course_designer_plan_events.course_designer_plan_id IS 'The plan this event belongs to.'; +COMMENT ON COLUMN course_designer_plan_events.actor_user_id IS 'User who caused the event, or NULL for system-generated events.'; +COMMENT ON COLUMN course_designer_plan_events.event_type IS 'Application-defined event type identifier.'; +COMMENT ON COLUMN course_designer_plan_events.stage IS 'Optional workflow stage associated with the event.'; +COMMENT ON COLUMN course_designer_plan_events.payload IS 'Event-specific structured data used to reconstruct timeline details.'; +COMMENT ON COLUMN course_designer_plan_events.occurred_at IS 'When the event occurred. May differ from created_at when backfilling events.'; diff --git a/services/headless-lms/models/.sqlx/query-0627e79ff4710afe3c10d4375b1ba0067b035a45be213eb80e7301361d1d4429.json b/services/headless-lms/models/.sqlx/query-0627e79ff4710afe3c10d4375b1ba0067b035a45be213eb80e7301361d1d4429.json new file mode 100644 index 00000000000..44ab795b65e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-0627e79ff4710afe3c10d4375b1ba0067b035a45be213eb80e7301361d1d4429.json @@ -0,0 +1,103 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n p.id,\n p.created_at,\n p.updated_at,\n p.created_by_user_id,\n p.name,\n p.status AS \"status: CourseDesignerPlanStatus\",\n p.active_stage AS \"active_stage: CourseDesignerStage\",\n p.last_weekly_stage_email_sent_at,\n COUNT(DISTINCT members.user_id)::BIGINT AS \"member_count!\",\n COUNT(DISTINCT stages.stage)::BIGINT AS \"stage_count!\"\nFROM course_designer_plans p\nJOIN course_designer_plan_members self_member\n ON self_member.course_designer_plan_id = p.id\n AND self_member.user_id = $1\n AND self_member.deleted_at IS NULL\nLEFT JOIN course_designer_plan_members members\n ON members.course_designer_plan_id = p.id\n AND members.deleted_at IS NULL\nLEFT JOIN course_designer_plan_stages stages\n ON stages.course_designer_plan_id = p.id\n AND stages.deleted_at IS NULL\nWHERE p.deleted_at IS NULL\nGROUP BY p.id\nORDER BY p.updated_at DESC, p.id DESC\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "status: CourseDesignerPlanStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "active_stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "last_weekly_stage_email_sent_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "member_count!", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "stage_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true, + null, + null + ] + }, + "hash": "0627e79ff4710afe3c10d4375b1ba0067b035a45be213eb80e7301361d1d4429" +} diff --git a/services/headless-lms/models/.sqlx/query-0ea8cc60a82bfb68f0dfc1717c77610c998036b5359ddf9f5ac311862967823d.json b/services/headless-lms/models/.sqlx/query-0ea8cc60a82bfb68f0dfc1717c77610c998036b5359ddf9f5ac311862967823d.json new file mode 100644 index 00000000000..3ff34f8ed8e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-0ea8cc60a82bfb68f0dfc1717c77610c998036b5359ddf9f5ac311862967823d.json @@ -0,0 +1,107 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plans\nSET\n name = $2,\n status = $3\nWHERE id = $1\n AND deleted_at IS NULL\nRETURNING\n id,\n created_at,\n updated_at,\n created_by_user_id,\n name,\n status AS \"status: CourseDesignerPlanStatus\",\n active_stage AS \"active_stage: CourseDesignerStage\",\n last_weekly_stage_email_sent_at\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "status: CourseDesignerPlanStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "active_stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "last_weekly_stage_email_sent_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "0ea8cc60a82bfb68f0dfc1717c77610c998036b5359ddf9f5ac311862967823d" +} diff --git a/services/headless-lms/models/.sqlx/query-2194bc23a7b6ed52a4205be56b8033004eca24a7b62694f2a138a38a9e4cc519.json b/services/headless-lms/models/.sqlx/query-2194bc23a7b6ed52a4205be56b8033004eca24a7b62694f2a138a38a9e4cc519.json new file mode 100644 index 00000000000..68b15bacb0a --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-2194bc23a7b6ed52a4205be56b8033004eca24a7b62694f2a138a38a9e4cc519.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT COUNT(*)::BIGINT AS \"count!\"\nFROM course_designer_plan_stages\nWHERE course_designer_plan_id = $1\n AND deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2194bc23a7b6ed52a4205be56b8033004eca24a7b62694f2a138a38a9e4cc519" +} diff --git a/services/headless-lms/models/.sqlx/query-2dadd4e83ccd5f82e12d01dfe372e250309efb4539de0b36e081e153fb936877.json b/services/headless-lms/models/.sqlx/query-2dadd4e83ccd5f82e12d01dfe372e250309efb4539de0b36e081e153fb936877.json new file mode 100644 index 00000000000..8b982e67415 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-2dadd4e83ccd5f82e12d01dfe372e250309efb4539de0b36e081e153fb936877.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n stages.id,\n stages.created_at,\n stages.updated_at,\n stages.stage AS \"stage: CourseDesignerStage\",\n stages.status AS \"status: CourseDesignerPlanStageStatus\",\n stages.planned_starts_on,\n stages.planned_ends_on,\n stages.actual_started_at,\n stages.actual_completed_at\nFROM course_designer_plan_stages stages\nJOIN course_designer_plan_members self_member\n ON self_member.course_designer_plan_id = stages.course_designer_plan_id\n AND self_member.user_id = $2\n AND self_member.deleted_at IS NULL\nWHERE stages.course_designer_plan_id = $1\n AND stages.deleted_at IS NULL\nORDER BY\n CASE stages.stage\n WHEN 'analysis' THEN 1\n WHEN 'design' THEN 2\n WHEN 'development' THEN 3\n WHEN 'implementation' THEN 4\n WHEN 'evaluation' THEN 5\n END,\n stages.id\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "status: CourseDesignerPlanStageStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_stage_status", + "kind": { + "Enum": [ + "not_started", + "in_progress", + "completed" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "planned_starts_on", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "planned_ends_on", + "type_info": "Date" + }, + { + "ordinal": 7, + "name": "actual_started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "actual_completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "2dadd4e83ccd5f82e12d01dfe372e250309efb4539de0b36e081e153fb936877" +} diff --git a/services/headless-lms/models/.sqlx/query-413005008c2b7eb57fbb4cbe35f9a400118ab5eb91e327f4ab293134e86621c1.json b/services/headless-lms/models/.sqlx/query-413005008c2b7eb57fbb4cbe35f9a400118ab5eb91e327f4ab293134e86621c1.json new file mode 100644 index 00000000000..971e2f1055b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-413005008c2b7eb57fbb4cbe35f9a400118ab5eb91e327f4ab293134e86621c1.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id, created_at, updated_at, user_id\nFROM course_designer_plan_members\nWHERE course_designer_plan_id = $1\n AND deleted_at IS NULL\nORDER BY created_at ASC, id ASC\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "413005008c2b7eb57fbb4cbe35f9a400118ab5eb91e327f4ab293134e86621c1" +} diff --git a/services/headless-lms/models/.sqlx/query-48335dfa0348b619bc7f835e4a3b3aa05b6ba9345a44ceed558692513654702d.json b/services/headless-lms/models/.sqlx/query-48335dfa0348b619bc7f835e4a3b3aa05b6ba9345a44ceed558692513654702d.json new file mode 100644 index 00000000000..900bbfc060d --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-48335dfa0348b619bc7f835e4a3b3aa05b6ba9345a44ceed558692513654702d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n members.id,\n members.created_at,\n members.updated_at,\n members.user_id\nFROM course_designer_plan_members members\nJOIN course_designer_plan_members self_member\n ON self_member.course_designer_plan_id = members.course_designer_plan_id\n AND self_member.user_id = $2\n AND self_member.deleted_at IS NULL\nWHERE members.course_designer_plan_id = $1\n AND members.deleted_at IS NULL\nORDER BY members.created_at ASC, members.id ASC\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "48335dfa0348b619bc7f835e4a3b3aa05b6ba9345a44ceed558692513654702d" +} diff --git a/services/headless-lms/models/.sqlx/query-6ac247dd9f8f71bf96a105b23ffb2622486ef4c22bec890635b623a2fa21c37b.json b/services/headless-lms/models/.sqlx/query-6ac247dd9f8f71bf96a105b23ffb2622486ef4c22bec890635b623a2fa21c37b.json new file mode 100644 index 00000000000..eb8e5cafeb4 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-6ac247dd9f8f71bf96a105b23ffb2622486ef4c22bec890635b623a2fa21c37b.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO course_designer_plans (created_by_user_id, name)\nVALUES ($1, $2)\nRETURNING\n id,\n created_at,\n updated_at,\n created_by_user_id,\n name,\n status AS \"status: CourseDesignerPlanStatus\",\n active_stage AS \"active_stage: CourseDesignerStage\",\n last_weekly_stage_email_sent_at\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "status: CourseDesignerPlanStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "active_stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "last_weekly_stage_email_sent_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "6ac247dd9f8f71bf96a105b23ffb2622486ef4c22bec890635b623a2fa21c37b" +} diff --git a/services/headless-lms/models/.sqlx/query-92a1fcd6a7140887b2349bc5112b0d3e7469e388a07a31ef6a1b919e24acd7ce.json b/services/headless-lms/models/.sqlx/query-92a1fcd6a7140887b2349bc5112b0d3e7469e388a07a31ef6a1b919e24acd7ce.json new file mode 100644 index 00000000000..5bdb10577e2 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-92a1fcd6a7140887b2349bc5112b0d3e7469e388a07a31ef6a1b919e24acd7ce.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n id,\n created_at,\n updated_at,\n stage AS \"stage: CourseDesignerStage\",\n status AS \"status: CourseDesignerPlanStageStatus\",\n planned_starts_on,\n planned_ends_on,\n actual_started_at,\n actual_completed_at\nFROM course_designer_plan_stages\nWHERE course_designer_plan_id = $1\n AND deleted_at IS NULL\nORDER BY\n CASE stage\n WHEN 'analysis' THEN 1\n WHEN 'design' THEN 2\n WHEN 'development' THEN 3\n WHEN 'implementation' THEN 4\n WHEN 'evaluation' THEN 5\n END,\n id\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "status: CourseDesignerPlanStageStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_stage_status", + "kind": { + "Enum": [ + "not_started", + "in_progress", + "completed" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "planned_starts_on", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "planned_ends_on", + "type_info": "Date" + }, + { + "ordinal": 7, + "name": "actual_started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "actual_completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "92a1fcd6a7140887b2349bc5112b0d3e7469e388a07a31ef6a1b919e24acd7ce" +} diff --git a/services/headless-lms/models/.sqlx/query-a01d1b5421f9a4767af3e65457a5b03e3fe4d697687f1438eb516441b2f053c1.json b/services/headless-lms/models/.sqlx/query-a01d1b5421f9a4767af3e65457a5b03e3fe4d697687f1438eb516441b2f053c1.json new file mode 100644 index 00000000000..96976ac0d89 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-a01d1b5421f9a4767af3e65457a5b03e3fe4d697687f1438eb516441b2f053c1.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO course_designer_plan_events (\n course_designer_plan_id,\n actor_user_id,\n event_type,\n stage,\n payload\n)\nVALUES ($1, $2, $3, $4, $5)\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + }, + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "a01d1b5421f9a4767af3e65457a5b03e3fe4d697687f1438eb516441b2f053c1" +} diff --git a/services/headless-lms/models/.sqlx/query-a5c698b800871fe0093d58d2d6fc01ddbe00c5aa81bdf66d390f4d923d48ec82.json b/services/headless-lms/models/.sqlx/query-a5c698b800871fe0093d58d2d6fc01ddbe00c5aa81bdf66d390f4d923d48ec82.json new file mode 100644 index 00000000000..833c47feb41 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-a5c698b800871fe0093d58d2d6fc01ddbe00c5aa81bdf66d390f4d923d48ec82.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n p.id,\n p.created_at,\n p.updated_at,\n p.created_by_user_id,\n p.name,\n p.status AS \"status: CourseDesignerPlanStatus\",\n p.active_stage AS \"active_stage: CourseDesignerStage\",\n p.last_weekly_stage_email_sent_at\nFROM course_designer_plans p\nJOIN course_designer_plan_members m\n ON m.course_designer_plan_id = p.id\n AND m.user_id = $2\n AND m.deleted_at IS NULL\nWHERE p.id = $1\n AND p.deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "status: CourseDesignerPlanStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "active_stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "last_weekly_stage_email_sent_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "a5c698b800871fe0093d58d2d6fc01ddbe00c5aa81bdf66d390f4d923d48ec82" +} diff --git a/services/headless-lms/models/.sqlx/query-bf61544e2c1844e3001b89360a78a3c6b0237f1b87a6e3761d7dbddf3c53b0ca.json b/services/headless-lms/models/.sqlx/query-bf61544e2c1844e3001b89360a78a3c6b0237f1b87a6e3761d7dbddf3c53b0ca.json new file mode 100644 index 00000000000..20dbdc152a4 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-bf61544e2c1844e3001b89360a78a3c6b0237f1b87a6e3761d7dbddf3c53b0ca.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plans\nSET status = $2\nWHERE id = $1\n AND deleted_at IS NULL\nRETURNING\n id,\n created_at,\n updated_at,\n created_by_user_id,\n name,\n status AS \"status: CourseDesignerPlanStatus\",\n active_stage AS \"active_stage: CourseDesignerStage\",\n last_weekly_stage_email_sent_at\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "status: CourseDesignerPlanStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "active_stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "last_weekly_stage_email_sent_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "bf61544e2c1844e3001b89360a78a3c6b0237f1b87a6e3761d7dbddf3c53b0ca" +} diff --git a/services/headless-lms/models/.sqlx/query-c8d0042fd40cd20e963f96eb070e84714eeb8653571856fb8a013a87cff93980.json b/services/headless-lms/models/.sqlx/query-c8d0042fd40cd20e963f96eb070e84714eeb8653571856fb8a013a87cff93980.json new file mode 100644 index 00000000000..c12dd72203c --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-c8d0042fd40cd20e963f96eb070e84714eeb8653571856fb8a013a87cff93980.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO course_designer_plan_members (course_designer_plan_id, user_id)\nVALUES ($1, $2)\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c8d0042fd40cd20e963f96eb070e84714eeb8653571856fb8a013a87cff93980" +} diff --git a/services/headless-lms/models/.sqlx/query-d108ba195f2512ce64f4300dacdfbb409d90598e2d0e465cc99eacfce14711e1.json b/services/headless-lms/models/.sqlx/query-d108ba195f2512ce64f4300dacdfbb409d90598e2d0e465cc99eacfce14711e1.json new file mode 100644 index 00000000000..c7059249d32 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-d108ba195f2512ce64f4300dacdfbb409d90598e2d0e465cc99eacfce14711e1.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n p.id,\n p.created_at,\n p.updated_at,\n p.created_by_user_id,\n p.name,\n p.status AS \"status: CourseDesignerPlanStatus\",\n p.active_stage AS \"active_stage: CourseDesignerStage\",\n p.last_weekly_stage_email_sent_at\nFROM course_designer_plans p\nJOIN course_designer_plan_members m\n ON m.course_designer_plan_id = p.id\n AND m.user_id = $2\n AND m.deleted_at IS NULL\nWHERE p.id = $1\n AND p.deleted_at IS NULL\nFOR UPDATE\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "status: CourseDesignerPlanStatus", + "type_info": { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "active_stage: CourseDesignerStage", + "type_info": { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "last_weekly_stage_email_sent_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "d108ba195f2512ce64f4300dacdfbb409d90598e2d0e465cc99eacfce14711e1" +} diff --git a/services/headless-lms/models/.sqlx/query-d1615df3085611c8a68aa4669bb431b6e5b27544883dc99441f9cfbc810fe887.json b/services/headless-lms/models/.sqlx/query-d1615df3085611c8a68aa4669bb431b6e5b27544883dc99441f9cfbc810fe887.json new file mode 100644 index 00000000000..255c8a03eca --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-d1615df3085611c8a68aa4669bb431b6e5b27544883dc99441f9cfbc810fe887.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO course_designer_plan_stages (\n course_designer_plan_id,\n stage,\n planned_starts_on,\n planned_ends_on\n)\nVALUES ($1, $2, $3, $4)\nON CONFLICT ON CONSTRAINT course_designer_plan_stages_plan_stage_unique DO UPDATE\nSET\n planned_starts_on = EXCLUDED.planned_starts_on,\n planned_ends_on = EXCLUDED.planned_ends_on\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + }, + "Date", + "Date" + ] + }, + "nullable": [] + }, + "hash": "d1615df3085611c8a68aa4669bb431b6e5b27544883dc99441f9cfbc810fe887" +} diff --git a/services/headless-lms/models/src/course_designer_plans.rs b/services/headless-lms/models/src/course_designer_plans.rs new file mode 100644 index 00000000000..a8264e2f7c9 --- /dev/null +++ b/services/headless-lms/models/src/course_designer_plans.rs @@ -0,0 +1,797 @@ +use crate::prelude::*; +use chrono::{Datelike, Duration, NaiveDate}; +use serde_json::{Value, json}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +#[sqlx(type_name = "course_designer_stage", rename_all = "snake_case")] +pub enum CourseDesignerStage { + Analysis, + Design, + Development, + Implementation, + Evaluation, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +#[sqlx(type_name = "course_designer_plan_status", rename_all = "snake_case")] +pub enum CourseDesignerPlanStatus { + Draft, + Scheduling, + ReadyToStart, + InProgress, + Completed, + Archived, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +#[sqlx( + type_name = "course_designer_plan_stage_status", + rename_all = "snake_case" +)] +pub enum CourseDesignerPlanStageStatus { + NotStarted, + InProgress, + Completed, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +#[serde(rename_all = "snake_case")] +pub enum CourseDesignerCourseSize { + Small, + Medium, + Large, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlan { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by_user_id: Uuid, + pub name: Option, + pub status: CourseDesignerPlanStatus, + pub active_stage: Option, + pub last_weekly_stage_email_sent_at: Option>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlanSummary { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by_user_id: Uuid, + pub name: Option, + pub status: CourseDesignerPlanStatus, + pub active_stage: Option, + pub last_weekly_stage_email_sent_at: Option>, + pub member_count: i64, + pub stage_count: i64, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlanMember { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub user_id: Uuid, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, FromRow)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlanStage { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub stage: CourseDesignerStage, + pub status: CourseDesignerPlanStageStatus, + pub planned_starts_on: NaiveDate, + pub planned_ends_on: NaiveDate, + pub actual_started_at: Option>, + pub actual_completed_at: Option>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlanDetails { + pub plan: CourseDesignerPlan, + pub members: Vec, + pub stages: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerScheduleStageInput { + pub stage: CourseDesignerStage, + pub planned_starts_on: NaiveDate, + pub planned_ends_on: NaiveDate, +} + +pub fn fixed_stage_order() -> [CourseDesignerStage; 5] { + [ + CourseDesignerStage::Analysis, + CourseDesignerStage::Design, + CourseDesignerStage::Development, + CourseDesignerStage::Implementation, + CourseDesignerStage::Evaluation, + ] +} + +pub fn validate_schedule_input(stages: &[CourseDesignerScheduleStageInput]) -> ModelResult<()> { + let expected_order = fixed_stage_order(); + if stages.len() != expected_order.len() { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Schedule must contain exactly 5 stages.".to_string(), + None, + )); + } + + for (idx, stage) in stages.iter().enumerate() { + if stage.stage != expected_order[idx] { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Schedule stages must be in the fixed order: analysis, design, development, implementation, evaluation." + .to_string(), + None, + )); + } + if stage.planned_starts_on > stage.planned_ends_on { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + format!("Stage {:?} starts after it ends.", stage.stage), + None, + )); + } + if idx > 0 { + let prev = &stages[idx - 1]; + if !no_gap_between(prev.planned_ends_on, stage.planned_starts_on) { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Schedule must have no gaps or overlaps between consecutive stages." + .to_string(), + None, + )); + } + } + } + + Ok(()) +} + +fn last_day_of_month(year: i32, month: u32) -> ModelResult { + for day in (28..=31).rev() { + if NaiveDate::from_ymd_opt(year, month, day).is_some() { + return Ok(day); + } + } + + Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Invalid month while generating schedule suggestion.".to_string(), + None, + )) +} + +fn add_months_clamped(date: NaiveDate, months: u32) -> ModelResult { + let total_months = date.year() * 12 + date.month0() as i32 + months as i32; + let target_year = total_months.div_euclid(12); + let target_month0 = total_months.rem_euclid(12) as u32; + let target_month = target_month0 + 1; + let target_day = date + .day() + .min(last_day_of_month(target_year, target_month)?); + + NaiveDate::from_ymd_opt(target_year, target_month, target_day).ok_or_else(|| { + ModelError::new( + ModelErrorType::InvalidRequest, + "Failed to generate schedule suggestion date.".to_string(), + None, + ) + }) +} + +fn suggestion_months(size: CourseDesignerCourseSize) -> [u32; 5] { + match size { + CourseDesignerCourseSize::Small => [1, 1, 2, 1, 1], + CourseDesignerCourseSize::Medium => [1, 2, 3, 2, 1], + CourseDesignerCourseSize::Large => [2, 2, 4, 3, 1], + } +} + +pub fn build_schedule_suggestion( + size: CourseDesignerCourseSize, + starts_on: NaiveDate, +) -> ModelResult> { + let mut current_start = starts_on; + let stage_order = fixed_stage_order(); + let month_durations = suggestion_months(size); + let mut out = Vec::with_capacity(stage_order.len()); + + for (stage, months) in stage_order.into_iter().zip(month_durations) { + let next_stage_start = add_months_clamped(current_start, months)?; + let planned_ends_on = next_stage_start - Duration::days(1); + out.push(CourseDesignerScheduleStageInput { + stage, + planned_starts_on: current_start, + planned_ends_on, + }); + current_start = planned_ends_on + Duration::days(1); + } + + Ok(out) +} + +async fn insert_plan_event( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + plan_id: Uuid, + actor_user_id: Option, + event_type: &str, + stage: Option, + payload: Value, +) -> ModelResult<()> { + sqlx::query!( + r#" +INSERT INTO course_designer_plan_events ( + course_designer_plan_id, + actor_user_id, + event_type, + stage, + payload +) +VALUES ($1, $2, $3, $4, $5) +"#, + plan_id, + actor_user_id, + event_type, + stage as Option, + payload + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn create_plan( + conn: &mut PgConnection, + user_id: Uuid, + name: Option, +) -> ModelResult { + let mut tx = conn.begin().await?; + + let plan: CourseDesignerPlan = sqlx::query_as!( + CourseDesignerPlan, + r#" +INSERT INTO course_designer_plans (created_by_user_id, name) +VALUES ($1, $2) +RETURNING + id, + created_at, + updated_at, + created_by_user_id, + name, + status AS "status: CourseDesignerPlanStatus", + active_stage AS "active_stage: CourseDesignerStage", + last_weekly_stage_email_sent_at +"#, + user_id, + name + ) + .fetch_one(&mut *tx) + .await?; + + sqlx::query!( + r#" +INSERT INTO course_designer_plan_members (course_designer_plan_id, user_id) +VALUES ($1, $2) +"#, + plan.id, + user_id + ) + .execute(&mut *tx) + .await?; + + insert_plan_event( + &mut tx, + plan.id, + Some(user_id), + "plan_created", + None, + json!({ "name": plan.name }), + ) + .await?; + + tx.commit().await?; + Ok(plan) +} + +pub async fn list_plans_for_user( + conn: &mut PgConnection, + user_id: Uuid, +) -> ModelResult> { + let plans = sqlx::query_as!( + CourseDesignerPlanSummary, + r#" +SELECT + p.id, + p.created_at, + p.updated_at, + p.created_by_user_id, + p.name, + p.status AS "status: CourseDesignerPlanStatus", + p.active_stage AS "active_stage: CourseDesignerStage", + p.last_weekly_stage_email_sent_at, + COUNT(DISTINCT members.user_id)::BIGINT AS "member_count!", + COUNT(DISTINCT stages.stage)::BIGINT AS "stage_count!" +FROM course_designer_plans p +JOIN course_designer_plan_members self_member + ON self_member.course_designer_plan_id = p.id + AND self_member.user_id = $1 + AND self_member.deleted_at IS NULL +LEFT JOIN course_designer_plan_members members + ON members.course_designer_plan_id = p.id + AND members.deleted_at IS NULL +LEFT JOIN course_designer_plan_stages stages + ON stages.course_designer_plan_id = p.id + AND stages.deleted_at IS NULL +WHERE p.deleted_at IS NULL +GROUP BY p.id +ORDER BY p.updated_at DESC, p.id DESC +"#, + user_id + ) + .fetch_all(conn) + .await?; + Ok(plans) +} + +pub async fn get_plan_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, +) -> ModelResult { + let plan = sqlx::query_as!( + CourseDesignerPlan, + r#" +SELECT + p.id, + p.created_at, + p.updated_at, + p.created_by_user_id, + p.name, + p.status AS "status: CourseDesignerPlanStatus", + p.active_stage AS "active_stage: CourseDesignerStage", + p.last_weekly_stage_email_sent_at +FROM course_designer_plans p +JOIN course_designer_plan_members m + ON m.course_designer_plan_id = p.id + AND m.user_id = $2 + AND m.deleted_at IS NULL +WHERE p.id = $1 + AND p.deleted_at IS NULL +"#, + plan_id, + user_id + ) + .fetch_one(conn) + .await?; + Ok(plan) +} + +pub async fn get_plan_members_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, +) -> ModelResult> { + let members = sqlx::query_as!( + CourseDesignerPlanMember, + r#" +SELECT + members.id, + members.created_at, + members.updated_at, + members.user_id +FROM course_designer_plan_members members +JOIN course_designer_plan_members self_member + ON self_member.course_designer_plan_id = members.course_designer_plan_id + AND self_member.user_id = $2 + AND self_member.deleted_at IS NULL +WHERE members.course_designer_plan_id = $1 + AND members.deleted_at IS NULL +ORDER BY members.created_at ASC, members.id ASC +"#, + plan_id, + user_id + ) + .fetch_all(conn) + .await?; + Ok(members) +} + +pub async fn get_plan_stages_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, +) -> ModelResult> { + let stages = sqlx::query_as!( + CourseDesignerPlanStage, + r#" +SELECT + stages.id, + stages.created_at, + stages.updated_at, + stages.stage AS "stage: CourseDesignerStage", + stages.status AS "status: CourseDesignerPlanStageStatus", + stages.planned_starts_on, + stages.planned_ends_on, + stages.actual_started_at, + stages.actual_completed_at +FROM course_designer_plan_stages stages +JOIN course_designer_plan_members self_member + ON self_member.course_designer_plan_id = stages.course_designer_plan_id + AND self_member.user_id = $2 + AND self_member.deleted_at IS NULL +WHERE stages.course_designer_plan_id = $1 + AND stages.deleted_at IS NULL +ORDER BY + CASE stages.stage + WHEN 'analysis' THEN 1 + WHEN 'design' THEN 2 + WHEN 'development' THEN 3 + WHEN 'implementation' THEN 4 + WHEN 'evaluation' THEN 5 + END, + stages.id +"#, + plan_id, + user_id + ) + .fetch_all(conn) + .await?; + Ok(stages) +} + +pub async fn get_plan_details_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, +) -> ModelResult { + let plan = get_plan_for_user(conn, plan_id, user_id).await?; + let members = get_plan_members_for_user(conn, plan_id, user_id).await?; + let stages = get_plan_stages_for_user(conn, plan_id, user_id).await?; + Ok(CourseDesignerPlanDetails { + plan, + members, + stages, + }) +} + +pub async fn replace_schedule_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, + name: Option, + stages: &[CourseDesignerScheduleStageInput], +) -> ModelResult { + let mut tx = conn.begin().await?; + + let locked_plan: CourseDesignerPlan = sqlx::query_as!( + CourseDesignerPlan, + r#" +SELECT + p.id, + p.created_at, + p.updated_at, + p.created_by_user_id, + p.name, + p.status AS "status: CourseDesignerPlanStatus", + p.active_stage AS "active_stage: CourseDesignerStage", + p.last_weekly_stage_email_sent_at +FROM course_designer_plans p +JOIN course_designer_plan_members m + ON m.course_designer_plan_id = p.id + AND m.user_id = $2 + AND m.deleted_at IS NULL +WHERE p.id = $1 + AND p.deleted_at IS NULL +FOR UPDATE +"#, + plan_id, + user_id + ) + .fetch_one(&mut *tx) + .await?; + + if matches!( + locked_plan.status, + CourseDesignerPlanStatus::InProgress + | CourseDesignerPlanStatus::Completed + | CourseDesignerPlanStatus::Archived + ) { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Cannot edit schedule for a plan that has already started or is closed.".to_string(), + None, + )); + } + + let existing_stage_count: i64 = sqlx::query_scalar!( + r#" +SELECT COUNT(*)::BIGINT AS "count!" +FROM course_designer_plan_stages +WHERE course_designer_plan_id = $1 + AND deleted_at IS NULL +"#, + plan_id + ) + .fetch_one(&mut *tx) + .await?; + + let updated_status = CourseDesignerPlanStatus::Scheduling; + + let updated_plan: CourseDesignerPlan = sqlx::query_as!( + CourseDesignerPlan, + r#" +UPDATE course_designer_plans +SET + name = $2, + status = $3 +WHERE id = $1 + AND deleted_at IS NULL +RETURNING + id, + created_at, + updated_at, + created_by_user_id, + name, + status AS "status: CourseDesignerPlanStatus", + active_stage AS "active_stage: CourseDesignerStage", + last_weekly_stage_email_sent_at +"#, + plan_id, + name, + updated_status as CourseDesignerPlanStatus + ) + .fetch_one(&mut *tx) + .await?; + + for stage in stages { + sqlx::query!( + r#" +INSERT INTO course_designer_plan_stages ( + course_designer_plan_id, + stage, + planned_starts_on, + planned_ends_on +) +VALUES ($1, $2, $3, $4) +ON CONFLICT ON CONSTRAINT course_designer_plan_stages_plan_stage_unique DO UPDATE +SET + planned_starts_on = EXCLUDED.planned_starts_on, + planned_ends_on = EXCLUDED.planned_ends_on +"#, + plan_id, + stage.stage as CourseDesignerStage, + stage.planned_starts_on, + stage.planned_ends_on + ) + .execute(&mut *tx) + .await?; + } + + let event_type = if existing_stage_count == 0 { + "schedule_created" + } else { + "schedule_updated" + }; + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + event_type, + None, + json!({ + "name": updated_plan.name, + "stages": stages, + }), + ) + .await?; + + let members = sqlx::query_as!( + CourseDesignerPlanMember, + r#" +SELECT id, created_at, updated_at, user_id +FROM course_designer_plan_members +WHERE course_designer_plan_id = $1 + AND deleted_at IS NULL +ORDER BY created_at ASC, id ASC +"#, + plan_id + ) + .fetch_all(&mut *tx) + .await?; + + let saved_stages = sqlx::query_as!( + CourseDesignerPlanStage, + r#" +SELECT + id, + created_at, + updated_at, + stage AS "stage: CourseDesignerStage", + status AS "status: CourseDesignerPlanStageStatus", + planned_starts_on, + planned_ends_on, + actual_started_at, + actual_completed_at +FROM course_designer_plan_stages +WHERE course_designer_plan_id = $1 + AND deleted_at IS NULL +ORDER BY + CASE stage + WHEN 'analysis' THEN 1 + WHEN 'design' THEN 2 + WHEN 'development' THEN 3 + WHEN 'implementation' THEN 4 + WHEN 'evaluation' THEN 5 + END, + id +"#, + plan_id + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(CourseDesignerPlanDetails { + plan: updated_plan, + members, + stages: saved_stages, + }) +} + +pub async fn finalize_schedule_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, +) -> ModelResult { + let mut tx = conn.begin().await?; + + let plan: CourseDesignerPlan = sqlx::query_as!( + CourseDesignerPlan, + r#" +SELECT + p.id, + p.created_at, + p.updated_at, + p.created_by_user_id, + p.name, + p.status AS "status: CourseDesignerPlanStatus", + p.active_stage AS "active_stage: CourseDesignerStage", + p.last_weekly_stage_email_sent_at +FROM course_designer_plans p +JOIN course_designer_plan_members m + ON m.course_designer_plan_id = p.id + AND m.user_id = $2 + AND m.deleted_at IS NULL +WHERE p.id = $1 + AND p.deleted_at IS NULL +FOR UPDATE +"#, + plan_id, + user_id + ) + .fetch_one(&mut *tx) + .await?; + + if matches!( + plan.status, + CourseDesignerPlanStatus::InProgress + | CourseDesignerPlanStatus::Completed + | CourseDesignerPlanStatus::Archived + ) { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Cannot finalize schedule for a plan that has already started or is closed." + .to_string(), + None, + )); + } + + let active_stage_count: i64 = sqlx::query_scalar!( + r#" +SELECT COUNT(*)::BIGINT AS "count!" +FROM course_designer_plan_stages +WHERE course_designer_plan_id = $1 + AND deleted_at IS NULL +"#, + plan_id + ) + .fetch_one(&mut *tx) + .await?; + if active_stage_count != 5 { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Schedule must contain all 5 stages before finalizing.".to_string(), + None, + )); + } + + let finalized_plan: CourseDesignerPlan = sqlx::query_as!( + CourseDesignerPlan, + r#" +UPDATE course_designer_plans +SET status = $2 +WHERE id = $1 + AND deleted_at IS NULL +RETURNING + id, + created_at, + updated_at, + created_by_user_id, + name, + status AS "status: CourseDesignerPlanStatus", + active_stage AS "active_stage: CourseDesignerStage", + last_weekly_stage_email_sent_at +"#, + plan_id, + CourseDesignerPlanStatus::ReadyToStart as CourseDesignerPlanStatus + ) + .fetch_one(&mut *tx) + .await?; + + let schedule_snapshot = sqlx::query_as!( + CourseDesignerPlanStage, + r#" +SELECT + id, + created_at, + updated_at, + stage AS "stage: CourseDesignerStage", + status AS "status: CourseDesignerPlanStageStatus", + planned_starts_on, + planned_ends_on, + actual_started_at, + actual_completed_at +FROM course_designer_plan_stages +WHERE course_designer_plan_id = $1 + AND deleted_at IS NULL +ORDER BY + CASE stage + WHEN 'analysis' THEN 1 + WHEN 'design' THEN 2 + WHEN 'development' THEN 3 + WHEN 'implementation' THEN 4 + WHEN 'evaluation' THEN 5 + END, + id +"#, + plan_id + ) + .fetch_all(&mut *tx) + .await?; + + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + "schedule_finalized", + None, + json!({ "stages": schedule_snapshot }), + ) + .await?; + + tx.commit().await?; + Ok(finalized_plan) +} + +pub fn no_gap_between(previous_end: NaiveDate, next_start: NaiveDate) -> bool { + previous_end + Duration::days(1) == next_start +} diff --git a/services/headless-lms/models/src/lib.rs b/services/headless-lms/models/src/lib.rs index 42d0a6fd0b5..3de6d1105e7 100644 --- a/services/headless-lms/models/src/lib.rs +++ b/services/headless-lms/models/src/lib.rs @@ -22,6 +22,7 @@ pub mod code_giveaways; pub mod course_background_question_answers; pub mod course_background_questions; pub mod course_custom_privacy_policy_checkbox_texts; +pub mod course_designer_plans; pub mod course_exams; pub mod course_instance_enrollments; pub mod course_instances; diff --git a/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs b/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs new file mode 100644 index 00000000000..dee17316345 --- /dev/null +++ b/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs @@ -0,0 +1,154 @@ +/*! +Handlers for HTTP requests to `/api/v0/main-frontend/course-plans`. +*/ + +use chrono::NaiveDate; +use models::course_designer_plans::{ + self, CourseDesignerCourseSize, CourseDesignerPlan, CourseDesignerPlanDetails, + CourseDesignerPlanSummary, CourseDesignerScheduleStageInput, +}; + +use crate::prelude::*; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CreateCourseDesignerPlanRequest { + pub name: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerScheduleSuggestionRequest { + pub course_size: CourseDesignerCourseSize, + pub starts_on: NaiveDate, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerScheduleSuggestionResponse { + pub stages: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct SaveCourseDesignerScheduleRequest { + pub name: Option, + pub stages: Vec, +} + +fn sanitize_optional_name(name: Option) -> Option { + name.and_then(|n| { + let trimmed = n.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +#[instrument(skip(pool))] +async fn post_new_plan( + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let plan = course_designer::create_plan( + &mut conn, + user.id, + sanitize_optional_name(payload.name.clone()), + ) + .await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(plan)) +} + +#[instrument(skip(pool))] +async fn get_plans( + pool: web::Data, + user: AuthUser, +) -> ControllerResult>> { + let mut conn = pool.acquire().await?; + let plans = course_designer::list_plans_for_user(&mut conn, user.id).await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(plans)) +} + +#[instrument(skip(pool))] +async fn get_plan( + plan_id: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let plan = course_designer::get_plan_details_for_user(&mut conn, *plan_id, user.id).await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(plan)) +} + +#[instrument(skip(pool))] +async fn post_schedule_suggestion( + plan_id: web::Path, + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + // Membership check by fetching the plan; suggestions are only available to plan members. + course_designer::get_plan_for_user(&mut conn, *plan_id, user.id).await?; + let stages = + course_designer::build_schedule_suggestion(payload.course_size, payload.starts_on)?; + let token = skip_authorize(); + token.authorized_ok(web::Json(CourseDesignerScheduleSuggestionResponse { + stages, + })) +} + +#[instrument(skip(pool))] +async fn put_schedule( + plan_id: web::Path, + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + course_designer::validate_schedule_input(&payload.stages)?; + let mut conn = pool.acquire().await?; + let details = course_designer::replace_schedule_for_user( + &mut conn, + *plan_id, + user.id, + sanitize_optional_name(payload.name.clone()), + &payload.stages, + ) + .await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(details)) +} + +#[instrument(skip(pool))] +async fn post_finalize_schedule( + plan_id: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let plan = course_designer::finalize_schedule_for_user(&mut conn, *plan_id, user.id).await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(plan)) +} + +pub fn _add_routes(cfg: &mut ServiceConfig) { + cfg.route("", web::post().to(post_new_plan)) + .route("", web::get().to(get_plans)) + .route("/{plan_id}", web::get().to(get_plan)) + .route( + "/{plan_id}/schedule/suggestions", + web::post().to(post_schedule_suggestion), + ) + .route("/{plan_id}/schedule", web::put().to(put_schedule)) + .route( + "/{plan_id}/schedule/finalize", + web::post().to(post_finalize_schedule), + ); +} diff --git a/services/headless-lms/server/src/controllers/main_frontend/mod.rs b/services/headless-lms/server/src/controllers/main_frontend/mod.rs index 05c435ddf83..99646f2b159 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/mod.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/mod.rs @@ -10,6 +10,7 @@ pub mod chapters; pub mod chatbot_models; pub mod chatbots; pub mod code_giveaways; +pub mod course_designer; pub mod course_instances; pub mod course_modules; pub mod courses; @@ -44,6 +45,7 @@ use actix_web::web::{self, ServiceConfig}; pub fn _add_routes(cfg: &mut ServiceConfig) { cfg.service(web::scope("/chapters").configure(chapters::_add_routes)) .service(web::scope("/course-instances").configure(course_instances::_add_routes)) + .service(web::scope("/course-plans").configure(course_designer::_add_routes)) .service(web::scope("/course-modules").configure(course_modules::_add_routes)) .service(web::scope("/courses").configure(courses::_add_routes)) .service(web::scope("/email-templates").configure(email_templates::_add_routes)) diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index 69c30160d1a..55f1da48d28 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -80,6 +80,16 @@ fn models(target: &mut File) { code_giveaways::CodeGiveaway, code_giveaways::CodeGiveawayStatus, code_giveaways::NewCodeGiveaway, + course_designer_plans::CourseDesignerCourseSize, + course_designer_plans::CourseDesignerPlan, + course_designer_plans::CourseDesignerPlanDetails, + course_designer_plans::CourseDesignerPlanMember, + course_designer_plans::CourseDesignerPlanStage, + course_designer_plans::CourseDesignerPlanStageStatus, + course_designer_plans::CourseDesignerPlanStatus, + course_designer_plans::CourseDesignerPlanSummary, + course_designer_plans::CourseDesignerScheduleStageInput, + course_designer_plans::CourseDesignerStage, course_background_question_answers::CourseBackgroundQuestionAnswer, course_background_question_answers::NewCourseBackgroundQuestionAnswer, course_background_questions::CourseBackgroundQuestion, @@ -364,6 +374,10 @@ fn controllers(target: &mut File) { chatbot_models::CourseInfo, certificates::CertificateConfigurationUpdate, + course_designer::CourseDesignerScheduleSuggestionRequest, + course_designer::CourseDesignerScheduleSuggestionResponse, + course_designer::CreateCourseDesignerPlanRequest, + course_designer::SaveCourseDesignerScheduleRequest, courses::GetFeedbackQuery, courses::CopyCourseRequest, courses::CopyCourseMode, diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx new file mode 100644 index 00000000000..13b7a1e9a5a --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -0,0 +1,384 @@ +"use client" + +import { css } from "@emotion/css" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useParams } from "next/navigation" +import React, { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { + CourseDesignerCourseSize, + CourseDesignerScheduleStageInput, + CourseDesignerStage, + finalizeCourseDesignerSchedule, + generateCourseDesignerScheduleSuggestion, + getCourseDesignerPlan, + saveCourseDesignerSchedule, +} from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Spinner from "@/shared-module/common/components/Spinner" +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +const containerStyles = css` + max-width: 1100px; + margin: 0 auto; + padding: 2rem; +` + +const sectionStyles = css` + background: white; + border: 1px solid #d9dde4; + border-radius: 12px; + padding: 1rem; + margin-bottom: 1rem; +` + +const rowStyles = css` + display: grid; + grid-template-columns: 180px minmax(140px, 1fr) minmax(140px, 1fr); + gap: 0.75rem; + align-items: center; + margin-bottom: 0.75rem; + + @media (max-width: 700px) { + grid-template-columns: 1fr; + } +` + +const toolbarStyles = css` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: end; +` + +const fieldStyles = css` + display: flex; + flex-direction: column; + gap: 0.25rem; + + input, + select { + padding: 0.5rem; + border-radius: 8px; + border: 1px solid #c8cfda; + font: inherit; + } +` + +const tableHeaderStyles = css` + ${rowStyles}; + font-weight: 700; + color: #4f5b6d; +` + +const todayDateInputValue = () => new Date().toISOString().slice(0, 10) + +// eslint-disable-next-line i18next/no-literal-string +const COURSE_DESIGNER_PLAN_QUERY_KEY = "course-designer-plan" +// eslint-disable-next-line i18next/no-literal-string +const COURSE_DESIGNER_PLANS_QUERY_KEY = "course-designer-plans" + +function CoursePlanSchedulePage() { + const { t } = useTranslation() + const params = useParams<{ id: string }>() + const queryClient = useQueryClient() + const planId = params.id + + const planQuery = useQuery({ + queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId], + queryFn: () => getCourseDesignerPlan(planId), + }) + + const [planName, setPlanName] = useState("") + // eslint-disable-next-line i18next/no-literal-string + const [courseSize, setCourseSize] = useState("medium") + const [startsOn, setStartsOn] = useState(todayDateInputValue()) + const [draftStages, setDraftStages] = useState>([]) + const [initializedFromQuery, setInitializedFromQuery] = useState(null) + + const stageLabel = (stage: CourseDesignerStage) => { + switch (stage) { + // eslint-disable-next-line i18next/no-literal-string + case "Analysis": + return t("course-plans-stage-analysis") + // eslint-disable-next-line i18next/no-literal-string + case "Design": + return t("course-plans-stage-design") + // eslint-disable-next-line i18next/no-literal-string + case "Development": + return t("course-plans-stage-development") + // eslint-disable-next-line i18next/no-literal-string + case "Implementation": + return t("course-plans-stage-implementation") + // eslint-disable-next-line i18next/no-literal-string + case "Evaluation": + return t("course-plans-stage-evaluation") + } + } + + const validateStages = (stages: Array): string | null => { + if (stages.length !== 5) { + return t("course-plans-validation-stage-count") + } + + for (let i = 0; i < stages.length; i++) { + const stage = stages[i] + if (stage.planned_starts_on > stage.planned_ends_on) { + return t("course-plans-validation-stage-range", { stage: stageLabel(stage.stage) }) + } + if (i > 0) { + // eslint-disable-next-line i18next/no-literal-string + const previous = new Date(`${stages[i - 1].planned_ends_on}T00:00:00Z`) + previous.setUTCDate(previous.getUTCDate() + 1) + const expected = previous.toISOString().slice(0, 10) + if (stage.planned_starts_on !== expected) { + return t("course-plans-validation-contiguous") + } + } + } + + return null + } + + useEffect(() => { + if (!planQuery.data || initializedFromQuery === planId) { + return + } + setPlanName(planQuery.data.plan.name ?? "") + if (planQuery.data.stages.length > 0) { + setDraftStages( + planQuery.data.stages.map((stage) => ({ + stage: stage.stage, + planned_starts_on: stage.planned_starts_on, + planned_ends_on: stage.planned_ends_on, + })), + ) + setStartsOn(planQuery.data.stages[0].planned_starts_on) + } + setInitializedFromQuery(planId) + }, [initializedFromQuery, planId, planQuery.data]) + + const suggestionMutation = useToastMutation( + () => + generateCourseDesignerScheduleSuggestion(planId, { + course_size: courseSize, + starts_on: startsOn, + }), + { notify: true, method: "POST" }, + { + onSuccess: (result) => { + setDraftStages(result.stages) + }, + }, + ) + + const saveMutation = useToastMutation( + () => + saveCourseDesignerSchedule(planId, { + name: planName.trim() === "" ? null : planName.trim(), + stages: draftStages, + }), + { notify: true, method: "PUT" }, + { + onSuccess: async (details) => { + setDraftStages( + details.stages.map((stage) => ({ + stage: stage.stage, + planned_starts_on: stage.planned_starts_on, + planned_ends_on: stage.planned_ends_on, + })), + ) + await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId] }) + await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) + }, + }, + ) + + const finalizeMutation = useToastMutation( + () => finalizeCourseDesignerSchedule(planId), + { notify: true, method: "POST" }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId] }) + await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) + }, + }, + ) + + const validationError = useMemo(() => validateStages(draftStages), [draftStages, t]) + + if (planQuery.isError) { + return + } + + if (planQuery.isLoading || !planQuery.data) { + return + } + + return ( +
+

{t("course-plans-schedule-title")}

+ +
+
+ + setPlanName(event.target.value)} + placeholder={t("course-plans-untitled-plan")} + /> +
+

+ {t("course-plans-schedule-summary", { + status: planQuery.data.plan.status, + members: planQuery.data.members.length, + activeStage: planQuery.data.plan.active_stage + ? stageLabel(planQuery.data.plan.active_stage) + : t("course-plans-none"), + })} +

+
+ +
+

{t("course-plans-generate-suggested-schedule")}

+
+
+ + +
+ +
+ + setStartsOn(event.target.value)} + /> +
+ + +
+
+ +
+

{t("course-plans-schedule-editor-title")}

+
+
{t("course-plans-stage-column")}
+
{t("course-plans-start-date-column")}
+
{t("course-plans-end-date-column")}
+
+ + {draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} + + {draftStages.map((stage, index) => ( +
+
{stageLabel(stage.stage)}
+ { + setDraftStages((current) => + current.map((item, currentIndex) => + currentIndex === index + ? { ...item, planned_starts_on: event.target.value } + : item, + ), + ) + }} + /> + { + setDraftStages((current) => + current.map((item, currentIndex) => + currentIndex === index + ? { ...item, planned_ends_on: event.target.value } + : item, + ), + ) + }} + /> +
+ ))} + + {validationError && ( +
+ {validationError} +
+ )} + +
+ + +
+
+
+ ) +} + +export default withErrorBoundary(withSignedIn(CoursePlanSchedulePage)) diff --git a/services/main-frontend/src/app/manage/course-plans/page.tsx b/services/main-frontend/src/app/manage/course-plans/page.tsx new file mode 100644 index 00000000000..99108739aee --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/page.tsx @@ -0,0 +1,140 @@ +"use client" + +import { css } from "@emotion/css" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useRouter } from "next/navigation" +import React from "react" +import { useTranslation } from "react-i18next" + +import { + createCourseDesignerPlan, + listCourseDesignerPlans, +} from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Spinner from "@/shared-module/common/components/Spinner" +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +const containerStyles = css` + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +` + +const headerStyles = css` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; +` + +const listStyles = css` + display: grid; + gap: 1rem; +` + +const cardStyles = css` + border: 1px solid #d9dde4; + border-radius: 12px; + padding: 1rem; + background: white; +` + +const metaStyles = css` + color: #5d6776; + font-size: 0.95rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.5rem; +` + +// eslint-disable-next-line i18next/no-literal-string +const COURSE_DESIGNER_PLANS_QUERY_KEY = "course-designer-plans" + +const coursePlanScheduleRoute = (planId: string) => { + // eslint-disable-next-line i18next/no-literal-string + return `/manage/course-plans/${planId}/schedule` +} + +function CoursePlansPage() { + const { t } = useTranslation() + const router = useRouter() + const queryClient = useQueryClient() + const plansQuery = useQuery({ + queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY], + queryFn: () => listCourseDesignerPlans(), + }) + + const createPlanMutation = useToastMutation( + () => createCourseDesignerPlan({}), + { notify: true, method: "POST" }, + { + onSuccess: async (plan) => { + await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) + router.push(coursePlanScheduleRoute(plan.id)) + }, + }, + ) + + return ( +
+
+

{t("course-plans-title")}

+ +
+ + {plansQuery.isError && } + {plansQuery.isLoading && } + + {plansQuery.isSuccess && ( +
+ {plansQuery.data.length === 0 &&

{t("course-plans-empty")}

} + {plansQuery.data.map((plan) => ( + + ))} +
+ )} +
+ ) +} + +export default withErrorBoundary(withSignedIn(CoursePlansPage)) diff --git a/services/main-frontend/src/services/backend/courseDesigner.ts b/services/main-frontend/src/services/backend/courseDesigner.ts new file mode 100644 index 00000000000..9e7aa252b64 --- /dev/null +++ b/services/main-frontend/src/services/backend/courseDesigner.ts @@ -0,0 +1,128 @@ +import { mainFrontendClient } from "../mainFrontendClient" + +export type CourseDesignerStage = + | "Analysis" + | "Design" + | "Development" + | "Implementation" + | "Evaluation" + +export type CourseDesignerPlanStatus = + | "Draft" + | "Scheduling" + | "ReadyToStart" + | "InProgress" + | "Completed" + | "Archived" + +export type CourseDesignerPlanStageStatus = "NotStarted" | "InProgress" | "Completed" + +export type CourseDesignerCourseSize = "small" | "medium" | "large" + +export interface CourseDesignerPlan { + id: string + created_at: string + updated_at: string + created_by_user_id: string + name: string | null + status: CourseDesignerPlanStatus + active_stage: CourseDesignerStage | null + last_weekly_stage_email_sent_at: string | null +} + +export interface CourseDesignerPlanSummary extends CourseDesignerPlan { + member_count: number + stage_count: number +} + +export interface CourseDesignerPlanMember { + id: string + created_at: string + updated_at: string + user_id: string +} + +export interface CourseDesignerPlanStage { + id: string + created_at: string + updated_at: string + stage: CourseDesignerStage + status: CourseDesignerPlanStageStatus + planned_starts_on: string + planned_ends_on: string + actual_started_at: string | null + actual_completed_at: string | null +} + +export interface CourseDesignerPlanDetails { + plan: CourseDesignerPlan + members: Array + stages: Array +} + +export interface CourseDesignerScheduleStageInput { + stage: CourseDesignerStage + planned_starts_on: string + planned_ends_on: string +} + +export interface CreateCourseDesignerPlanRequest { + name?: string | null +} + +export interface CourseDesignerScheduleSuggestionRequest { + course_size: CourseDesignerCourseSize + starts_on: string +} + +export interface CourseDesignerScheduleSuggestionResponse { + stages: Array +} + +export interface SaveCourseDesignerScheduleRequest { + name?: string | null + stages: Array +} + +export const createCourseDesignerPlan = async ( + payload: CreateCourseDesignerPlanRequest = {}, +): Promise => { + const response = await mainFrontendClient.post("/course-plans", payload) + return response.data as CourseDesignerPlan +} + +export const listCourseDesignerPlans = async (): Promise> => { + const response = await mainFrontendClient.get("/course-plans") + return response.data as Array +} + +export const getCourseDesignerPlan = async (planId: string): Promise => { + const response = await mainFrontendClient.get(`/course-plans/${planId}`) + return response.data as CourseDesignerPlanDetails +} + +export const generateCourseDesignerScheduleSuggestion = async ( + planId: string, + payload: CourseDesignerScheduleSuggestionRequest, +): Promise => { + const response = await mainFrontendClient.post( + `/course-plans/${planId}/schedule/suggestions`, + payload, + ) + return response.data as CourseDesignerScheduleSuggestionResponse +} + +export const saveCourseDesignerSchedule = async ( + planId: string, + payload: SaveCourseDesignerScheduleRequest, +): Promise => { + const response = await mainFrontendClient.put(`/course-plans/${planId}/schedule`, payload) + return response.data as CourseDesignerPlanDetails +} + +export const finalizeCourseDesignerSchedule = async ( + planId: string, +): Promise => { + const response = await mainFrontendClient.post(`/course-plans/${planId}/schedule/finalize`) + return response.data as CourseDesignerPlan +} diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 866f0b0acaa..6b6ae813639 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -287,6 +287,39 @@ "course-navigation": "Navigate to course '{{ title }}'", "course-overview": "Course overview", "course-pages-for": "Course pages for {{course-name}}", + "course-plans-active-stage-value": "Active stage: {{stage}}", + "course-plans-course-size-label": "Course size", + "course-plans-course-size-large": "Large", + "course-plans-course-size-medium": "Medium", + "course-plans-course-size-small": "Small", + "course-plans-empty": "No course design plans yet.", + "course-plans-end-date-column": "End date", + "course-plans-finalize-schedule": "Finalize schedule", + "course-plans-generate-suggested-schedule": "Generate suggested schedule", + "course-plans-generate-suggestion": "Generate suggestion", + "course-plans-members-count": "Members: {{count}}", + "course-plans-new-course-design": "New course design", + "course-plans-none": "None", + "course-plans-plan-name-label": "Plan name", + "course-plans-save-schedule": "Save schedule", + "course-plans-schedule-editor-title": "Schedule editor", + "course-plans-schedule-empty-help": "Generate a suggestion first, or load an existing schedule from this plan.", + "course-plans-schedule-summary": "Status: {{status}} | Members: {{members}} | Active stage: {{activeStage}}", + "course-plans-schedule-title": "Schedule course design plan", + "course-plans-scheduled-stages-count": "Scheduled stages: {{count}}/5", + "course-plans-stage-analysis": "Analysis", + "course-plans-stage-column": "Stage", + "course-plans-stage-design": "Design", + "course-plans-stage-development": "Development", + "course-plans-stage-evaluation": "Evaluation", + "course-plans-stage-implementation": "Implementation", + "course-plans-start-date-column": "Start date", + "course-plans-starts-on-label": "Starts on", + "course-plans-title": "Course Designer Plans", + "course-plans-untitled-plan": "Untitled plan", + "course-plans-validation-contiguous": "Stages must be contiguous (no gaps or overlaps).", + "course-plans-validation-stage-count": "Schedule must contain 5 stages.", + "course-plans-validation-stage-range": "{{stage}} starts after it ends.", "course-progress": "Course progress", "course-settings": "Course settings", "course-status-summary": "Course status summary", From 4bc02bfc1c01be1fe10908091d801db57778d491 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 24 Feb 2026 13:28:24 +0200 Subject: [PATCH 02/16] Fixes --- .../main_frontend/course_designer.rs | 26 ++- .../packages/common/src/bindings.guard.ts | 214 +++++++++++++++++- shared-module/packages/common/src/bindings.ts | 92 ++++++++ 3 files changed, 317 insertions(+), 15 deletions(-) diff --git a/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs b/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs index dee17316345..a3bede72312 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs @@ -4,7 +4,7 @@ Handlers for HTTP requests to `/api/v0/main-frontend/course-plans`. use chrono::NaiveDate; use models::course_designer_plans::{ - self, CourseDesignerCourseSize, CourseDesignerPlan, CourseDesignerPlanDetails, + CourseDesignerCourseSize, CourseDesignerPlan, CourseDesignerPlanDetails, CourseDesignerPlanSummary, CourseDesignerScheduleStageInput, }; @@ -54,7 +54,7 @@ async fn post_new_plan( user: AuthUser, ) -> ControllerResult> { let mut conn = pool.acquire().await?; - let plan = course_designer::create_plan( + let plan = models::course_designer_plans::create_plan( &mut conn, user.id, sanitize_optional_name(payload.name.clone()), @@ -70,7 +70,7 @@ async fn get_plans( user: AuthUser, ) -> ControllerResult>> { let mut conn = pool.acquire().await?; - let plans = course_designer::list_plans_for_user(&mut conn, user.id).await?; + let plans = models::course_designer_plans::list_plans_for_user(&mut conn, user.id).await?; let token = skip_authorize(); token.authorized_ok(web::Json(plans)) } @@ -82,7 +82,9 @@ async fn get_plan( user: AuthUser, ) -> ControllerResult> { let mut conn = pool.acquire().await?; - let plan = course_designer::get_plan_details_for_user(&mut conn, *plan_id, user.id).await?; + let plan = + models::course_designer_plans::get_plan_details_for_user(&mut conn, *plan_id, user.id) + .await?; let token = skip_authorize(); token.authorized_ok(web::Json(plan)) } @@ -96,9 +98,11 @@ async fn post_schedule_suggestion( ) -> ControllerResult> { let mut conn = pool.acquire().await?; // Membership check by fetching the plan; suggestions are only available to plan members. - course_designer::get_plan_for_user(&mut conn, *plan_id, user.id).await?; - let stages = - course_designer::build_schedule_suggestion(payload.course_size, payload.starts_on)?; + models::course_designer_plans::get_plan_for_user(&mut conn, *plan_id, user.id).await?; + let stages = models::course_designer_plans::build_schedule_suggestion( + payload.course_size, + payload.starts_on, + )?; let token = skip_authorize(); token.authorized_ok(web::Json(CourseDesignerScheduleSuggestionResponse { stages, @@ -112,9 +116,9 @@ async fn put_schedule( pool: web::Data, user: AuthUser, ) -> ControllerResult> { - course_designer::validate_schedule_input(&payload.stages)?; + models::course_designer_plans::validate_schedule_input(&payload.stages)?; let mut conn = pool.acquire().await?; - let details = course_designer::replace_schedule_for_user( + let details = models::course_designer_plans::replace_schedule_for_user( &mut conn, *plan_id, user.id, @@ -133,7 +137,9 @@ async fn post_finalize_schedule( user: AuthUser, ) -> ControllerResult> { let mut conn = pool.acquire().await?; - let plan = course_designer::finalize_schedule_for_user(&mut conn, *plan_id, user.id).await?; + let plan = + models::course_designer_plans::finalize_schedule_for_user(&mut conn, *plan_id, user.id) + .await?; let token = skip_authorize(); token.authorized_ok(web::Json(plan)) } diff --git a/shared-module/packages/common/src/bindings.guard.ts b/shared-module/packages/common/src/bindings.guard.ts index dcd38040677..087d86ca72a 100644 --- a/shared-module/packages/common/src/bindings.guard.ts +++ b/shared-module/packages/common/src/bindings.guard.ts @@ -5,7 +5,7 @@ * Generated type guards for "bindings.ts". * WARNING: Do not manually change this file. */ -import { Action, ActionOnResource, Resource, ErrorData, ErrorResponse, SpecRequest, ConsentQuery, ConsentResponse, ConsentDenyQuery, CertificateAllRequirements, CertificateConfiguration, CertificateConfigurationAndRequirements, CertificateTextAnchor, PaperSize, Chapter, ChapterStatus, ChapterUpdate, ChapterWithStatus, DatabaseChapter, NewChapter, UserCourseInstanceChapterProgress, ChapterAvailability, UserChapterProgress, CourseUserInfo, ChapterLockPreview, UnreturnedExercise, ChatbotConfiguration, NewChatbotConf, VerbosityLevel, ReasoningEffortLevel, ChatbotConfigurationModel, ChatbotConversationMessage, MessageRole, ChatbotConversationMessageCitation, ChatbotConversationMessageToolCall, ChatbotConversationMessageToolOutput, ChatbotConversation, ChatbotConversationInfo, CodeGiveawayCode, CodeGiveaway, CodeGiveawayStatus, NewCodeGiveaway, CourseBackgroundQuestionAnswer, NewCourseBackgroundQuestionAnswer, CourseBackgroundQuestion, CourseBackgroundQuestionType, CourseBackgroundQuestionsAndAnswers, CourseCustomPrivacyPolicyCheckboxText, CourseEnrollmentInfo, CourseEnrollmentsInfo, CourseInstanceEnrollment, CourseInstanceEnrollmentsInfo, ChapterScore, CourseInstance, CourseInstanceForm, PointMap, Points, CourseModuleCompletion, CourseModuleCompletionWithRegistrationInfo, AutomaticCompletionRequirements, CompletionPolicy, CourseModule, ModifiedModule, ModuleUpdates, NewCourseModule, NewModule, Course, CourseMaterialCourse, CourseBreadcrumbInfo, CourseCount, CourseStructure, CourseUpdate, NewCourse, CourseLanguageVersionNavigationInfo, EmailTemplate, EmailTemplateNew, EmailTemplateUpdate, EmailTemplateType, CourseExam, Exam, ExamEnrollment, ExamInstructions, ExamInstructionsUpdate, NewExam, OrgExam, ExerciseRepository, ExerciseRepositoryStatus, CourseMaterialExerciseServiceInfo, ExerciseServiceInfoApi, ExerciseService, ExerciseServiceIframeRenderingInfo, ExerciseServiceNewOrUpdate, AnswerRequiringAttention, ExerciseAnswersInCourseRequiringAttentionCount, ExerciseSlideSubmission, ExerciseSlideSubmissionAndUserExerciseState, ExerciseSlideSubmissionAndUserExerciseStateList, ExerciseSlideSubmissionCount, ExerciseSlideSubmissionCountByExercise, ExerciseSlideSubmissionCountByWeekAndHour, ExerciseSlideSubmissionInfo, CourseMaterialExerciseSlide, ExerciseSlide, ExerciseTaskGrading, ExerciseTaskGradingResult, UserPointsUpdateStrategy, ExerciseTaskSubmission, PeerOrSelfReviewsReceived, CourseMaterialExerciseTask, ExerciseTask, ActivityProgress, CourseMaterialExercise, Exercise, ExerciseGradingStatus, ExerciseStatus, ExerciseStatusSummaryForUser, GradingProgress, ExerciseResetLog, Feedback, FeedbackBlock, FeedbackCount, NewFeedback, FlaggedAnswer, NewFlaggedAnswer, NewFlaggedAnswerWithToken, ReportReason, GeneratedCertificate, CertificateUpdateRequest, Term, TermUpdate, AverageMetric, CohortActivity, CountResult, StudentsByCountryTotalsResult, CustomViewExerciseSubmissions, CustomViewExerciseTaskGrading, CustomViewExerciseTaskSpec, CustomViewExerciseTaskSubmission, CustomViewExerciseTasks, CourseCompletionStats, DomainCompletionStats, GlobalCourseModuleStatEntry, GlobalStatEntry, TimeGranularity, AnswerRequiringAttentionWithTasks, AnswersRequiringAttention, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult, StudentExerciseTaskSubmission, StudentExerciseTaskSubmissionResult, CourseMaterialPeerOrSelfReviewData, CourseMaterialPeerOrSelfReviewDataAnswerToReview, CourseMaterialPeerOrSelfReviewQuestionAnswer, CourseMaterialPeerOrSelfReviewSubmission, CompletionRegistrationLink, CourseInstanceCompletionSummary, ManualCompletionPreview, ManualCompletionPreviewUser, TeacherManualCompletion, TeacherManualCompletionRequest, UserCompletionInformation, UserCourseModuleCompletion, UserModuleCompletionStatus, UserWithModuleCompletions, ProgressOverview, CompletionGridRow, CertificateGridRow, UserMarketingConsent, MaterialReference, NewMaterialReference, Organization, AuthorizedClientInfo, PageAudioFile, HistoryChangeReason, PageHistory, PageVisitDatumSummaryByCourse, PageVisitDatumSummaryByCoursesCountries, PageVisitDatumSummaryByCourseDeviceTypes, PageVisitDatumSummaryByPages, CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask, CmsPageUpdate, ContentManagementPage, CoursePageWithUserData, ExerciseWithExerciseTasks, HistoryRestoreData, IsChapterFrontPage, NewPage, Page, PageChapterAndCourseInformation, PageDetailsUpdate, PageInfo, PageNavigationInformation, PageRoutingData, PageSearchResult, PageWithExercises, SearchRequest, PartnerBlockNew, PartnersBlock, CmsPeerOrSelfReviewConfig, CmsPeerOrSelfReviewConfiguration, CourseMaterialPeerOrSelfReviewConfig, PeerOrSelfReviewConfig, PeerReviewProcessingStrategy, PeerOrSelfReviewAnswer, PeerOrSelfReviewQuestionAndAnswer, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers, CmsPeerOrSelfReviewQuestion, PeerOrSelfReviewQuestion, PeerOrSelfReviewQuestionType, PeerOrSelfReviewSubmission, PeerOrSelfReviewSubmissionWithSubmissionOwner, PeerReviewQueueEntry, PendingRole, PlaygroundExample, PlaygroundExampleData, PrivacyLink, BlockProposal, BlockProposalAction, BlockProposalInfo, EditedBlockNoLongerExistsData, EditedBlockStillExistsData, NewProposedBlockEdit, ProposalStatus, EditProposalInfo, NewProposedPageEdits, PageProposal, ProposalCount, NewRegrading, NewRegradingIdType, Regrading, RegradingInfo, RegradingSubmissionInfo, RepositoryExercise, NewResearchForm, NewResearchFormQuestion, NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion, ResearchFormQuestionAnswer, RoleDomain, RoleInfo, RoleUser, UserRole, StudentCountry, SuspectedCheaters, ThresholdData, NewTeacherGradingDecision, TeacherDecisionType, TeacherGradingDecision, UserChapterLockingStatus, ChapterLockingStatus, UserCourseExerciseServiceVariable, UserCourseSettings, UserDetail, ExerciseUserCounts, ReviewingStage, UserCourseChapterExerciseProgress, UserCourseProgress, UserExerciseState, UserResearchConsent, User, UploadResult, CreateAccountDetails, Login, LoginResponse, VerifyEmailRequest, UserInfo, SaveCourseSettingsPayload, ChaptersWithStatus, CourseMaterialCourseModule, ExamData, ExamEnrollmentData, CourseMaterialPeerOrSelfReviewDataWithToken, CourseInfo, CertificateConfigurationUpdate, GetFeedbackQuery, CopyCourseRequest, CopyCourseMode, ExamCourseInfo, NewExerciseRepository, ExerciseServiceWithError, ExerciseSubmissions, MarkAsRead, PlaygroundViewsMessage, GetEditProposalsQuery, RoleQuery, BulkUserDetailsRequest, UserDetailsRequest, UserInfoPayload, CronJobInfo, DeploymentInfo, EventInfo, IngressInfo, JobInfo, PodDisruptionBudgetInfo, PodInfo, ServiceInfo, ServicePortInfo, HealthStatus, SystemHealthStatus, Pagination, OEmbedResponse, GutenbergBlock } from "./bindings"; +import { Action, ActionOnResource, Resource, ErrorData, ErrorResponse, SpecRequest, ConsentQuery, ConsentResponse, ConsentDenyQuery, CertificateAllRequirements, CertificateConfiguration, CertificateConfigurationAndRequirements, CertificateTextAnchor, PaperSize, Chapter, ChapterStatus, ChapterUpdate, ChapterWithStatus, DatabaseChapter, NewChapter, UserCourseInstanceChapterProgress, ChapterAvailability, UserChapterProgress, CourseUserInfo, ChapterLockPreview, UnreturnedExercise, ChatbotConfiguration, NewChatbotConf, VerbosityLevel, ReasoningEffortLevel, ChatbotConfigurationModel, ChatbotConversationMessage, MessageRole, ChatbotConversationMessageCitation, ChatbotConversationMessageToolCall, ChatbotConversationMessageToolOutput, ChatbotConversation, ChatbotConversationInfo, CodeGiveawayCode, CodeGiveaway, CodeGiveawayStatus, NewCodeGiveaway, CourseDesignerCourseSize, CourseDesignerPlan, CourseDesignerPlanDetails, CourseDesignerPlanMember, CourseDesignerPlanStage, CourseDesignerPlanStageStatus, CourseDesignerPlanStatus, CourseDesignerPlanSummary, CourseDesignerScheduleStageInput, CourseDesignerStage, CourseBackgroundQuestionAnswer, NewCourseBackgroundQuestionAnswer, CourseBackgroundQuestion, CourseBackgroundQuestionType, CourseBackgroundQuestionsAndAnswers, CourseCustomPrivacyPolicyCheckboxText, CourseEnrollmentInfo, CourseEnrollmentsInfo, CourseInstanceEnrollment, CourseInstanceEnrollmentsInfo, ChapterScore, CourseInstance, CourseInstanceForm, PointMap, Points, CourseModuleCompletion, CourseModuleCompletionWithRegistrationInfo, AutomaticCompletionRequirements, CompletionPolicy, CourseModule, ModifiedModule, ModuleUpdates, NewCourseModule, NewModule, Course, CourseMaterialCourse, CourseBreadcrumbInfo, CourseCount, CourseStructure, CourseUpdate, NewCourse, CourseLanguageVersionNavigationInfo, EmailTemplate, EmailTemplateNew, EmailTemplateUpdate, EmailTemplateType, CourseExam, Exam, ExamEnrollment, ExamInstructions, ExamInstructionsUpdate, NewExam, OrgExam, ExerciseRepository, ExerciseRepositoryStatus, CourseMaterialExerciseServiceInfo, ExerciseServiceInfoApi, ExerciseService, ExerciseServiceIframeRenderingInfo, ExerciseServiceNewOrUpdate, AnswerRequiringAttention, ExerciseAnswersInCourseRequiringAttentionCount, ExerciseSlideSubmission, ExerciseSlideSubmissionAndUserExerciseState, ExerciseSlideSubmissionAndUserExerciseStateList, ExerciseSlideSubmissionCount, ExerciseSlideSubmissionCountByExercise, ExerciseSlideSubmissionCountByWeekAndHour, ExerciseSlideSubmissionInfo, CourseMaterialExerciseSlide, ExerciseSlide, ExerciseTaskGrading, ExerciseTaskGradingResult, UserPointsUpdateStrategy, ExerciseTaskSubmission, PeerOrSelfReviewsReceived, CourseMaterialExerciseTask, ExerciseTask, ActivityProgress, CourseMaterialExercise, Exercise, ExerciseGradingStatus, ExerciseStatus, ExerciseStatusSummaryForUser, GradingProgress, ExerciseResetLog, Feedback, FeedbackBlock, FeedbackCount, NewFeedback, FlaggedAnswer, NewFlaggedAnswer, NewFlaggedAnswerWithToken, ReportReason, GeneratedCertificate, CertificateUpdateRequest, Term, TermUpdate, AverageMetric, CohortActivity, CountResult, StudentsByCountryTotalsResult, CustomViewExerciseSubmissions, CustomViewExerciseTaskGrading, CustomViewExerciseTaskSpec, CustomViewExerciseTaskSubmission, CustomViewExerciseTasks, CourseCompletionStats, DomainCompletionStats, GlobalCourseModuleStatEntry, GlobalStatEntry, TimeGranularity, AnswerRequiringAttentionWithTasks, AnswersRequiringAttention, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult, StudentExerciseTaskSubmission, StudentExerciseTaskSubmissionResult, CourseMaterialPeerOrSelfReviewData, CourseMaterialPeerOrSelfReviewDataAnswerToReview, CourseMaterialPeerOrSelfReviewQuestionAnswer, CourseMaterialPeerOrSelfReviewSubmission, CompletionRegistrationLink, CourseInstanceCompletionSummary, ManualCompletionPreview, ManualCompletionPreviewUser, TeacherManualCompletion, TeacherManualCompletionRequest, UserCompletionInformation, UserCourseModuleCompletion, UserModuleCompletionStatus, UserWithModuleCompletions, ProgressOverview, CompletionGridRow, CertificateGridRow, UserMarketingConsent, MaterialReference, NewMaterialReference, Organization, AuthorizedClientInfo, PageAudioFile, HistoryChangeReason, PageHistory, PageVisitDatumSummaryByCourse, PageVisitDatumSummaryByCoursesCountries, PageVisitDatumSummaryByCourseDeviceTypes, PageVisitDatumSummaryByPages, CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask, CmsPageUpdate, ContentManagementPage, CoursePageWithUserData, ExerciseWithExerciseTasks, HistoryRestoreData, IsChapterFrontPage, NewPage, Page, PageChapterAndCourseInformation, PageDetailsUpdate, PageInfo, PageNavigationInformation, PageRoutingData, PageSearchResult, PageWithExercises, SearchRequest, PartnerBlockNew, PartnersBlock, CmsPeerOrSelfReviewConfig, CmsPeerOrSelfReviewConfiguration, CourseMaterialPeerOrSelfReviewConfig, PeerOrSelfReviewConfig, PeerReviewProcessingStrategy, PeerOrSelfReviewAnswer, PeerOrSelfReviewQuestionAndAnswer, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers, CmsPeerOrSelfReviewQuestion, PeerOrSelfReviewQuestion, PeerOrSelfReviewQuestionType, PeerOrSelfReviewSubmission, PeerOrSelfReviewSubmissionWithSubmissionOwner, PeerReviewQueueEntry, PendingRole, PlaygroundExample, PlaygroundExampleData, PrivacyLink, BlockProposal, BlockProposalAction, BlockProposalInfo, EditedBlockNoLongerExistsData, EditedBlockStillExistsData, NewProposedBlockEdit, ProposalStatus, EditProposalInfo, NewProposedPageEdits, PageProposal, ProposalCount, NewRegrading, NewRegradingIdType, Regrading, RegradingInfo, RegradingSubmissionInfo, RepositoryExercise, NewResearchForm, NewResearchFormQuestion, NewResearchFormQuestionAnswer, ResearchForm, ResearchFormQuestion, ResearchFormQuestionAnswer, RoleDomain, RoleInfo, RoleUser, UserRole, StudentCountry, SuspectedCheaters, ThresholdData, NewTeacherGradingDecision, TeacherDecisionType, TeacherGradingDecision, UserChapterLockingStatus, ChapterLockingStatus, UserCourseExerciseServiceVariable, UserCourseSettings, UserDetail, ExerciseUserCounts, ReviewingStage, UserCourseChapterExerciseProgress, UserCourseProgress, UserExerciseState, UserResearchConsent, User, UploadResult, CreateAccountDetails, Login, LoginResponse, VerifyEmailRequest, UserInfo, SaveCourseSettingsPayload, ChaptersWithStatus, CourseMaterialCourseModule, ExamData, ExamEnrollmentData, CourseMaterialPeerOrSelfReviewDataWithToken, CourseInfo, CertificateConfigurationUpdate, CourseDesignerScheduleSuggestionRequest, CourseDesignerScheduleSuggestionResponse, CreateCourseDesignerPlanRequest, SaveCourseDesignerScheduleRequest, GetFeedbackQuery, CopyCourseRequest, CopyCourseMode, ExamCourseInfo, NewExerciseRepository, ExerciseServiceWithError, ExerciseSubmissions, MarkAsRead, PlaygroundViewsMessage, GetEditProposalsQuery, RoleQuery, BulkUserDetailsRequest, UserDetailsRequest, UserInfoPayload, CronJobInfo, DeploymentInfo, EventInfo, IngressInfo, JobInfo, PodDisruptionBudgetInfo, PodInfo, ServiceInfo, ServicePortInfo, HealthStatus, SystemHealthStatus, Pagination, OEmbedResponse, GutenbergBlock } from "./bindings"; export function isAction(obj: unknown): obj is Action { const typedObj = obj as Action @@ -901,6 +901,160 @@ export function isNewCodeGiveaway(obj: unknown): obj is NewCodeGiveaway { ) } +export function isCourseDesignerCourseSize(obj: unknown): obj is CourseDesignerCourseSize { + const typedObj = obj as CourseDesignerCourseSize + return ( + (typedObj === "medium" || + typedObj === "small" || + typedObj === "large") + ) +} + +export function isCourseDesignerPlan(obj: unknown): obj is CourseDesignerPlan { + const typedObj = obj as CourseDesignerPlan + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["id"] === "string" && + typeof typedObj["created_at"] === "string" && + typeof typedObj["updated_at"] === "string" && + typeof typedObj["created_by_user_id"] === "string" && + (typedObj["name"] === null || + typeof typedObj["name"] === "string") && + isCourseDesignerPlanStatus(typedObj["status"]) as boolean && + (typedObj["active_stage"] === null || + typedObj["active_stage"] === "Analysis" || + typedObj["active_stage"] === "Design" || + typedObj["active_stage"] === "Development" || + typedObj["active_stage"] === "Implementation" || + typedObj["active_stage"] === "Evaluation") && + (typedObj["last_weekly_stage_email_sent_at"] === null || + typeof typedObj["last_weekly_stage_email_sent_at"] === "string") + ) +} + +export function isCourseDesignerPlanDetails(obj: unknown): obj is CourseDesignerPlanDetails { + const typedObj = obj as CourseDesignerPlanDetails + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + isCourseDesignerPlan(typedObj["plan"]) as boolean && + Array.isArray(typedObj["members"]) && + typedObj["members"].every((e: any) => + isCourseDesignerPlanMember(e) as boolean + ) && + Array.isArray(typedObj["stages"]) && + typedObj["stages"].every((e: any) => + isCourseDesignerPlanStage(e) as boolean + ) + ) +} + +export function isCourseDesignerPlanMember(obj: unknown): obj is CourseDesignerPlanMember { + const typedObj = obj as CourseDesignerPlanMember + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["id"] === "string" && + typeof typedObj["created_at"] === "string" && + typeof typedObj["updated_at"] === "string" && + typeof typedObj["user_id"] === "string" + ) +} + +export function isCourseDesignerPlanStage(obj: unknown): obj is CourseDesignerPlanStage { + const typedObj = obj as CourseDesignerPlanStage + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["id"] === "string" && + typeof typedObj["created_at"] === "string" && + typeof typedObj["updated_at"] === "string" && + isCourseDesignerStage(typedObj["stage"]) as boolean && + isCourseDesignerPlanStageStatus(typedObj["status"]) as boolean && + typeof typedObj["planned_starts_on"] === "string" && + typeof typedObj["planned_ends_on"] === "string" && + (typedObj["actual_started_at"] === null || + typeof typedObj["actual_started_at"] === "string") && + (typedObj["actual_completed_at"] === null || + typeof typedObj["actual_completed_at"] === "string") + ) +} + +export function isCourseDesignerPlanStageStatus(obj: unknown): obj is CourseDesignerPlanStageStatus { + const typedObj = obj as CourseDesignerPlanStageStatus + return ( + (typedObj === "NotStarted" || + typedObj === "InProgress" || + typedObj === "Completed") + ) +} + +export function isCourseDesignerPlanStatus(obj: unknown): obj is CourseDesignerPlanStatus { + const typedObj = obj as CourseDesignerPlanStatus + return ( + (typedObj === "InProgress" || + typedObj === "Completed" || + typedObj === "Draft" || + typedObj === "Scheduling" || + typedObj === "ReadyToStart" || + typedObj === "Archived") + ) +} + +export function isCourseDesignerPlanSummary(obj: unknown): obj is CourseDesignerPlanSummary { + const typedObj = obj as CourseDesignerPlanSummary + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["id"] === "string" && + typeof typedObj["created_at"] === "string" && + typeof typedObj["updated_at"] === "string" && + typeof typedObj["created_by_user_id"] === "string" && + (typedObj["name"] === null || + typeof typedObj["name"] === "string") && + isCourseDesignerPlanStatus(typedObj["status"]) as boolean && + (typedObj["active_stage"] === null || + typedObj["active_stage"] === "Analysis" || + typedObj["active_stage"] === "Design" || + typedObj["active_stage"] === "Development" || + typedObj["active_stage"] === "Implementation" || + typedObj["active_stage"] === "Evaluation") && + (typedObj["last_weekly_stage_email_sent_at"] === null || + typeof typedObj["last_weekly_stage_email_sent_at"] === "string") && + typeof typedObj["member_count"] === "number" && + typeof typedObj["stage_count"] === "number" + ) +} + +export function isCourseDesignerScheduleStageInput(obj: unknown): obj is CourseDesignerScheduleStageInput { + const typedObj = obj as CourseDesignerScheduleStageInput + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + isCourseDesignerStage(typedObj["stage"]) as boolean && + typeof typedObj["planned_starts_on"] === "string" && + typeof typedObj["planned_ends_on"] === "string" + ) +} + +export function isCourseDesignerStage(obj: unknown): obj is CourseDesignerStage { + const typedObj = obj as CourseDesignerStage + return ( + (typedObj === "Analysis" || + typedObj === "Design" || + typedObj === "Development" || + typedObj === "Implementation" || + typedObj === "Evaluation") + ) +} + export function isCourseBackgroundQuestionAnswer(obj: unknown): obj is CourseBackgroundQuestionAnswer { const typedObj = obj as CourseBackgroundQuestionAnswer return ( @@ -2186,11 +2340,11 @@ export function isExerciseTask(obj: unknown): obj is ExerciseTask { export function isActivityProgress(obj: unknown): obj is ActivityProgress { const typedObj = obj as ActivityProgress return ( - (typedObj === "Initialized" || + (typedObj === "InProgress" || + typedObj === "Completed" || + typedObj === "Initialized" || typedObj === "Started" || - typedObj === "InProgress" || - typedObj === "Submitted" || - typedObj === "Completed") + typedObj === "Submitted") ) } @@ -5136,6 +5290,56 @@ export function isCertificateConfigurationUpdate(obj: unknown): obj is Certifica ) } +export function isCourseDesignerScheduleSuggestionRequest(obj: unknown): obj is CourseDesignerScheduleSuggestionRequest { + const typedObj = obj as CourseDesignerScheduleSuggestionRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + isCourseDesignerCourseSize(typedObj["course_size"]) as boolean && + typeof typedObj["starts_on"] === "string" + ) +} + +export function isCourseDesignerScheduleSuggestionResponse(obj: unknown): obj is CourseDesignerScheduleSuggestionResponse { + const typedObj = obj as CourseDesignerScheduleSuggestionResponse + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + Array.isArray(typedObj["stages"]) && + typedObj["stages"].every((e: any) => + isCourseDesignerScheduleStageInput(e) as boolean + ) + ) +} + +export function isCreateCourseDesignerPlanRequest(obj: unknown): obj is CreateCourseDesignerPlanRequest { + const typedObj = obj as CreateCourseDesignerPlanRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + (typedObj["name"] === null || + typeof typedObj["name"] === "string") + ) +} + +export function isSaveCourseDesignerScheduleRequest(obj: unknown): obj is SaveCourseDesignerScheduleRequest { + const typedObj = obj as SaveCourseDesignerScheduleRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + (typedObj["name"] === null || + typeof typedObj["name"] === "string") && + Array.isArray(typedObj["stages"]) && + typedObj["stages"].every((e: any) => + isCourseDesignerScheduleStageInput(e) as boolean + ) + ) +} + export function isGetFeedbackQuery(obj: unknown): obj is GetFeedbackQuery { const typedObj = obj as GetFeedbackQuery return ( diff --git a/shared-module/packages/common/src/bindings.ts b/shared-module/packages/common/src/bindings.ts index b67fdf2932d..d3b44b18d87 100644 --- a/shared-module/packages/common/src/bindings.ts +++ b/shared-module/packages/common/src/bindings.ts @@ -431,6 +431,80 @@ export interface NewCodeGiveaway { require_course_specific_consent_form_question_id: string | null } +export type CourseDesignerCourseSize = "small" | "medium" | "large" + +export interface CourseDesignerPlan { + id: string + created_at: string + updated_at: string + created_by_user_id: string + name: string | null + status: CourseDesignerPlanStatus + active_stage: CourseDesignerStage | null + last_weekly_stage_email_sent_at: string | null +} + +export interface CourseDesignerPlanDetails { + plan: CourseDesignerPlan + members: Array + stages: Array +} + +export interface CourseDesignerPlanMember { + id: string + created_at: string + updated_at: string + user_id: string +} + +export interface CourseDesignerPlanStage { + id: string + created_at: string + updated_at: string + stage: CourseDesignerStage + status: CourseDesignerPlanStageStatus + planned_starts_on: string + planned_ends_on: string + actual_started_at: string | null + actual_completed_at: string | null +} + +export type CourseDesignerPlanStageStatus = "NotStarted" | "InProgress" | "Completed" + +export type CourseDesignerPlanStatus = + | "Draft" + | "Scheduling" + | "ReadyToStart" + | "InProgress" + | "Completed" + | "Archived" + +export interface CourseDesignerPlanSummary { + id: string + created_at: string + updated_at: string + created_by_user_id: string + name: string | null + status: CourseDesignerPlanStatus + active_stage: CourseDesignerStage | null + last_weekly_stage_email_sent_at: string | null + member_count: number + stage_count: number +} + +export interface CourseDesignerScheduleStageInput { + stage: CourseDesignerStage + planned_starts_on: string + planned_ends_on: string +} + +export type CourseDesignerStage = + | "Analysis" + | "Design" + | "Development" + | "Implementation" + | "Evaluation" + export interface CourseBackgroundQuestionAnswer { id: string created_at: string @@ -2522,6 +2596,24 @@ export interface CertificateConfigurationUpdate { certificate_grade_text_anchor: CertificateTextAnchor | null } +export interface CourseDesignerScheduleSuggestionRequest { + course_size: CourseDesignerCourseSize + starts_on: string +} + +export interface CourseDesignerScheduleSuggestionResponse { + stages: Array +} + +export interface CreateCourseDesignerPlanRequest { + name: string | null +} + +export interface SaveCourseDesignerScheduleRequest { + name: string | null + stages: Array +} + export interface GetFeedbackQuery { read: boolean page: number | undefined From e75aaee725d9d56f5996a762572829fad8f6b8ac Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 24 Feb 2026 15:26:53 +0200 Subject: [PATCH 03/16] Add month management functionality to course plans - Introduced new atoms for managing draft stages in course plans using Jotai. - Implemented functions to add and remove months from stages, ensuring contiguous stage management. - Enhanced the CoursePlanSchedulePage component to utilize the new state management and display month labels. - Added corresponding tests for the new functionality in scheduleStageTransforms. - Updated localization strings for new actions related to month management in course plans. --- eslint.config.js | 1 + .../__tests__/scheduleStageTransforms.test.ts | 133 ++++++++ .../course-plans/[id]/schedule/page.tsx | 323 +++++++++++++----- .../[id]/schedule/scheduleAtoms.ts | 30 ++ .../[id]/schedule/scheduleStageTransforms.ts | 156 +++++++++ .../common/src/locales/en/main-frontend.json | 10 +- 6 files changed, 574 insertions(+), 79 deletions(-) create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleAtoms.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts diff --git a/eslint.config.js b/eslint.config.js index 324817aea55..e32ff5cefed 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -394,6 +394,7 @@ const config = [ "querySelectorAll", "getAttribute", "useRegisterBreadcrumbs", + "format", ], }, "object-properties": { diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts new file mode 100644 index 00000000000..45a6e2a7540 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts @@ -0,0 +1,133 @@ +/// + +import { addMonths, endOfMonth, format, startOfMonth } from "date-fns" + +import { addMonthToStage, removeMonthFromStage } from "../scheduleStageTransforms" + +import { + CourseDesignerScheduleStageInput, + CourseDesignerStage, +} from "@/services/backend/courseDesigner" + +const STAGE_ORDER: CourseDesignerStage[] = [ + "Analysis", + "Design", + "Development", + "Implementation", + "Evaluation", +] + +const makeStage = ( + stage: CourseDesignerStage, + start: Date, + monthCount: number, +): CourseDesignerScheduleStageInput => { + const startMonth = startOfMonth(start) + const endMonth = endOfMonth(addMonths(startMonth, monthCount - 1)) + return { + stage, + planned_starts_on: format(startMonth, "yyyy-MM-dd"), + planned_ends_on: format(endMonth, "yyyy-MM-dd"), + } +} + +const buildContiguousStages = ( + totalMonthsPerStage: number[], +): CourseDesignerScheduleStageInput[] => { + let cursor = startOfMonth(new Date(2026, 1, 1)) // Feb 2026 + const stages: CourseDesignerScheduleStageInput[] = [] + + STAGE_ORDER.forEach((stage, idx) => { + const months = totalMonthsPerStage[idx] + stages.push(makeStage(stage, cursor, months)) + cursor = addMonths(cursor, months) + }) + + return stages +} + +const countMonthsForStage = (stage: CourseDesignerScheduleStageInput): number => { + const start = startOfMonth(new Date(stage.planned_starts_on)) + const end = endOfMonth(new Date(stage.planned_ends_on)) + let count = 0 + let current = start + while (current <= end) { + count += 1 + current = addMonths(current, 1) + } + return count +} + +describe("scheduleStageTransforms", () => { + it("adds a month to the first stage and keeps plan contiguous", () => { + const original = buildContiguousStages([2, 2, 2, 2, 2]) + const updated = addMonthToStage(original, 0) + expect(updated).not.toBeNull() + const stages = updated! + + expect(countMonthsForStage(stages[0])).toBe(3) + expect(countMonthsForStage(stages[1])).toBe(2) + expect(countMonthsForStage(stages[2])).toBe(2) + expect(countMonthsForStage(stages[3])).toBe(2) + expect(countMonthsForStage(stages[4])).toBe(2) + }) + + it("adds a month to the last stage and extends the plan", () => { + const original = buildContiguousStages([2, 2, 2, 2, 2]) + const updated = addMonthToStage(original, 4) + expect(updated).not.toBeNull() + const stages = updated! + + expect(countMonthsForStage(stages[4])).toBe(3) + }) + + it("adds a month to a middle stage and keeps other lengths", () => { + const original = buildContiguousStages([2, 2, 2, 2, 2]) + const updated = addMonthToStage(original, 2) + expect(updated).not.toBeNull() + const stages = updated! + + expect(countMonthsForStage(stages[0])).toBe(2) + expect(countMonthsForStage(stages[1])).toBe(2) + expect(countMonthsForStage(stages[2])).toBe(3) + expect(countMonthsForStage(stages[3])).toBe(2) + expect(countMonthsForStage(stages[4])).toBe(2) + }) + + it("removes a month from the first stage when it has more than one month", () => { + const original = buildContiguousStages([3, 2, 2, 2, 2]) + const updated = removeMonthFromStage(original, 0) + expect(updated).not.toBeNull() + const stages = updated! + + expect(countMonthsForStage(stages[0])).toBe(2) + }) + + it("does not remove a month from a stage with only one month", () => { + const original = buildContiguousStages([1, 2, 2, 2, 2]) + const updated = removeMonthFromStage(original, 0) + expect(updated).toBeNull() + }) + + it("removes a month from the last stage when it has more than one month", () => { + const original = buildContiguousStages([2, 2, 2, 2, 3]) + const updated = removeMonthFromStage(original, 4) + expect(updated).not.toBeNull() + const stages = updated! + + expect(countMonthsForStage(stages[4])).toBe(2) + }) + + it("removes a month from a middle stage and keeps others the same length", () => { + const original = buildContiguousStages([2, 2, 3, 2, 2]) + const updated = removeMonthFromStage(original, 2) + expect(updated).not.toBeNull() + const stages = updated! + + expect(countMonthsForStage(stages[2])).toBe(2) + expect(countMonthsForStage(stages[0])).toBe(2) + expect(countMonthsForStage(stages[1])).toBe(2) + expect(countMonthsForStage(stages[3])).toBe(2) + expect(countMonthsForStage(stages[4])).toBe(2) + }) +}) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx index 13b7a1e9a5a..6f8102297ea 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -2,10 +2,32 @@ import { css } from "@emotion/css" import { useQuery, useQueryClient } from "@tanstack/react-query" +import { + Berries, + Cabin, + Campfire, + CandleLight, + Leaf, + MapleLeaf, + MistyCloud, + PineTree, + Sleigh, + Sunrise, + WaterLiquid, + WinterSnowflake, +} from "@vectopus/atlas-icons-react" +import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" +import { useAtomValue, useSetAtom } from "jotai" import { useParams } from "next/navigation" -import React, { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" +import { + addMonthToStageAtomFamily, + draftStagesAtomFamily, + removeMonthFromStageAtomFamily, +} from "./scheduleAtoms" + import { CourseDesignerCourseSize, CourseDesignerScheduleStageInput, @@ -20,8 +42,50 @@ import ErrorBanner from "@/shared-module/common/components/ErrorBanner" import Spinner from "@/shared-module/common/components/Spinner" import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import { baseTheme } from "@/shared-module/common/styles" import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" +const STAGE_ORDER: CourseDesignerStage[] = [ + "Analysis", + "Design", + "Development", + "Implementation", + "Evaluation", +] + +const MONTH_ICONS = [ + WinterSnowflake, + Sleigh, + Sunrise, + WaterLiquid, + Leaf, + Campfire, + Cabin, + Berries, + MapleLeaf, + MistyCloud, + CandleLight, + PineTree, +] as const + +/** Returns for each stage the list of month labels (e.g. "Feb 2026") in that stage's range. */ +function getStageMonthLabels( + stages: Array, +): Array<{ stage: CourseDesignerStage; labels: string[] }> { + return stages.map((s) => { + const start = parseISO(s.planned_starts_on) + const end = parseISO(s.planned_ends_on) + const labels: string[] = [] + let d = startOfMonth(start) + const endMonth = endOfMonth(end) + while (d <= endMonth) { + labels.push(format(d, "MMM yyyy")) + d = addMonths(d, 1) + } + return { stage: s.stage, labels } + }) +} + const containerStyles = css` max-width: 1100px; margin: 0 auto; @@ -36,18 +100,6 @@ const sectionStyles = css` margin-bottom: 1rem; ` -const rowStyles = css` - display: grid; - grid-template-columns: 180px minmax(140px, 1fr) minmax(140px, 1fr); - gap: 0.75rem; - align-items: center; - margin-bottom: 0.75rem; - - @media (max-width: 700px) { - grid-template-columns: 1fr; - } -` - const toolbarStyles = css` display: flex; flex-wrap: wrap; @@ -69,17 +121,89 @@ const fieldStyles = css` } ` -const tableHeaderStyles = css` - ${rowStyles}; - font-weight: 700; - color: #4f5b6d; +const stageCardStyles = css` + display: flex; + gap: 1rem; + border: 1px solid ${baseTheme.colors.gray[300]}; + border-radius: 12px; + padding: 1rem; + margin-bottom: 0.75rem; + background: ${baseTheme.colors.gray[50]}; +` + +const stageMonthBlocksStyles = css` + display: flex; + flex-direction: column; + gap: 4px; + align-items: stretch; + flex-shrink: 0; + width: 72px; +` + +const stageMonthBlockStyles = css` + width: 72px; + height: 72px; + background: ${baseTheme.colors.green[400]}; + border: 1px solid ${baseTheme.colors.green[600]}; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 0.25rem; + color: white; +` + +const stageMonthBlockMonthStyles = css` + font-size: 0.8rem; + font-weight: 600; + line-height: 1.2; + text-align: center; +` + +const stageMonthBlockYearStyles = css` + font-size: 0.7rem; + font-weight: 500; + opacity: 0.85; + line-height: 1.2; +` + +const stageMonthBlockIconStyles = css` + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 2px; + + svg { + width: 18px; + height: 18px; + } +` + +const stageCardRightStyles = css` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +` + +const stageDescriptionPlaceholderStyles = css` + font-size: 0.85rem; + color: ${baseTheme.colors.gray[500]}; + font-style: italic; +` + +const stageCardActionsStyles = css` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; ` const todayDateInputValue = () => new Date().toISOString().slice(0, 10) -// eslint-disable-next-line i18next/no-literal-string const COURSE_DESIGNER_PLAN_QUERY_KEY = "course-designer-plan" -// eslint-disable-next-line i18next/no-literal-string + const COURSE_DESIGNER_PLANS_QUERY_KEY = "course-designer-plans" function CoursePlanSchedulePage() { @@ -97,24 +221,27 @@ function CoursePlanSchedulePage() { // eslint-disable-next-line i18next/no-literal-string const [courseSize, setCourseSize] = useState("medium") const [startsOn, setStartsOn] = useState(todayDateInputValue()) - const [draftStages, setDraftStages] = useState>([]) const [initializedFromQuery, setInitializedFromQuery] = useState(null) + const draftStages = useAtomValue(draftStagesAtomFamily(planId)) + const setDraftStages = useSetAtom(draftStagesAtomFamily(planId)) + const addStageMonth = useSetAtom(addMonthToStageAtomFamily(planId)) + const removeStageMonth = useSetAtom(removeMonthFromStageAtomFamily(planId)) + const stageLabel = (stage: CourseDesignerStage) => { switch (stage) { - // eslint-disable-next-line i18next/no-literal-string case "Analysis": return t("course-plans-stage-analysis") - // eslint-disable-next-line i18next/no-literal-string + case "Design": return t("course-plans-stage-design") - // eslint-disable-next-line i18next/no-literal-string + case "Development": return t("course-plans-stage-development") - // eslint-disable-next-line i18next/no-literal-string + case "Implementation": return t("course-plans-stage-implementation") - // eslint-disable-next-line i18next/no-literal-string + case "Evaluation": return t("course-plans-stage-evaluation") } @@ -150,17 +277,16 @@ function CoursePlanSchedulePage() { } setPlanName(planQuery.data.plan.name ?? "") if (planQuery.data.stages.length > 0) { - setDraftStages( - planQuery.data.stages.map((stage) => ({ - stage: stage.stage, - planned_starts_on: stage.planned_starts_on, - planned_ends_on: stage.planned_ends_on, - })), - ) + const stages = planQuery.data.stages.map((stage) => ({ + stage: stage.stage, + planned_starts_on: stage.planned_starts_on, + planned_ends_on: stage.planned_ends_on, + })) + setDraftStages(stages) setStartsOn(planQuery.data.stages[0].planned_starts_on) } setInitializedFromQuery(planId) - }, [initializedFromQuery, planId, planQuery.data]) + }, [initializedFromQuery, planId, planQuery.data, setDraftStages]) const suggestionMutation = useToastMutation( () => @@ -185,13 +311,12 @@ function CoursePlanSchedulePage() { { notify: true, method: "PUT" }, { onSuccess: async (details) => { - setDraftStages( - details.stages.map((stage) => ({ - stage: stage.stage, - planned_starts_on: stage.planned_starts_on, - planned_ends_on: stage.planned_ends_on, - })), - ) + const stages = details.stages.map((stage) => ({ + stage: stage.stage, + planned_starts_on: stage.planned_starts_on, + planned_ends_on: stage.planned_ends_on, + })) + setDraftStages(stages) await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId] }) await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) }, @@ -211,6 +336,8 @@ function CoursePlanSchedulePage() { const validationError = useMemo(() => validateStages(draftStages), [draftStages, t]) + const stageMonths = useMemo(() => getStageMonthLabels(draftStages), [draftStages]) + if (planQuery.isError) { return } @@ -260,11 +387,11 @@ function CoursePlanSchedulePage() { value={courseSize} onChange={(event) => setCourseSize(event.target.value as CourseDesignerCourseSize)} > - {/* eslint-disable-next-line i18next/no-literal-string */} + {} - {/* eslint-disable-next-line i18next/no-literal-string */} + {} - {/* eslint-disable-next-line i18next/no-literal-string */} + {} @@ -292,45 +419,85 @@ function CoursePlanSchedulePage() {

{t("course-plans-schedule-editor-title")}

-
-
{t("course-plans-stage-column")}
-
{t("course-plans-start-date-column")}
-
{t("course-plans-end-date-column")}
-
{draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} - {draftStages.map((stage, index) => ( -
-
{stageLabel(stage.stage)}
- { - setDraftStages((current) => - current.map((item, currentIndex) => - currentIndex === index - ? { ...item, planned_starts_on: event.target.value } - : item, - ), - ) - }} - /> - { - setDraftStages((current) => - current.map((item, currentIndex) => - currentIndex === index - ? { ...item, planned_ends_on: event.target.value } - : item, - ), - ) - }} - /> + {draftStages.length === 5 && ( +
+ {STAGE_ORDER.map((stage, stageIndex) => { + const { labels } = stageMonths[stageIndex] ?? { labels: [] } + const canShrink = labels.length > 1 + const stageInput = draftStages[stageIndex] + const monthDates: Date[] = [] + if (stageInput) { + let d = startOfMonth(parseISO(stageInput.planned_starts_on)) + const endMonth = endOfMonth(parseISO(stageInput.planned_ends_on)) + while (d <= endMonth) { + monthDates.push(d) + d = addMonths(d, 1) + } + } + return ( +
+
+ {monthDates.map((d, i) => { + const MonthIcon = MONTH_ICONS[d.getMonth()] + return ( +
+
+ +
+ {format(d, "MMMM")} + {format(d, "yyyy")} +
+ ) + })} +
+
+

+ {stageLabel(stage)} +

+

+ {t("course-plans-stage-description-placeholder")} +

+
+ + +
+
+
+ ) + })}
- ))} + )} {validationError && (
+ atom([]), +) + +export const addMonthToStageAtomFamily = atomFamily((planId: string) => + atom(null, (get, set, stageIndex: number) => { + const stages = get(draftStagesAtomFamily(planId)) + const updated = addMonthToStage(stages, stageIndex) + if (updated) { + set(draftStagesAtomFamily(planId), updated) + } + }), +) + +export const removeMonthFromStageAtomFamily = atomFamily((planId: string) => + atom(null, (get, set, stageIndex: number) => { + const stages = get(draftStagesAtomFamily(planId)) + const updated = removeMonthFromStage(stages, stageIndex) + if (updated) { + set(draftStagesAtomFamily(planId), updated) + } + }), +) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts new file mode 100644 index 00000000000..bd68b2c8af4 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts @@ -0,0 +1,156 @@ +import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" + +import { + CourseDesignerScheduleStageInput, + CourseDesignerStage, +} from "@/services/backend/courseDesigner" + +const STAGE_ORDER: CourseDesignerStage[] = [ + "Analysis", + "Design", + "Development", + "Implementation", + "Evaluation", +] + +type StageInput = CourseDesignerScheduleStageInput + +type MonthWithStage = { + date: Date + stage: CourseDesignerStage +} + +/** Builds a flat month timeline (one entry per calendar month) from the 5 stage ranges. */ +export const buildMonthTimeline = (stages: StageInput[]): MonthWithStage[] | null => { + if (stages.length !== 5) { + return null + } + + const firstStage = stages[0] + const lastStage = stages[stages.length - 1] + + const planStart = startOfMonth(parseISO(firstStage.planned_starts_on)) + const planEnd = endOfMonth(parseISO(lastStage.planned_ends_on)) + + const months: MonthWithStage[] = [] + let current = planStart + + while (current <= planEnd) { + const owningStage = stages.find((stage) => { + const stageStart = startOfMonth(parseISO(stage.planned_starts_on)) + const stageEnd = endOfMonth(parseISO(stage.planned_ends_on)) + return current >= stageStart && current <= stageEnd + }) + + if (!owningStage) { + return null + } + + months.push({ date: current, stage: owningStage.stage }) + current = addMonths(current, 1) + } + + return months +} + +const toStageRanges = (months: MonthWithStage[]): StageInput[] => { + const result: StageInput[] = [] + + STAGE_ORDER.forEach((stage) => { + const stageMonths = months.filter((m) => m.stage === stage) + if (stageMonths.length === 0) { + throw new Error("Stage has no months when rebuilding ranges") + } + const first = stageMonths[0].date + const last = stageMonths[stageMonths.length - 1].date + result.push({ + stage, + planned_starts_on: format(startOfMonth(first), "yyyy-MM-dd"), + planned_ends_on: format(endOfMonth(last), "yyyy-MM-dd"), + }) + }) + + return result +} + +/** Adds one month at the end of the given stage, cascading later stages; plan length +1. */ +export const addMonthToStage = (stages: StageInput[], stageIndex: number): StageInput[] | null => { + const months = buildMonthTimeline(stages) + if (!months) { + return null + } + if (stageIndex < 0 || stageIndex >= STAGE_ORDER.length) { + return null + } + + const lastDate = months[months.length - 1]?.date + if (!lastDate) { + return null + } + + const lengths: number[] = STAGE_ORDER.map( + (stage) => months.filter((m) => m.stage === stage).length, + ) + + lengths[stageIndex] += 1 + + const newLastDate = addMonths(lastDate, 1) + months.push({ date: newLastDate, stage: STAGE_ORDER[STAGE_ORDER.length - 1] }) + + const newMonths: MonthWithStage[] = [] + let cursor = 0 + STAGE_ORDER.forEach((stage, idx) => { + const len = lengths[idx] + for (let i = 0; i < len; i += 1) { + const source = months[cursor] + if (!source) { + throw new Error("Month index out of bounds while assigning stages") + } + newMonths.push({ date: source.date, stage }) + cursor += 1 + } + }) + + return toStageRanges(newMonths) +} + +/** Removes one month from the end of the given stage, cascading later stages; plan length -1. */ +export const removeMonthFromStage = ( + stages: StageInput[], + stageIndex: number, +): StageInput[] | null => { + const months = buildMonthTimeline(stages) + if (!months) { + return null + } + if (stageIndex < 0 || stageIndex >= STAGE_ORDER.length) { + return null + } + + const lengths: number[] = STAGE_ORDER.map( + (stage) => months.filter((m) => m.stage === stage).length, + ) + + if (lengths[stageIndex] <= 1) { + return null + } + + months.pop() + lengths[stageIndex] -= 1 + + const newMonths: MonthWithStage[] = [] + let cursor = 0 + STAGE_ORDER.forEach((stage, idx) => { + const len = lengths[idx] + for (let i = 0; i < len; i += 1) { + const source = months[cursor] + if (!source) { + throw new Error("Month index out of bounds while assigning stages") + } + newMonths.push({ date: source.date, stage }) + cursor += 1 + } + }) + + return toStageRanges(newMonths) +} diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 6b6ae813639..b5928bb2124 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -288,6 +288,7 @@ "course-overview": "Course overview", "course-pages-for": "Course pages for {{course-name}}", "course-plans-active-stage-value": "Active stage: {{stage}}", + "course-plans-add-one-month": "+1 month", "course-plans-course-size-label": "Course size", "course-plans-course-size-large": "Large", "course-plans-course-size-medium": "Medium", @@ -298,9 +299,13 @@ "course-plans-generate-suggested-schedule": "Generate suggested schedule", "course-plans-generate-suggestion": "Generate suggestion", "course-plans-members-count": "Members: {{count}}", + "course-plans-month-unassigned": "Unassigned", "course-plans-new-course-design": "New course design", "course-plans-none": "None", "course-plans-plan-name-label": "Plan name", + "course-plans-project-end": "Project end", + "course-plans-project-start": "Project start", + "course-plans-remove-one-month": "-1 month", "course-plans-save-schedule": "Save schedule", "course-plans-schedule-editor-title": "Schedule editor", "course-plans-schedule-empty-help": "Generate a suggestion first, or load an existing schedule from this plan.", @@ -309,17 +314,20 @@ "course-plans-scheduled-stages-count": "Scheduled stages: {{count}}/5", "course-plans-stage-analysis": "Analysis", "course-plans-stage-column": "Stage", + "course-plans-stage-description-placeholder": "Stage description (optional)", "course-plans-stage-design": "Design", "course-plans-stage-development": "Development", "course-plans-stage-evaluation": "Evaluation", "course-plans-stage-implementation": "Implementation", "course-plans-start-date-column": "Start date", "course-plans-starts-on-label": "Starts on", - "course-plans-title": "Course Designer Plans", + "course-plans-title": "Course Plans", "course-plans-untitled-plan": "Untitled plan", + "course-plans-validation-calendar-order": "Stages must appear in order (Analysis, Design, Development, Implementation, Evaluation) with no gaps.", "course-plans-validation-contiguous": "Stages must be contiguous (no gaps or overlaps).", "course-plans-validation-stage-count": "Schedule must contain 5 stages.", "course-plans-validation-stage-range": "{{stage}} starts after it ends.", + "course-plans-year-label": "Year", "course-progress": "Course progress", "course-settings": "Course settings", "course-status-summary": "Course status summary", From 5225b97738d227ff98efe53b50b3da06ccf16b4c Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 24 Feb 2026 17:07:04 +0200 Subject: [PATCH 04/16] WIP --- .../models/src/course_designer_plans.rs | 12 +- .../course-plans/[id]/schedule/page.tsx | 589 ++++++++++++++---- 2 files changed, 488 insertions(+), 113 deletions(-) diff --git a/services/headless-lms/models/src/course_designer_plans.rs b/services/headless-lms/models/src/course_designer_plans.rs index a8264e2f7c9..ab2382a63e2 100644 --- a/services/headless-lms/models/src/course_designer_plans.rs +++ b/services/headless-lms/models/src/course_designer_plans.rs @@ -165,6 +165,16 @@ pub fn validate_schedule_input(stages: &[CourseDesignerScheduleStageInput]) -> M Ok(()) } +fn first_day_of_month(date: NaiveDate) -> ModelResult { + NaiveDate::from_ymd_opt(date.year(), date.month(), 1).ok_or_else(|| { + ModelError::new( + ModelErrorType::InvalidRequest, + "Invalid date while generating schedule suggestion.".to_string(), + None, + ) + }) +} + fn last_day_of_month(year: i32, month: u32) -> ModelResult { for day in (28..=31).rev() { if NaiveDate::from_ymd_opt(year, month, day).is_some() { @@ -209,7 +219,7 @@ pub fn build_schedule_suggestion( size: CourseDesignerCourseSize, starts_on: NaiveDate, ) -> ModelResult> { - let mut current_start = starts_on; + let mut current_start = first_day_of_month(starts_on)?; let stage_order = fixed_stage_order(); let month_durations = suggestion_months(size); let mut out = Vec::with_capacity(stage_order.len()); diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx index 6f8102297ea..c52a2d23575 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -1,6 +1,6 @@ "use client" -import { css } from "@emotion/css" +import { css, cx } from "@emotion/css" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Berries, @@ -19,8 +19,9 @@ import { import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" import { useAtomValue, useSetAtom } from "jotai" import { useParams } from "next/navigation" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" +import { animated, to, useSprings } from "react-spring" import { addMonthToStageAtomFamily, @@ -68,6 +69,18 @@ const MONTH_ICONS = [ PineTree, ] as const +const BLOCK = 72 +const GAP = 4 +const COL_W = 72 + +type MonthNode = { + id: string + date: Date + stageIndex: number + slotIndex: number + label: string +} + /** Returns for each stage the list of month labels (e.g. "Feb 2026") in that stage's range. */ function getStageMonthLabels( stages: Array, @@ -200,7 +213,29 @@ const stageCardActionsStyles = css` flex-wrap: wrap; ` -const todayDateInputValue = () => new Date().toISOString().slice(0, 10) +const scheduleEditorTimelineRootStyles = css` + position: relative; + contain: layout paint; +` + +const scheduleMonthOverlayStyles = css` + position: absolute; + inset: 0; + pointer-events: none; + overflow: visible; +` + +const scheduleMonthOverlayHiddenStyles = css` + opacity: 0; +` + +const todayDateInputValue = () => format(new Date(), "yyyy-MM-dd") + +function awaitable(x: unknown): Promise { + return Array.isArray(x) + ? Promise.all(x.map((v) => (v != null ? Promise.resolve(v) : Promise.resolve()))) + : Promise.resolve(x) +} const COURSE_DESIGNER_PLAN_QUERY_KEY = "course-designer-plan" @@ -228,48 +263,48 @@ function CoursePlanSchedulePage() { const addStageMonth = useSetAtom(addMonthToStageAtomFamily(planId)) const removeStageMonth = useSetAtom(removeMonthFromStageAtomFamily(planId)) - const stageLabel = (stage: CourseDesignerStage) => { - switch (stage) { - case "Analysis": - return t("course-plans-stage-analysis") - - case "Design": - return t("course-plans-stage-design") - - case "Development": - return t("course-plans-stage-development") - - case "Implementation": - return t("course-plans-stage-implementation") - - case "Evaluation": - return t("course-plans-stage-evaluation") - } - } - - const validateStages = (stages: Array): string | null => { - if (stages.length !== 5) { - return t("course-plans-validation-stage-count") - } + const stageLabel = useCallback( + (stage: CourseDesignerStage) => { + switch (stage) { + case "Analysis": + return t("course-plans-stage-analysis") + case "Design": + return t("course-plans-stage-design") + case "Development": + return t("course-plans-stage-development") + case "Implementation": + return t("course-plans-stage-implementation") + case "Evaluation": + return t("course-plans-stage-evaluation") + } + }, + [t], + ) - for (let i = 0; i < stages.length; i++) { - const stage = stages[i] - if (stage.planned_starts_on > stage.planned_ends_on) { - return t("course-plans-validation-stage-range", { stage: stageLabel(stage.stage) }) + const validateStages = useCallback( + (stages: Array): string | null => { + if (stages.length !== 5) { + return t("course-plans-validation-stage-count") } - if (i > 0) { - // eslint-disable-next-line i18next/no-literal-string - const previous = new Date(`${stages[i - 1].planned_ends_on}T00:00:00Z`) - previous.setUTCDate(previous.getUTCDate() + 1) - const expected = previous.toISOString().slice(0, 10) - if (stage.planned_starts_on !== expected) { - return t("course-plans-validation-contiguous") + for (let i = 0; i < stages.length; i++) { + const stage = stages[i] + if (stage.planned_starts_on > stage.planned_ends_on) { + return t("course-plans-validation-stage-range", { stage: stageLabel(stage.stage) }) + } + if (i > 0) { + // eslint-disable-next-line i18next/no-literal-string + const previous = new Date(`${stages[i - 1].planned_ends_on}T00:00:00Z`) + previous.setUTCDate(previous.getUTCDate() + 1) + const expected = previous.toISOString().slice(0, 10) + if (stage.planned_starts_on !== expected) { + return t("course-plans-validation-contiguous") + } } } - } - - return null - } + return null + }, + [t, stageLabel], + ) useEffect(() => { if (!planQuery.data || initializedFromQuery === planId) { @@ -334,10 +369,300 @@ function CoursePlanSchedulePage() { }, ) - const validationError = useMemo(() => validateStages(draftStages), [draftStages, t]) + const validationError = useMemo(() => validateStages(draftStages), [draftStages, validateStages]) const stageMonths = useMemo(() => getStageMonthLabels(draftStages), [draftStages]) + const monthNodes: MonthNode[] = useMemo(() => { + if (draftStages.length !== 5) { + return [] + } + const nodes: MonthNode[] = [] + draftStages.forEach((s, stageIndex) => { + const start = startOfMonth(parseISO(s.planned_starts_on)) + const end = endOfMonth(parseISO(s.planned_ends_on)) + let d = start + let slot = 0 + while (d <= end) { + nodes.push({ + id: format(d, "yyyy-MM"), + date: d, + stageIndex, + slotIndex: slot, + label: format(d, "MMM yyyy"), + }) + d = addMonths(d, 1) + slot += 1 + } + }) + return nodes + }, [draftStages]) + + const scheduleEditorRootRef = useRef(null) + const monthColRefs = useRef>([]) + const [anchors, setAnchors] = useState>([]) + const [layoutTick, setLayoutTick] = useState(0) + const pendingAnimRef = useRef(false) + const prevIdsRef = useRef>(new Set()) + const prevMonthNodesRef = useRef([]) + const prevTargetByIdRef = useRef(new Map()) + const leavingSnapshotRef = useRef(null) + const leaveAnimGenRef = useRef(0) + const [leavingNodes, setLeavingNodes] = useState([]) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) + const [lastChangedStageIndex, setLastChangedStageIndex] = useState(null) + + const [cardPulseSprings, cardPulseApi] = useSprings(STAGE_ORDER.length, () => ({ + scale: 1, + config: { tension: 300, friction: 20 }, + })) + + useEffect(() => { + if (lastChangedStageIndex === null || anchors.length !== STAGE_ORDER.length) { + return + } + const idx = lastChangedStageIndex + let timeoutId: ReturnType | null = null + const raf2 = requestAnimationFrame(() => { + requestAnimationFrame(() => { + cardPulseApi.start((i) => (i === idx ? { scale: 1.02 } : {})) + timeoutId = setTimeout(() => { + cardPulseApi.start((i) => (i === idx ? { scale: 1 } : {})) + setLastChangedStageIndex(null) + }, 400) + }) + }) + return () => { + cancelAnimationFrame(raf2) + if (timeoutId !== null) { + clearTimeout(timeoutId) + } + } + }, [lastChangedStageIndex, cardPulseApi, anchors.length]) + + useEffect(() => { + // eslint-disable-next-line i18next/no-literal-string -- media query, not user-facing + const mq = window.matchMedia("(prefers-reduced-motion: reduce)") + setPrefersReducedMotion(mq.matches) + const handler = () => { + setPrefersReducedMotion(mq.matches) + } + mq.addEventListener("change", handler) + return () => { + mq.removeEventListener("change", handler) + } + }, []) + + const renderedMonthNodes = useMemo( + () => [...monthNodes, ...leavingNodes], + [monthNodes, leavingNodes], + ) + + useEffect(() => { + pendingAnimRef.current = true + }, [monthNodes]) + + useLayoutEffect(() => { + const root = scheduleEditorRootRef.current + if (!root || draftStages.length !== 5) { + return + } + + const measure = () => { + const rootRect = root.getBoundingClientRect() + const next = STAGE_ORDER.map((_, i) => { + const el = monthColRefs.current[i] + if (!el) { + return null + } + const r = el.getBoundingClientRect() + return { x: r.left - rootRect.left, y: r.top - rootRect.top } + }) + if (next.every(Boolean)) { + requestAnimationFrame(() => { + setAnchors(next as Array<{ x: number; y: number }>) + setLayoutTick((t) => t + 1) + }) + } + } + + measure() + const ro = new ResizeObserver(measure) + ro.observe(root) + monthColRefs.current.forEach((el) => el && ro.observe(el)) + window.addEventListener("resize", measure) + return () => { + ro.disconnect() + window.removeEventListener("resize", measure) + } + }, [draftStages.length]) + + const springConfig = useMemo( + () => + prefersReducedMotion ? { tension: 1000, friction: 100 } : { tension: 160, friction: 32 }, + [prefersReducedMotion], + ) + + const [springs, springsApi] = useSprings(renderedMonthNodes.length, () => ({ + x: 0, + y: 0, + scale: 1, + opacity: 1, + zIndex: 1, + config: springConfig, + })) + + useLayoutEffect(() => { + if (!pendingAnimRef.current) { + return + } + if (anchors.length !== STAGE_ORDER.length) { + return + } + pendingAnimRef.current = false + + const prev = prevIdsRef.current + const nextIds = new Set(monthNodes.map((m) => m.id)) + const entering = new Set(Array.from(nextIds).filter((id) => !prev.has(id))) + const leavingIds = new Set(Array.from(prev).filter((id) => !nextIds.has(id))) + if (leavingIds.size > 0 && leavingSnapshotRef.current === null) { + leavingSnapshotRef.current = prevMonthNodesRef.current.filter((m) => leavingIds.has(m.id)) + queueMicrotask(() => setLeavingNodes(leavingSnapshotRef.current ?? [])) + } + prevMonthNodesRef.current = monthNodes + prevIdsRef.current = nextIds + + const nodes = renderedMonthNodes + const nCurrent = monthNodes.length + + springsApi.start((i) => { + if (i >= nCurrent) { + return null + } + const m = nodes[i] + const a = anchors[m.stageIndex] ?? { x: 0, y: 0 } + const tx = a.x + const ty = a.y + m.slotIndex * (BLOCK + GAP) + const prevTarget = prevTargetByIdRef.current.get(m.id) + const isMoving = prevTarget !== undefined && (prevTarget.x !== tx || prevTarget.y !== ty) + const isEntering = entering.has(m.id) + + if (isEntering) { + if (prefersReducedMotion) { + return { x: tx, y: ty, scale: 1, opacity: 1, zIndex: 1, immediate: true } + } + return { + to: [ + { + x: tx, + y: ty, + opacity: 0, + scale: 0.98, + zIndex: 1, + immediate: true, + }, + { + x: tx, + y: ty, + opacity: 1, + scale: 1, + immediate: false, + config: springConfig, + }, + ], + } + } + + if (isMoving && !prefersReducedMotion) { + return { + to: [ + { zIndex: 10, immediate: true }, + { + x: tx, + y: ty, + scale: 1, + opacity: 1, + immediate: false, + config: springConfig, + }, + { zIndex: 1, immediate: true }, + ], + } + } + + return { x: tx, y: ty, scale: 1, opacity: 1, zIndex: 1 } + }) + + const nextMap = new Map() + for (const node of monthNodes) { + const a = anchors[node.stageIndex] ?? { x: 0, y: 0 } + nextMap.set(node.id, { + x: a.x, + y: a.y + node.slotIndex * (BLOCK + GAP), + }) + } + const snapshot = leavingSnapshotRef.current + if (snapshot) { + for (const m of snapshot) { + const pos = prevTargetByIdRef.current.get(m.id) + if (pos) { + nextMap.set(m.id, pos) + } + } + } + prevTargetByIdRef.current = nextMap + }, [ + layoutTick, + anchors, + monthNodes, + renderedMonthNodes, + springsApi, + springConfig, + prefersReducedMotion, + ]) + + useLayoutEffect(() => { + if (anchors.length !== STAGE_ORDER.length || leavingNodes.length === 0) { + return + } + leaveAnimGenRef.current += 1 + const gen = leaveAnimGenRef.current + const nCurrent = monthNodes.length + const nodes = renderedMonthNodes + const p = springsApi.start((i) => { + if (i < nCurrent) { + return null + } + const m = nodes[i] + const a = anchors[m.stageIndex] ?? { x: 0, y: 0 } + const prevPos = prevTargetByIdRef.current.get(m.id) + const tx = prevPos?.x ?? a.x + const ty = prevPos?.y ?? a.y + m.slotIndex * (BLOCK + GAP) + return { + x: tx, + y: ty, + opacity: 0, + scale: 0.98, + zIndex: 1, + config: springConfig, + } + }) + void awaitable(p).then(() => { + if (leaveAnimGenRef.current !== gen) { + return + } + leavingSnapshotRef.current = null + setLeavingNodes([]) + }) + }, [ + anchors, + leavingNodes.length, + monthNodes.length, + renderedMonthNodes, + springsApi, + springConfig, + ]) + if (planQuery.isError) { return } @@ -423,79 +748,119 @@ function CoursePlanSchedulePage() { {draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} {draftStages.length === 5 && ( -
- {STAGE_ORDER.map((stage, stageIndex) => { - const { labels } = stageMonths[stageIndex] ?? { labels: [] } - const canShrink = labels.length > 1 - const stageInput = draftStages[stageIndex] - const monthDates: Date[] = [] - if (stageInput) { - let d = startOfMonth(parseISO(stageInput.planned_starts_on)) - const endMonth = endOfMonth(parseISO(stageInput.planned_ends_on)) - while (d <= endMonth) { - monthDates.push(d) - d = addMonths(d, 1) - } - } - return ( -
-
- {monthDates.map((d, i) => { - const MonthIcon = MONTH_ICONS[d.getMonth()] - return ( -
-
- -
- {format(d, "MMMM")} - {format(d, "yyyy")} -
- ) - })} -
-
-

+
+ {STAGE_ORDER.map((stage, stageIndex) => { + const { labels } = stageMonths[stageIndex] ?? { labels: [] } + const canShrink = labels.length > 1 + const placeholderHeight = + labels.length * (BLOCK + GAP) - (labels.length > 0 ? GAP : 0) + return ( + `scale(${s})`), + }} + > +
{ + monthColRefs.current[stageIndex] = el + }} > - {stageLabel(stage)} -

-

- {t("course-plans-stage-description-placeholder")} -

-
- - + {stageLabel(stage)} + +

+ {t("course-plans-stage-description-placeholder")} +

+
+ + +
+
+ + ) + })} +
+
+ {springs.map((s, i) => { + const m = renderedMonthNodes[i] + const MonthIcon = MONTH_ICONS[m.date.getMonth()] + return ( + `translate3d(${x}px, ${y}px, 0) scale(${scale})`, + ), + opacity: s.opacity, + zIndex: s.zIndex, + }} + > +
+
+ +
+ {format(m.date, "MMMM")} + {format(m.date, "yyyy")}
-
-
- ) - })} + + ) + })} +
)} From c6b117333cdeef9dd6b323df22827fd3feee876e Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 24 Feb 2026 17:48:40 +0200 Subject: [PATCH 05/16] Switch animation to motion --- services/main-frontend/package.json | 1 + services/main-frontend/pnpm-lock.yaml | 62 ++ .../course-plans/[id]/schedule/page.tsx | 576 +++++------------- .../[id]/schedule/scheduleStageTransforms.ts | 55 +- 4 files changed, 245 insertions(+), 449 deletions(-) diff --git a/services/main-frontend/package.json b/services/main-frontend/package.json index 6a1922a5082..71eb6763117 100644 --- a/services/main-frontend/package.json +++ b/services/main-frontend/package.json @@ -54,6 +54,7 @@ "katex": "^0.16.25", "lodash": "^4.17.21", "monaco-editor": "^0.52.2", + "motion": "^12.34.3", "next": "16.0.7", "papaparse": "^5.5.3", "react": "19.2.1", diff --git a/services/main-frontend/pnpm-lock.yaml b/services/main-frontend/pnpm-lock.yaml index 11a04cb158f..b4b42106530 100644 --- a/services/main-frontend/pnpm-lock.yaml +++ b/services/main-frontend/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: monaco-editor: specifier: ^0.52.2 version: 0.52.2 + motion: + specifier: ^12.34.3 + version: 12.34.3(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next: specifier: 16.0.7 version: 16.0.7(@babel/core@7.28.4)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -3497,6 +3500,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.34.3: + resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -4302,6 +4319,26 @@ packages: moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + motion-dom@12.34.3: + resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + + motion@12.34.3: + resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -9590,6 +9627,16 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.34.3(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + motion-dom: 12.34.3 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + fresh@0.5.2: {} fresh@2.0.0: {} @@ -10701,6 +10748,21 @@ snapshots: moo@0.5.2: {} + motion-dom@12.34.3: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.3(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + framer-motion: 12.34.3(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + mrmime@2.0.1: {} ms@2.0.0: {} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx index c52a2d23575..87cb07fef23 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -1,6 +1,6 @@ "use client" -import { css, cx } from "@emotion/css" +import { css } from "@emotion/css" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Berries, @@ -18,10 +18,10 @@ import { } from "@vectopus/atlas-icons-react" import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" import { useAtomValue, useSetAtom } from "jotai" +import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react" import { useParams } from "next/navigation" -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { animated, to, useSprings } from "react-spring" import { addMonthToStageAtomFamily, @@ -69,34 +69,26 @@ const MONTH_ICONS = [ PineTree, ] as const -const BLOCK = 72 -const GAP = 4 -const COL_W = 72 - -type MonthNode = { +type StageMonth = { id: string date: Date - stageIndex: number - slotIndex: number label: string } -/** Returns for each stage the list of month labels (e.g. "Feb 2026") in that stage's range. */ -function getStageMonthLabels( - stages: Array, -): Array<{ stage: CourseDesignerStage; labels: string[] }> { - return stages.map((s) => { - const start = parseISO(s.planned_starts_on) - const end = parseISO(s.planned_ends_on) - const labels: string[] = [] - let d = startOfMonth(start) - const endMonth = endOfMonth(end) - while (d <= endMonth) { - labels.push(format(d, "MMM yyyy")) - d = addMonths(d, 1) - } - return { stage: s.stage, labels } - }) +function getStageMonths(stage: CourseDesignerScheduleStageInput): StageMonth[] { + const start = startOfMonth(parseISO(stage.planned_starts_on)) + const end = endOfMonth(parseISO(stage.planned_ends_on)) + const months: StageMonth[] = [] + let d = start + while (d <= end) { + months.push({ + id: format(d, "yyyy-MM"), + date: d, + label: format(d, "MMM yyyy"), + }) + d = addMonths(d, 1) + } + return months } const containerStyles = css` @@ -135,6 +127,8 @@ const fieldStyles = css` ` const stageCardStyles = css` + position: relative; + transform-origin: center; display: flex; gap: 1rem; border: 1px solid ${baseTheme.colors.gray[300]}; @@ -213,30 +207,8 @@ const stageCardActionsStyles = css` flex-wrap: wrap; ` -const scheduleEditorTimelineRootStyles = css` - position: relative; - contain: layout paint; -` - -const scheduleMonthOverlayStyles = css` - position: absolute; - inset: 0; - pointer-events: none; - overflow: visible; -` - -const scheduleMonthOverlayHiddenStyles = css` - opacity: 0; -` - const todayDateInputValue = () => format(new Date(), "yyyy-MM-dd") -function awaitable(x: unknown): Promise { - return Array.isArray(x) - ? Promise.all(x.map((v) => (v != null ? Promise.resolve(v) : Promise.resolve()))) - : Promise.resolve(x) -} - const COURSE_DESIGNER_PLAN_QUERY_KEY = "course-designer-plan" const COURSE_DESIGNER_PLANS_QUERY_KEY = "course-designer-plans" @@ -371,297 +343,20 @@ function CoursePlanSchedulePage() { const validationError = useMemo(() => validateStages(draftStages), [draftStages, validateStages]) - const stageMonths = useMemo(() => getStageMonthLabels(draftStages), [draftStages]) - - const monthNodes: MonthNode[] = useMemo(() => { + const stageMonthsByStage = useMemo(() => { if (draftStages.length !== 5) { - return [] - } - const nodes: MonthNode[] = [] - draftStages.forEach((s, stageIndex) => { - const start = startOfMonth(parseISO(s.planned_starts_on)) - const end = endOfMonth(parseISO(s.planned_ends_on)) - let d = start - let slot = 0 - while (d <= end) { - nodes.push({ - id: format(d, "yyyy-MM"), - date: d, - stageIndex, - slotIndex: slot, - label: format(d, "MMM yyyy"), - }) - d = addMonths(d, 1) - slot += 1 - } - }) - return nodes - }, [draftStages]) - - const scheduleEditorRootRef = useRef(null) - const monthColRefs = useRef>([]) - const [anchors, setAnchors] = useState>([]) - const [layoutTick, setLayoutTick] = useState(0) - const pendingAnimRef = useRef(false) - const prevIdsRef = useRef>(new Set()) - const prevMonthNodesRef = useRef([]) - const prevTargetByIdRef = useRef(new Map()) - const leavingSnapshotRef = useRef(null) - const leaveAnimGenRef = useRef(0) - const [leavingNodes, setLeavingNodes] = useState([]) - const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) - const [lastChangedStageIndex, setLastChangedStageIndex] = useState(null) - - const [cardPulseSprings, cardPulseApi] = useSprings(STAGE_ORDER.length, () => ({ - scale: 1, - config: { tension: 300, friction: 20 }, - })) - - useEffect(() => { - if (lastChangedStageIndex === null || anchors.length !== STAGE_ORDER.length) { - return - } - const idx = lastChangedStageIndex - let timeoutId: ReturnType | null = null - const raf2 = requestAnimationFrame(() => { - requestAnimationFrame(() => { - cardPulseApi.start((i) => (i === idx ? { scale: 1.02 } : {})) - timeoutId = setTimeout(() => { - cardPulseApi.start((i) => (i === idx ? { scale: 1 } : {})) - setLastChangedStageIndex(null) - }, 400) - }) - }) - return () => { - cancelAnimationFrame(raf2) - if (timeoutId !== null) { - clearTimeout(timeoutId) - } - } - }, [lastChangedStageIndex, cardPulseApi, anchors.length]) - - useEffect(() => { - // eslint-disable-next-line i18next/no-literal-string -- media query, not user-facing - const mq = window.matchMedia("(prefers-reduced-motion: reduce)") - setPrefersReducedMotion(mq.matches) - const handler = () => { - setPrefersReducedMotion(mq.matches) - } - mq.addEventListener("change", handler) - return () => { - mq.removeEventListener("change", handler) - } - }, []) - - const renderedMonthNodes = useMemo( - () => [...monthNodes, ...leavingNodes], - [monthNodes, leavingNodes], - ) - - useEffect(() => { - pendingAnimRef.current = true - }, [monthNodes]) - - useLayoutEffect(() => { - const root = scheduleEditorRootRef.current - if (!root || draftStages.length !== 5) { - return - } - - const measure = () => { - const rootRect = root.getBoundingClientRect() - const next = STAGE_ORDER.map((_, i) => { - const el = monthColRefs.current[i] - if (!el) { - return null - } - const r = el.getBoundingClientRect() - return { x: r.left - rootRect.left, y: r.top - rootRect.top } - }) - if (next.every(Boolean)) { - requestAnimationFrame(() => { - setAnchors(next as Array<{ x: number; y: number }>) - setLayoutTick((t) => t + 1) - }) - } - } - - measure() - const ro = new ResizeObserver(measure) - ro.observe(root) - monthColRefs.current.forEach((el) => el && ro.observe(el)) - window.addEventListener("resize", measure) - return () => { - ro.disconnect() - window.removeEventListener("resize", measure) + return {} as Record } - }, [draftStages.length]) - - const springConfig = useMemo( - () => - prefersReducedMotion ? { tension: 1000, friction: 100 } : { tension: 160, friction: 32 }, - [prefersReducedMotion], - ) - - const [springs, springsApi] = useSprings(renderedMonthNodes.length, () => ({ - x: 0, - y: 0, - scale: 1, - opacity: 1, - zIndex: 1, - config: springConfig, - })) - - useLayoutEffect(() => { - if (!pendingAnimRef.current) { - return + const byStage = {} as Record + for (const stage of STAGE_ORDER) { + const stageInput = draftStages.find((s) => s.stage === stage) + byStage[stage] = stageInput ? getStageMonths(stageInput) : [] } - if (anchors.length !== STAGE_ORDER.length) { - return - } - pendingAnimRef.current = false - - const prev = prevIdsRef.current - const nextIds = new Set(monthNodes.map((m) => m.id)) - const entering = new Set(Array.from(nextIds).filter((id) => !prev.has(id))) - const leavingIds = new Set(Array.from(prev).filter((id) => !nextIds.has(id))) - if (leavingIds.size > 0 && leavingSnapshotRef.current === null) { - leavingSnapshotRef.current = prevMonthNodesRef.current.filter((m) => leavingIds.has(m.id)) - queueMicrotask(() => setLeavingNodes(leavingSnapshotRef.current ?? [])) - } - prevMonthNodesRef.current = monthNodes - prevIdsRef.current = nextIds - - const nodes = renderedMonthNodes - const nCurrent = monthNodes.length - - springsApi.start((i) => { - if (i >= nCurrent) { - return null - } - const m = nodes[i] - const a = anchors[m.stageIndex] ?? { x: 0, y: 0 } - const tx = a.x - const ty = a.y + m.slotIndex * (BLOCK + GAP) - const prevTarget = prevTargetByIdRef.current.get(m.id) - const isMoving = prevTarget !== undefined && (prevTarget.x !== tx || prevTarget.y !== ty) - const isEntering = entering.has(m.id) - - if (isEntering) { - if (prefersReducedMotion) { - return { x: tx, y: ty, scale: 1, opacity: 1, zIndex: 1, immediate: true } - } - return { - to: [ - { - x: tx, - y: ty, - opacity: 0, - scale: 0.98, - zIndex: 1, - immediate: true, - }, - { - x: tx, - y: ty, - opacity: 1, - scale: 1, - immediate: false, - config: springConfig, - }, - ], - } - } - - if (isMoving && !prefersReducedMotion) { - return { - to: [ - { zIndex: 10, immediate: true }, - { - x: tx, - y: ty, - scale: 1, - opacity: 1, - immediate: false, - config: springConfig, - }, - { zIndex: 1, immediate: true }, - ], - } - } + return byStage + }, [draftStages]) - return { x: tx, y: ty, scale: 1, opacity: 1, zIndex: 1 } - }) - - const nextMap = new Map() - for (const node of monthNodes) { - const a = anchors[node.stageIndex] ?? { x: 0, y: 0 } - nextMap.set(node.id, { - x: a.x, - y: a.y + node.slotIndex * (BLOCK + GAP), - }) - } - const snapshot = leavingSnapshotRef.current - if (snapshot) { - for (const m of snapshot) { - const pos = prevTargetByIdRef.current.get(m.id) - if (pos) { - nextMap.set(m.id, pos) - } - } - } - prevTargetByIdRef.current = nextMap - }, [ - layoutTick, - anchors, - monthNodes, - renderedMonthNodes, - springsApi, - springConfig, - prefersReducedMotion, - ]) - - useLayoutEffect(() => { - if (anchors.length !== STAGE_ORDER.length || leavingNodes.length === 0) { - return - } - leaveAnimGenRef.current += 1 - const gen = leaveAnimGenRef.current - const nCurrent = monthNodes.length - const nodes = renderedMonthNodes - const p = springsApi.start((i) => { - if (i < nCurrent) { - return null - } - const m = nodes[i] - const a = anchors[m.stageIndex] ?? { x: 0, y: 0 } - const prevPos = prevTargetByIdRef.current.get(m.id) - const tx = prevPos?.x ?? a.x - const ty = prevPos?.y ?? a.y + m.slotIndex * (BLOCK + GAP) - return { - x: tx, - y: ty, - opacity: 0, - scale: 0.98, - zIndex: 1, - config: springConfig, - } - }) - void awaitable(p).then(() => { - if (leaveAnimGenRef.current !== gen) { - return - } - leavingSnapshotRef.current = null - setLeavingNodes([]) - }) - }, [ - anchors, - leavingNodes.length, - monthNodes.length, - renderedMonthNodes, - springsApi, - springConfig, - ]) + const reduceMotion = useReducedMotion() + const [pulseStage, setPulseStage] = useState(null) if (planQuery.isError) { return @@ -748,120 +443,139 @@ function CoursePlanSchedulePage() { {draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} {draftStages.length === 5 && ( -
+
{STAGE_ORDER.map((stage, stageIndex) => { - const { labels } = stageMonths[stageIndex] ?? { labels: [] } - const canShrink = labels.length > 1 - const placeholderHeight = - labels.length * (BLOCK + GAP) - (labels.length > 0 ? GAP : 0) + const months = stageMonthsByStage[stage] ?? [] + const canShrink = months.length > 1 return ( - `scale(${s})`), - }} + transition={ + reduceMotion + ? { duration: 0 } + : { type: "spring", stiffness: 300, damping: 30 } + } > -
{ - monthColRefs.current[stageIndex] = el + { + if (pulseStage === stageIndex) { + setPulseStage(null) + } }} > -
-
-
-

- {stageLabel(stage)} -

-

- {t("course-plans-stage-description-placeholder")} -

-
- - +
+ {/* eslint-disable-next-line i18next/no-literal-string -- Motion API value */} + + {months.map((m) => { + const MonthIcon = MONTH_ICONS[m.date.getMonth()] + return ( + +
+
+ +
+ + {format(m.date, "MMMM")} + + + {format(m.date, "yyyy")} + +
+
+ ) + })} +
-
- - ) - })} -
-
- {springs.map((s, i) => { - const m = renderedMonthNodes[i] - const MonthIcon = MONTH_ICONS[m.date.getMonth()] - return ( - `translate3d(${x}px, ${y}px, 0) scale(${scale})`, - ), - opacity: s.opacity, - zIndex: s.zIndex, - }} - > -
-
- +
+

+ {stageLabel(stage)} +

+

+ {t("course-plans-stage-description-placeholder")} +

+
+ + +
- {format(m.date, "MMMM")} - {format(m.date, "yyyy")} -
- + + ) })}
-
+ )} {validationError && ( diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts index bd68b2c8af4..19f6eeb04d1 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts @@ -26,19 +26,36 @@ export const buildMonthTimeline = (stages: StageInput[]): MonthWithStage[] | nul return null } - const firstStage = stages[0] - const lastStage = stages[stages.length - 1] + const byStage = new Map() + stages.forEach((stage) => { + byStage.set(stage.stage, stage) + }) + + for (const stage of STAGE_ORDER) { + if (!byStage.has(stage)) { + return null + } + } + + const starts = STAGE_ORDER.map((stage) => + startOfMonth(parseISO(byStage.get(stage)!.planned_starts_on)), + ) + const ends = STAGE_ORDER.map((stage) => endOfMonth(parseISO(byStage.get(stage)!.planned_ends_on))) - const planStart = startOfMonth(parseISO(firstStage.planned_starts_on)) - const planEnd = endOfMonth(parseISO(lastStage.planned_ends_on)) + const planStart = starts.reduce((a, b) => (a < b ? a : b)) + const planEnd = ends.reduce((a, b) => (a > b ? a : b)) const months: MonthWithStage[] = [] let current = planStart while (current <= planEnd) { - const owningStage = stages.find((stage) => { - const stageStart = startOfMonth(parseISO(stage.planned_starts_on)) - const stageEnd = endOfMonth(parseISO(stage.planned_ends_on)) + const owningStage = STAGE_ORDER.find((stage) => { + const input = byStage.get(stage) + if (!input) { + return false + } + const stageStart = startOfMonth(parseISO(input.planned_starts_on)) + const stageEnd = endOfMonth(parseISO(input.planned_ends_on)) return current >= stageStart && current <= stageEnd }) @@ -46,20 +63,20 @@ export const buildMonthTimeline = (stages: StageInput[]): MonthWithStage[] | nul return null } - months.push({ date: current, stage: owningStage.stage }) + months.push({ date: current, stage: owningStage }) current = addMonths(current, 1) } return months } -const toStageRanges = (months: MonthWithStage[]): StageInput[] => { +const toStageRanges = (months: MonthWithStage[]): StageInput[] | null => { const result: StageInput[] = [] - STAGE_ORDER.forEach((stage) => { + for (const stage of STAGE_ORDER) { const stageMonths = months.filter((m) => m.stage === stage) if (stageMonths.length === 0) { - throw new Error("Stage has no months when rebuilding ranges") + return null } const first = stageMonths[0].date const last = stageMonths[stageMonths.length - 1].date @@ -68,7 +85,7 @@ const toStageRanges = (months: MonthWithStage[]): StageInput[] => { planned_starts_on: format(startOfMonth(first), "yyyy-MM-dd"), planned_ends_on: format(endOfMonth(last), "yyyy-MM-dd"), }) - }) + } return result } @@ -99,17 +116,18 @@ export const addMonthToStage = (stages: StageInput[], stageIndex: number): Stage const newMonths: MonthWithStage[] = [] let cursor = 0 - STAGE_ORDER.forEach((stage, idx) => { + for (let idx = 0; idx < STAGE_ORDER.length; idx += 1) { + const stage = STAGE_ORDER[idx] const len = lengths[idx] for (let i = 0; i < len; i += 1) { const source = months[cursor] if (!source) { - throw new Error("Month index out of bounds while assigning stages") + return null } newMonths.push({ date: source.date, stage }) cursor += 1 } - }) + } return toStageRanges(newMonths) } @@ -140,17 +158,18 @@ export const removeMonthFromStage = ( const newMonths: MonthWithStage[] = [] let cursor = 0 - STAGE_ORDER.forEach((stage, idx) => { + for (let idx = 0; idx < STAGE_ORDER.length; idx += 1) { + const stage = STAGE_ORDER[idx] const len = lengths[idx] for (let i = 0; i < len; i += 1) { const source = months[cursor] if (!source) { - throw new Error("Month index out of bounds while assigning stages") + return null } newMonths.push({ date: source.date, stage }) cursor += 1 } - }) + } return toStageRanges(newMonths) } From 6d7ff1e4fbe53e7f72bab692995fc96b70285671 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 25 Feb 2026 09:50:20 +0200 Subject: [PATCH 06/16] Make schedule creation a wizard --- .../course-plans/[id]/schedule/page.tsx | 923 +++++++++++++----- .../[id]/schedule/scheduleAtoms.ts | 6 + .../common/src/locales/ar/main-frontend.json | 7 + .../common/src/locales/en/main-frontend.json | 7 + .../common/src/locales/fi/main-frontend.json | 7 + .../common/src/locales/sv/main-frontend.json | 7 + .../common/src/locales/uk/main-frontend.json | 7 + 7 files changed, 722 insertions(+), 242 deletions(-) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx index 87cb07fef23..13af2b52cf1 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -1,6 +1,6 @@ "use client" -import { css } from "@emotion/css" +import { css, cx } from "@emotion/css" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Berries, @@ -21,12 +21,14 @@ import { useAtomValue, useSetAtom } from "jotai" import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react" import { useParams } from "next/navigation" import { useCallback, useEffect, useMemo, useState } from "react" +import { useProgressBar } from "react-aria" import { useTranslation } from "react-i18next" import { addMonthToStageAtomFamily, draftStagesAtomFamily, removeMonthFromStageAtomFamily, + scheduleWizardStepAtomFamily, } from "./scheduleAtoms" import { @@ -97,6 +99,94 @@ const containerStyles = css` padding: 2rem; ` +const wizardStepsStripStyles = css` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; + position: relative; + gap: 0.5rem; +` + +const wizardStepPillStyles = (isActive: boolean, isCompleted: boolean) => css` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + position: relative; + z-index: 1; + + .wizard-step-circle { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.95rem; + transition: + background-color 0.2s, + color 0.2s, + border-color 0.2s, + box-shadow 0.2s; + border: 2px solid ${baseTheme.colors.gray[300]}; + background: white; + color: ${baseTheme.colors.gray[500]}; + } + + ${isCompleted && + css` + .wizard-step-circle { + background: ${baseTheme.colors.green[500]}; + border-color: ${baseTheme.colors.green[600]}; + color: white; + } + `} + + ${isActive && + css` + .wizard-step-circle { + background: ${baseTheme.colors.green[600]}; + border-color: ${baseTheme.colors.green[700]}; + color: white; + box-shadow: 0 0 0 4px ${baseTheme.colors.green[100]}; + } + `} + + .wizard-step-label { + font-size: 0.8rem; + font-weight: 600; + color: ${isActive ? baseTheme.colors.green[700] : baseTheme.colors.gray[500]}; + text-align: center; + line-height: 1.2; + } +` + +const wizardStepsConnectorStyles = css` + position: absolute; + top: 19px; + left: 0; + right: 0; + height: 2px; + background: ${baseTheme.colors.gray[200]}; + z-index: 0; + pointer-events: none; +` + +const wizardProgressFillBaseStyles = css` + position: absolute; + top: 19px; + left: 0; + width: 100%; + height: 2px; + background: ${baseTheme.colors.green[500]}; + z-index: 0; + pointer-events: none; + transform-origin: left; +` + const sectionStyles = css` background: white; border: 1px solid #d9dde4; @@ -105,6 +195,51 @@ const sectionStyles = css` margin-bottom: 1rem; ` +const wizardStepCardStyles = css` + background: white; + border-radius: 16px; + padding: 2rem 2.25rem; + margin-bottom: 1rem; + border: 1px solid ${baseTheme.colors.gray[200]}; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.06), + 0 0 1px rgba(0, 0, 0, 0.04); + border-left: 4px solid ${baseTheme.colors.green[500]}; + + h2 { + font-size: 1.35rem; + font-weight: 700; + color: ${baseTheme.colors.gray[800]}; + margin: 0 0 1.5rem 0; + letter-spacing: -0.02em; + } + + input[type="text"], + input[type="month"], + select { + padding: 0.65rem 0.85rem; + border-radius: 10px; + border: 1px solid ${baseTheme.colors.gray[300]}; + font-size: 1rem; + transition: + border-color 0.2s, + box-shadow 0.2s; + + :focus { + outline: none; + border-color: ${baseTheme.colors.green[500]}; + box-shadow: 0 0 0 3px ${baseTheme.colors.green[100]}; + } + } + + label { + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; + font-size: 0.9rem; + margin-bottom: 0.15rem; + } +` + const toolbarStyles = css` display: flex; flex-wrap: wrap; @@ -207,7 +342,18 @@ const stageCardActionsStyles = css` flex-wrap: wrap; ` -const todayDateInputValue = () => format(new Date(), "yyyy-MM-dd") +const wizardNavStyles = css` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +` + +const todayMonthValue = () => format(new Date(), "yyyy-MM") + +function monthToStartsOnDate(month: string): string { + return month ? `${month}-01` : "" +} const COURSE_DESIGNER_PLAN_QUERY_KEY = "course-designer-plan" @@ -227,9 +373,19 @@ function CoursePlanSchedulePage() { const [planName, setPlanName] = useState("") // eslint-disable-next-line i18next/no-literal-string const [courseSize, setCourseSize] = useState("medium") - const [startsOn, setStartsOn] = useState(todayDateInputValue()) + const [startsOnMonth, setStartsOnMonth] = useState(todayMonthValue()) const [initializedFromQuery, setInitializedFromQuery] = useState(null) + const wizardStep = useAtomValue(scheduleWizardStepAtomFamily(planId)) + const setWizardStep = useSetAtom(scheduleWizardStepAtomFamily(planId)) + const [wizardDirection, setWizardDirection] = useState<1 | -1>(1) + const goToStep = useCallback( + (step: 0 | 1 | 2, direction: 1 | -1) => { + setWizardDirection(direction) + setWizardStep(step) + }, + [setWizardStep], + ) const draftStages = useAtomValue(draftStagesAtomFamily(planId)) const setDraftStages = useSetAtom(draftStagesAtomFamily(planId)) const addStageMonth = useSetAtom(addMonthToStageAtomFamily(planId)) @@ -290,16 +446,19 @@ function CoursePlanSchedulePage() { planned_ends_on: stage.planned_ends_on, })) setDraftStages(stages) - setStartsOn(planQuery.data.stages[0].planned_starts_on) + setStartsOnMonth(planQuery.data.stages[0].planned_starts_on.slice(0, 7)) + setWizardStep(2) + } else { + setWizardStep(0) } setInitializedFromQuery(planId) - }, [initializedFromQuery, planId, planQuery.data, setDraftStages]) + }, [initializedFromQuery, planId, planQuery.data, setDraftStages, setWizardStep]) const suggestionMutation = useToastMutation( () => generateCourseDesignerScheduleSuggestion(planId, { course_size: courseSize, - starts_on: startsOn, + starts_on: monthToStartsOnDate(startsOnMonth), }), { notify: true, method: "POST" }, { @@ -358,6 +517,54 @@ function CoursePlanSchedulePage() { const reduceMotion = useReducedMotion() const [pulseStage, setPulseStage] = useState(null) + const progressPercentage = ((wizardStep + 1 - 1) / (3 - 1)) * 100 + const progressBar = useProgressBar({ + label: t("course-plans-wizard-progress-label"), + value: wizardStep + 1, + minValue: 1, + maxValue: 3, + // eslint-disable-next-line i18next/no-literal-string -- step counter, not user-facing copy + valueLabel: `Step ${wizardStep + 1} of 3`, + }) + + const stepTransition = reduceMotion + ? { duration: 0 } + : { + type: "tween" as const, + duration: 0.25, + // eslint-disable-next-line i18next/no-literal-string -- Motion ease value + ease: "easeOut" as const, + } + const stepVariants = { + initial: (dir: number) => + reduceMotion ? { opacity: 0 } : { x: dir === 1 ? 40 : -40, opacity: 0 }, + animate: { x: 0, opacity: 1 }, + exit: (dir: number) => + reduceMotion ? { opacity: 0 } : { x: dir === 1 ? -40 : 40, opacity: 0 }, + } + + const staggerContainerVariants = { + hidden: {}, + visible: { + transition: { + staggerChildren: reduceMotion ? 0 : 0.05, + delayChildren: reduceMotion ? 0 : 0.02, + }, + }, + } + const staggerChildVariants = { + hidden: reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }, + visible: reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }, + } + const staggerTransition = reduceMotion + ? { duration: 0 } + : { + type: "tween" as const, + duration: 0.2, + // eslint-disable-next-line i18next/no-literal-string -- Motion ease + ease: "easeOut" as const, + } + if (planQuery.isError) { return } @@ -368,261 +575,493 @@ function CoursePlanSchedulePage() { return (
-

{t("course-plans-schedule-title")}

- -
-
- - setPlanName(event.target.value)} - placeholder={t("course-plans-untitled-plan")} - /> -
-

- {t("course-plans-schedule-summary", { - status: planQuery.data.plan.status, - members: planQuery.data.members.length, - activeStage: planQuery.data.plan.active_stage - ? stageLabel(planQuery.data.plan.active_stage) - : t("course-plans-none"), - })} -

-
+

+ {t("course-plans-schedule-title")} +

+

+ {t("course-plans-wizard-progress-label")}: {progressBar.progressBarProps["aria-valuetext"]} +

-
-

{t("course-plans-generate-suggested-schedule")}

-
-
- - -
- -
- - setStartsOn(event.target.value)} - /> -
- - + {/* eslint-disable-next-line i18next/no-literal-string -- step indicator */} + {wizardStep > 0 ? "✓" : "1"} + + {t("course-plans-wizard-step-name")} +
+
1)}> + 1 + ? { scale: [1, 1.12, 1] } + : {} + } + transition={ + reduceMotion + ? { duration: 0 } + : { + type: "tween", + duration: 0.25, + // eslint-disable-next-line i18next/no-literal-string -- Motion ease + ease: "easeOut", + } + } + > + {/* eslint-disable-next-line i18next/no-literal-string -- step indicator */} + {wizardStep > 1 ? "✓" : "2"} + + {t("course-plans-wizard-step-size-and-date")} +
+
+ + 3 + + {t("course-plans-wizard-step-schedule")}
-
-

{t("course-plans-schedule-editor-title")}

+
+ {t("course-plans-wizard-progress-label")}: {progressBar.progressBarProps["aria-valuetext"]} +
- {draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} + {/* eslint-disable i18next/no-literal-string -- Motion API uses literal mode/variant names */} + + + {wizardStep === 0 && ( + + +

{t("course-plans-wizard-step-name")}

+
+ +
+ + setPlanName(event.target.value)} + placeholder={t("course-plans-untitled-plan")} + /> +
+
+ +

+ {t("course-plans-wizard-name-hint")} +

+
+ +
+ +
+
+
+ )} - {draftStages.length === 5 && ( - -
- {STAGE_ORDER.map((stage, stageIndex) => { - const months = stageMonthsByStage[stage] ?? [] - const canShrink = months.length > 1 - return ( - +

{t("course-plans-wizard-step-size-and-date")}

+
+ +
+
+ + +
+
+ + setStartsOnMonth(event.target.value)} + /> +
+
+
+ +
+ +
+
+ + )} + + {wizardStep === 2 && ( + + +

{t("course-plans-wizard-step-schedule")}

+
+ +
+ +
+
+ + + {draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} +
+ + {draftStages.length === 5 && ( + + +
{ - if (pulseStage === stageIndex) { - setPulseStage(null) - } - }} > -
- {/* eslint-disable-next-line i18next/no-literal-string -- Motion API value */} - - {months.map((m) => { - const MonthIcon = MONTH_ICONS[m.date.getMonth()] - return ( - { + const months = stageMonthsByStage[stage] ?? [] + const canShrink = months.length > 1 + return ( + + { + if (pulseStage === stageIndex) { + setPulseStage(null) } - > -
-
- -
- - {format(m.date, "MMMM")} - - - {format(m.date, "yyyy")} - + }} + > +
+ {} + + {months.map((m) => { + const MonthIcon = MONTH_ICONS[m.date.getMonth()] + return ( + +
+
+ +
+ + {format(m.date, "MMMM")} + + + {format(m.date, "yyyy")} + +
+
+ ) + })} +
+
+
+

+ {stageLabel(stage)} +

+

+ {t("course-plans-stage-description-placeholder")} +

+
+ +
- - ) - })} - -
-
-

- {stageLabel(stage)} -

-

- {t("course-plans-stage-description-placeholder")} -

-
- - -
-
- +
+
+
+ ) + })} +
+ + + )} + + + {validationError && ( + + {validationError} - ) - })} -
-
- )} - - {validationError && ( -
- {validationError} -
- )} - -
- - -
-
+ )} +
+ + +
+ + + +
+
+ + )} + + + {/* eslint-enable i18next/no-literal-string */}
) } diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleAtoms.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleAtoms.ts index f3cd84fb224..3eed54a480a 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleAtoms.ts +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleAtoms.ts @@ -28,3 +28,9 @@ export const removeMonthFromStageAtomFamily = atomFamily((planId: string) => } }), ) + +export type ScheduleWizardStep = 0 | 1 | 2 + +export const scheduleWizardStepAtomFamily = atomFamily((_planId: string) => + atom(0), +) diff --git a/shared-module/packages/common/src/locales/ar/main-frontend.json b/shared-module/packages/common/src/locales/ar/main-frontend.json index 19323e59e2a..fd7afdea70f 100644 --- a/shared-module/packages/common/src/locales/ar/main-frontend.json +++ b/shared-module/packages/common/src/locales/ar/main-frontend.json @@ -279,6 +279,13 @@ "course-navigation": "التنقل إلى الدورة '{{ title }}'", "course-overview": "نظرة عامة على الدورة", "course-pages-for": "صفحات الدورة لـ {{course-name}}", + "course-plans-reset-suggestion": "إعادة تعيين الاقتراح", + "course-plans-wizard-name-hint": "هذا اسم عمل مؤقت ويمكن تغييره لاحقاً.", + "course-plans-wizard-progress-label": "تقدم إعداد الجدول", + "course-plans-wizard-starts-on-month-label": "الشهر الذي يبدأ فيه العمل", + "course-plans-wizard-step-name": "تسمية المشروع", + "course-plans-wizard-step-schedule": "محرر الجدول", + "course-plans-wizard-step-size-and-date": "حجم الدورة وشهر البدء", "course-progress": "تقدم الدورة", "course-settings": "إعدادات الدورة", "course-status-summary": "ملخص حالة الدورة", diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index b5928bb2124..4c8b7940348 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -306,6 +306,7 @@ "course-plans-project-end": "Project end", "course-plans-project-start": "Project start", "course-plans-remove-one-month": "-1 month", + "course-plans-reset-suggestion": "Reset suggestion", "course-plans-save-schedule": "Save schedule", "course-plans-schedule-editor-title": "Schedule editor", "course-plans-schedule-empty-help": "Generate a suggestion first, or load an existing schedule from this plan.", @@ -327,6 +328,12 @@ "course-plans-validation-contiguous": "Stages must be contiguous (no gaps or overlaps).", "course-plans-validation-stage-count": "Schedule must contain 5 stages.", "course-plans-validation-stage-range": "{{stage}} starts after it ends.", + "course-plans-wizard-name-hint": "This is a working name and can be changed later.", + "course-plans-wizard-progress-label": "Schedule setup progress", + "course-plans-wizard-starts-on-month-label": "Month work starts", + "course-plans-wizard-step-name": "Name the project", + "course-plans-wizard-step-schedule": "Schedule editor", + "course-plans-wizard-step-size-and-date": "Course size and start month", "course-plans-year-label": "Year", "course-progress": "Course progress", "course-settings": "Course settings", diff --git a/shared-module/packages/common/src/locales/fi/main-frontend.json b/shared-module/packages/common/src/locales/fi/main-frontend.json index f62026a98d0..58ec05b0bac 100644 --- a/shared-module/packages/common/src/locales/fi/main-frontend.json +++ b/shared-module/packages/common/src/locales/fi/main-frontend.json @@ -284,6 +284,13 @@ "course-navigation": "Siirry kurssiin '{{ title }}'", "course-overview": "Kurssin yhteenveto", "course-pages-for": "Kurssin {{course-name}} sivut", + "course-plans-reset-suggestion": "Palauta ehdotus", + "course-plans-wizard-name-hint": "Tämä on työnimi, jota voi myöhemmin muuttaa.", + "course-plans-wizard-progress-label": "Aikataulun asetuksen edistyminen", + "course-plans-wizard-starts-on-month-label": "Työn alkamiskuukausi", + "course-plans-wizard-step-name": "Nimeä projekti", + "course-plans-wizard-step-schedule": "Aikataulueditori", + "course-plans-wizard-step-size-and-date": "Kurssin koko ja alkamiskuukausi", "course-progress": "Kurssin edistyminen", "course-settings": "Kurssin asetukset", "course-status-summary": "Kurssin tilan yhteenveto", diff --git a/shared-module/packages/common/src/locales/sv/main-frontend.json b/shared-module/packages/common/src/locales/sv/main-frontend.json index 7114da0996a..6bfc2d77a77 100644 --- a/shared-module/packages/common/src/locales/sv/main-frontend.json +++ b/shared-module/packages/common/src/locales/sv/main-frontend.json @@ -278,6 +278,13 @@ "course-navigation": "Navigera till kurs '{{ title }}'", "course-overview": "Kursöversikt", "course-pages-for": "Kursens sidor för {{course-name}}", + "course-plans-reset-suggestion": "Återställ förslag", + "course-plans-wizard-name-hint": "Detta är ett arbetsnamn som kan ändras senare.", + "course-plans-wizard-progress-label": "Framsteg för schemainställning", + "course-plans-wizard-starts-on-month-label": "Månad då arbetet startar", + "course-plans-wizard-step-name": "Namnge projektet", + "course-plans-wizard-step-schedule": "Schemaläggare", + "course-plans-wizard-step-size-and-date": "Kurstorlek och startmånad", "course-progress": "Kursframsteg", "course-settings": "Kursinställningar", "course-status-summary": "Sammanfattning av kursstatus", diff --git a/shared-module/packages/common/src/locales/uk/main-frontend.json b/shared-module/packages/common/src/locales/uk/main-frontend.json index 02b102f01f7..9ed16c954c2 100644 --- a/shared-module/packages/common/src/locales/uk/main-frontend.json +++ b/shared-module/packages/common/src/locales/uk/main-frontend.json @@ -281,6 +281,13 @@ "course-navigation": "Перейдіть до курсу '{{ title }}'", "course-overview": "Огляд курсу", "course-pages-for": "Сторінки курсу для {{course-name}}", + "course-plans-reset-suggestion": "Скинути пропозицію", + "course-plans-wizard-name-hint": "Це робоча назва, її можна змінити пізніше.", + "course-plans-wizard-progress-label": "Прогрес налаштування розкладу", + "course-plans-wizard-starts-on-month-label": "Місяць початку робіт", + "course-plans-wizard-step-name": "Назвіть проєкт", + "course-plans-wizard-step-schedule": "Редактор розкладу", + "course-plans-wizard-step-size-and-date": "Розмір курсу та місяць початку", "course-progress": "Прогрес курсу", "course-settings": "Налаштування курсу", "course-status-summary": "Підсумок про стан курсу", From 0c9d7239d6f4ee6afef52d3dc6152bad87bd9307 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 25 Feb 2026 10:04:21 +0200 Subject: [PATCH 07/16] Add finalize schedule functionality to CoursePlanSchedulePage - Introduced a new handleFinalizeSchedule function to streamline the finalization process of course schedules. - Updated button states to include checks for finalizeMutation.isPending, ensuring proper user feedback during operations. - Refactored test cases to utilize parseISO for date handling, improving date management in schedule stage transformations. --- .../__tests__/scheduleStageTransforms.test.ts | 6 +++--- .../manage/course-plans/[id]/schedule/page.tsx | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts index 45a6e2a7540..993f4ba659f 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/__tests__/scheduleStageTransforms.test.ts @@ -1,6 +1,6 @@ /// -import { addMonths, endOfMonth, format, startOfMonth } from "date-fns" +import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" import { addMonthToStage, removeMonthFromStage } from "../scheduleStageTransforms" @@ -47,8 +47,8 @@ const buildContiguousStages = ( } const countMonthsForStage = (stage: CourseDesignerScheduleStageInput): number => { - const start = startOfMonth(new Date(stage.planned_starts_on)) - const end = endOfMonth(new Date(stage.planned_ends_on)) + const start = startOfMonth(parseISO(stage.planned_starts_on)) + const end = endOfMonth(parseISO(stage.planned_ends_on)) let count = 0 let current = start while (current <= end) { diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx index 13af2b52cf1..30ca96e184e 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -501,6 +501,14 @@ function CoursePlanSchedulePage() { ) const validationError = useMemo(() => validateStages(draftStages), [draftStages, validateStages]) + const handleFinalizeSchedule = () => { + void saveMutation + .mutateAsync() + .then(() => finalizeMutation.mutateAsync()) + .catch(() => { + // Mutation hooks already surface errors to the user; stop the chain on failure. + }) + } const stageMonthsByStage = useMemo(() => { if (draftStages.length !== 5) { @@ -1036,7 +1044,10 @@ function CoursePlanSchedulePage() { variant="primary" size="medium" disabled={ - draftStages.length !== 5 || validationError !== null || saveMutation.isPending + draftStages.length !== 5 || + validationError !== null || + saveMutation.isPending || + finalizeMutation.isPending } onClick={() => saveMutation.mutate()} > @@ -1051,7 +1062,7 @@ function CoursePlanSchedulePage() { finalizeMutation.isPending || saveMutation.isPending } - onClick={() => finalizeMutation.mutate()} + onClick={handleFinalizeSchedule} > {t("course-plans-finalize-schedule")} From 1c871aff0d0794e37aa53b84cbf9a08b48286c4e Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 25 Feb 2026 10:29:50 +0200 Subject: [PATCH 08/16] Refactor --- .../[id]/schedule/components/MonthBlock.tsx | 107 ++ .../components/ScheduleWizardPage.tsx | 205 ++++ .../components/ScheduleWizardProgress.tsx | 219 ++++ .../[id]/schedule/components/StageCard.tsx | 158 +++ .../schedule/components/steps/NameStep.tsx | 84 ++ .../components/steps/ScheduleEditorStep.tsx | 172 +++ .../schedule/components/steps/SetupStep.tsx | 119 ++ .../hooks/useScheduleWizardController.ts | 268 ++++ .../course-plans/[id]/schedule/page.tsx | 1076 +---------------- .../[id]/schedule/scheduleConstants.ts | 15 + .../[id]/schedule/scheduleMappers.ts | 83 ++ .../[id]/schedule/scheduleStageTransforms.ts | 10 +- .../[id]/schedule/scheduleValidation.ts | 58 + .../components/CoursePlanCard.tsx | 65 + .../components/CoursePlanList.tsx | 30 + .../components/CoursePlansListPage.tsx | 77 ++ .../course-plans/coursePlanQueryKeys.ts | 4 + .../manage/course-plans/coursePlanRoutes.ts | 4 + .../src/app/manage/course-plans/page.tsx | 136 +-- 19 files changed, 1675 insertions(+), 1215 deletions(-) create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/MonthBlock.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardPage.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardProgress.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/StageCard.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/NameStep.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/ScheduleEditorStep.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/SetupStep.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/hooks/useScheduleWizardController.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleConstants.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleMappers.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleValidation.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/components/CoursePlanList.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/components/CoursePlansListPage.tsx create mode 100644 services/main-frontend/src/app/manage/course-plans/coursePlanQueryKeys.ts create mode 100644 services/main-frontend/src/app/manage/course-plans/coursePlanRoutes.ts diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/MonthBlock.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/MonthBlock.tsx new file mode 100644 index 00000000000..62d8eb008d9 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/MonthBlock.tsx @@ -0,0 +1,107 @@ +"use client" + +import { css } from "@emotion/css" +import { + Berries, + Cabin, + Campfire, + CandleLight, + Leaf, + MapleLeaf, + MistyCloud, + PineTree, + Sleigh, + Sunrise, + WaterLiquid, + WinterSnowflake, +} from "@vectopus/atlas-icons-react" +import { format } from "date-fns" +import { motion } from "motion/react" +import { forwardRef } from "react" + +import { StageMonth } from "../scheduleMappers" + +const MONTH_ICONS = [ + WinterSnowflake, + Sleigh, + Sunrise, + WaterLiquid, + Leaf, + Campfire, + Cabin, + Berries, + MapleLeaf, + MistyCloud, + CandleLight, + PineTree, +] as const + +const stageMonthBlockStyles = css` + min-width: 84px; + border-radius: 12px; + border: 1px solid #d9dde4; + background: white; + padding: 0.6rem 0.65rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); +` + +const stageMonthBlockMonthStyles = css` + font-size: 0.78rem; + font-weight: 700; + color: #415167; + line-height: 1.1; +` + +const stageMonthBlockYearStyles = css` + font-size: 0.72rem; + color: #6a7686; + line-height: 1.1; +` + +const stageMonthBlockIconStyles = css` + width: 18px; + height: 18px; + color: #2d7b4f; + display: flex; + align-items: center; + justify-content: center; +` + +interface MonthBlockProps { + month: StageMonth + reduceMotion: boolean + layoutId: string +} + +const MonthBlock = forwardRef(function MonthBlock( + { month, reduceMotion, layoutId }, + ref, +) { + const MonthIcon = MONTH_ICONS[month.date.getMonth()] + + return ( + +
+
+ +
+ {format(month.date, "MMMM")} + {format(month.date, "yyyy")} +
+
+ ) +}) + +export default MonthBlock diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardPage.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardPage.tsx new file mode 100644 index 00000000000..12a1e8e5b3f --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardPage.tsx @@ -0,0 +1,205 @@ +"use client" + +import { css, cx } from "@emotion/css" +import { AnimatePresence, motion, useReducedMotion } from "motion/react" +import { useParams } from "next/navigation" +import { useCallback, useMemo } from "react" +import { useTranslation } from "react-i18next" + +import useScheduleWizardController from "../hooks/useScheduleWizardController" +import { ScheduleWizardStepId } from "../scheduleConstants" + +import ScheduleWizardProgress from "./ScheduleWizardProgress" +import NameStep from "./steps/NameStep" +import ScheduleEditorStep from "./steps/ScheduleEditorStep" +import SetupStep from "./steps/SetupStep" + +import { CourseDesignerStage } from "@/services/backend/courseDesigner" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Spinner from "@/shared-module/common/components/Spinner" +import { baseTheme } from "@/shared-module/common/styles" + +const containerStyles = css` + max-width: 1100px; + margin: 0 auto; + padding: 2rem; +` + +const titleStyles = css` + font-size: 1.75rem; + font-weight: 700; + color: ${baseTheme.colors.gray[800]}; + margin: 0 0 0.5rem 0; + letter-spacing: -0.02em; +` + +const sectionStyles = css` + background: white; + border: 1px solid #d9dde4; + border-radius: 12px; + padding: 1rem; + margin-bottom: 1rem; +` + +const wizardStepCardStyles = css` + background: white; + border-radius: 16px; + padding: 2rem 2.25rem; + margin-bottom: 1rem; + border: 1px solid ${baseTheme.colors.gray[200]}; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.06), + 0 0 1px rgba(0, 0, 0, 0.04); + border-left: 4px solid ${baseTheme.colors.green[500]}; + + h2 { + font-size: 1.35rem; + font-weight: 700; + color: ${baseTheme.colors.gray[800]}; + margin: 0 0 1.5rem 0; + letter-spacing: -0.02em; + } +` + +function stepTransitionDirection(step: ScheduleWizardStepId): string { + return step +} + +export default function ScheduleWizardPage() { + const { t } = useTranslation() + const params = useParams<{ id: string }>() + const planId = params.id + const controller = useScheduleWizardController(planId) + const reduceMotion = !!useReducedMotion() + + const stageLabel = useCallback( + (stage: CourseDesignerStage) => { + switch (stage) { + case "Analysis": + return t("course-plans-stage-analysis") + case "Design": + return t("course-plans-stage-design") + case "Development": + return t("course-plans-stage-development") + case "Implementation": + return t("course-plans-stage-implementation") + case "Evaluation": + return t("course-plans-stage-evaluation") + } + }, + [t], + ) + + const validationError = useMemo(() => { + const issue = controller.ui.validationIssue + if (!issue) { + return null + } + + switch (issue.code) { + case "stage_count": + return t("course-plans-validation-stage-count") + case "invalid_range": + return t("course-plans-validation-stage-range", { stage: stageLabel(issue.stage) }) + case "non_contiguous": + return t("course-plans-validation-contiguous") + } + }, [controller.ui.validationIssue, stageLabel, t]) + + const stepTransition = reduceMotion + ? { duration: 0 } + : { + type: "tween" as const, + duration: 0.25, + // eslint-disable-next-line i18next/no-literal-string -- Motion ease value + ease: "easeOut" as const, + } + + const stepVariants = { + initial: (dir: number) => + reduceMotion ? { opacity: 0 } : { x: dir === 1 ? 40 : -40, opacity: 0 }, + animate: { x: 0, opacity: 1 }, + exit: (dir: number) => + reduceMotion ? { opacity: 0 } : { x: dir === 1 ? -40 : 40, opacity: 0 }, + } + + if (controller.planQuery.isError) { + return + } + + if (controller.planQuery.isLoading || !controller.planQuery.data) { + return + } + + return ( +
+

{t("course-plans-schedule-title")}

+ + + + {/* eslint-disable i18next/no-literal-string -- Motion API uses literal mode/variant names */} + + + {controller.ui.step === "name" && ( + controller.actions.goToStep("setup")} + /> + )} + + {controller.ui.step === "setup" && ( + controller.actions.goToStep("name")} + onContinue={() => { + void controller.actions.generateSuggestion({ goToScheduleStep: true }) + }} + /> + )} + + {controller.ui.step === "schedule" && ( + { + void controller.actions.generateSuggestion() + }} + onAddMonth={controller.actions.addMonth} + onRemoveMonth={controller.actions.removeMonth} + onBack={() => controller.actions.goToStep("setup")} + onSave={() => { + void controller.actions.saveDraft() + }} + onFinalize={() => { + void controller.actions.finalizeDraft() + }} + /> + )} + + + {/* eslint-enable i18next/no-literal-string */} +
+ ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardProgress.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardProgress.tsx new file mode 100644 index 00000000000..869ca9142c3 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardProgress.tsx @@ -0,0 +1,219 @@ +"use client" + +import { css } from "@emotion/css" +import { motion, useReducedMotion } from "motion/react" +import { useProgressBar } from "react-aria" +import { useTranslation } from "react-i18next" + +import { SCHEDULE_WIZARD_STEPS, ScheduleWizardStepId } from "../scheduleConstants" + +import { baseTheme } from "@/shared-module/common/styles" + +const progressTextStyles = css` + font-size: 0.95rem; + color: ${baseTheme.colors.gray[500]}; + margin: 0 0 2rem 0; +` + +const wizardStepsStripStyles = css` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; + position: relative; + gap: 0.5rem; +` + +const wizardStepsConnectorStyles = css` + position: absolute; + top: 19px; + left: 0; + right: 0; + height: 2px; + background: ${baseTheme.colors.gray[200]}; + z-index: 0; + pointer-events: none; +` + +const wizardProgressFillBaseStyles = css` + position: absolute; + top: 19px; + left: 0; + width: 100%; + height: 2px; + background: ${baseTheme.colors.green[500]}; + z-index: 0; + pointer-events: none; + transform-origin: left; +` + +const wizardStepPillStyles = (isActive: boolean, isCompleted: boolean) => css` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + position: relative; + z-index: 1; + + .wizard-step-circle { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.95rem; + transition: + background-color 0.2s, + color 0.2s, + border-color 0.2s, + box-shadow 0.2s; + border: 2px solid ${baseTheme.colors.gray[300]}; + background: white; + color: ${baseTheme.colors.gray[500]}; + } + + ${isCompleted && + css` + .wizard-step-circle { + background: ${baseTheme.colors.green[500]}; + border-color: ${baseTheme.colors.green[600]}; + color: white; + } + `} + + ${isActive && + css` + .wizard-step-circle { + background: ${baseTheme.colors.green[600]}; + border-color: ${baseTheme.colors.green[700]}; + color: white; + box-shadow: 0 0 0 4px ${baseTheme.colors.green[100]}; + } + `} + + .wizard-step-label { + font-size: 0.8rem; + font-weight: 600; + color: ${isActive ? baseTheme.colors.green[700] : baseTheme.colors.gray[500]}; + text-align: center; + line-height: 1.2; + } +` + +const srOnlyStyles = css` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +` + +interface ScheduleWizardProgressProps { + step: ScheduleWizardStepId +} + +export default function ScheduleWizardProgress({ step }: ScheduleWizardProgressProps) { + const { t } = useTranslation() + const reduceMotion = !!useReducedMotion() + + const currentStepIndex = SCHEDULE_WIZARD_STEPS.indexOf(step) + const progressPercentage = + (currentStepIndex / Math.max(1, SCHEDULE_WIZARD_STEPS.length - 1)) * 100 + + const progressBar = useProgressBar({ + label: t("course-plans-wizard-progress-label"), + value: currentStepIndex + 1, + minValue: 1, + maxValue: SCHEDULE_WIZARD_STEPS.length, + // eslint-disable-next-line i18next/no-literal-string -- step counter, not user-facing copy + valueLabel: `Step ${currentStepIndex + 1} of ${SCHEDULE_WIZARD_STEPS.length}`, + }) + + return ( + <> +

+ {t("course-plans-wizard-progress-label")}: {progressBar.progressBarProps["aria-valuetext"]} +

+ +
+
+ + + {SCHEDULE_WIZARD_STEPS.map((stepId, index) => { + const isActive = index === currentStepIndex + const isCompleted = index < currentStepIndex + let label = "" + switch (stepId) { + case "name": + label = t("course-plans-wizard-step-name") + break + case "setup": + label = t("course-plans-wizard-step-size-and-date") + break + case "schedule": + label = t("course-plans-wizard-step-schedule") + break + } + // eslint-disable-next-line i18next/no-literal-string -- step circle shows checkmark/step number + const stepMarker = isCompleted ? "✓" : String(index + 1) + return ( +
+ + {stepMarker} + + {label} +
+ ) + })} +
+ +
+ {t("course-plans-wizard-progress-label")}: {progressBar.progressBarProps["aria-valuetext"]} +
+ + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/StageCard.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/StageCard.tsx new file mode 100644 index 00000000000..52cbc4b88b0 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/StageCard.tsx @@ -0,0 +1,158 @@ +"use client" + +import { css } from "@emotion/css" +import { AnimatePresence, motion } from "motion/react" +import { useTranslation } from "react-i18next" + +import { StageMonth } from "../scheduleMappers" + +import MonthBlock from "./MonthBlock" + +import Button from "@/shared-module/common/components/Button" +import { baseTheme } from "@/shared-module/common/styles" + +const stageCardStyles = css` + border: 1px solid ${baseTheme.colors.gray[200]}; + border-radius: 14px; + background: #fbfcfd; + padding: 1rem; +` + +const stageRowStyles = css` + display: flex; + gap: 1rem; + width: 100%; + + @media (max-width: 900px) { + flex-direction: column; + } +` + +const stageMonthBlocksStyles = css` + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; + flex: 0 0 auto; +` + +const stageCardRightStyles = css` + min-width: 240px; + flex: 1 1 38%; + display: flex; + flex-direction: column; + gap: 0.5rem; +` + +const stageTitleStyles = css` + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; +` + +const stageDescriptionPlaceholderStyles = css` + margin: 0; + font-size: 0.88rem; + color: ${baseTheme.colors.gray[500]}; +` + +const stageCardActionsStyles = css` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.2rem; +` + +interface StageCardProps { + title: string + months: Array + canShrink: boolean + reduceMotion: boolean + isPulsing: boolean + onPulseComplete: () => void + onAddMonth: () => void + onRemoveMonth: () => void + testId?: string +} + +export default function StageCard({ + title, + months, + canShrink, + reduceMotion, + isPulsing, + onPulseComplete, + onAddMonth, + onRemoveMonth, + testId, +}: StageCardProps) { + const { t } = useTranslation() + + return ( + + { + if (isPulsing) { + onPulseComplete() + } + }} + > +
+ {/* eslint-disable-next-line i18next/no-literal-string -- Motion mode name */} + + {months.map((month) => { + // eslint-disable-next-line i18next/no-literal-string -- stable layout animation id prefix + const monthLayoutId = `month-${month.id}` + return ( + + ) + })} + +
+ +
+

{title}

+

+ {t("course-plans-stage-description-placeholder")} +

+ +
+ + +
+
+
+
+ ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/NameStep.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/NameStep.tsx new file mode 100644 index 00000000000..29fe7574651 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/NameStep.tsx @@ -0,0 +1,84 @@ +"use client" + +import { css } from "@emotion/css" +import { useTranslation } from "react-i18next" + +import Button from "@/shared-module/common/components/Button" +import { baseTheme } from "@/shared-module/common/styles" + +const fieldStyles = css` + display: flex; + flex-direction: column; + gap: 0.35rem; + + label { + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; + font-size: 0.9rem; + margin-bottom: 0.15rem; + } + + input[type="text"] { + padding: 0.65rem 0.85rem; + border-radius: 10px; + border: 1px solid ${baseTheme.colors.gray[300]}; + font-size: 1rem; + transition: + border-color 0.2s, + box-shadow 0.2s; + + :focus { + outline: none; + border-color: ${baseTheme.colors.green[500]}; + box-shadow: 0 0 0 3px ${baseTheme.colors.green[100]}; + } + } +` + +const hintStyles = css` + margin-top: 0.75rem; + color: ${baseTheme.colors.gray[500]}; + font-size: 0.9rem; +` + +const wizardNavStyles = css` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +` + +interface NameStepProps { + planName: string + onPlanNameChange: (value: string) => void + onContinue: () => void +} + +export default function NameStep({ planName, onPlanNameChange, onContinue }: NameStepProps) { + const { t } = useTranslation() + + return ( + <> +

{t("course-plans-wizard-step-name")}

+ +
+ + onPlanNameChange(event.target.value)} + placeholder={t("course-plans-untitled-plan")} + /> +
+ +

{t("course-plans-wizard-name-hint")}

+ +
+ +
+ + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/ScheduleEditorStep.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/ScheduleEditorStep.tsx new file mode 100644 index 00000000000..f9e1db30bd3 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/ScheduleEditorStep.tsx @@ -0,0 +1,172 @@ +"use client" + +import { css } from "@emotion/css" +import { AnimatePresence, LayoutGroup, motion } from "motion/react" +import { useState } from "react" +import { useTranslation } from "react-i18next" + +import { SCHEDULE_STAGE_COUNT } from "../../scheduleConstants" +import { StageCardViewModel } from "../../scheduleMappers" +import StageCard from "../StageCard" + +import { CourseDesignerStage } from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" + +const toolbarStyles = css` + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-end; +` + +const stageListStyles = css` + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +` + +const wizardNavStyles = css` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +` + +const validationErrorStyles = css` + margin-top: 0.75rem; + color: #8d2323; + font-weight: 600; +` + +interface ScheduleEditorStepProps { + draftStageCount: number + stageCards: Array + stageLabel: (stage: CourseDesignerStage) => string + validationError: string | null + startsOnMonth: string + reduceMotion: boolean + isGeneratingSuggestion: boolean + isSaving: boolean + isFinalizing: boolean + onResetSuggestion: () => void + onAddMonth: (stage: CourseDesignerStage) => void + onRemoveMonth: (stage: CourseDesignerStage) => void + onBack: () => void + onSave: () => void + onFinalize: () => void +} + +export default function ScheduleEditorStep({ + draftStageCount, + stageCards, + stageLabel, + validationError, + startsOnMonth, + reduceMotion, + isGeneratingSuggestion, + isSaving, + isFinalizing, + onResetSuggestion, + onAddMonth, + onRemoveMonth, + onBack, + onSave, + onFinalize, +}: ScheduleEditorStepProps) { + const { t } = useTranslation() + const [pulseStage, setPulseStage] = useState(null) + + const isCompleteSchedule = stageCards.length === SCHEDULE_STAGE_COUNT + const submitDisabled = !isCompleteSchedule || validationError !== null || isSaving || isFinalizing + + return ( + <> +

{t("course-plans-wizard-step-schedule")}

+ +
+ +
+ + {draftStageCount === 0 &&

{t("course-plans-schedule-empty-help")}

} + + {isCompleteSchedule && ( + +
+ {stageCards.map((card) => { + // eslint-disable-next-line i18next/no-literal-string -- stable test id prefix + const stageTestId = `course-plan-stage-${card.stage}` + return ( + { + if (pulseStage === card.stage) { + setPulseStage(null) + } + }} + onAddMonth={() => { + setPulseStage(card.stage) + onAddMonth(card.stage) + }} + onRemoveMonth={() => { + setPulseStage(card.stage) + onRemoveMonth(card.stage) + }} + testId={stageTestId} + /> + ) + })} +
+
+ )} + + + {validationError && ( + + {validationError} + + )} + + +
+ + + +
+ + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/SetupStep.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/SetupStep.tsx new file mode 100644 index 00000000000..dd4cd2d02b4 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/SetupStep.tsx @@ -0,0 +1,119 @@ +"use client" + +import { css } from "@emotion/css" +import { useTranslation } from "react-i18next" + +import { CourseDesignerCourseSize } from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" +import { baseTheme } from "@/shared-module/common/styles" + +const toolbarStyles = css` + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-end; +` + +const fieldStyles = css` + display: flex; + flex-direction: column; + gap: 0.35rem; + + label { + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; + font-size: 0.9rem; + margin-bottom: 0.15rem; + } + + input[type="month"], + select { + padding: 0.65rem 0.85rem; + border-radius: 10px; + border: 1px solid ${baseTheme.colors.gray[300]}; + font-size: 1rem; + transition: + border-color 0.2s, + box-shadow 0.2s; + + :focus { + outline: none; + border-color: ${baseTheme.colors.green[500]}; + box-shadow: 0 0 0 3px ${baseTheme.colors.green[100]}; + } + } +` + +const wizardNavStyles = css` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +` + +interface SetupStepProps { + courseSize: CourseDesignerCourseSize + startsOnMonth: string + isGeneratingSuggestion: boolean + onCourseSizeChange: (value: CourseDesignerCourseSize) => void + onStartsOnMonthChange: (value: string) => void + onBack: () => void + onContinue: () => void +} + +export default function SetupStep({ + courseSize, + startsOnMonth, + isGeneratingSuggestion, + onCourseSizeChange, + onStartsOnMonthChange, + onBack, + onContinue, +}: SetupStepProps) { + const { t } = useTranslation() + + return ( + <> +

{t("course-plans-wizard-step-size-and-date")}

+ +
+
+ + +
+ +
+ + onStartsOnMonthChange(event.target.value)} + /> +
+
+ +
+ + +
+ + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/hooks/useScheduleWizardController.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/hooks/useScheduleWizardController.ts new file mode 100644 index 00000000000..4e36746756b --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/hooks/useScheduleWizardController.ts @@ -0,0 +1,268 @@ +"use client" + +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useAtomValue, useSetAtom } from "jotai" +import { useCallback, useEffect, useMemo, useState } from "react" + +import { + addMonthToStageAtomFamily, + draftStagesAtomFamily, + removeMonthFromStageAtomFamily, + ScheduleWizardStep, + scheduleWizardStepAtomFamily, +} from "../scheduleAtoms" +import { SCHEDULE_STAGE_ORDER, ScheduleWizardStepId } from "../scheduleConstants" +import { + buildStageCardViewModels, + getStartsOnMonthFromStages, + toDraftStages, +} from "../scheduleMappers" +import { validateScheduleStages } from "../scheduleValidation" + +import { coursePlanQueryKeys } from "@/app/manage/course-plans/coursePlanQueryKeys" +import { + CourseDesignerCourseSize, + CourseDesignerStage, + finalizeCourseDesignerSchedule, + generateCourseDesignerScheduleSuggestion, + getCourseDesignerPlan, + saveCourseDesignerSchedule, +} from "@/services/backend/courseDesigner" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" + +const todayMonthValue = () => { + const date = new Date() + const year = String(date.getFullYear()) + const month = String(date.getMonth() + 1).padStart(2, "0") + return `${year}-${month}` +} + +function monthToStartsOnDate(month: string): string { + return month ? `${month}-01` : "" +} + +function atomStepToId(step: ScheduleWizardStep): ScheduleWizardStepId { + switch (step) { + case 0: + return "name" + case 1: + return "setup" + case 2: + return "schedule" + } +} + +function stepIdToAtomStep(step: ScheduleWizardStepId): ScheduleWizardStep { + switch (step) { + case "name": + return 0 + case "setup": + return 1 + case "schedule": + return 2 + } +} + +function stepIndex(step: ScheduleWizardStepId): number { + switch (step) { + case "name": + return 0 + case "setup": + return 1 + case "schedule": + return 2 + } +} + +export default function useScheduleWizardController(planId: string) { + const queryClient = useQueryClient() + + const planQuery = useQuery({ + queryKey: coursePlanQueryKeys.detail(planId), + queryFn: () => getCourseDesignerPlan(planId), + }) + + const [planName, setPlanName] = useState("") + // eslint-disable-next-line i18next/no-literal-string + const [courseSize, setCourseSize] = useState("medium") + const [startsOnMonth, setStartsOnMonth] = useState(todayMonthValue()) + const [initializedFromQuery, setInitializedFromQuery] = useState(null) + + const wizardStepAtom = useAtomValue(scheduleWizardStepAtomFamily(planId)) + const setWizardStepAtom = useSetAtom(scheduleWizardStepAtomFamily(planId)) + const [wizardDirection, setWizardDirection] = useState<1 | -1>(1) + + const draftStages = useAtomValue(draftStagesAtomFamily(planId)) + const setDraftStages = useSetAtom(draftStagesAtomFamily(planId)) + const addStageMonthByIndex = useSetAtom(addMonthToStageAtomFamily(planId)) + const removeStageMonthByIndex = useSetAtom(removeMonthFromStageAtomFamily(planId)) + + const step = atomStepToId(wizardStepAtom) + + const goToStep = useCallback( + (nextStep: ScheduleWizardStepId, direction?: 1 | -1) => { + const nextAtomStep = stepIdToAtomStep(nextStep) + const resolvedDirection = + direction ?? + (stepIndex(nextStep) >= stepIndex(atomStepToId(wizardStepAtom)) + ? (1 as const) + : (-1 as const)) + setWizardDirection(resolvedDirection) + setWizardStepAtom(nextAtomStep) + }, + [setWizardStepAtom, wizardStepAtom], + ) + + useEffect(() => { + if (!planQuery.data || initializedFromQuery === planId) { + return + } + + setPlanName(planQuery.data.plan.name ?? "") + + if (planQuery.data.stages.length > 0) { + setDraftStages(toDraftStages(planQuery.data.stages)) + const firstMonth = getStartsOnMonthFromStages(planQuery.data.stages) + if (firstMonth) { + setStartsOnMonth(firstMonth) + } + setWizardStepAtom(stepIdToAtomStep("schedule")) + setWizardDirection(1) + } else { + setWizardStepAtom(stepIdToAtomStep("name")) + setWizardDirection(1) + } + + setInitializedFromQuery(planId) + }, [initializedFromQuery, planId, planQuery.data, setDraftStages, setWizardStepAtom]) + + const suggestionMutation = useToastMutation( + () => + generateCourseDesignerScheduleSuggestion(planId, { + course_size: courseSize, + starts_on: monthToStartsOnDate(startsOnMonth), + }), + { notify: true, method: "POST" }, + { + onSuccess: (result) => { + setDraftStages(result.stages) + }, + }, + ) + + const saveMutation = useToastMutation( + () => + saveCourseDesignerSchedule(planId, { + name: planName.trim() === "" ? null : planName.trim(), + stages: draftStages, + }), + { notify: true, method: "PUT" }, + { + onSuccess: async (details) => { + setDraftStages(toDraftStages(details.stages)) + await queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.detail(planId) }) + await queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.list() }) + }, + }, + ) + + const finalizeMutation = useToastMutation( + () => finalizeCourseDesignerSchedule(planId), + { notify: true, method: "POST" }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.detail(planId) }) + await queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.list() }) + }, + }, + ) + + const validationIssue = useMemo(() => validateScheduleStages(draftStages), [draftStages]) + const stageCards = useMemo(() => buildStageCardViewModels(draftStages), [draftStages]) + + const addMonth = useCallback( + (stage: CourseDesignerStage) => { + const index = SCHEDULE_STAGE_ORDER.indexOf(stage) + if (index >= 0) { + addStageMonthByIndex(index) + } + }, + [addStageMonthByIndex], + ) + + const removeMonth = useCallback( + (stage: CourseDesignerStage) => { + const index = SCHEDULE_STAGE_ORDER.indexOf(stage) + if (index >= 0) { + removeStageMonthByIndex(index) + } + }, + [removeStageMonthByIndex], + ) + + const generateSuggestion = useCallback( + async (options?: { goToScheduleStep?: boolean }) => { + try { + await suggestionMutation.mutateAsync() + if (options?.goToScheduleStep) { + goToStep("schedule", 1) + } + return true + } catch { + return false + } + }, + [goToStep, suggestionMutation], + ) + + const saveDraft = useCallback(async () => { + try { + await saveMutation.mutateAsync() + return true + } catch { + return false + } + }, [saveMutation]) + + const finalizeDraft = useCallback(async () => { + try { + await saveMutation.mutateAsync() + await finalizeMutation.mutateAsync() + return true + } catch { + return false + } + }, [finalizeMutation, saveMutation]) + + return { + planQuery, + ui: { + step, + wizardDirection, + planName, + courseSize, + startsOnMonth, + draftStageCount: draftStages.length, + stageCards, + validationIssue, + }, + status: { + isGeneratingSuggestion: suggestionMutation.isPending, + isSaving: saveMutation.isPending, + isFinalizing: finalizeMutation.isPending, + }, + actions: { + setPlanName, + setCourseSize, + setStartsOnMonth, + goToStep, + generateSuggestion, + addMonth, + removeMonth, + saveDraft, + finalizeDraft, + }, + } +} + +export type ScheduleWizardController = ReturnType diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx index 30ca96e184e..8314992faff 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -1,1080 +1,8 @@ "use client" -import { css, cx } from "@emotion/css" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { - Berries, - Cabin, - Campfire, - CandleLight, - Leaf, - MapleLeaf, - MistyCloud, - PineTree, - Sleigh, - Sunrise, - WaterLiquid, - WinterSnowflake, -} from "@vectopus/atlas-icons-react" -import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" -import { useAtomValue, useSetAtom } from "jotai" -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react" -import { useParams } from "next/navigation" -import { useCallback, useEffect, useMemo, useState } from "react" -import { useProgressBar } from "react-aria" -import { useTranslation } from "react-i18next" +import ScheduleWizardPage from "./components/ScheduleWizardPage" -import { - addMonthToStageAtomFamily, - draftStagesAtomFamily, - removeMonthFromStageAtomFamily, - scheduleWizardStepAtomFamily, -} from "./scheduleAtoms" - -import { - CourseDesignerCourseSize, - CourseDesignerScheduleStageInput, - CourseDesignerStage, - finalizeCourseDesignerSchedule, - generateCourseDesignerScheduleSuggestion, - getCourseDesignerPlan, - saveCourseDesignerSchedule, -} from "@/services/backend/courseDesigner" -import Button from "@/shared-module/common/components/Button" -import ErrorBanner from "@/shared-module/common/components/ErrorBanner" -import Spinner from "@/shared-module/common/components/Spinner" import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" -import useToastMutation from "@/shared-module/common/hooks/useToastMutation" -import { baseTheme } from "@/shared-module/common/styles" import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" -const STAGE_ORDER: CourseDesignerStage[] = [ - "Analysis", - "Design", - "Development", - "Implementation", - "Evaluation", -] - -const MONTH_ICONS = [ - WinterSnowflake, - Sleigh, - Sunrise, - WaterLiquid, - Leaf, - Campfire, - Cabin, - Berries, - MapleLeaf, - MistyCloud, - CandleLight, - PineTree, -] as const - -type StageMonth = { - id: string - date: Date - label: string -} - -function getStageMonths(stage: CourseDesignerScheduleStageInput): StageMonth[] { - const start = startOfMonth(parseISO(stage.planned_starts_on)) - const end = endOfMonth(parseISO(stage.planned_ends_on)) - const months: StageMonth[] = [] - let d = start - while (d <= end) { - months.push({ - id: format(d, "yyyy-MM"), - date: d, - label: format(d, "MMM yyyy"), - }) - d = addMonths(d, 1) - } - return months -} - -const containerStyles = css` - max-width: 1100px; - margin: 0 auto; - padding: 2rem; -` - -const wizardStepsStripStyles = css` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 2rem; - position: relative; - gap: 0.5rem; -` - -const wizardStepPillStyles = (isActive: boolean, isCompleted: boolean) => css` - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - position: relative; - z-index: 1; - - .wizard-step-circle { - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 0.95rem; - transition: - background-color 0.2s, - color 0.2s, - border-color 0.2s, - box-shadow 0.2s; - border: 2px solid ${baseTheme.colors.gray[300]}; - background: white; - color: ${baseTheme.colors.gray[500]}; - } - - ${isCompleted && - css` - .wizard-step-circle { - background: ${baseTheme.colors.green[500]}; - border-color: ${baseTheme.colors.green[600]}; - color: white; - } - `} - - ${isActive && - css` - .wizard-step-circle { - background: ${baseTheme.colors.green[600]}; - border-color: ${baseTheme.colors.green[700]}; - color: white; - box-shadow: 0 0 0 4px ${baseTheme.colors.green[100]}; - } - `} - - .wizard-step-label { - font-size: 0.8rem; - font-weight: 600; - color: ${isActive ? baseTheme.colors.green[700] : baseTheme.colors.gray[500]}; - text-align: center; - line-height: 1.2; - } -` - -const wizardStepsConnectorStyles = css` - position: absolute; - top: 19px; - left: 0; - right: 0; - height: 2px; - background: ${baseTheme.colors.gray[200]}; - z-index: 0; - pointer-events: none; -` - -const wizardProgressFillBaseStyles = css` - position: absolute; - top: 19px; - left: 0; - width: 100%; - height: 2px; - background: ${baseTheme.colors.green[500]}; - z-index: 0; - pointer-events: none; - transform-origin: left; -` - -const sectionStyles = css` - background: white; - border: 1px solid #d9dde4; - border-radius: 12px; - padding: 1rem; - margin-bottom: 1rem; -` - -const wizardStepCardStyles = css` - background: white; - border-radius: 16px; - padding: 2rem 2.25rem; - margin-bottom: 1rem; - border: 1px solid ${baseTheme.colors.gray[200]}; - box-shadow: - 0 4px 24px rgba(0, 0, 0, 0.06), - 0 0 1px rgba(0, 0, 0, 0.04); - border-left: 4px solid ${baseTheme.colors.green[500]}; - - h2 { - font-size: 1.35rem; - font-weight: 700; - color: ${baseTheme.colors.gray[800]}; - margin: 0 0 1.5rem 0; - letter-spacing: -0.02em; - } - - input[type="text"], - input[type="month"], - select { - padding: 0.65rem 0.85rem; - border-radius: 10px; - border: 1px solid ${baseTheme.colors.gray[300]}; - font-size: 1rem; - transition: - border-color 0.2s, - box-shadow 0.2s; - - :focus { - outline: none; - border-color: ${baseTheme.colors.green[500]}; - box-shadow: 0 0 0 3px ${baseTheme.colors.green[100]}; - } - } - - label { - font-weight: 600; - color: ${baseTheme.colors.gray[700]}; - font-size: 0.9rem; - margin-bottom: 0.15rem; - } -` - -const toolbarStyles = css` - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: end; -` - -const fieldStyles = css` - display: flex; - flex-direction: column; - gap: 0.25rem; - - input, - select { - padding: 0.5rem; - border-radius: 8px; - border: 1px solid #c8cfda; - font: inherit; - } -` - -const stageCardStyles = css` - position: relative; - transform-origin: center; - display: flex; - gap: 1rem; - border: 1px solid ${baseTheme.colors.gray[300]}; - border-radius: 12px; - padding: 1rem; - margin-bottom: 0.75rem; - background: ${baseTheme.colors.gray[50]}; -` - -const stageMonthBlocksStyles = css` - display: flex; - flex-direction: column; - gap: 4px; - align-items: stretch; - flex-shrink: 0; - width: 72px; -` - -const stageMonthBlockStyles = css` - width: 72px; - height: 72px; - background: ${baseTheme.colors.green[400]}; - border: 1px solid ${baseTheme.colors.green[600]}; - border-radius: 4px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 0 0.25rem; - color: white; -` - -const stageMonthBlockMonthStyles = css` - font-size: 0.8rem; - font-weight: 600; - line-height: 1.2; - text-align: center; -` - -const stageMonthBlockYearStyles = css` - font-size: 0.7rem; - font-weight: 500; - opacity: 0.85; - line-height: 1.2; -` - -const stageMonthBlockIconStyles = css` - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 2px; - - svg { - width: 18px; - height: 18px; - } -` - -const stageCardRightStyles = css` - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -` - -const stageDescriptionPlaceholderStyles = css` - font-size: 0.85rem; - color: ${baseTheme.colors.gray[500]}; - font-style: italic; -` - -const stageCardActionsStyles = css` - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -` - -const wizardNavStyles = css` - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-top: 1rem; -` - -const todayMonthValue = () => format(new Date(), "yyyy-MM") - -function monthToStartsOnDate(month: string): string { - return month ? `${month}-01` : "" -} - -const COURSE_DESIGNER_PLAN_QUERY_KEY = "course-designer-plan" - -const COURSE_DESIGNER_PLANS_QUERY_KEY = "course-designer-plans" - -function CoursePlanSchedulePage() { - const { t } = useTranslation() - const params = useParams<{ id: string }>() - const queryClient = useQueryClient() - const planId = params.id - - const planQuery = useQuery({ - queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId], - queryFn: () => getCourseDesignerPlan(planId), - }) - - const [planName, setPlanName] = useState("") - // eslint-disable-next-line i18next/no-literal-string - const [courseSize, setCourseSize] = useState("medium") - const [startsOnMonth, setStartsOnMonth] = useState(todayMonthValue()) - const [initializedFromQuery, setInitializedFromQuery] = useState(null) - - const wizardStep = useAtomValue(scheduleWizardStepAtomFamily(planId)) - const setWizardStep = useSetAtom(scheduleWizardStepAtomFamily(planId)) - const [wizardDirection, setWizardDirection] = useState<1 | -1>(1) - const goToStep = useCallback( - (step: 0 | 1 | 2, direction: 1 | -1) => { - setWizardDirection(direction) - setWizardStep(step) - }, - [setWizardStep], - ) - const draftStages = useAtomValue(draftStagesAtomFamily(planId)) - const setDraftStages = useSetAtom(draftStagesAtomFamily(planId)) - const addStageMonth = useSetAtom(addMonthToStageAtomFamily(planId)) - const removeStageMonth = useSetAtom(removeMonthFromStageAtomFamily(planId)) - - const stageLabel = useCallback( - (stage: CourseDesignerStage) => { - switch (stage) { - case "Analysis": - return t("course-plans-stage-analysis") - case "Design": - return t("course-plans-stage-design") - case "Development": - return t("course-plans-stage-development") - case "Implementation": - return t("course-plans-stage-implementation") - case "Evaluation": - return t("course-plans-stage-evaluation") - } - }, - [t], - ) - - const validateStages = useCallback( - (stages: Array): string | null => { - if (stages.length !== 5) { - return t("course-plans-validation-stage-count") - } - for (let i = 0; i < stages.length; i++) { - const stage = stages[i] - if (stage.planned_starts_on > stage.planned_ends_on) { - return t("course-plans-validation-stage-range", { stage: stageLabel(stage.stage) }) - } - if (i > 0) { - // eslint-disable-next-line i18next/no-literal-string - const previous = new Date(`${stages[i - 1].planned_ends_on}T00:00:00Z`) - previous.setUTCDate(previous.getUTCDate() + 1) - const expected = previous.toISOString().slice(0, 10) - if (stage.planned_starts_on !== expected) { - return t("course-plans-validation-contiguous") - } - } - } - return null - }, - [t, stageLabel], - ) - - useEffect(() => { - if (!planQuery.data || initializedFromQuery === planId) { - return - } - setPlanName(planQuery.data.plan.name ?? "") - if (planQuery.data.stages.length > 0) { - const stages = planQuery.data.stages.map((stage) => ({ - stage: stage.stage, - planned_starts_on: stage.planned_starts_on, - planned_ends_on: stage.planned_ends_on, - })) - setDraftStages(stages) - setStartsOnMonth(planQuery.data.stages[0].planned_starts_on.slice(0, 7)) - setWizardStep(2) - } else { - setWizardStep(0) - } - setInitializedFromQuery(planId) - }, [initializedFromQuery, planId, planQuery.data, setDraftStages, setWizardStep]) - - const suggestionMutation = useToastMutation( - () => - generateCourseDesignerScheduleSuggestion(planId, { - course_size: courseSize, - starts_on: monthToStartsOnDate(startsOnMonth), - }), - { notify: true, method: "POST" }, - { - onSuccess: (result) => { - setDraftStages(result.stages) - }, - }, - ) - - const saveMutation = useToastMutation( - () => - saveCourseDesignerSchedule(planId, { - name: planName.trim() === "" ? null : planName.trim(), - stages: draftStages, - }), - { notify: true, method: "PUT" }, - { - onSuccess: async (details) => { - const stages = details.stages.map((stage) => ({ - stage: stage.stage, - planned_starts_on: stage.planned_starts_on, - planned_ends_on: stage.planned_ends_on, - })) - setDraftStages(stages) - await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId] }) - await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) - }, - }, - ) - - const finalizeMutation = useToastMutation( - () => finalizeCourseDesignerSchedule(planId), - { notify: true, method: "POST" }, - { - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLAN_QUERY_KEY, planId] }) - await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) - }, - }, - ) - - const validationError = useMemo(() => validateStages(draftStages), [draftStages, validateStages]) - const handleFinalizeSchedule = () => { - void saveMutation - .mutateAsync() - .then(() => finalizeMutation.mutateAsync()) - .catch(() => { - // Mutation hooks already surface errors to the user; stop the chain on failure. - }) - } - - const stageMonthsByStage = useMemo(() => { - if (draftStages.length !== 5) { - return {} as Record - } - const byStage = {} as Record - for (const stage of STAGE_ORDER) { - const stageInput = draftStages.find((s) => s.stage === stage) - byStage[stage] = stageInput ? getStageMonths(stageInput) : [] - } - return byStage - }, [draftStages]) - - const reduceMotion = useReducedMotion() - const [pulseStage, setPulseStage] = useState(null) - - const progressPercentage = ((wizardStep + 1 - 1) / (3 - 1)) * 100 - const progressBar = useProgressBar({ - label: t("course-plans-wizard-progress-label"), - value: wizardStep + 1, - minValue: 1, - maxValue: 3, - // eslint-disable-next-line i18next/no-literal-string -- step counter, not user-facing copy - valueLabel: `Step ${wizardStep + 1} of 3`, - }) - - const stepTransition = reduceMotion - ? { duration: 0 } - : { - type: "tween" as const, - duration: 0.25, - // eslint-disable-next-line i18next/no-literal-string -- Motion ease value - ease: "easeOut" as const, - } - const stepVariants = { - initial: (dir: number) => - reduceMotion ? { opacity: 0 } : { x: dir === 1 ? 40 : -40, opacity: 0 }, - animate: { x: 0, opacity: 1 }, - exit: (dir: number) => - reduceMotion ? { opacity: 0 } : { x: dir === 1 ? -40 : 40, opacity: 0 }, - } - - const staggerContainerVariants = { - hidden: {}, - visible: { - transition: { - staggerChildren: reduceMotion ? 0 : 0.05, - delayChildren: reduceMotion ? 0 : 0.02, - }, - }, - } - const staggerChildVariants = { - hidden: reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }, - visible: reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }, - } - const staggerTransition = reduceMotion - ? { duration: 0 } - : { - type: "tween" as const, - duration: 0.2, - // eslint-disable-next-line i18next/no-literal-string -- Motion ease - ease: "easeOut" as const, - } - - if (planQuery.isError) { - return - } - - if (planQuery.isLoading || !planQuery.data) { - return - } - - return ( -
-

- {t("course-plans-schedule-title")} -

-

- {t("course-plans-wizard-progress-label")}: {progressBar.progressBarProps["aria-valuetext"]} -

- -
-
- -
0)}> - 0 - ? { scale: [1, 1.12, 1] } - : {} - } - transition={ - reduceMotion - ? { duration: 0 } - : { - type: "tween", - duration: 0.25, - // eslint-disable-next-line i18next/no-literal-string -- Motion ease - ease: "easeOut", - } - } - > - {/* eslint-disable-next-line i18next/no-literal-string -- step indicator */} - {wizardStep > 0 ? "✓" : "1"} - - {t("course-plans-wizard-step-name")} -
-
1)}> - 1 - ? { scale: [1, 1.12, 1] } - : {} - } - transition={ - reduceMotion - ? { duration: 0 } - : { - type: "tween", - duration: 0.25, - // eslint-disable-next-line i18next/no-literal-string -- Motion ease - ease: "easeOut", - } - } - > - {/* eslint-disable-next-line i18next/no-literal-string -- step indicator */} - {wizardStep > 1 ? "✓" : "2"} - - {t("course-plans-wizard-step-size-and-date")} -
-
- - 3 - - {t("course-plans-wizard-step-schedule")} -
-
- -
- {t("course-plans-wizard-progress-label")}: {progressBar.progressBarProps["aria-valuetext"]} -
- - {/* eslint-disable i18next/no-literal-string -- Motion API uses literal mode/variant names */} - - - {wizardStep === 0 && ( - - -

{t("course-plans-wizard-step-name")}

-
- -
- - setPlanName(event.target.value)} - placeholder={t("course-plans-untitled-plan")} - /> -
-
- -

- {t("course-plans-wizard-name-hint")} -

-
- -
- -
-
-
- )} - - {wizardStep === 1 && ( - - -

{t("course-plans-wizard-step-size-and-date")}

-
- -
-
- - -
-
- - setStartsOnMonth(event.target.value)} - /> -
-
-
- -
- - -
-
-
- )} - - {wizardStep === 2 && ( - - -

{t("course-plans-wizard-step-schedule")}

-
- -
- -
-
- - - {draftStages.length === 0 &&

{t("course-plans-schedule-empty-help")}

} -
- - {draftStages.length === 5 && ( - - -
- {STAGE_ORDER.map((stage, stageIndex) => { - const months = stageMonthsByStage[stage] ?? [] - const canShrink = months.length > 1 - return ( - - { - if (pulseStage === stageIndex) { - setPulseStage(null) - } - }} - > -
- {} - - {months.map((m) => { - const MonthIcon = MONTH_ICONS[m.date.getMonth()] - return ( - -
-
- -
- - {format(m.date, "MMMM")} - - - {format(m.date, "yyyy")} - -
-
- ) - })} -
-
-
-

- {stageLabel(stage)} -

-

- {t("course-plans-stage-description-placeholder")} -

-
- - -
-
-
-
- ) - })} -
-
-
- )} - - - {validationError && ( - - {validationError} - - )} - - - -
- - - -
-
-
- )} -
-
- {/* eslint-enable i18next/no-literal-string */} -
- ) -} - -export default withErrorBoundary(withSignedIn(CoursePlanSchedulePage)) +export default withErrorBoundary(withSignedIn(ScheduleWizardPage)) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleConstants.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleConstants.ts new file mode 100644 index 00000000000..074bd8ebef0 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleConstants.ts @@ -0,0 +1,15 @@ +import { CourseDesignerStage } from "@/services/backend/courseDesigner" + +export const SCHEDULE_STAGE_ORDER: CourseDesignerStage[] = [ + "Analysis", + "Design", + "Development", + "Implementation", + "Evaluation", +] + +export const SCHEDULE_STAGE_COUNT = SCHEDULE_STAGE_ORDER.length + +export const SCHEDULE_WIZARD_STEPS = ["name", "setup", "schedule"] as const + +export type ScheduleWizardStepId = (typeof SCHEDULE_WIZARD_STEPS)[number] diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleMappers.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleMappers.ts new file mode 100644 index 00000000000..22862b3fbb8 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleMappers.ts @@ -0,0 +1,83 @@ +import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" + +import { SCHEDULE_STAGE_COUNT, SCHEDULE_STAGE_ORDER } from "./scheduleConstants" + +import { + CourseDesignerPlanStage, + CourseDesignerScheduleStageInput, + CourseDesignerStage, +} from "@/services/backend/courseDesigner" + +type StageRangeLike = Pick< + CourseDesignerPlanStage, + "stage" | "planned_starts_on" | "planned_ends_on" +> + +export type StageMonth = { + id: string + date: Date + label: string +} + +export type StageCardViewModel = { + stage: CourseDesignerStage + months: StageMonth[] + canShrink: boolean +} + +export function toDraftStages( + stages: Array, +): Array { + return stages.map((stage) => ({ + stage: stage.stage, + planned_starts_on: stage.planned_starts_on, + planned_ends_on: stage.planned_ends_on, + })) +} + +export function getStartsOnMonthFromStages( + stages: Array>, +): string | null { + return stages[0]?.planned_starts_on?.slice(0, 7) ?? null +} + +export function getStageMonths(stage: CourseDesignerScheduleStageInput): StageMonth[] { + const start = startOfMonth(parseISO(stage.planned_starts_on)) + const end = endOfMonth(parseISO(stage.planned_ends_on)) + const months: StageMonth[] = [] + let current = start + + while (current <= end) { + months.push({ + id: format(current, "yyyy-MM"), + date: current, + label: format(current, "MMM yyyy"), + }) + current = addMonths(current, 1) + } + + return months +} + +export function buildStageCardViewModels( + stages: Array, +): Array { + if (stages.length !== SCHEDULE_STAGE_COUNT) { + return [] + } + + const byStage = new Map() + stages.forEach((stage) => { + byStage.set(stage.stage, stage) + }) + + return SCHEDULE_STAGE_ORDER.map((stage) => { + const stageInput = byStage.get(stage) + const months = stageInput ? getStageMonths(stageInput) : [] + return { + stage, + months, + canShrink: months.length > 1, + } + }) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts index 19f6eeb04d1..d177bcb227a 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts @@ -1,17 +1,13 @@ import { addMonths, endOfMonth, format, parseISO, startOfMonth } from "date-fns" +import { SCHEDULE_STAGE_ORDER } from "./scheduleConstants" + import { CourseDesignerScheduleStageInput, CourseDesignerStage, } from "@/services/backend/courseDesigner" -const STAGE_ORDER: CourseDesignerStage[] = [ - "Analysis", - "Design", - "Development", - "Implementation", - "Evaluation", -] +const STAGE_ORDER: CourseDesignerStage[] = SCHEDULE_STAGE_ORDER type StageInput = CourseDesignerScheduleStageInput diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleValidation.ts b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleValidation.ts new file mode 100644 index 00000000000..5aa10879237 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleValidation.ts @@ -0,0 +1,58 @@ +import { SCHEDULE_STAGE_COUNT } from "./scheduleConstants" + +import { + CourseDesignerScheduleStageInput, + CourseDesignerStage, +} from "@/services/backend/courseDesigner" + +export type ScheduleValidationIssue = + | { + code: "stage_count" + actualCount: number + } + | { + code: "invalid_range" + stage: CourseDesignerStage + } + | { + code: "non_contiguous" + stage: CourseDesignerStage + expectedStart: string + actualStart: string + } + +function addOneDayIsoDate(dateOnly: string): string { + // Use UTC so date-only inputs are stable across local timezones. + const date = new Date(`${dateOnly}T00:00:00Z`) + date.setUTCDate(date.getUTCDate() + 1) + return date.toISOString().slice(0, 10) +} + +export function validateScheduleStages( + stages: Array, +): ScheduleValidationIssue | null { + if (stages.length !== SCHEDULE_STAGE_COUNT) { + return { code: "stage_count", actualCount: stages.length } + } + + for (let i = 0; i < stages.length; i += 1) { + const stage = stages[i] + if (stage.planned_starts_on > stage.planned_ends_on) { + return { code: "invalid_range", stage: stage.stage } + } + + if (i > 0) { + const expectedStart = addOneDayIsoDate(stages[i - 1].planned_ends_on) + if (stage.planned_starts_on !== expectedStart) { + return { + code: "non_contiguous", + stage: stage.stage, + expectedStart, + actualStart: stage.planned_starts_on, + } + } + } + } + + return null +} diff --git a/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx new file mode 100644 index 00000000000..ab19b1c7a43 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx @@ -0,0 +1,65 @@ +"use client" + +import { css } from "@emotion/css" +import { useRouter } from "next/navigation" +import { useTranslation } from "react-i18next" + +import { coursePlanScheduleRoute } from "../coursePlanRoutes" + +import { CourseDesignerPlanSummary } from "@/services/backend/courseDesigner" + +const cardStyles = css` + border: 1px solid #d9dde4; + border-radius: 12px; + padding: 1rem; + background: white; +` + +const headerStyles = css` + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: baseline; + text-align: left; + width: 100%; +` + +const metaStyles = css` + color: #5d6776; + font-size: 0.95rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.5rem; +` + +interface CoursePlanCardProps { + plan: CourseDesignerPlanSummary +} + +export default function CoursePlanCard({ plan }: CoursePlanCardProps) { + const router = useRouter() + const { t } = useTranslation() + + return ( + + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/components/CoursePlanList.tsx b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanList.tsx new file mode 100644 index 00000000000..e976d3c70e3 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanList.tsx @@ -0,0 +1,30 @@ +"use client" + +import { css } from "@emotion/css" +import { useTranslation } from "react-i18next" + +import CoursePlanCard from "./CoursePlanCard" + +import { CourseDesignerPlanSummary } from "@/services/backend/courseDesigner" + +const listStyles = css` + display: grid; + gap: 1rem; +` + +interface CoursePlanListProps { + plans: Array +} + +export default function CoursePlanList({ plans }: CoursePlanListProps) { + const { t } = useTranslation() + + return ( +
+ {plans.length === 0 &&

{t("course-plans-empty")}

} + {plans.map((plan) => ( + + ))} +
+ ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/components/CoursePlansListPage.tsx b/services/main-frontend/src/app/manage/course-plans/components/CoursePlansListPage.tsx new file mode 100644 index 00000000000..140089b3a96 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/components/CoursePlansListPage.tsx @@ -0,0 +1,77 @@ +"use client" + +import { css } from "@emotion/css" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useRouter } from "next/navigation" +import { useTranslation } from "react-i18next" + +import { coursePlanQueryKeys } from "../coursePlanQueryKeys" +import { coursePlanScheduleRoute } from "../coursePlanRoutes" + +import CoursePlanList from "./CoursePlanList" + +import { + createCourseDesignerPlan, + listCourseDesignerPlans, +} from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Spinner from "@/shared-module/common/components/Spinner" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" + +const containerStyles = css` + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +` + +const headerStyles = css` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; +` + +export default function CoursePlansListPage() { + const { t } = useTranslation() + const router = useRouter() + const queryClient = useQueryClient() + + const plansQuery = useQuery({ + queryKey: coursePlanQueryKeys.list(), + queryFn: () => listCourseDesignerPlans(), + }) + + const createPlanMutation = useToastMutation( + () => createCourseDesignerPlan({}), + { notify: true, method: "POST" }, + { + onSuccess: async (plan) => { + await queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.list() }) + router.push(coursePlanScheduleRoute(plan.id)) + }, + }, + ) + + return ( +
+
+

{t("course-plans-title")}

+ +
+ + {plansQuery.isError && } + {plansQuery.isLoading && } + + {plansQuery.isSuccess && } +
+ ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/coursePlanQueryKeys.ts b/services/main-frontend/src/app/manage/course-plans/coursePlanQueryKeys.ts new file mode 100644 index 00000000000..c90e336e7f2 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/coursePlanQueryKeys.ts @@ -0,0 +1,4 @@ +export const coursePlanQueryKeys = { + list: () => ["course-designer-plans"] as const, + detail: (planId: string) => ["course-designer-plan", planId] as const, +} diff --git a/services/main-frontend/src/app/manage/course-plans/coursePlanRoutes.ts b/services/main-frontend/src/app/manage/course-plans/coursePlanRoutes.ts new file mode 100644 index 00000000000..6956d506884 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/coursePlanRoutes.ts @@ -0,0 +1,4 @@ +export const coursePlanScheduleRoute = (planId: string) => { + // eslint-disable-next-line i18next/no-literal-string + return `/manage/course-plans/${planId}/schedule` +} diff --git a/services/main-frontend/src/app/manage/course-plans/page.tsx b/services/main-frontend/src/app/manage/course-plans/page.tsx index 99108739aee..939b7663bec 100644 --- a/services/main-frontend/src/app/manage/course-plans/page.tsx +++ b/services/main-frontend/src/app/manage/course-plans/page.tsx @@ -1,140 +1,8 @@ "use client" -import { css } from "@emotion/css" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { useRouter } from "next/navigation" -import React from "react" -import { useTranslation } from "react-i18next" +import CoursePlansListPage from "./components/CoursePlansListPage" -import { - createCourseDesignerPlan, - listCourseDesignerPlans, -} from "@/services/backend/courseDesigner" -import Button from "@/shared-module/common/components/Button" -import ErrorBanner from "@/shared-module/common/components/ErrorBanner" -import Spinner from "@/shared-module/common/components/Spinner" import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" -import useToastMutation from "@/shared-module/common/hooks/useToastMutation" import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" -const containerStyles = css` - max-width: 1000px; - margin: 0 auto; - padding: 2rem; -` - -const headerStyles = css` - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 2rem; -` - -const listStyles = css` - display: grid; - gap: 1rem; -` - -const cardStyles = css` - border: 1px solid #d9dde4; - border-radius: 12px; - padding: 1rem; - background: white; -` - -const metaStyles = css` - color: #5d6776; - font-size: 0.95rem; - display: flex; - flex-wrap: wrap; - gap: 1rem; - margin-top: 0.5rem; -` - -// eslint-disable-next-line i18next/no-literal-string -const COURSE_DESIGNER_PLANS_QUERY_KEY = "course-designer-plans" - -const coursePlanScheduleRoute = (planId: string) => { - // eslint-disable-next-line i18next/no-literal-string - return `/manage/course-plans/${planId}/schedule` -} - -function CoursePlansPage() { - const { t } = useTranslation() - const router = useRouter() - const queryClient = useQueryClient() - const plansQuery = useQuery({ - queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY], - queryFn: () => listCourseDesignerPlans(), - }) - - const createPlanMutation = useToastMutation( - () => createCourseDesignerPlan({}), - { notify: true, method: "POST" }, - { - onSuccess: async (plan) => { - await queryClient.invalidateQueries({ queryKey: [COURSE_DESIGNER_PLANS_QUERY_KEY] }) - router.push(coursePlanScheduleRoute(plan.id)) - }, - }, - ) - - return ( -
-
-

{t("course-plans-title")}

- -
- - {plansQuery.isError && } - {plansQuery.isLoading && } - - {plansQuery.isSuccess && ( -
- {plansQuery.data.length === 0 &&

{t("course-plans-empty")}

} - {plansQuery.data.map((plan) => ( - - ))} -
- )} -
- ) -} - -export default withErrorBoundary(withSignedIn(CoursePlansPage)) +export default withErrorBoundary(withSignedIn(CoursePlansListPage)) From 1b37da688085ca3792a51abbb99e429602b45cf4 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 25 Feb 2026 14:38:09 +0200 Subject: [PATCH 09/16] Fixes --- .../models/src/course_designer_plans.rs | 7 +++- .../components/CoursePlanCard.tsx | 38 ++++++++++++++++++- .../common/src/locales/en/main-frontend.json | 6 +++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/services/headless-lms/models/src/course_designer_plans.rs b/services/headless-lms/models/src/course_designer_plans.rs index ab2382a63e2..06227667b7d 100644 --- a/services/headless-lms/models/src/course_designer_plans.rs +++ b/services/headless-lms/models/src/course_designer_plans.rs @@ -543,7 +543,12 @@ WHERE course_designer_plan_id = $1 .fetch_one(&mut *tx) .await?; - let updated_status = CourseDesignerPlanStatus::Scheduling; + // Keep finalized plans finalized when the schedule is re-saved (including no-op saves). + let updated_status = if matches!(locked_plan.status, CourseDesignerPlanStatus::ReadyToStart) { + locked_plan.status + } else { + CourseDesignerPlanStatus::Scheduling + }; let updated_plan: CourseDesignerPlan = sqlx::query_as!( CourseDesignerPlan, diff --git a/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx index ab19b1c7a43..4ac1db3c8de 100644 --- a/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx +++ b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx @@ -40,6 +40,40 @@ interface CoursePlanCardProps { export default function CoursePlanCard({ plan }: CoursePlanCardProps) { const router = useRouter() const { t } = useTranslation() + const statusLabel = (() => { + switch (plan.status) { + case "Draft": + return t("course-plans-status-draft") + case "Scheduling": + return t("course-plans-status-scheduling") + case "ReadyToStart": + return t("course-plans-status-ready-to-start") + case "InProgress": + return t("course-plans-status-in-progress") + case "Completed": + return t("course-plans-status-completed") + case "Archived": + return t("course-plans-status-archived") + } + })() + + const activeStageLabel = (() => { + switch (plan.active_stage) { + case "Analysis": + return t("course-plans-stage-analysis") + case "Design": + return t("course-plans-stage-design") + case "Development": + return t("course-plans-stage-development") + case "Implementation": + return t("course-plans-stage-implementation") + case "Evaluation": + return t("course-plans-stage-evaluation") + case null: + case undefined: + return t("course-plans-none") + } + })() return ( +
+
+ ) + } + + const currentStage = plan.active_stage + const currentStageData = currentStage ? stages.find((s) => s.stage === currentStage) : null + + const today = new Date().toISOString().slice(0, 10) + let timeRemainingText: string | null = null + if (currentStageData) { + const days = daysBetween(today, currentStageData.planned_ends_on) + if (days > 0) { + timeRemainingText = t("course-plans-time-remaining-days", { + count: days, + stage: stageLabel(currentStageData.stage), + }) + } else if (days < 0) { + timeRemainingText = t("course-plans-time-remaining-overdue", { + count: -days, + stage: stageLabel(currentStageData.stage), + }) + } + } + + const canAct = + plan.status === "InProgress" && + currentStage && + currentStageData && + currentStageData.status !== "Completed" + + // eslint-disable-next-line i18next/no-literal-string + const timeRemainingSuffix = timeRemainingText != null ? ` · ${timeRemainingText}` : null + + return ( +
+

{plan.name ?? t("course-plans-untitled-plan")}

+ + {currentStage && ( +

+ {t("course-plans-active-stage-value", { + stage: stageLabel(currentStage), + })} + {timeRemainingSuffix} +

+ )} + + {canAct && ( +
+ + + +
+ )} + +
+

+ {t("course-plans-instructions-placeholder")} +

+
+ + {SCHEDULE_STAGE_ORDER.map((stageEnum) => { + const stageData = stages.find((s) => s.stage === stageEnum) as + | CourseDesignerPlanStageWithTasks + | undefined + if (!stageData) { + return null + } + const isActive = plan.active_stage === stageEnum + return ( + + void queryClient.invalidateQueries({ + queryKey: coursePlanQueryKeys.detail(planId), + }) + } + /> + ) + })} +
+ ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx new file mode 100644 index 00000000000..23a48b6ea1b --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx @@ -0,0 +1,241 @@ +"use client" + +import { css } from "@emotion/css" +import { useState } from "react" +import { useTranslation } from "react-i18next" + +import { + type CourseDesignerPlanStageTask, + type CourseDesignerPlanStageWithTasks, + createCourseDesignerStageTask, + deleteCourseDesignerStageTask, + updateCourseDesignerStageTask, +} from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" +import CheckBox from "@/shared-module/common/components/InputFields/CheckBox" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import { baseTheme } from "@/shared-module/common/styles" + +const cardStyles = css` + background: white; + border: 1px solid ${baseTheme.colors.gray[200]}; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; +` + +const headerStyles = css` + font-size: 1.1rem; + font-weight: 600; + color: ${baseTheme.colors.gray[800]}; + margin: 0 0 0.5rem 0; +` + +const dateRangeStyles = css` + font-size: 0.9rem; + color: ${baseTheme.colors.gray[500]}; + margin-bottom: 1rem; +` + +const addRowStyles = css` + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +` + +const taskInputStyles = css` + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid ${baseTheme.colors.gray[300]}; + border-radius: 8px; + font-size: 0.95rem; +` + +const taskListStyles = css` + list-style: none; + padding: 0; + margin: 0; +` + +const taskRowStyles = css` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid ${baseTheme.colors.gray[100]}; + + :last-child { + border-bottom: none; + } +` + +const taskTitleStyles = css` + flex: 1; + font-size: 0.95rem; + color: ${baseTheme.colors.gray[800]}; +` + +const taskTitleCompletedStyles = css` + text-decoration: line-through; + color: ${baseTheme.colors.gray[500]}; +` + +const emptyTasksStyles = css` + color: ${baseTheme.colors.gray[500]}; + font-size: 0.9rem; + font-style: italic; + padding: 0.5rem 0; +` + +function formatDateRange(startsOn: string, endsOn: string): string { + const s = new Date(startsOn) + const e = new Date(endsOn) + // eslint-disable-next-line i18next/no-literal-string + return `${s.toLocaleDateString()} – ${e.toLocaleDateString()}` +} + +interface WorkspaceStageSectionProps { + planId: string + stage: CourseDesignerPlanStageWithTasks + stageLabel: string + isActive: boolean + onInvalidate: () => void +} + +export default function WorkspaceStageSection({ + planId, + stage, + stageLabel, + isActive, + onInvalidate, +}: WorkspaceStageSectionProps) { + const { t } = useTranslation() + const [newTaskTitle, setNewTaskTitle] = useState("") + + const createMutation = useToastMutation( + (title: string) => createCourseDesignerStageTask(planId, stage.id, { title: title.trim() }), + { notify: true, method: "POST" }, + { + onSuccess: () => { + setNewTaskTitle("") + onInvalidate() + }, + }, + ) + + const updateMutation = useToastMutation( + ({ taskId, is_completed }: { taskId: string; is_completed: boolean }) => + updateCourseDesignerStageTask(planId, taskId, { is_completed }), + { notify: false }, + { onSuccess: onInvalidate }, + ) + + const deleteMutation = useToastMutation( + (taskId: string) => deleteCourseDesignerStageTask(planId, taskId), + { notify: true, method: "DELETE" }, + { onSuccess: onInvalidate }, + ) + + const handleAddTask = () => { + const title = newTaskTitle.trim() + if (!title) { + return + } + createMutation.mutate(title) + } + + return ( +
+

+ {stageLabel} + {isActive && ( + + ({t("course-plans-status-in-progress")}) + + )} +

+

+ {formatDateRange(stage.planned_starts_on, stage.planned_ends_on)} +

+
+ setNewTaskTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddTask()} + /> + +
+
    + {stage.tasks.length === 0 ? ( +
  • {t("course-plans-no-tasks")}
  • + ) : ( + stage.tasks.map((task) => ( + updateMutation.mutate({ taskId: task.id, is_completed })} + onDelete={() => deleteMutation.mutate(task.id)} + isUpdating={updateMutation.isPending} + isDeleting={deleteMutation.isPending} + /> + )) + )} +
+
+ ) +} + +interface WorkspaceTaskRowProps { + task: CourseDesignerPlanStageTask + onToggle: (is_completed: boolean) => void + onDelete: () => void + isUpdating: boolean + isDeleting: boolean +} + +function WorkspaceTaskRow({ + task, + onToggle, + onDelete, + isUpdating, + isDeleting, +}: WorkspaceTaskRowProps) { + const { t } = useTranslation() + return ( +
  • + onToggle(e.target.checked)} + disabled={isUpdating} + /> + + {task.title} + + +
  • + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/page.tsx new file mode 100644 index 00000000000..6f48ec81872 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/page.tsx @@ -0,0 +1,8 @@ +"use client" + +import CoursePlanWorkspacePage from "./components/CoursePlanWorkspacePage" + +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +export default withErrorBoundary(withSignedIn(CoursePlanWorkspacePage)) diff --git a/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx index 4ac1db3c8de..a170c5eb00c 100644 --- a/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx +++ b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx @@ -4,7 +4,7 @@ import { css } from "@emotion/css" import { useRouter } from "next/navigation" import { useTranslation } from "react-i18next" -import { coursePlanScheduleRoute } from "../coursePlanRoutes" +import { coursePlanHubRoute } from "../coursePlanRoutes" import { CourseDesignerPlanSummary } from "@/services/backend/courseDesigner" @@ -79,7 +79,7 @@ export default function CoursePlanCard({ plan }: CoursePlanCardProps) { + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx index 05e22c4e57d..8adf855aff6 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx @@ -3,12 +3,14 @@ import { css } from "@emotion/css" import { useQuery, useQueryClient } from "@tanstack/react-query" import { useParams } from "next/navigation" -import { useCallback } from "react" +import { useCallback, useState } from "react" import { useTranslation } from "react-i18next" import { coursePlanQueryKeys } from "../../../coursePlanQueryKeys" import { SCHEDULE_STAGE_ORDER } from "../../schedule/scheduleConstants" +import CompactPhaseStatusWidget from "./CompactPhaseStatusWidget" +import PlanOverviewPanel, { type OverviewStage } from "./PlanOverviewPanel" import WorkspaceStageSection from "./WorkspaceStageSection" import { @@ -24,45 +26,130 @@ import ErrorBanner from "@/shared-module/common/components/ErrorBanner" import Spinner from "@/shared-module/common/components/Spinner" import useToastMutation from "@/shared-module/common/hooks/useToastMutation" import { baseTheme } from "@/shared-module/common/styles" +import { respondToOrLarger } from "@/shared-module/common/styles/respond" -const containerStyles = css` - max-width: 900px; +const pageRootStyles = css` + padding: 2rem 0 3rem 0; + min-height: 100vh; +` + +const contentWrapperStyles = css` + max-width: 72rem; margin: 0 auto; - padding: 2rem; + padding: 0 2rem; + + ${respondToOrLarger.lg} { + padding: 0 3rem; + } +` + +const headerRowStyles = css` + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; +` + +const headerBlockStyles = css` + flex: 1; + min-width: 0; ` const titleStyles = css` font-size: 1.75rem; font-weight: 700; - color: ${baseTheme.colors.gray[800]}; - margin: 0 0 0.5rem 0; + color: ${baseTheme.colors.gray[900]}; + margin: 0 0 0.25rem 0; +` + +const metadataRowStyles = css` + font-size: 0.9rem; + color: ${baseTheme.colors.gray[500]}; + margin: 0; +` + +const mainLayoutStyles = css` + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + + ${respondToOrLarger.md} { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + align-items: flex-start; + } ` const cardStyles = css` background: white; - border: 1px solid ${baseTheme.colors.gray[200]}; border-radius: 12px; - padding: 1.25rem; - margin-bottom: 1rem; + padding: 1.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + border: 1px solid ${baseTheme.colors.gray[200]}; ` -const timeRemainingStyles = css` - font-size: 1rem; +const sectionTitleStyles = css` + font-size: 1.1rem; + font-weight: 600; + color: ${baseTheme.colors.gray[900]}; + margin: 0 0 0.5rem 0; +` + +const instructionsSectionTitleStyles = css` + font-size: 1.15rem; + font-weight: 600; + color: ${baseTheme.colors.gray[900]}; + margin: 0 0 0.5rem 0; +` + +const aboutHeadingStyles = css` + font-size: 0.9rem; + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; + margin: 0 0 0.35rem 0; +` + +const aboutTextStyles = css` + font-size: 0.9rem; color: ${baseTheme.colors.gray[600]}; - margin-bottom: 1rem; + line-height: 1.55; + margin: 0 0 0.75rem 0; ` -const actionsRowStyles = css` - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 1rem; +const keyGoalsHeadingStyles = css` + font-size: 0.9rem; + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; + margin: 0 0 0.35rem 0; ` -const instructionsPlaceholderStyles = css` +const keyGoalsListStyles = css` + list-style: none; + padding: 0; + margin: 0; + font-size: 0.9rem; + color: ${baseTheme.colors.gray[600]}; + line-height: 1.5; +` + +const keyGoalItemStyles = css` + padding: 0.2rem 0; + padding-left: 1.25rem; + position: relative; + + ::before { + content: "•"; + position: absolute; + left: 0; + color: ${baseTheme.colors.green[600]}; + } +` + +const emptyStateStyles = css` color: ${baseTheme.colors.gray[500]}; + font-size: 0.95rem; font-style: italic; - padding: 1rem 0; ` function daysBetween(from: string, to: string): number { @@ -73,10 +160,11 @@ function daysBetween(from: string, to: string): number { } export default function CoursePlanWorkspacePage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const params = useParams<{ id: string }>() const planId = params.id ?? "" const queryClient = useQueryClient() + const [isOverviewOpen, setIsOverviewOpen] = useState(false) const planQuery = useQuery({ queryKey: coursePlanQueryKeys.detail(planId), @@ -132,16 +220,20 @@ export default function CoursePlanWorkspacePage() { if (planQuery.isError) { return ( -
    - +
    +
    + +
    ) } if (planQuery.isLoading || !planQuery.data) { return ( -
    - +
    +
    + +
    ) } @@ -150,18 +242,20 @@ export default function CoursePlanWorkspacePage() { if (plan.status === "ReadyToStart" && !plan.active_stage) { return ( -
    -

    {plan.name ?? t("course-plans-untitled-plan")}

    -
    -

    {t("course-plans-status-ready-to-start")}

    - +
    +
    +

    {plan.name ?? t("course-plans-untitled-plan")}

    +
    +

    {t("course-plans-status-ready-to-start")}

    + +
    ) @@ -172,101 +266,219 @@ export default function CoursePlanWorkspacePage() { const today = new Date().toISOString().slice(0, 10) let timeRemainingText: string | null = null + let timeRemainingShort: string | null = null + let daysLeft: number | null = null if (currentStageData) { const days = daysBetween(today, currentStageData.planned_ends_on) + daysLeft = days if (days > 0) { - timeRemainingText = t("course-plans-time-remaining-days", { - count: days, - stage: stageLabel(currentStageData.stage), + const months = Math.floor(days / 30) + const remainingDays = days % 30 + timeRemainingText = t("course-plans-time-remaining-summary", { + months, + days: remainingDays, }) + timeRemainingShort = + days <= 31 ? t("course-plans-days-left", { count: days }) : timeRemainingText } else if (days < 0) { timeRemainingText = t("course-plans-time-remaining-overdue", { count: -days, stage: stageLabel(currentStageData.stage), }) + timeRemainingShort = timeRemainingText } } + const tasksRemainingCount = + currentStageData?.tasks != null + ? currentStageData.tasks.filter((task) => !task.is_completed).length + : -1 + const isUrgent = daysLeft != null && daysLeft <= 0 + + const lastEditedText = plan.updated_at + ? t("course-plans-last-edited", { + time: new Date(plan.updated_at).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }), + }) + : null + + const currentPhaseEndDateFormatted = + currentStageData?.planned_ends_on != null + ? new Date(currentStageData.planned_ends_on).toLocaleDateString(i18n.language, { + // eslint-disable-next-line i18next/no-literal-string -- Intl date format keys + month: "long", + // eslint-disable-next-line i18next/no-literal-string -- Intl date format keys + year: "numeric", + }) + : null + + const activeStageTaskCompleted = + currentStageData?.tasks != null + ? currentStageData.tasks.filter((task) => task.is_completed).length + : 0 + const activeStageTaskTotal = currentStageData?.tasks != null ? currentStageData.tasks.length : 0 + const currentStageIndex = currentStage ? SCHEDULE_STAGE_ORDER.indexOf(currentStage) : -1 + const nextStage = + currentStageIndex >= 0 && currentStageIndex < SCHEDULE_STAGE_ORDER.length - 1 + ? SCHEDULE_STAGE_ORDER[currentStageIndex + 1] + : null + const nextStageLabel = nextStage ? stageLabel(nextStage) : null + const canAct = plan.status === "InProgress" && currentStage && currentStageData && currentStageData.status !== "Completed" - // eslint-disable-next-line i18next/no-literal-string - const timeRemainingSuffix = timeRemainingText != null ? ` · ${timeRemainingText}` : null + const currentStageSection = + currentStageData && currentStage ? ( + + void queryClient.invalidateQueries({ + queryKey: coursePlanQueryKeys.detail(planId), + }) + } + /> + ) : null + + const stageDescriptionItems = + currentStage === "Analysis" + ? [ + t("course-plans-stage-description-analysis-1"), + t("course-plans-stage-description-analysis-2"), + t("course-plans-stage-description-analysis-3"), + t("course-plans-stage-description-analysis-4"), + t("course-plans-stage-description-analysis-5"), + ] + : currentStage === "Design" + ? [ + t("course-plans-stage-description-design-1"), + t("course-plans-stage-description-design-2"), + t("course-plans-stage-description-design-3"), + t("course-plans-stage-description-design-4"), + t("course-plans-stage-description-design-5"), + ] + : currentStage === "Development" + ? [ + t("course-plans-stage-description-development-1"), + t("course-plans-stage-description-development-2"), + ] + : currentStage === "Implementation" + ? [ + t("course-plans-stage-description-implementation-1"), + t("course-plans-stage-description-implementation-2"), + t("course-plans-stage-description-implementation-3"), + ] + : currentStage === "Evaluation" + ? [ + t("course-plans-stage-description-evaluation-1"), + t("course-plans-stage-description-evaluation-2"), + ] + : [] + + const keyGoalsContent = + currentStage && stageDescriptionItems.length > 0 + ? stageDescriptionItems.map((line, index) => ( +
  • + {line} +
  • + )) + : [ +
  • + {t("course-plans-key-goal-1")} +
  • , +
  • + {t("course-plans-key-goal-2")} +
  • , +
  • + {t("course-plans-key-goal-3")} +
  • , + ] return ( -
    -

    {plan.name ?? t("course-plans-untitled-plan")}

    - - {currentStage && ( -

    - {t("course-plans-active-stage-value", { - stage: stageLabel(currentStage), - })} - {timeRemainingSuffix} -

    - )} - - {canAct && ( -
    - - - +
    + setIsOverviewOpen(false)} + planName={plan.name ?? t("course-plans-untitled-plan")} + stages={stages as OverviewStage[]} + activeStage={currentStage ?? null} + stageLabel={stageLabel} + canActOnCurrentStage={Boolean(canAct)} + onExtendCurrentStage={() => currentStage && extendMutation.mutate(currentStage)} + onAdvanceStage={() => advanceMutation.mutate()} + isExtendPending={extendMutation.isPending} + isAdvancePending={advanceMutation.isPending} + timeRemainingText={timeRemainingText} + timeRemainingShort={timeRemainingShort} + currentPhaseEndDateFormatted={currentPhaseEndDateFormatted} + activeStageTaskCompleted={activeStageTaskCompleted} + activeStageTaskTotal={activeStageTaskTotal} + nextStageLabel={nextStageLabel} + /> + +
    +
    +
    +

    {plan.name ?? t("course-plans-untitled-plan")}

    + {lastEditedText &&

    {lastEditedText}

    } +
    + {currentStage && ( + setIsOverviewOpen(true)} + /> + )}
    - )} -
    -

    - {t("course-plans-instructions-placeholder")} -

    -
    +
    +
    +

    + {t("course-plans-instructions-heading")} +

    +

    {t("course-plans-about-this-phase")}

    +

    + {currentStage + ? t( + `course-plans-phase-brief-${currentStage.toLowerCase()}` as + | "course-plans-phase-brief-analysis" + | "course-plans-phase-brief-design" + | "course-plans-phase-brief-development" + | "course-plans-phase-brief-implementation" + | "course-plans-phase-brief-evaluation", + ) + : t("course-plans-instructions-placeholder")} +

    +

    {t("course-plans-key-goals")}

    +
      {keyGoalsContent}
    +
    - {SCHEDULE_STAGE_ORDER.map((stageEnum) => { - const stageData = stages.find((s) => s.stage === stageEnum) as - | CourseDesignerPlanStageWithTasks - | undefined - if (!stageData) { - return null - } - const isActive = plan.active_stage === stageEnum - return ( - - void queryClient.invalidateQueries({ - queryKey: coursePlanQueryKeys.detail(planId), - }) - } - /> - ) - })} +
    +

    {t("course-plans-tasks-heading")}

    + {currentStageSection ?? ( +

    {t("course-plans-no-active-stage")}

    + )} +
    +
    +
    ) } diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx new file mode 100644 index 00000000000..93a2af7b2ba --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx @@ -0,0 +1,455 @@ +"use client" + +import { css } from "@emotion/css" +import { CheckCircle } from "@vectopus/atlas-icons-react" +import { useTranslation } from "react-i18next" + +import { SCHEDULE_STAGE_ORDER } from "../../schedule/scheduleConstants" + +import { type CourseDesignerStage } from "@/services/backend/courseDesigner" +import Button from "@/shared-module/common/components/Button" +import StandardDialog from "@/shared-module/common/components/dialogs/StandardDialog" +import { baseTheme } from "@/shared-module/common/styles" + +const NODE_COLUMN_WIDTH = 28 +const SPINE_OFFSET = 13 +const CONTENT_OFFSET = NODE_COLUMN_WIDTH + 12 + +const overviewContentWrapperStyles = css` + max-width: 67rem; + margin: 0 auto; + width: 100%; +` + +const overviewContentStyles = css` + display: flex; + flex-direction: column; + gap: 0.75rem; +` + +const heroBlockStyles = css` + padding: 0.5rem 0 0.4rem 0; + border-bottom: 1px solid ${baseTheme.colors.gray[100]}; +` + +const heroSublabelStyles = css` + font-size: 0.75rem; + font-weight: 500; + color: ${baseTheme.colors.gray[500]}; + text-transform: uppercase; + letter-spacing: 0.03em; + margin: 0 0 0.2rem 0; +` + +const heroPhaseNameStyles = css` + font-size: 1.1rem; + font-weight: 600; + color: ${baseTheme.colors.gray[900]}; + margin: 0 0 0.25rem 0; +` + +const heroStatusLineStyles = css` + font-size: 0.9rem; + color: ${baseTheme.colors.gray[600]}; + margin: 0; +` + +const noActiveHeroStyles = css` + font-size: 0.9rem; + color: ${baseTheme.colors.gray[500]}; + margin: 0; +` + +const overviewStageListStyles = css` + position: relative; + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 0.75rem; + padding: 20px 0; + &::before { + content: ""; + position: absolute; + left: ${SPINE_OFFSET}px; + top: 20px; + bottom: 20px; + width: 2px; + background: #e5e7eb; + } +` + +const overviewStageRowStyles = css` + position: relative; + display: flex; + align-items: flex-start; + font-size: 0.9rem; + color: ${baseTheme.colors.gray[600]}; + padding: 0.4rem 0 0.4rem ${CONTENT_OFFSET}px; + min-height: 2.5rem; +` + +const overviewStageRowCurrentStyles = css` + background: ${baseTheme.colors.green[50]}; + color: ${baseTheme.colors.gray[800]}; + margin-left: -${CONTENT_OFFSET}px; + padding-left: ${CONTENT_OFFSET}px; + padding-top: 0.6rem; + padding-bottom: 0.6rem; + border-radius: 6px; + min-height: 2.75rem; +` + +const overviewNodeColumnStyles = css` + position: absolute; + left: 0; + width: ${NODE_COLUMN_WIDTH}px; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; +` + +const overviewNodeCurrentStyles = css` + width: 12px; + height: 12px; + border-radius: 999px; + background: ${baseTheme.colors.green[600]}; + box-shadow: 0 0 0 2px ${baseTheme.colors.green[100]}; + flex-shrink: 0; +` + +const overviewNodePlannedStyles = css` + width: 9px; + height: 9px; + border-radius: 999px; + border: 2px solid ${baseTheme.colors.gray[300]}; + background: transparent; + flex-shrink: 0; +` + +const overviewNodeCompletedStyles = css` + color: ${baseTheme.colors.gray[500]}; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +` + +const overviewStageContentStyles = css` + display: flex; + flex-direction: column; + gap: 0.2rem; + flex: 1; + min-width: 0; +` + +const overviewStageNameStyles = css` + font-weight: 500; +` + +const overviewStageNameCurrentStyles = css` + font-weight: 600; + color: ${baseTheme.colors.gray[900]}; +` + +const overviewStageMetaStackStyles = css` + display: flex; + flex-direction: column; + gap: 0.15rem; +` + +const overviewStageDateStyles = css` + font-size: 0.78rem; + color: ${baseTheme.colors.gray[500]}; +` + +const overviewStageTaskProgressStyles = css` + font-size: 0.75rem; + color: ${baseTheme.colors.gray[500]}; + margin-top: 0.15rem; +` + +const overviewStatusPillStyles = css` + align-self: flex-start; + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: uppercase; +` + +const overviewStatusInProgressStyles = css` + background: ${baseTheme.colors.green[25]}; + color: ${baseTheme.colors.green[700]}; +` + +const overviewStatusCompletedStyles = css` + background: ${baseTheme.colors.gray[100]}; + color: ${baseTheme.colors.gray[700]}; +` + +const overviewStatusPlannedStyles = css` + background: ${baseTheme.colors.clear[100]}; + color: ${baseTheme.colors.gray[600]}; +` + +const overviewActionsStyles = css` + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.6rem; + margin-top: 0; + padding-top: 0.5rem; +` + +const overviewActionsPrimaryStyles = css` + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +` + +const overviewActionsSecondaryStyles = css` + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +` + +const textLinkStyles = css` + font-size: 0.8rem; + padding: 0.2rem 0.4rem; + min-height: 0; + text-transform: none; + letter-spacing: 0; + color: ${baseTheme.colors.gray[500]}; + background: transparent; + border: none; + font-weight: 400; + + :hover:not(:disabled) { + color: ${baseTheme.colors.green[700]}; + background: transparent; + text-decoration: underline; + } +` + +export interface OverviewStage { + id: string + planned_starts_on: string + planned_ends_on: string + status: string + stage: CourseDesignerStage +} + +export interface PlanOverviewPanelProps { + isOpen: boolean + onClose: () => void + planName: string + stages: OverviewStage[] + activeStage: CourseDesignerStage | null + stageLabel: (stage: CourseDesignerStage) => string + canActOnCurrentStage: boolean + onExtendCurrentStage: () => void + onAdvanceStage: () => void + isExtendPending: boolean + isAdvancePending: boolean + timeRemainingText: string | null + timeRemainingShort?: string | null + currentPhaseEndDateFormatted?: string | null + activeStageTaskCompleted?: number + activeStageTaskTotal?: number + nextStageLabel?: string | null +} + +const PlanOverviewPanel: React.FC = ({ + isOpen, + onClose, + planName, + stages, + activeStage, + stageLabel, + canActOnCurrentStage, + onExtendCurrentStage, + onAdvanceStage, + isExtendPending, + isAdvancePending, + timeRemainingText, + timeRemainingShort = null, + currentPhaseEndDateFormatted = null, + activeStageTaskCompleted = 0, + activeStageTaskTotal = 0, + nextStageLabel = null, +}) => { + const { t, i18n } = useTranslation() + + const formatMonthYear = (isoDate: string): string => + new Date(isoDate).toLocaleDateString(i18n.language, { + // eslint-disable-next-line i18next/no-literal-string -- Intl date format keys + month: "long", + // eslint-disable-next-line i18next/no-literal-string -- Intl date format keys + year: "numeric", + }) + + if (!isOpen) { + return null + } + + const statusLine = + activeStage && (timeRemainingShort ?? timeRemainingText) + ? currentPhaseEndDateFormatted + ? t("course-plans-overview-ends-date-remaining", { + date: currentPhaseEndDateFormatted, + time: timeRemainingShort ?? timeRemainingText, + }) + : t("course-plans-overview-current-phase-status", { + time: timeRemainingShort ?? timeRemainingText, + }) + : null + + return ( + +
    +
    +
    + {activeStage ? ( + <> +

    + {t("course-plans-overview-current-phase-label")} +

    +

    {stageLabel(activeStage)}

    + {statusLine &&

    {statusLine}

    } + + ) : ( +

    {t("course-plans-overview-subtitle-no-active")}

    + )} +
    + +
    + {SCHEDULE_STAGE_ORDER.map((stageEnum) => { + const stageData = stages.find((s) => s.stage === stageEnum) + if (!stageData) { + return null + } + const isCurrent = activeStage === stageEnum + const isCompleted = stageData.status === "Completed" + const showTaskProgress = isCurrent && activeStageTaskTotal > 0 + + return ( +
    +
    + {isCompleted ? ( + + + + ) : isCurrent ? ( + + ) : ( + + )} +
    +
    + + {stageLabel(stageEnum)} + +
    + + {t("course-plans-overview-stage-dates", { + startsOn: formatMonthYear(stageData.planned_starts_on), + endsOn: formatMonthYear(stageData.planned_ends_on), + })} + + + {t( + stageData.status === "InProgress" + ? "course-plans-status-in-progress" + : stageData.status === "Completed" + ? "course-plans-status-completed" + : "course-plans-status-planned", + )} + +
    + {showTaskProgress && ( +

    + {t("course-plans-overview-phase-tasks-progress", { + completed: activeStageTaskCompleted, + total: activeStageTaskTotal, + })} +

    + )} +
    +
    + ) + })} +
    + +
    + {canActOnCurrentStage ? ( + <> +
    + +
    +
    + + +
    + + ) : ( + {t("course-plans-overview-no-actions")} + )} +
    +
    +
    +
    + ) +} + +export default PlanOverviewPanel diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/StageNavigationBar.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/StageNavigationBar.tsx new file mode 100644 index 00000000000..f4bbad5c2fc --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/StageNavigationBar.tsx @@ -0,0 +1,153 @@ +"use client" + +import { css, cx } from "@emotion/css" +import { useTranslation } from "react-i18next" + +import type { CourseDesignerStage } from "@/services/backend/courseDesigner" +import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" +import { baseTheme } from "@/shared-module/common/styles" +import { respondToOrLarger } from "@/shared-module/common/styles/respond" + +const STATUS_SEPARATOR = " · " + +const barWrapperStyles = css` + display: flex; + justify-content: flex-end; + padding: 0 1.5rem 0.5rem; + + ${respondToOrLarger.xl} { + padding: 0 3rem 0.5rem; + } +` + +const navCardStyles = css` + display: inline-flex; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + padding: 0.6rem 1rem; + border-radius: 12px; + background: white; + border: 1px solid ${baseTheme.colors.gray[200]}; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); + cursor: pointer; + transition: + box-shadow 150ms ease, + border-color 150ms ease; + + :hover { + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08); + border-color: ${baseTheme.colors.green[200]}; + } +` + +const tabsRowStyles = css` + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +` + +const tabStyles = css` + padding: 0.4rem 0.75rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + color: ${baseTheme.colors.gray[600]}; + background: transparent; + border: none; + cursor: pointer; + transition: + color 120ms ease, + background 120ms ease; +` + +const tabActiveStyles = css` + color: ${baseTheme.colors.green[800]}; + background: ${baseTheme.colors.green[100]}; +` + +const progressTrackStyles = css` + height: 3px; + border-radius: 999px; + background: ${baseTheme.colors.gray[200]}; + overflow: hidden; +` + +const progressFillStyles = css` + height: 100%; + border-radius: 999px; + background: ${baseTheme.colors.green[600]}; + transition: width 200ms ease; +` + +const statusLineStyles = css` + font-size: 0.8rem; + color: ${baseTheme.colors.gray[500]}; +` + +const statusLineUrgentStyles = css` + color: ${baseTheme.colors.crimson[600]}; + font-weight: 500; +` + +export interface StageNavigationBarProps { + stages: ReadonlyArray + activeStage: CourseDesignerStage + stageLabel: (stage: CourseDesignerStage) => string + timeRemainingShort: string | null + tasksRemainingCount: number + isUrgent: boolean + onClick: () => void +} + +/** Renders stage tabs with progress and compact status (days left, tasks remaining). */ +export default function StageNavigationBar({ + stages, + activeStage, + stageLabel, + timeRemainingShort, + tasksRemainingCount, + isUrgent, + onClick, +}: StageNavigationBarProps) { + const { t } = useTranslation() + const activeIndex = stages.indexOf(activeStage) + const progressPercent = stages.length > 0 ? ((activeIndex + 0.5) / stages.length) * 100 : 0 + + return ( + +
    + +
    +
    + ) +} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/StageSummaryHeader.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/StageSummaryHeader.tsx new file mode 100644 index 00000000000..8127c164df8 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/StageSummaryHeader.tsx @@ -0,0 +1,182 @@ +"use client" + +import { css } from "@emotion/css" +import { + Berries, + Cabin, + Campfire, + CandleLight, + Leaf, + MapleLeaf, + MistyCloud, + PineTree, + Sleigh, + Sunrise, + WaterLiquid, + WinterSnowflake, +} from "@vectopus/atlas-icons-react" +import { useTranslation } from "react-i18next" + +import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" +import { baseTheme } from "@/shared-module/common/styles" +import { respondToOrLarger } from "@/shared-module/common/styles/respond" + +const MONTH_ICONS = [ + WinterSnowflake, + Sleigh, + Sunrise, + WaterLiquid, + Leaf, + Campfire, + Cabin, + Berries, + MapleLeaf, + MistyCloud, + CandleLight, + PineTree, +] as const + +const stageHeaderBarStyles = css` + display: flex; + justify-content: flex-end; + padding: 0 1.5rem; + + ${respondToOrLarger.xl} { + padding: 0 3rem; + } +` + +const stageHeaderCardStyles = css` + display: inline-flex; + align-items: center; + gap: 1rem; + min-width: 22rem; + padding: 0.85rem 1.5rem; + border-radius: 999px; + background: white; + border: 1px solid ${baseTheme.colors.gray[200]}; + box-shadow: 0 12px 25px rgba(15, 23, 42, 0.08); + cursor: pointer; + transition: + box-shadow 150ms ease, + transform 150ms ease, + border-color 150ms ease; + + :hover { + box-shadow: 0 18px 35px rgba(15, 23, 42, 0.12); + transform: translateY(-1px); + border-color: ${baseTheme.colors.primary[200]}; + } + + :active { + transform: translateY(0); + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08); + } +` + +const stageHeaderDateBlockStyles = css` + display: flex; + align-items: center; + gap: 0.5rem; +` + +const stageHeaderMonthIconStyles = css` + width: 20px; + height: 20px; + color: ${baseTheme.colors.green[600]}; + flex-shrink: 0; +` + +const stagePillStyles = css` + border-radius: 999px; + padding: 0.35rem 0.75rem; + background: ${baseTheme.colors.gray[100]}; + color: ${baseTheme.colors.gray[800]}; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +` + +const stageHeaderTextStyles = css` + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1; + min-width: 0; +` + +const stageHeaderWelcomeStyles = css` + font-size: 0.8rem; + color: ${baseTheme.colors.gray[500]}; + margin-bottom: 0.15rem; +` + +const stageHeaderPrimaryStyles = css` + font-size: 0.95rem; + font-weight: 600; + color: ${baseTheme.colors.gray[900]}; +` + +const stageHeaderSecondaryStyles = css` + font-size: 0.85rem; + color: ${baseTheme.colors.gray[600]}; +` + +const stageHeaderDateStyles = css` + font-size: 0.9rem; + color: ${baseTheme.colors.gray[600]}; + white-space: nowrap; +` + +export interface StageSummaryHeaderProps { + fullDateLabel: string + welcomeLabel: string + monthIndex: number + stageLabel: string + timeRemainingLabel: string | null + metaLabel: string | null + onClick: () => void +} + +const StageSummaryHeader: React.FC = ({ + fullDateLabel, + welcomeLabel, + monthIndex, + stageLabel, + timeRemainingLabel, + metaLabel, + onClick, +}) => { + const MonthIcon = MONTH_ICONS[monthIndex] ?? MONTH_ICONS[0] + + return ( + +
    + +
    +
    + ) +} + +export default StageSummaryHeader diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx index fc6ca088881..9af21e0e548 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx @@ -1,6 +1,7 @@ "use client" -import { css } from "@emotion/css" +import { css, cx } from "@emotion/css" +import { Trash } from "@vectopus/atlas-icons-react" import { useState } from "react" import { useTranslation } from "react-i18next" @@ -31,24 +32,114 @@ const headerStyles = css` margin: 0 0 0.5rem 0; ` +const dateRowStyles = css` + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: ${baseTheme.colors.gray[500]}; + margin-bottom: 0.5rem; +` + const dateRangeStyles = css` font-size: 0.9rem; color: ${baseTheme.colors.gray[500]}; +` + +const progressBlockStyles = css` margin-bottom: 1rem; ` -const addRowStyles = css` +const progressLabelStyles = css` + font-size: 0.8rem; + color: ${baseTheme.colors.gray[500]}; + margin-bottom: 0.35rem; +` + +const progressPercentStyles = css` + color: ${baseTheme.colors.gray[500]}; +` + +const progressTrackStyles = css` + height: 6px; + border-radius: 999px; + background: ${baseTheme.colors.gray[200]}; + overflow: hidden; +` + +const progressFillStyles = css` + height: 100%; + border-radius: 999px; + background: ${baseTheme.colors.green[600]}; + transition: width 200ms ease; +` + +const criteriaBlockStyles = css` + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid ${baseTheme.colors.gray[200]}; +` + +const criteriaTitleStyles = css` + font-size: 0.8rem; + font-weight: 600; + color: ${baseTheme.colors.gray[500]}; + margin: 0 0 0.4rem 0; + text-transform: uppercase; + letter-spacing: 0.02em; +` + +const criteriaListStyles = css` + list-style: none; + padding: 0; + margin: 0; + font-size: 0.8rem; + color: ${baseTheme.colors.gray[600]}; + line-height: 1.5; +` + +const criteriaItemStyles = css` display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0; + padding-left: 0; + position: relative; + line-height: 1.25; + + ::before { + content: "☐"; + flex-shrink: 0; + width: 1.1em; + font-size: 0.8rem; + line-height: 1; + color: ${baseTheme.colors.gray[500]}; + display: inline-flex; + align-items: center; + justify-content: center; + } +` + +const addRowStyles = css` + display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 0.5rem; margin-bottom: 0.75rem; + align-items: center; + + @media (max-width: 30rem) { + grid-template-columns: minmax(0, 1fr); + } ` const taskInputStyles = css` flex: 1; - padding: 0.5rem 0.75rem; + min-height: 2.25rem; + padding: 0.4rem 0.75rem; border: 1px solid ${baseTheme.colors.gray[300]}; border-radius: 8px; font-size: 0.95rem; + box-sizing: border-box; ` const taskListStyles = css` @@ -69,9 +160,47 @@ const taskRowStyles = css` } ` +const taskRowCheckboxWrapperStyles = css` + margin-bottom: 0; + flex-shrink: 0; + display: flex; + align-items: center; + + input[type="checkbox"] { + transform: none; + } +` + +const taskRowWithHoverStyles = css` + :hover .task-row-delete { + opacity: 1; + } +` + +const taskDeleteButtonStyles = css` + flex-shrink: 0; + padding: 0.35rem; + min-width: 0; + opacity: 0; + transition: opacity 120ms ease; + color: ${baseTheme.colors.gray[500]}; + + :hover { + color: ${baseTheme.colors.crimson[600]}; + } + + @media (hover: none) { + opacity: 1; + } +` + const taskTitleStyles = css` flex: 1; + display: inline-flex; + align-items: center; + min-height: 1.25rem; font-size: 0.95rem; + line-height: 1.25; color: ${baseTheme.colors.gray[800]}; ` @@ -87,11 +216,13 @@ const emptyTasksStyles = css` padding: 0.5rem 0; ` -function formatDateRange(startsOn: string, endsOn: string): string { +function formatPhaseTimeline(startsOn: string, endsOn: string): string { const s = new Date(startsOn) const e = new Date(endsOn) // eslint-disable-next-line i18next/no-literal-string - return `${s.toLocaleDateString()} – ${e.toLocaleDateString()}` + const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" } + // eslint-disable-next-line i18next/no-literal-string + return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}` } interface WorkspaceStageSectionProps { @@ -100,6 +231,7 @@ interface WorkspaceStageSectionProps { stageLabel: string isActive: boolean onInvalidate: () => void + showStageTitle?: boolean } export default function WorkspaceStageSection({ @@ -108,6 +240,7 @@ export default function WorkspaceStageSection({ stageLabel, isActive, onInvalidate, + showStageTitle = true, }: WorkspaceStageSectionProps) { const { t } = useTranslation() const [newTaskTitle, setNewTaskTitle] = useState("") @@ -146,24 +279,61 @@ export default function WorkspaceStageSection({ return (
    -

    - {stageLabel} - {isActive && ( - - ({t("course-plans-status-in-progress")}) - - )} -

    -

    - {formatDateRange(stage.planned_starts_on, stage.planned_ends_on)} -

    + {showStageTitle && ( +

    + {stageLabel} + {isActive && ( + + ({t("course-plans-status-in-progress")}) + + )} +

    + )} +
    + {t("course-plans-phase-timeline")} + + {formatPhaseTimeline(stage.planned_starts_on, stage.planned_ends_on)} + +
    +
    +
    + {t("course-plans-phase-progress", { + completed: stage.tasks.filter((t) => t.is_completed).length, + total: stage.tasks.length, + })} + {stage.tasks.length > 0 && ( + + {" "} + {t("course-plans-phase-progress-percent", { + percent: Math.round( + (stage.tasks.filter((t) => t.is_completed).length / stage.tasks.length) * 100, + ), + })} + + )} +
    +
    +
    0 + ? (stage.tasks.filter((t) => t.is_completed).length / stage.tasks.length) * 100 + : 0 + }%`, + }), + )} + /> +
    +
    +
    +

    {t("course-plans-phase-complete-when")}

    +
      +
    • {t("course-plans-phase-criteria-generic")}
    • +
    +
    ) } @@ -219,13 +395,14 @@ function WorkspaceTaskRow({ }: WorkspaceTaskRowProps) { const { t } = useTranslation() return ( -
  • +
  • onToggle(e.target.checked)} disabled={isUpdating} + className={taskRowCheckboxWrapperStyles} /> {task.title} -
  • ) diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index d97b334fe18..4410686d4c8 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -287,6 +287,7 @@ "course-navigation": "Navigate to course '{{ title }}'", "course-overview": "Course overview", "course-pages-for": "Course pages for {{course-name}}", + "course-plans-about-this-phase": "About this phase", "course-plans-active-stage-value": "Active stage: {{stage}}", "course-plans-add-one-month": "+1 month", "course-plans-add-one-month-to-phase": "Add 1 month to this phase", @@ -295,18 +296,54 @@ "course-plans-course-size-large": "Large", "course-plans-course-size-medium": "Medium", "course-plans-course-size-small": "Small", + "course-plans-current-month-label": "{{month}}", + "course-plans-days-left": "{{count}} days left", "course-plans-empty": "No course design plans yet.", "course-plans-end-date-column": "End date", "course-plans-finalize-schedule": "Finalize schedule", "course-plans-generate-suggested-schedule": "Generate suggested schedule", "course-plans-generate-suggestion": "Generate suggestion", + "course-plans-instructions-aria-label": "Instructions for the current phase", + "course-plans-instructions-heading": "Work on this phase", "course-plans-instructions-placeholder": "Instructions will be added here.", + "course-plans-key-goal-1": "Define target audience", + "course-plans-key-goal-2": "Identify transformation", + "course-plans-key-goal-3": "Validate demand", + "course-plans-key-goals": "Key goals", + "course-plans-last-edited": "Last edited {{time}}", "course-plans-mark-phase-done-proceed": "Mark phase done and proceed", "course-plans-members-count": "Members: {{count}}", "course-plans-month-unassigned": "Unassigned", "course-plans-new-course-design": "New course design", + "course-plans-no-active-stage": "There is no active phase right now.", "course-plans-no-tasks": "No tasks yet.", "course-plans-none": "None", + "course-plans-overview-adjust-timeline": "Adjust timeline", + "course-plans-overview-complete-phase-hint": "Recommended when all deliverables are done.", + "course-plans-overview-complete-phase-move-to": "Complete phase & move to {{stage}}", + "course-plans-overview-course-progress": "Course progress", + "course-plans-overview-current-phase-label": "Current phase", + "course-plans-overview-current-phase-status": "In progress · {{time}}", + "course-plans-overview-ends-date-remaining": "Ends {{date}} · {{time}}", + "course-plans-overview-no-actions": "There are no actions available for the current phase.", + "course-plans-overview-phase-tasks-progress": "{{completed}} of {{total}} tasks complete", + "course-plans-overview-stage-dates": "{{startsOn}} – {{endsOn}}", + "course-plans-overview-start-next-hint": "Advance without marking current phase complete.", + "course-plans-overview-start-next-phase-now": "Start next phase now", + "course-plans-overview-subtitle": "You are currently in {{stage}}. {{timeRemaining}}", + "course-plans-overview-subtitle-no-active": "This plan has no active phase at the moment.", + "course-plans-overview-title": "Plan overview", + "course-plans-phase-brief-analysis": "Clarify who the course is for and what change it will create. Validate demand before building.", + "course-plans-phase-brief-design": "Define structure, learning objectives, and assessment approach.", + "course-plans-phase-brief-development": "Create content, activities, and materials.", + "course-plans-phase-brief-evaluation": "Review outcomes and improve for next run.", + "course-plans-phase-brief-implementation": "Launch and support learners through the course.", + "course-plans-phase-complete-when": "Phase complete when:", + "course-plans-phase-criteria-generic": "Key deliverables completed", + "course-plans-phase-dates-label": "Scheduled", + "course-plans-phase-progress": "{{completed}}/{{total}} tasks completed", + "course-plans-phase-progress-percent": "{{percent}}% complete", + "course-plans-phase-timeline": "Phase timeline", "course-plans-plan-name-label": "Plan name", "course-plans-project-end": "Project end", "course-plans-project-start": "Project start", @@ -320,6 +357,23 @@ "course-plans-scheduled-stages-count": "Scheduled stages: {{count}}/5", "course-plans-stage-analysis": "Analysis", "course-plans-stage-column": "Stage", + "course-plans-stage-description-analysis-1": "Needs analysis", + "course-plans-stage-description-analysis-2": "Target group", + "course-plans-stage-description-analysis-3": "LMS", + "course-plans-stage-description-analysis-4": "Resources", + "course-plans-stage-description-analysis-5": "Budget", + "course-plans-stage-description-design-1": "Syllabus", + "course-plans-stage-description-design-2": "Structure of the course", + "course-plans-stage-description-design-3": "Content format", + "course-plans-stage-description-design-4": "Contributors/Editors", + "course-plans-stage-description-design-5": "Visual design", + "course-plans-stage-description-development-1": "Agile workflow", + "course-plans-stage-description-development-2": "Content development", + "course-plans-stage-description-evaluation-1": "Feedback", + "course-plans-stage-description-evaluation-2": "Prepare for launch", + "course-plans-stage-description-implementation-1": "Content on the LMS", + "course-plans-stage-description-implementation-2": "Test–Feedback–Development", + "course-plans-stage-description-implementation-3": "Plan research opportunities", "course-plans-stage-description-placeholder": "Stage description (optional)", "course-plans-stage-design": "Design", "course-plans-stage-development": "Development", @@ -333,17 +387,26 @@ "course-plans-status-completed": "Completed", "course-plans-status-draft": "Draft", "course-plans-status-in-progress": "In progress", + "course-plans-status-planned": "Planned", "course-plans-status-ready-to-start": "Ready to start", "course-plans-status-scheduling": "Scheduling", + "course-plans-status-with-time": "In progress · {{time}}", "course-plans-task-placeholder": "Task title", + "course-plans-task-remaining": "{{count}} task remaining", + "course-plans-tasks-aria-label": "Tasks for the current phase", + "course-plans-tasks-heading": "Tasks for this phase", + "course-plans-tasks-remaining": "{{count}} tasks remaining", "course-plans-time-remaining-days": "{{count}} days left in {{stage}}", "course-plans-time-remaining-overdue": "{{count}} days overdue in {{stage}}", + "course-plans-time-remaining-summary": "{{months, number}} months {{days, number}} days remaining", "course-plans-title": "Course Plans", "course-plans-untitled-plan": "Untitled plan", "course-plans-validation-calendar-order": "Stages must appear in order (Analysis, Design, Development, Implementation, Evaluation) with no gaps.", "course-plans-validation-contiguous": "Stages must be contiguous (no gaps or overlaps).", "course-plans-validation-stage-count": "Schedule must contain 5 stages.", "course-plans-validation-stage-range": "{{stage}} starts after it ends.", + "course-plans-view-plan": "View plan", + "course-plans-welcome-back": "Welcome back", "course-plans-wizard-name-hint": "This is a working name and can be changed later.", "course-plans-wizard-progress-label": "Schedule setup progress", "course-plans-wizard-starts-on-month-label": "Month work starts", diff --git a/shared-module/packages/common/src/locales/fi/main-frontend.json b/shared-module/packages/common/src/locales/fi/main-frontend.json index 58ec05b0bac..59dd627ebbe 100644 --- a/shared-module/packages/common/src/locales/fi/main-frontend.json +++ b/shared-module/packages/common/src/locales/fi/main-frontend.json @@ -284,7 +284,116 @@ "course-navigation": "Siirry kurssiin '{{ title }}'", "course-overview": "Kurssin yhteenveto", "course-pages-for": "Kurssin {{course-name}} sivut", + "course-plans-about-this-phase": "Tästä vaiheesta", + "course-plans-active-stage-value": "Aktiivinen vaihe: {{stage}}", + "course-plans-add-one-month": "+1 kuukausi", + "course-plans-add-one-month-to-phase": "Lisää 1 kuukausi tähän vaiheeseen", + "course-plans-add-task": "Lisää tehtävä", + "course-plans-course-size-label": "Kurssin koko", + "course-plans-course-size-large": "Suuri", + "course-plans-course-size-medium": "Keskikokoinen", + "course-plans-course-size-small": "Pieni", + "course-plans-current-month-label": "{{month}}", + "course-plans-days-left": "{{count}} päivää jäljellä", + "course-plans-empty": "Kurssisuunnitelmia ei ole vielä.", + "course-plans-end-date-column": "Päättymispäivä", + "course-plans-finalize-schedule": "Viimeistele aikataulu", + "course-plans-generate-suggested-schedule": "Luo aikatauluehdotus", + "course-plans-generate-suggestion": "Luo ehdotus", + "course-plans-instructions-aria-label": "Ohjeet nykyiselle vaiheelle", + "course-plans-instructions-heading": "Työskentele tässä vaiheessa", + "course-plans-instructions-placeholder": "Ohjeet lisätään tähän.", + "course-plans-key-goal-1": "Määritä kohderyhmä", + "course-plans-key-goal-2": "Tunnista muutos", + "course-plans-key-goal-3": "Validoi kysyntä", + "course-plans-key-goals": "Keskeiset tavoitteet", + "course-plans-last-edited": "Viimeksi muokattu {{time}}", + "course-plans-mark-phase-done-proceed": "Merkitse vaihe valmiiksi ja jatka", + "course-plans-members-count": "Jäseniä: {{count}}", + "course-plans-month-unassigned": "Ei liitetty", + "course-plans-new-course-design": "Uusi kurssisuunnitelma", + "course-plans-no-active-stage": "Tällä hetkellä ei ole aktiivista vaihetta.", + "course-plans-no-tasks": "Ei tehtäviä vielä.", + "course-plans-none": "Ei yhtään", + "course-plans-overview-adjust-timeline": "Säädä aikataulua", + "course-plans-overview-complete-phase-hint": "Suositus, kun kaikki toimitettavat on tehty.", + "course-plans-overview-complete-phase-move-to": "Merkitse vaihe valmiiksi ja siirry vaiheeseen {{stage}}", + "course-plans-overview-course-progress": "Kurssin edistyminen", + "course-plans-overview-current-phase-label": "Nykyinen vaihe", + "course-plans-overview-current-phase-status": "Käynnissä · {{time}}", + "course-plans-overview-ends-date-remaining": "Päättyy {{date}} · {{time}}", + "course-plans-overview-no-actions": "Tälle vaiheelle ei ole tällä hetkellä toimintoja.", + "course-plans-overview-phase-tasks-progress": "{{completed}}/{{total}} tehtävää tehty", + "course-plans-overview-stage-dates": "{{startsOn}} – {{endsOn}}", + "course-plans-overview-start-next-hint": "Siirry eteenpäin merkitsemättä nykyistä vaihetta valmiiksi.", + "course-plans-overview-start-next-phase-now": "Aloita seuraava vaihe nyt", + "course-plans-overview-subtitle": "Olet parhaillaan vaiheessa {{stage}}. {{timeRemaining}}", + "course-plans-overview-subtitle-no-active": "Tällä suunnitelmalla ei ole nyt aktiivista vaihetta.", + "course-plans-overview-title": "Suunnitelman yleiskuva", + "course-plans-phase-brief-analysis": "Selkeytä kohderyhmä ja muutos. Validoi kysyntä ennen rakentamista.", + "course-plans-phase-brief-design": "Määritä rakenne, oppimistavoitteet ja arviointi.", + "course-plans-phase-brief-development": "Luo sisältö, aktiviteetit ja materiaalit.", + "course-plans-phase-brief-evaluation": "Arvioi tulokset ja paranna seuraavaa toteutusta.", + "course-plans-phase-brief-implementation": "Käynnistä kurssi ja tue osallistujia.", + "course-plans-phase-complete-when": "Vaihe valmis kun:", + "course-plans-phase-criteria-generic": "Keskeiset tuotokset valmiina", + "course-plans-phase-dates-label": "Aikataulutus", + "course-plans-phase-progress": "{{completed}}/{{total}} tehtävää suoritettu", + "course-plans-phase-progress-percent": "{{percent}}% valmis", + "course-plans-phase-timeline": "Vaiheen aikataulu", + "course-plans-plan-name-label": "Suunnitelman nimi", + "course-plans-project-end": "Projektin loppu", + "course-plans-project-start": "Projektin alku", + "course-plans-remove-one-month": "-1 kuukausi", "course-plans-reset-suggestion": "Palauta ehdotus", + "course-plans-stage-analysis": "Analyysi", + "course-plans-stage-column": "Vaihe", + "course-plans-stage-description-analysis-1": "Tarpeiden analyysi", + "course-plans-stage-description-analysis-2": "Kohderyhmä", + "course-plans-stage-description-analysis-3": "Oppimisympäristö (LMS)", + "course-plans-stage-description-analysis-4": "Resurssit", + "course-plans-stage-description-analysis-5": "Budjetti", + "course-plans-stage-description-design-1": "Syllabus", + "course-plans-stage-description-design-2": "Kurssin rakenne", + "course-plans-stage-description-design-3": "Sisällön formaatti", + "course-plans-stage-description-design-4": "Tekijät / toimittajat", + "course-plans-stage-description-design-5": "Visuaalinen ilme", + "course-plans-stage-description-development-1": "Ketterä työnkulku", + "course-plans-stage-description-development-2": "Sisällön tuotanto", + "course-plans-stage-description-evaluation-1": "Palaute", + "course-plans-stage-description-evaluation-2": "Valmistautuminen lanseeraukseen", + "course-plans-stage-description-implementation-1": "Sisältö oppimisympäristössä", + "course-plans-stage-description-implementation-2": "Testaus – palaute – jatkokehitys", + "course-plans-stage-description-implementation-3": "Tutkimusmahdollisuuksien suunnittelu", + "course-plans-stage-description-placeholder": "Vaiheen kuvaus (valinnainen)", + "course-plans-stage-design": "Suunnittelu", + "course-plans-stage-development": "Toteutus", + "course-plans-stage-evaluation": "Arviointi", + "course-plans-stage-implementation": "Käyttöönotto", + "course-plans-start-date-column": "Alkupäivä", + "course-plans-start-next-phase-early": "Aloita seuraava vaihe etuajassa", + "course-plans-start-plan": "Käynnistä suunnitelma", + "course-plans-starts-on-label": "Alkaa", + "course-plans-status-archived": "Arkistoitu", + "course-plans-status-completed": "Valmis", + "course-plans-status-draft": "Luonnos", + "course-plans-status-in-progress": "Käynnissä", + "course-plans-status-planned": "Suunniteltu", + "course-plans-status-ready-to-start": "Valmis aloitettavaksi", + "course-plans-status-scheduling": "Aikataulutetaan", + "course-plans-status-with-time": "Käynnissä · {{time}}", + "course-plans-task-placeholder": "Tehtävän nimi", + "course-plans-task-remaining": "{{count}} tehtävä jäljellä", + "course-plans-tasks-aria-label": "Tehtävät nykyiselle vaiheelle", + "course-plans-tasks-heading": "Tehtävät tähän vaiheeseen", + "course-plans-tasks-remaining": "{{count}} tehtävää jäljellä", + "course-plans-time-remaining-days": "{{count}} päivää jäljellä vaiheessa {{stage}}", + "course-plans-time-remaining-overdue": "{{count}} päivää myöhässä vaiheessa {{stage}}", + "course-plans-time-remaining-summary": "{{months, number}} kk {{days, number}} pv jäljellä", + "course-plans-title": "Kurssisuunnitelmat", + "course-plans-untitled-plan": "Nimetön suunnitelma", + "course-plans-view-plan": "Näytä suunnitelma", + "course-plans-welcome-back": "Tervetuloa takaisin", "course-plans-wizard-name-hint": "Tämä on työnimi, jota voi myöhemmin muuttaa.", "course-plans-wizard-progress-label": "Aikataulun asetuksen edistyminen", "course-plans-wizard-starts-on-month-label": "Työn alkamiskuukausi", From 37cd07e82fe88459ecf5bec87924ff894f096a58 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Fri, 27 Feb 2026 09:15:35 +0200 Subject: [PATCH 15/16] Enhance Course Plan functionality with schedule adjustment feature - Updated CoursePlanWorkspacePage to allow extending the current stage with a specified number of months. - Modified PlanOverviewPanel to include a dialog for adjusting the schedule, enabling users to select the number of months to extend. - Added new localization strings for the schedule adjustment feature in main-frontend.json. --- .../components/CoursePlanWorkspacePage.tsx | 7 +- .../components/PlanOverviewPanel.tsx | 201 ++++++++++++++---- .../common/src/locales/en/main-frontend.json | 10 +- 3 files changed, 174 insertions(+), 44 deletions(-) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx index 8adf855aff6..4eaa6c16f51 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx @@ -183,7 +183,8 @@ export default function CoursePlanWorkspacePage() { ) const extendMutation = useToastMutation( - (stage: CourseDesignerStage) => extendCourseDesignerStage(planId, stage, 1), + (params: { stage: CourseDesignerStage; months: number }) => + extendCourseDesignerStage(planId, params.stage, params.months), { notify: true, method: "POST" }, { onSuccess: () => { @@ -414,7 +415,9 @@ export default function CoursePlanWorkspacePage() { activeStage={currentStage ?? null} stageLabel={stageLabel} canActOnCurrentStage={Boolean(canAct)} - onExtendCurrentStage={() => currentStage && extendMutation.mutate(currentStage)} + onExtendCurrentStage={(months) => + currentStage && extendMutation.mutate({ stage: currentStage, months }) + } onAdvanceStage={() => advanceMutation.mutate()} isExtendPending={extendMutation.isPending} isAdvancePending={advanceMutation.isPending} diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx index 93a2af7b2ba..b99d53e240d 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx @@ -2,12 +2,14 @@ import { css } from "@emotion/css" import { CheckCircle } from "@vectopus/atlas-icons-react" +import React, { useState } from "react" import { useTranslation } from "react-i18next" import { SCHEDULE_STAGE_ORDER } from "../../schedule/scheduleConstants" import { type CourseDesignerStage } from "@/services/backend/courseDesigner" import Button from "@/shared-module/common/components/Button" +import SelectField from "@/shared-module/common/components/InputFields/SelectField" import StandardDialog from "@/shared-module/common/components/dialogs/StandardDialog" import { baseTheme } from "@/shared-module/common/styles" @@ -218,24 +220,6 @@ const overviewActionsSecondaryStyles = css` flex-wrap: wrap; ` -const textLinkStyles = css` - font-size: 0.8rem; - padding: 0.2rem 0.4rem; - min-height: 0; - text-transform: none; - letter-spacing: 0; - color: ${baseTheme.colors.gray[500]}; - background: transparent; - border: none; - font-weight: 400; - - :hover:not(:disabled) { - color: ${baseTheme.colors.green[700]}; - background: transparent; - text-decoration: underline; - } -` - export interface OverviewStage { id: string planned_starts_on: string @@ -252,7 +236,7 @@ export interface PlanOverviewPanelProps { activeStage: CourseDesignerStage | null stageLabel: (stage: CourseDesignerStage) => string canActOnCurrentStage: boolean - onExtendCurrentStage: () => void + onExtendCurrentStage: (months: number) => void onAdvanceStage: () => void isExtendPending: boolean isAdvancePending: boolean @@ -284,15 +268,56 @@ const PlanOverviewPanel: React.FC = ({ nextStageLabel = null, }) => { const { t, i18n } = useTranslation() + const [isAdjustDialogOpen, setIsAdjustDialogOpen] = useState(false) + const [extendMonths, setExtendMonths] = useState(1) - const formatMonthYear = (isoDate: string): string => - new Date(isoDate).toLocaleDateString(i18n.language, { + /** Formats a Date as localized month and year. */ + const formatMonthYearFromDate = (date: Date): string => + date.toLocaleDateString(i18n.language, { // eslint-disable-next-line i18next/no-literal-string -- Intl date format keys month: "long", // eslint-disable-next-line i18next/no-literal-string -- Intl date format keys year: "numeric", }) + const formatMonthYear = (isoDate: string): string => formatMonthYearFromDate(new Date(isoDate)) + + /** Adds a month offset to a Date while preserving other fields. */ + const addMonths = (date: Date, months: number): Date => { + const result = new Date(date) + result.setMonth(result.getMonth() + months) + return result + } + + const activeStageData = + activeStage != null ? (stages.find((stage) => stage.stage === activeStage) ?? null) : null + + const currentPhaseEndLabel = + currentPhaseEndDateFormatted ?? + (activeStageData != null ? formatMonthYear(activeStageData.planned_ends_on) : null) + + const latestStageEndIso = + stages.length > 0 + ? stages.reduce( + (latest, stage) => (stage.planned_ends_on > latest ? stage.planned_ends_on : latest), + stages[0]!.planned_ends_on, + ) + : null + + const currentPlanEndLabel = latestStageEndIso != null ? formatMonthYear(latestStageEndIso) : null + + const newPhaseEndLabel = + activeStageData != null && extendMonths > 0 + ? formatMonthYearFromDate(addMonths(new Date(activeStageData.planned_ends_on), extendMonths)) + : null + + const newPlanEndLabel = + latestStageEndIso != null && extendMonths > 0 + ? formatMonthYearFromDate(addMonths(new Date(latestStageEndIso), extendMonths)) + : null + + const canAdjustSchedule = activeStage != null && activeStageData != null + if (!isOpen) { return null } @@ -419,28 +444,24 @@ const PlanOverviewPanel: React.FC = ({ ? t("course-plans-overview-complete-phase-move-to", { stage: nextStageLabel, }) - : t("course-plans-mark-phase-done-proceed")} - -
    -
    - -
    + {canAdjustSchedule && ( +
    + +
    + )} ) : ( {t("course-plans-overview-no-actions")} @@ -448,6 +469,104 @@ const PlanOverviewPanel: React.FC = ({
    + + {canAdjustSchedule && ( + setIsAdjustDialogOpen(false)} + title={t("course-plans-adjust-schedule-title")} + isDismissable + leftAlignTitle + > +
    +

    + {t("course-plans-adjust-schedule-description", { + phase: activeStage ? stageLabel(activeStage) : "", + })} +

    + + setExtendMonths(Number(value))} + options={Array.from({ length: 6 }, (_item, index) => { + const months = index + 1 + return { + value: String(months), + label: t("course-plans-adjust-schedule-month-option", { count: months }), + } + })} + /> + + {currentPhaseEndLabel && newPhaseEndLabel && ( +

    + {t("course-plans-adjust-schedule-phase-dates", { + currentEnd: currentPhaseEndLabel, + newEnd: newPhaseEndLabel, + })} +

    + )} + + {currentPlanEndLabel && newPlanEndLabel && ( +

    + {t("course-plans-adjust-schedule-plan-dates", { + delayMonths: extendMonths, + currentPlanEnd: currentPlanEndLabel, + newPlanEnd: newPlanEndLabel, + })} +

    + )} + +
    + + +
    +
    +
    + )} ) } diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 4410686d4c8..71b95638b4d 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -292,6 +292,13 @@ "course-plans-add-one-month": "+1 month", "course-plans-add-one-month-to-phase": "Add 1 month to this phase", "course-plans-add-task": "Add task", + "course-plans-adjust-schedule-apply": "Extend by {{months}} month(s)", + "course-plans-adjust-schedule-description": "Extend the {{phase}} phase. This will push all later phases back by the same number of months.", + "course-plans-adjust-schedule-month-option": "{{count}} month", + "course-plans-adjust-schedule-months-label": "How many months to extend?", + "course-plans-adjust-schedule-phase-dates": "Current phase ends {{currentEnd}}. After extending, it will end {{newEnd}}.", + "course-plans-adjust-schedule-plan-dates": "Your overall plan will be delayed by {{delayMonths}} month(s): from {{currentPlanEnd}} to {{newPlanEnd}}.", + "course-plans-adjust-schedule-title": "Adjust current phase schedule", "course-plans-course-size-label": "Course size", "course-plans-course-size-large": "Large", "course-plans-course-size-medium": "Medium", @@ -318,7 +325,9 @@ "course-plans-no-active-stage": "There is no active phase right now.", "course-plans-no-tasks": "No tasks yet.", "course-plans-none": "None", + "course-plans-overview-adjust-schedule": "Adjust schedule…", "course-plans-overview-adjust-timeline": "Adjust timeline", + "course-plans-overview-complete-final-phase": "Mark plan as completed", "course-plans-overview-complete-phase-hint": "Recommended when all deliverables are done.", "course-plans-overview-complete-phase-move-to": "Complete phase & move to {{stage}}", "course-plans-overview-course-progress": "Course progress", @@ -329,7 +338,6 @@ "course-plans-overview-phase-tasks-progress": "{{completed}} of {{total}} tasks complete", "course-plans-overview-stage-dates": "{{startsOn}} – {{endsOn}}", "course-plans-overview-start-next-hint": "Advance without marking current phase complete.", - "course-plans-overview-start-next-phase-now": "Start next phase now", "course-plans-overview-subtitle": "You are currently in {{stage}}. {{timeRemaining}}", "course-plans-overview-subtitle-no-active": "This plan has no active phase at the moment.", "course-plans-overview-title": "Plan overview", From 44feca35b9ee0c4d6322cb3ac844380bd5fed4f5 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Fri, 27 Feb 2026 10:00:22 +0200 Subject: [PATCH 16/16] Enhance CoursePlanWorkspacePage layout and accessibility - Introduced a responsive grid layout for the CoursePlanWorkspacePage to improve the organization of components. - Added new styles for various sections including header, instructions, tasks, workspace, and chatbot areas. - Integrated BreakFromCentered component for better layout management. - Updated localization strings to enhance accessibility for screen readers, including aria-labels for workspace and assistant components. --- .../components/CoursePlanWorkspacePage.tsx | 309 +++++++++++++----- .../common/src/locales/en/main-frontend.json | 2 + 2 files changed, 226 insertions(+), 85 deletions(-) diff --git a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx index 4eaa6c16f51..e381110b134 100644 --- a/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx @@ -22,6 +22,7 @@ import { startCourseDesignerPlan, } from "@/services/backend/courseDesigner" import Button from "@/shared-module/common/components/Button" +import BreakFromCentered from "@/shared-module/common/components/Centering/BreakFromCentered" import ErrorBanner from "@/shared-module/common/components/ErrorBanner" import Spinner from "@/shared-module/common/components/Spinner" import useToastMutation from "@/shared-module/common/hooks/useToastMutation" @@ -33,13 +34,25 @@ const pageRootStyles = css` min-height: 100vh; ` -const contentWrapperStyles = css` - max-width: 72rem; +const workspaceShellStyles = css` + width: 100%; margin: 0 auto; - padding: 0 2rem; + padding: 0 1.25rem 3rem; + + ${respondToOrLarger.md} { + padding: 0 1.75rem 3rem; + } ${respondToOrLarger.lg} { - padding: 0 3rem; + padding: 0 2.25rem 3rem; + } + + ${respondToOrLarger.xl} { + padding: 0 2.75rem 3rem; + } + + ${respondToOrLarger.xxxl} { + padding: 0 3rem 3rem; } ` @@ -70,17 +83,89 @@ const metadataRowStyles = css` margin: 0; ` -const mainLayoutStyles = css` +const workspaceGridStyles = css` display: grid; - grid-template-columns: 1fr; - gap: 1.5rem; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "header" + "instructions" + "tasks" + "workspace" + "chatbot"; + grid-auto-rows: minmax(0, auto); + gap: 1.25rem; + + ${respondToOrLarger.sm} { + gap: 1.5rem; + } ${respondToOrLarger.md} { - grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: 1.6rem; + } + + ${respondToOrLarger.lg} { + gap: 1.6rem; + } + + ${respondToOrLarger.xl} { + grid-template-columns: + minmax(24rem, 1.35fr) + minmax(34rem, 2.1fr); + grid-template-areas: + "header header" + "tasks instructions" + "tasks workspace" + "chatbot workspace"; + gap: 1.75rem; + } + + ${respondToOrLarger.xxl} { + grid-template-columns: + minmax(26rem, 1.35fr) + minmax(40rem, 2.1fr); + grid-template-areas: + "header header" + "tasks instructions" + "tasks workspace" + "chatbot workspace"; + gap: 2rem; + } + + ${respondToOrLarger.xxxxl} { + grid-template-columns: + minmax(24rem, 1.2fr) + minmax(40rem, 2.6fr) + minmax(24rem, 1.3fr); + grid-template-areas: + "header header header" + "tasks instructions chatbot" + "tasks workspace chatbot" + "tasks workspace chatbot"; + gap: 2.25rem; align-items: flex-start; } ` +const headerAreaStyles = css` + grid-area: header; +` + +const instructionsAreaStyles = css` + grid-area: instructions; +` + +const tasksAreaStyles = css` + grid-area: tasks; +` + +const workspaceAreaStyles = css` + grid-area: workspace; +` + +const chatbotAreaStyles = css` + grid-area: chatbot; +` + const cardStyles = css` background: white; border-radius: 12px; @@ -89,6 +174,24 @@ const cardStyles = css` border: 1px solid ${baseTheme.colors.gray[200]}; ` +const tasksCardStyles = css` + min-height: 80vh; + display: flex; + flex-direction: column; +` + +const workspaceCardStyles = css` + min-height: 60vh; + display: flex; + flex-direction: column; +` + +const chatbotCardStyles = css` + display: flex; + flex-direction: column; + min-height: 80vh; +` + const sectionTitleStyles = css` font-size: 1.1rem; font-weight: 600; @@ -222,7 +325,7 @@ export default function CoursePlanWorkspacePage() { if (planQuery.isError) { return (
    -
    +
    @@ -232,7 +335,7 @@ export default function CoursePlanWorkspacePage() { if (planQuery.isLoading || !planQuery.data) { return (
    -
    +
    @@ -244,7 +347,7 @@ export default function CoursePlanWorkspacePage() { if (plan.status === "ReadyToStart" && !plan.active_stage) { return (
    -
    +

    {plan.name ?? t("course-plans-untitled-plan")}

    {t("course-plans-status-ready-to-start")}

    @@ -406,82 +509,118 @@ export default function CoursePlanWorkspacePage() { ] return ( -
    - setIsOverviewOpen(false)} - planName={plan.name ?? t("course-plans-untitled-plan")} - stages={stages as OverviewStage[]} - activeStage={currentStage ?? null} - stageLabel={stageLabel} - canActOnCurrentStage={Boolean(canAct)} - onExtendCurrentStage={(months) => - currentStage && extendMutation.mutate({ stage: currentStage, months }) - } - onAdvanceStage={() => advanceMutation.mutate()} - isExtendPending={extendMutation.isPending} - isAdvancePending={advanceMutation.isPending} - timeRemainingText={timeRemainingText} - timeRemainingShort={timeRemainingShort} - currentPhaseEndDateFormatted={currentPhaseEndDateFormatted} - activeStageTaskCompleted={activeStageTaskCompleted} - activeStageTaskTotal={activeStageTaskTotal} - nextStageLabel={nextStageLabel} - /> - -
    -
    -
    -

    {plan.name ?? t("course-plans-untitled-plan")}

    - {lastEditedText &&

    {lastEditedText}

    } + +
    + setIsOverviewOpen(false)} + planName={plan.name ?? t("course-plans-untitled-plan")} + stages={stages as OverviewStage[]} + activeStage={currentStage ?? null} + stageLabel={stageLabel} + canActOnCurrentStage={Boolean(canAct)} + onExtendCurrentStage={(months) => + currentStage && extendMutation.mutate({ stage: currentStage, months }) + } + onAdvanceStage={() => advanceMutation.mutate()} + isExtendPending={extendMutation.isPending} + isAdvancePending={advanceMutation.isPending} + timeRemainingText={timeRemainingText} + timeRemainingShort={timeRemainingShort} + currentPhaseEndDateFormatted={currentPhaseEndDateFormatted} + activeStageTaskCompleted={activeStageTaskCompleted} + activeStageTaskTotal={activeStageTaskTotal} + nextStageLabel={nextStageLabel} + /> + +
    +
    +
    +
    +
    +

    {plan.name ?? t("course-plans-untitled-plan")}

    + {lastEditedText &&

    {lastEditedText}

    } +
    + {currentStage && ( + setIsOverviewOpen(true)} + /> + )} +
    +
    + +
    +

    + {t("course-plans-instructions-heading")} +

    +

    {t("course-plans-about-this-phase")}

    +

    + {currentStage + ? t( + `course-plans-phase-brief-${currentStage.toLowerCase()}` as + | "course-plans-phase-brief-analysis" + | "course-plans-phase-brief-design" + | "course-plans-phase-brief-development" + | "course-plans-phase-brief-implementation" + | "course-plans-phase-brief-evaluation", + ) + : t("course-plans-instructions-placeholder")} +

    +

    {t("course-plans-key-goals")}

    +
      {keyGoalsContent}
    +
    + +
    +

    {t("course-plans-tasks-heading")}

    + {currentStageSection ?? ( +

    {t("course-plans-no-active-stage")}

    + )} +
    + +
    + {/* eslint-disable-next-line i18next/no-literal-string */} +

    Workspace

    + {/* eslint-disable-next-line i18next/no-literal-string */} +

    + This area will host tools and editors for working on the current stage of your + course design. +

    +
    + +
    + {/* eslint-disable-next-line i18next/no-literal-string */} +

    Assistant

    + {/* eslint-disable-next-line i18next/no-literal-string */} +

    + A course design assistant chatbot will appear here to help you with tasks and + questions about each stage. +

    +
    - {currentStage && ( - setIsOverviewOpen(true)} - /> - )} -
    - -
    -
    -

    - {t("course-plans-instructions-heading")} -

    -

    {t("course-plans-about-this-phase")}

    -

    - {currentStage - ? t( - `course-plans-phase-brief-${currentStage.toLowerCase()}` as - | "course-plans-phase-brief-analysis" - | "course-plans-phase-brief-design" - | "course-plans-phase-brief-development" - | "course-plans-phase-brief-implementation" - | "course-plans-phase-brief-evaluation", - ) - : t("course-plans-instructions-placeholder")} -

    -

    {t("course-plans-key-goals")}

    -
      {keyGoalsContent}
    -
    - -
    -

    {t("course-plans-tasks-heading")}

    - {currentStageSection ?? ( -

    {t("course-plans-no-active-stage")}

    - )} -
    -
    + ) } diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 71b95638b4d..6950a3a0693 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -299,6 +299,7 @@ "course-plans-adjust-schedule-phase-dates": "Current phase ends {{currentEnd}}. After extending, it will end {{newEnd}}.", "course-plans-adjust-schedule-plan-dates": "Your overall plan will be delayed by {{delayMonths}} month(s): from {{currentPlanEnd}} to {{newPlanEnd}}.", "course-plans-adjust-schedule-title": "Adjust current phase schedule", + "course-plans-assistant-aria-label": "Assistant for helping with this phase", "course-plans-course-size-label": "Course size", "course-plans-course-size-large": "Large", "course-plans-course-size-medium": "Medium", @@ -421,6 +422,7 @@ "course-plans-wizard-step-name": "Name the project", "course-plans-wizard-step-schedule": "Schedule editor", "course-plans-wizard-step-size-and-date": "Course size and start month", + "course-plans-workspace-aria-label": "Workspace for doing phase work", "course-plans-year-label": "Year", "course-progress": "Course progress", "course-settings": "Course settings",