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/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-115e8920adf206f53197a387a640b11307ac593db2e46ebad8846338f5da34ae.json b/services/headless-lms/models/.sqlx/query-115e8920adf206f53197a387a640b11307ac593db2e46ebad8846338f5da34ae.json new file mode 100644 index 00000000000..429d64b38e4 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-115e8920adf206f53197a387a640b11307ac593db2e46ebad8846338f5da34ae.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO course_designer_plan_stage_tasks (\n course_designer_plan_stage_id,\n title,\n description,\n order_number,\n is_auto_generated,\n created_by_user_id\n)\nVALUES ($1, $2, $3, $4, FALSE, $5)\nRETURNING\n id,\n created_at,\n updated_at,\n course_designer_plan_stage_id,\n title,\n description,\n order_number,\n is_completed,\n completed_at,\n completed_by_user_id,\n is_auto_generated,\n created_by_user_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": "course_designer_plan_stage_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "order_number", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "is_completed", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "completed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "completed_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "is_auto_generated", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_by_user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Int4", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + true, + true, + false, + true + ] + }, + "hash": "115e8920adf206f53197a387a640b11307ac593db2e46ebad8846338f5da34ae" +} diff --git a/services/headless-lms/models/.sqlx/query-1c4c29506309533e35b2b10c53a1689d959edd5bc49b443efcb3dd36a8909165.json b/services/headless-lms/models/.sqlx/query-1c4c29506309533e35b2b10c53a1689d959edd5bc49b443efcb3dd36a8909165.json new file mode 100644 index 00000000000..0522f0f882e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-1c4c29506309533e35b2b10c53a1689d959edd5bc49b443efcb3dd36a8909165.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 ON m.course_designer_plan_id = p.id\n AND m.user_id = $2 AND m.deleted_at IS NULL\nWHERE p.id = $1 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": "1c4c29506309533e35b2b10c53a1689d959edd5bc49b443efcb3dd36a8909165" +} diff --git a/services/headless-lms/models/.sqlx/query-1fbf00a624ec709faa946fa91da6458b3ca008d637740412ca17affb621e7d97.json b/services/headless-lms/models/.sqlx/query-1fbf00a624ec709faa946fa91da6458b3ca008d637740412ca17affb621e7d97.json new file mode 100644 index 00000000000..845e81b1b75 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-1fbf00a624ec709faa946fa91da6458b3ca008d637740412ca17affb621e7d97.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plans\nSET status = $2, active_stage = $3\nWHERE id = $1 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + }, + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "1fbf00a624ec709faa946fa91da6458b3ca008d637740412ca17affb621e7d97" +} 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-2fdb3b7acfb839af3ec2404bf705b969d9187c36675e684d01cc15e51e9586e0.json b/services/headless-lms/models/.sqlx/query-2fdb3b7acfb839af3ec2404bf705b969d9187c36675e684d01cc15e51e9586e0.json new file mode 100644 index 00000000000..bf411059c72 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-2fdb3b7acfb839af3ec2404bf705b969d9187c36675e684d01cc15e51e9586e0.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id, created_at, 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 AND deleted_at IS NULL\nORDER BY CASE stage WHEN 'analysis' THEN 1 WHEN 'design' THEN 2 WHEN 'development' THEN 3 WHEN 'implementation' THEN 4 WHEN 'evaluation' THEN 5 END\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": "2fdb3b7acfb839af3ec2404bf705b969d9187c36675e684d01cc15e51e9586e0" +} diff --git a/services/headless-lms/models/.sqlx/query-312d1160a49c0f40c4abcc13327c2a95f1c1454590c5629950969a32ce5fa70b.json b/services/headless-lms/models/.sqlx/query-312d1160a49c0f40c4abcc13327c2a95f1c1454590c5629950969a32ce5fa70b.json new file mode 100644 index 00000000000..5fd0763bf4b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-312d1160a49c0f40c4abcc13327c2a95f1c1454590c5629950969a32ce5fa70b.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plan_stage_tasks t\nSET\n title = $2,\n description = $3,\n is_completed = $4,\n completed_at = $5,\n completed_by_user_id = $6\nFROM course_designer_plan_stages s\nJOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id\n AND m.user_id = $7 AND m.deleted_at IS NULL\nWHERE t.course_designer_plan_stage_id = s.id AND s.course_designer_plan_id = $1\n AND t.id = $8 AND t.deleted_at IS NULL\nRETURNING\n t.id,\n t.created_at,\n t.updated_at,\n t.course_designer_plan_stage_id,\n t.title,\n t.description,\n t.order_number,\n t.is_completed,\n t.completed_at,\n t.completed_by_user_id,\n t.is_auto_generated,\n t.created_by_user_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": "course_designer_plan_stage_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "order_number", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "is_completed", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "completed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "completed_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "is_auto_generated", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_by_user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Bool", + "Timestamptz", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + true, + true, + false, + true + ] + }, + "hash": "312d1160a49c0f40c4abcc13327c2a95f1c1454590c5629950969a32ce5fa70b" +} diff --git a/services/headless-lms/models/.sqlx/query-33ef238557577f0455e7464e6ef8811f5b60f8f9f80545a14916a8964d3b56b1.json b/services/headless-lms/models/.sqlx/query-33ef238557577f0455e7464e6ef8811f5b60f8f9f80545a14916a8964d3b56b1.json new file mode 100644 index 00000000000..d417fb4fa5d --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-33ef238557577f0455e7464e6ef8811f5b60f8f9f80545a14916a8964d3b56b1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT MAX(order_number)::INTEGER\nFROM course_designer_plan_stage_tasks\nWHERE course_designer_plan_stage_id = $1 AND deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "max", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "33ef238557577f0455e7464e6ef8811f5b60f8f9f80545a14916a8964d3b56b1" +} diff --git a/services/headless-lms/models/.sqlx/query-3f9c38734f5c4903ce95b8247a5bb54ca82e4a12997d85481853597412a5a1a6.json b/services/headless-lms/models/.sqlx/query-3f9c38734f5c4903ce95b8247a5bb54ca82e4a12997d85481853597412a5a1a6.json new file mode 100644 index 00000000000..6d3c61578ee --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-3f9c38734f5c4903ce95b8247a5bb54ca82e4a12997d85481853597412a5a1a6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plan_stages\nSET planned_ends_on = $2\nWHERE id = $1 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Date" + ] + }, + "nullable": [] + }, + "hash": "3f9c38734f5c4903ce95b8247a5bb54ca82e4a12997d85481853597412a5a1a6" +} diff --git a/services/headless-lms/models/.sqlx/query-43792942081e488f680acdc89a3290f1b46fea2c4f5fc0ea754ab797376b40fa.json b/services/headless-lms/models/.sqlx/query-43792942081e488f680acdc89a3290f1b46fea2c4f5fc0ea754ab797376b40fa.json new file mode 100644 index 00000000000..fe24eae4dea --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-43792942081e488f680acdc89a3290f1b46fea2c4f5fc0ea754ab797376b40fa.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plan_stages\nSET status = $2, actual_started_at = $3\nWHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_plan_stage_status", + "kind": { + "Enum": [ + "not_started", + "in_progress", + "completed" + ] + } + } + }, + "Timestamptz", + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "43792942081e488f680acdc89a3290f1b46fea2c4f5fc0ea754ab797376b40fa" +} 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-68e1c24a5c81cccbf11f5f2ff58798754bf56630aa5dd80b39a56eb6bcb8d055.json b/services/headless-lms/models/.sqlx/query-68e1c24a5c81cccbf11f5f2ff58798754bf56630aa5dd80b39a56eb6bcb8d055.json new file mode 100644 index 00000000000..e2d488eb493 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-68e1c24a5c81cccbf11f5f2ff58798754bf56630aa5dd80b39a56eb6bcb8d055.json @@ -0,0 +1,108 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT id, created_at, 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 AND stage = $2 AND 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": "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", + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "68e1c24a5c81cccbf11f5f2ff58798754bf56630aa5dd80b39a56eb6bcb8d055" +} 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-7b125a8722a471495e4117e5153bc3806fb100ea5a471f69243923eadd4a538c.json b/services/headless-lms/models/.sqlx/query-7b125a8722a471495e4117e5153bc3806fb100ea5a471f69243923eadd4a538c.json new file mode 100644 index 00000000000..863634f9667 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-7b125a8722a471495e4117e5153bc3806fb100ea5a471f69243923eadd4a538c.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plan_stages\nSET status = $2, actual_completed_at = $3\nWHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_plan_stage_status", + "kind": { + "Enum": [ + "not_started", + "in_progress", + "completed" + ] + } + } + }, + "Timestamptz", + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "7b125a8722a471495e4117e5153bc3806fb100ea5a471f69243923eadd4a538c" +} diff --git a/services/headless-lms/models/.sqlx/query-84e83c10a293ef3252c5a0c5ef3ed19fed04c47c2a27d08584c09f42bc237cd0.json b/services/headless-lms/models/.sqlx/query-84e83c10a293ef3252c5a0c5ef3ed19fed04c47c2a27d08584c09f42bc237cd0.json new file mode 100644 index 00000000000..ff665105347 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-84e83c10a293ef3252c5a0c5ef3ed19fed04c47c2a27d08584c09f42bc237cd0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plan_stages\nSET planned_starts_on = $2, planned_ends_on = $3\nWHERE id = $1 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Date", + "Date" + ] + }, + "nullable": [] + }, + "hash": "84e83c10a293ef3252c5a0c5ef3ed19fed04c47c2a27d08584c09f42bc237cd0" +} diff --git a/services/headless-lms/models/.sqlx/query-9275233ba053dccf57b81e7caf1c1da3597b86b3265b880f7a9517805e28fe43.json b/services/headless-lms/models/.sqlx/query-9275233ba053dccf57b81e7caf1c1da3597b86b3265b880f7a9517805e28fe43.json new file mode 100644 index 00000000000..150fbe03b3b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-9275233ba053dccf57b81e7caf1c1da3597b86b3265b880f7a9517805e28fe43.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT s.id AS \"id?\"\nFROM course_designer_plan_stages s\nJOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id\n AND m.user_id = $2 AND m.deleted_at IS NULL\nWHERE s.id = $3 AND s.course_designer_plan_id = $1 AND s.deleted_at IS NULL\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id?", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9275233ba053dccf57b81e7caf1c1da3597b86b3265b880f7a9517805e28fe43" +} 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-98d2024e476b3d7164a4d0d111116edf60beb1c982c0e8453ae9bf976573f8fa.json b/services/headless-lms/models/.sqlx/query-98d2024e476b3d7164a4d0d111116edf60beb1c982c0e8453ae9bf976573f8fa.json new file mode 100644 index 00000000000..6689963f306 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-98d2024e476b3d7164a4d0d111116edf60beb1c982c0e8453ae9bf976573f8fa.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plans\nSET status = $2, active_stage = NULL\nWHERE id = $1 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_plan_status", + "kind": { + "Enum": [ + "draft", + "scheduling", + "ready_to_start", + "in_progress", + "completed", + "archived" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "98d2024e476b3d7164a4d0d111116edf60beb1c982c0e8453ae9bf976573f8fa" +} 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-a1608b5e88488bef17d4766885c8bceb0796b17c7f2b06c55f3f37d2ee1f6476.json b/services/headless-lms/models/.sqlx/query-a1608b5e88488bef17d4766885c8bceb0796b17c7f2b06c55f3f37d2ee1f6476.json new file mode 100644 index 00000000000..66e58ec217b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-a1608b5e88488bef17d4766885c8bceb0796b17c7f2b06c55f3f37d2ee1f6476.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plan_stage_tasks t\nSET deleted_at = $4\nFROM course_designer_plan_stages s\nJOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id\n AND m.user_id = $2 AND m.deleted_at IS NULL\nWHERE t.course_designer_plan_stage_id = s.id AND s.course_designer_plan_id = $1\n AND t.id = $3 AND t.deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a1608b5e88488bef17d4766885c8bceb0796b17c7f2b06c55f3f37d2ee1f6476" +} 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-a9bb85cad044304914c9a02a0c62026592b75f642db016d66743365ad09d401f.json b/services/headless-lms/models/.sqlx/query-a9bb85cad044304914c9a02a0c62026592b75f642db016d66743365ad09d401f.json new file mode 100644 index 00000000000..15aa31da51f --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-a9bb85cad044304914c9a02a0c62026592b75f642db016d66743365ad09d401f.json @@ -0,0 +1,89 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n t.id,\n t.created_at,\n t.updated_at,\n t.course_designer_plan_stage_id,\n t.title,\n t.description,\n t.order_number,\n t.is_completed,\n t.completed_at,\n t.completed_by_user_id,\n t.is_auto_generated,\n t.created_by_user_id\nFROM course_designer_plan_stage_tasks t\nJOIN course_designer_plan_stages s ON s.id = t.course_designer_plan_stage_id AND s.deleted_at IS NULL\nJOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id\n AND m.user_id = $2\n AND m.deleted_at IS NULL\nWHERE s.course_designer_plan_id = $1\n AND t.deleted_at IS NULL\nORDER BY s.id, t.order_number, t.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": "course_designer_plan_stage_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "order_number", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "is_completed", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "completed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "completed_by_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "is_auto_generated", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_by_user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + true, + true, + false, + true + ] + }, + "hash": "a9bb85cad044304914c9a02a0c62026592b75f642db016d66743365ad09d401f" +} 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/.sqlx/query-f27e0fc53bbc8a048eb2a4e01196f464667d18ff01a63b9fc291b478eb7e875e.json b/services/headless-lms/models/.sqlx/query-f27e0fc53bbc8a048eb2a4e01196f464667d18ff01a63b9fc291b478eb7e875e.json new file mode 100644 index 00000000000..11d7c6d5fe2 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-f27e0fc53bbc8a048eb2a4e01196f464667d18ff01a63b9fc291b478eb7e875e.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE course_designer_plans\nSET active_stage = $2\nWHERE id = $1 AND deleted_at IS NULL\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "course_designer_stage", + "kind": { + "Enum": [ + "analysis", + "design", + "development", + "implementation", + "evaluation" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "f27e0fc53bbc8a048eb2a4e01196f464667d18ff01a63b9fc291b478eb7e875e" +} 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..de7242a67f1 --- /dev/null +++ b/services/headless-lms/models/src/course_designer_plans.rs @@ -0,0 +1,1434 @@ +use crate::prelude::*; +use chrono::{Datelike, Duration, NaiveDate}; +use serde_json::{Value, json}; +use sqlx::Row; + +#[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, FromRow)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlanStageTask { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub course_designer_plan_stage_id: Uuid, + pub title: String, + pub description: Option, + pub order_number: i32, + pub is_completed: bool, + pub completed_at: Option>, + pub completed_by_user_id: Option, + pub is_auto_generated: bool, + pub created_by_user_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CourseDesignerPlanStageWithTasks { + #[serde(flatten)] + pub stage: CourseDesignerPlanStage, + pub tasks: Vec, +} + +#[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 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() { + 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 = 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()); + + 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_tasks_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + user_id: Uuid, +) -> ModelResult> { + let tasks = sqlx::query_as!( + CourseDesignerPlanStageTask, + r#" +SELECT + t.id, + t.created_at, + t.updated_at, + t.course_designer_plan_stage_id, + t.title, + t.description, + t.order_number, + t.is_completed, + t.completed_at, + t.completed_by_user_id, + t.is_auto_generated, + t.created_by_user_id +FROM course_designer_plan_stage_tasks t +JOIN course_designer_plan_stages s ON s.id = t.course_designer_plan_stage_id AND s.deleted_at IS NULL +JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id + AND m.user_id = $2 + AND m.deleted_at IS NULL +WHERE s.course_designer_plan_id = $1 + AND t.deleted_at IS NULL +ORDER BY s.id, t.order_number, t.id +"#, + plan_id, + user_id + ) + .fetch_all(conn) + .await?; + Ok(tasks) +} + +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?; + let tasks = get_plan_tasks_for_user(conn, plan_id, user_id).await?; + let mut tasks_by_stage: std::collections::HashMap> = + tasks + .into_iter() + .fold(std::collections::HashMap::new(), |mut acc, t| { + acc.entry(t.course_designer_plan_stage_id) + .or_default() + .push(t); + acc + }); + let stages_with_tasks: Vec = stages + .into_iter() + .map(|stage| { + let stage_id = stage.id; + let stage_tasks = tasks_by_stage.remove(&stage_id).unwrap_or_default(); + CourseDesignerPlanStageWithTasks { + stage, + tasks: stage_tasks, + } + }) + .collect(); + Ok(CourseDesignerPlanDetails { + plan, + members, + stages: stages_with_tasks, + }) +} + +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?; + + // 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, + 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?; + + tx.commit().await?; + + get_plan_details_for_user(conn, plan_id, user_id).await +} + +pub async fn create_stage_task_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + stage_id: Uuid, + user_id: Uuid, + title: String, + description: Option, +) -> ModelResult { + let title = title.trim(); + if title.is_empty() { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Task title cannot be empty.".to_string(), + None, + )); + } + let stage_ok = sqlx::query_scalar!( + r#" +SELECT s.id AS "id?" +FROM course_designer_plan_stages s +JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id + AND m.user_id = $2 AND m.deleted_at IS NULL +WHERE s.id = $3 AND s.course_designer_plan_id = $1 AND s.deleted_at IS NULL +"#, + plan_id, + user_id, + stage_id + ) + .fetch_optional(&mut *conn) + .await? + .flatten(); + if stage_ok.is_none() { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Stage not found or user is not a plan member.".to_string(), + None, + )); + } + let max_order: Option = sqlx::query_scalar!( + r#" +SELECT MAX(order_number)::INTEGER +FROM course_designer_plan_stage_tasks +WHERE course_designer_plan_stage_id = $1 AND deleted_at IS NULL +"#, + stage_id + ) + .fetch_one(&mut *conn) + .await?; + let order_number = max_order.unwrap_or(0) + 1; + let task: CourseDesignerPlanStageTask = sqlx::query_as!( + CourseDesignerPlanStageTask, + r#" +INSERT INTO course_designer_plan_stage_tasks ( + course_designer_plan_stage_id, + title, + description, + order_number, + is_auto_generated, + created_by_user_id +) +VALUES ($1, $2, $3, $4, FALSE, $5) +RETURNING + id, + created_at, + updated_at, + course_designer_plan_stage_id, + title, + description, + order_number, + is_completed, + completed_at, + completed_by_user_id, + is_auto_generated, + created_by_user_id +"#, + stage_id, + title, + description, + order_number, + user_id + ) + .fetch_one(&mut *conn) + .await?; + Ok(task) +} + +pub async fn update_stage_task_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + task_id: Uuid, + user_id: Uuid, + title: Option, + description: Option, + is_completed: Option, +) -> ModelResult { + let row = sqlx::query( + r#" +SELECT t.title, t.description, t.is_completed +FROM course_designer_plan_stage_tasks t +JOIN course_designer_plan_stages s ON s.id = t.course_designer_plan_stage_id AND s.deleted_at IS NULL +JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id + AND m.user_id = $2 AND m.deleted_at IS NULL +WHERE t.id = $3 AND s.course_designer_plan_id = $1 AND t.deleted_at IS NULL +"#, + ) + .bind(plan_id) + .bind(user_id) + .bind(task_id) + .fetch_optional(&mut *conn) + .await?; + let (current_title, current_description, current_completed) = match row { + Some(r) => ( + r.get::("title"), + r.get::, _>("description"), + r.get::("is_completed"), + ), + None => { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Task not found or user is not a plan member.".to_string(), + None, + )); + } + }; + let new_title = title.map(|t| t.trim().to_string()).unwrap_or(current_title); + if new_title.is_empty() { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Task title cannot be empty.".to_string(), + None, + )); + } + let new_description = description.or(current_description); + let new_completed = is_completed.unwrap_or(current_completed); + let now = Utc::now(); + let completed_at = if new_completed { Some(now) } else { None }; + let completed_by = if new_completed { Some(user_id) } else { None }; + let task: CourseDesignerPlanStageTask = sqlx::query_as!( + CourseDesignerPlanStageTask, + r#" +UPDATE course_designer_plan_stage_tasks t +SET + title = $2, + description = $3, + is_completed = $4, + completed_at = $5, + completed_by_user_id = $6 +FROM course_designer_plan_stages s +JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id + AND m.user_id = $7 AND m.deleted_at IS NULL +WHERE t.course_designer_plan_stage_id = s.id AND s.course_designer_plan_id = $1 + AND t.id = $8 AND t.deleted_at IS NULL +RETURNING + t.id, + t.created_at, + t.updated_at, + t.course_designer_plan_stage_id, + t.title, + t.description, + t.order_number, + t.is_completed, + t.completed_at, + t.completed_by_user_id, + t.is_auto_generated, + t.created_by_user_id +"#, + plan_id, + new_title, + new_description, + new_completed, + completed_at, + completed_by, + user_id, + task_id + ) + .fetch_one(&mut *conn) + .await + .map_err(|e| { + if let sqlx::Error::RowNotFound = e { + ModelError::new( + ModelErrorType::PreconditionFailed, + "Task not found or user is not a plan member.".to_string(), + None, + ) + } else { + e.into() + } + })?; + Ok(task) +} + +pub async fn delete_stage_task_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + task_id: Uuid, + user_id: Uuid, +) -> ModelResult<()> { + let updated = sqlx::query!( + r#" +UPDATE course_designer_plan_stage_tasks t +SET deleted_at = $4 +FROM course_designer_plan_stages s +JOIN course_designer_plan_members m ON m.course_designer_plan_id = s.course_designer_plan_id + AND m.user_id = $2 AND m.deleted_at IS NULL +WHERE t.course_designer_plan_stage_id = s.id AND s.course_designer_plan_id = $1 + AND t.id = $3 AND t.deleted_at IS NULL +"#, + plan_id, + user_id, + task_id, + Utc::now() + ) + .execute(conn) + .await?; + if updated.rows_affected() == 0 { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Task not found or user is not a plan member.".to_string(), + None, + )); + } + Ok(()) +} + +pub async fn start_plan_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 plan.status != CourseDesignerPlanStatus::ReadyToStart { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Plan can only be started when status is ready_to_start.".to_string(), + None, + )); + } + let first_stage = CourseDesignerStage::Analysis; + let now = Utc::now(); + sqlx::query!( + r#" +UPDATE course_designer_plans +SET status = $2, active_stage = $3 +WHERE id = $1 AND deleted_at IS NULL +"#, + plan_id, + CourseDesignerPlanStatus::InProgress as CourseDesignerPlanStatus, + first_stage as CourseDesignerStage + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + r#" +UPDATE course_designer_plan_stages +SET status = $2, actual_started_at = $3 +WHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL +"#, + plan_id, + CourseDesignerPlanStageStatus::InProgress as CourseDesignerPlanStageStatus, + now, + first_stage as CourseDesignerStage + ) + .execute(&mut *tx) + .await?; + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + "plan_started", + Some(first_stage), + json!({}), + ) + .await?; + tx.commit().await?; + get_plan_for_user(conn, plan_id, user_id).await +} + +pub async fn extend_stage_for_user( + conn: &mut PgConnection, + plan_id: Uuid, + stage: CourseDesignerStage, + months: u32, + user_id: Uuid, +) -> ModelResult { + if months == 0 { + return Err(ModelError::new( + ModelErrorType::InvalidRequest, + "Months must be at least 1.".to_string(), + None, + )); + } + 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 plan.status != CourseDesignerPlanStatus::InProgress { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Can only extend a stage when plan is in progress.".to_string(), + None, + )); + } + if plan.active_stage != Some(stage) { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Can only extend the current active stage.".to_string(), + None, + )); + } + let stage_row: CourseDesignerPlanStage = 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 stage = $2 AND deleted_at IS NULL +"#, + plan_id, + stage as CourseDesignerStage + ) + .fetch_one(&mut *tx) + .await?; + let new_ends_on = add_months_clamped(stage_row.planned_ends_on, months)?; + sqlx::query!( + r#" +UPDATE course_designer_plan_stages +SET planned_ends_on = $2 +WHERE id = $1 AND deleted_at IS NULL +"#, + stage_row.id, + new_ends_on + ) + .execute(&mut *tx) + .await?; + let stage_order = fixed_stage_order(); + let current_idx = stage_order + .iter() + .position(|s| *s == stage) + .ok_or_else(|| { + ModelError::new(ModelErrorType::Generic, "Invalid stage.".to_string(), None) + })?; + let all_stages: Vec = 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 +"#, + plan_id + ) + .fetch_all(&mut *tx) + .await?; + let later_stage_rows: Vec<_> = all_stages + .into_iter() + .enumerate() + .filter(|(i, _)| *i > current_idx) + .map(|(_, s)| (s.id, s.planned_starts_on, s.planned_ends_on)) + .collect(); + let mut prev_end = new_ends_on; + for (st_id, old_start, old_end) in later_stage_rows { + let new_start = prev_end + Duration::days(1); + let duration_days = (old_end - old_start).num_days(); + let new_ends = new_start + Duration::days(duration_days); + sqlx::query!( + r#" +UPDATE course_designer_plan_stages +SET planned_starts_on = $2, planned_ends_on = $3 +WHERE id = $1 AND deleted_at IS NULL +"#, + st_id, + new_start, + new_ends + ) + .execute(&mut *tx) + .await?; + prev_end = new_ends; + } + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + "stage_extended", + Some(stage), + json!({ "stage_id": stage_row.id, "months": months, "new_ends_on": new_ends_on }), + ) + .await?; + tx.commit().await?; + get_plan_details_for_user(conn, plan_id, user_id).await +} + +pub async fn advance_to_next_stage_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 plan.status != CourseDesignerPlanStatus::InProgress { + return Err(ModelError::new( + ModelErrorType::PreconditionFailed, + "Plan must be in progress to advance.".to_string(), + None, + )); + } + let current_stage = plan.active_stage.ok_or_else(|| { + ModelError::new( + ModelErrorType::PreconditionFailed, + "Plan has no active stage.".to_string(), + None, + ) + })?; + let now = Utc::now(); + sqlx::query!( + r#" +UPDATE course_designer_plan_stages +SET status = $2, actual_completed_at = $3 +WHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL +"#, + plan_id, + CourseDesignerPlanStageStatus::Completed as CourseDesignerPlanStageStatus, + now, + current_stage as CourseDesignerStage + ) + .execute(&mut *tx) + .await?; + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + "stage_completed", + Some(current_stage), + json!({}), + ) + .await?; + let stage_order = fixed_stage_order(); + let current_idx = stage_order + .iter() + .position(|s| *s == current_stage) + .ok_or_else(|| { + ModelError::new(ModelErrorType::Generic, "Invalid stage.".to_string(), None) + })?; + let next_stage = if current_idx + 1 < stage_order.len() { + Some(stage_order[current_idx + 1]) + } else { + None + }; + match next_stage { + Some(next) => { + sqlx::query!( + r#" +UPDATE course_designer_plans +SET active_stage = $2 +WHERE id = $1 AND deleted_at IS NULL +"#, + plan_id, + next as CourseDesignerStage + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + r#" +UPDATE course_designer_plan_stages +SET status = $2, actual_started_at = $3 +WHERE course_designer_plan_id = $1 AND stage = $4 AND deleted_at IS NULL +"#, + plan_id, + CourseDesignerPlanStageStatus::InProgress as CourseDesignerPlanStageStatus, + now, + next as CourseDesignerStage + ) + .execute(&mut *tx) + .await?; + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + "stage_started", + Some(next), + json!({}), + ) + .await?; + } + None => { + sqlx::query!( + r#" +UPDATE course_designer_plans +SET status = $2, active_stage = NULL +WHERE id = $1 AND deleted_at IS NULL +"#, + plan_id, + CourseDesignerPlanStatus::Completed as CourseDesignerPlanStatus + ) + .execute(&mut *tx) + .await?; + insert_plan_event( + &mut tx, + plan_id, + Some(user_id), + "plan_completed", + None, + json!({}), + ) + .await?; + } + } + tx.commit().await?; + get_plan_details_for_user(conn, plan_id, user_id).await +} + +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..aac3fed5752 --- /dev/null +++ b/services/headless-lms/server/src/controllers/main_frontend/course_designer.rs @@ -0,0 +1,323 @@ +/*! +Handlers for HTTP requests to `/api/v0/main-frontend/course-plans`. +*/ + +use actix_web::HttpResponse; +use chrono::NaiveDate; +use models::course_designer_plans::{ + CourseDesignerCourseSize, CourseDesignerPlan, CourseDesignerPlanDetails, + CourseDesignerPlanStageTask, CourseDesignerPlanSummary, CourseDesignerScheduleStageInput, + CourseDesignerStage, +}; + +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, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct CreateCourseDesignerStageTaskRequest { + pub title: String, + pub description: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct UpdateCourseDesignerStageTaskRequest { + pub title: Option, + pub description: Option, + pub is_completed: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct ExtendStageRequest { + pub months: u32, +} + +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 = models::course_designer_plans::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 = models::course_designer_plans::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 = + 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)) +} + +#[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. + 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, + })) +} + +#[instrument(skip(pool))] +async fn put_schedule( + plan_id: web::Path, + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + models::course_designer_plans::validate_schedule_input(&payload.stages)?; + let mut conn = pool.acquire().await?; + let details = models::course_designer_plans::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 = + 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)) +} + +#[instrument(skip(pool))] +async fn post_stage_task( + path: web::Path<(Uuid, Uuid)>, + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let (plan_id, stage_id) = path.into_inner(); + let mut conn = pool.acquire().await?; + let task = models::course_designer_plans::create_stage_task_for_user( + &mut conn, + plan_id, + stage_id, + user.id, + payload.title.clone(), + payload.description.clone(), + ) + .await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(task)) +} + +#[instrument(skip(pool))] +async fn patch_task( + path: web::Path<(Uuid, Uuid)>, + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let (plan_id, task_id) = path.into_inner(); + let mut conn = pool.acquire().await?; + let task = models::course_designer_plans::update_stage_task_for_user( + &mut conn, + plan_id, + task_id, + user.id, + payload.title.clone(), + payload.description.clone(), + payload.is_completed, + ) + .await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(task)) +} + +#[instrument(skip(pool))] +async fn delete_task( + path: web::Path<(Uuid, Uuid)>, + pool: web::Data, + user: AuthUser, +) -> ControllerResult { + let (plan_id, task_id) = path.into_inner(); + let mut conn = pool.acquire().await?; + models::course_designer_plans::delete_stage_task_for_user(&mut conn, plan_id, task_id, user.id) + .await?; + let token = skip_authorize(); + token.authorized_ok(HttpResponse::NoContent().finish()) +} + +#[instrument(skip(pool))] +async fn post_start_plan( + plan_id: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let plan = + models::course_designer_plans::start_plan_for_user(&mut conn, *plan_id, user.id).await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(plan)) +} + +fn parse_stage(path_stage: &str) -> Option { + match path_stage.to_lowercase().as_str() { + "analysis" => Some(CourseDesignerStage::Analysis), + "design" => Some(CourseDesignerStage::Design), + "development" => Some(CourseDesignerStage::Development), + "implementation" => Some(CourseDesignerStage::Implementation), + "evaluation" => Some(CourseDesignerStage::Evaluation), + _ => None, + } +} + +#[instrument(skip(pool))] +async fn post_extend_stage( + path: web::Path<(Uuid, String)>, + payload: web::Json, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let (plan_id, stage_str) = path.into_inner(); + let stage = parse_stage(&stage_str).ok_or_else(|| { + ControllerError::new( + ControllerErrorType::BadRequest, + "Invalid stage name.".to_string(), + None, + ) + })?; + let mut conn = pool.acquire().await?; + let details = models::course_designer_plans::extend_stage_for_user( + &mut conn, + plan_id, + stage, + payload.months, + user.id, + ) + .await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(details)) +} + +#[instrument(skip(pool))] +async fn post_advance_stage( + plan_id: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let details = + models::course_designer_plans::advance_to_next_stage_for_user(&mut conn, *plan_id, user.id) + .await?; + let token = skip_authorize(); + token.authorized_ok(web::Json(details)) +} + +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), + ) + .route("/{plan_id}/start", web::post().to(post_start_plan)) + .route( + "/{plan_id}/stages/advance", + web::post().to(post_advance_stage), + ) + .route( + "/{plan_id}/stages/{stage}/extend", + web::post().to(post_extend_stage), + ) + .route( + "/{plan_id}/stages/{stage_id}/tasks", + web::post().to(post_stage_task), + ) + .route("/{plan_id}/tasks/{task_id}", web::patch().to(patch_task)) + .route("/{plan_id}/tasks/{task_id}", web::delete().to(delete_task)); +} 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..c198c3782a1 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -80,6 +80,18 @@ 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::CourseDesignerPlanStageTask, + course_designer_plans::CourseDesignerPlanStageWithTasks, + 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 +376,13 @@ fn controllers(target: &mut File) { chatbot_models::CourseInfo, certificates::CertificateConfigurationUpdate, + course_designer::CourseDesignerScheduleSuggestionRequest, + course_designer::CourseDesignerScheduleSuggestionResponse, + course_designer::CreateCourseDesignerPlanRequest, + course_designer::CreateCourseDesignerStageTaskRequest, + course_designer::ExtendStageRequest, + course_designer::SaveCourseDesignerScheduleRequest, + course_designer::UpdateCourseDesignerStageTaskRequest, courses::GetFeedbackQuery, courses::CopyCourseRequest, courses::CopyCourseMode, 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]/page.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/page.tsx new file mode 100644 index 00000000000..20fafa8a73e --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/page.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { useParams, useRouter } from "next/navigation" +import { useEffect } from "react" + +import { coursePlanQueryKeys } from "../coursePlanQueryKeys" + +import { getCourseDesignerPlan } from "@/services/backend/courseDesigner" +import Spinner from "@/shared-module/common/components/Spinner" +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import { + manageCoursePlanScheduleRoute, + manageCoursePlanWorkspaceRoute, +} from "@/shared-module/common/utils/routes" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +function CoursePlanHubRedirect() { + const params = useParams<{ id: string }>() + const router = useRouter() + const planId = params.id + + const planQuery = useQuery({ + queryKey: coursePlanQueryKeys.detail(planId), + queryFn: () => getCourseDesignerPlan(planId), + enabled: !!planId, + }) + + useEffect(() => { + if (!planQuery.data) { + return + } + const { plan } = planQuery.data + if (plan.status === "Draft" || plan.status === "Scheduling") { + router.replace(manageCoursePlanScheduleRoute(planId)) + return + } + if ( + plan.status === "ReadyToStart" || + plan.status === "InProgress" || + plan.status === "Completed" + ) { + router.replace(manageCoursePlanWorkspaceRoute(planId)) + } + }, [planQuery.data, planId, router]) + + if (planQuery.isLoading || planQuery.isError) { + return + } + return +} + +export default withErrorBoundary(withSignedIn(CoursePlanHubRedirect)) 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..993f4ba659f --- /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, parseISO, 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(parseISO(stage.planned_starts_on)) + const end = endOfMonth(parseISO(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/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..77d28571fbd --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/ScheduleWizardPage.tsx @@ -0,0 +1,210 @@ +"use client" + +import { css, cx } from "@emotion/css" +import { AnimatePresence, motion, useReducedMotion } from "motion/react" +import { useParams, useRouter } 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" +import { manageCoursePlanWorkspaceRoute } from "@/shared-module/common/utils/routes" + +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 router = useRouter() + 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={async () => { + const ok = await controller.actions.finalizeDraft() + if (ok) { + router.push(manageCoursePlanWorkspaceRoute(planId)) + } + }} + /> + )} + + + {/* 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..2f3746888f5 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/StageCard.tsx @@ -0,0 +1,217 @@ +"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 { CourseDesignerStage } from "@/services/backend/courseDesigner" +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 stageDescriptionListStyles = css` + margin: 0; + padding-left: 1.1rem; + font-size: 0.88rem; + color: ${baseTheme.colors.gray[500]}; +` + +const stageDescriptionItemStyles = css` + margin: 0.1rem 0; +` + +const stageCardActionsStyles = css` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.2rem; +` + +interface StageCardProps { + stage: CourseDesignerStage + title: string + months: Array + canShrink: boolean + reduceMotion: boolean + isPulsing: boolean + onPulseComplete: () => void + onAddMonth: () => void + onRemoveMonth: () => void + testId?: string +} + +export default function StageCard({ + stage, + title, + months, + canShrink, + reduceMotion, + isPulsing, + onPulseComplete, + onAddMonth, + onRemoveMonth, + testId, +}: StageCardProps) { + const { t } = useTranslation() + + const descriptionLines = + stage === "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"), + ] + : stage === "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"), + ] + : stage === "Development" + ? [ + t("course-plans-stage-description-development-1"), + t("course-plans-stage-description-development-2"), + ] + : stage === "Implementation" + ? [ + t("course-plans-stage-description-implementation-1"), + t("course-plans-stage-description-implementation-2"), + t("course-plans-stage-description-implementation-3"), + ] + : stage === "Evaluation" + ? [ + t("course-plans-stage-description-evaluation-1"), + t("course-plans-stage-description-evaluation-2"), + ] + : [] + + 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}

+ {descriptionLines.length > 0 ? ( +
    + {descriptionLines.map((line) => ( +
  • + {line} +
  • + ))} +
+ ) : ( +

+ {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..8e55ccbd0a0 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/components/steps/ScheduleEditorStep.tsx @@ -0,0 +1,173 @@ +"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 new file mode 100644 index 00000000000..8314992faff --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/page.tsx @@ -0,0 +1,8 @@ +"use client" + +import ScheduleWizardPage from "./components/ScheduleWizardPage" + +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +export default withErrorBoundary(withSignedIn(ScheduleWizardPage)) 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 new file mode 100644 index 00000000000..3eed54a480a --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleAtoms.ts @@ -0,0 +1,36 @@ +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" + +import { addMonthToStage, removeMonthFromStage } from "./scheduleStageTransforms" + +import { CourseDesignerScheduleStageInput } from "@/services/backend/courseDesigner" + +export const draftStagesAtomFamily = atomFamily((_planId: string) => + 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) + } + }), +) + +export type ScheduleWizardStep = 0 | 1 | 2 + +export const scheduleWizardStepAtomFamily = atomFamily((_planId: string) => + atom(0), +) 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 new file mode 100644 index 00000000000..d177bcb227a --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/schedule/scheduleStageTransforms.ts @@ -0,0 +1,171 @@ +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[] = SCHEDULE_STAGE_ORDER + +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 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 = 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 = 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 + }) + + if (!owningStage) { + return null + } + + months.push({ date: current, stage: owningStage }) + current = addMonths(current, 1) + } + + return months +} + +const toStageRanges = (months: MonthWithStage[]): StageInput[] | null => { + const result: StageInput[] = [] + + for (const stage of STAGE_ORDER) { + const stageMonths = months.filter((m) => m.stage === stage) + if (stageMonths.length === 0) { + return null + } + 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 + 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) { + return null + } + 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 + 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) { + return null + } + newMonths.push({ date: source.date, stage }) + cursor += 1 + } + } + + return toStageRanges(newMonths) +} 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/[id]/workspace/components/CompactPhaseStatusWidget.tsx b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CompactPhaseStatusWidget.tsx new file mode 100644 index 00000000000..4f9a2e842b0 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CompactPhaseStatusWidget.tsx @@ -0,0 +1,102 @@ +"use client" + +import { css } from "@emotion/css" +import { useTranslation } from "react-i18next" + +import { baseTheme } from "@/shared-module/common/styles" + +const widgetCardStyles = css` + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + padding: 0.6rem 0.9rem; + border-radius: 10px; + background: white; + border: 1px solid ${baseTheme.colors.gray[200]}; + box-shadow: none; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease; + text-align: left; + + :hover { + border-color: ${baseTheme.colors.gray[300]}; + } +` + +const phaseNameStyles = css` + font-size: 0.95rem; + font-weight: 600; + color: ${baseTheme.colors.gray[900]}; + margin: 0; +` + +const statusLineStyles = css` + font-size: 0.85rem; + color: ${baseTheme.colors.gray[600]}; + margin: 0; +` + +const statusLineUrgentStyles = css` + color: ${baseTheme.colors.crimson[600]}; +` + +const tasksLineStyles = css` + font-size: 0.8rem; + color: ${baseTheme.colors.gray[500]}; + margin: 0; +` + +const viewPlanLinkStyles = css` + font-size: 0.85rem; + font-weight: 500; + color: ${baseTheme.colors.green[700]}; + margin-top: 0.25rem; + text-decoration: none; + + :hover { + text-decoration: underline; + } +` + +export interface CompactPhaseStatusWidgetProps { + phaseName: string + statusTimeLine: string + tasksRemainingCount: number + isUrgent: boolean + onClick: () => void +} + +/** Compact status summary: phase name, status + time, tasks remaining, View plan link. */ +export default function CompactPhaseStatusWidget({ + phaseName, + statusTimeLine, + tasksRemainingCount, + isUrgent, + onClick, +}: CompactPhaseStatusWidgetProps) { + const { t } = useTranslation() + + const tasksText = + tasksRemainingCount === 1 + ? t("course-plans-task-remaining", { count: 1 }) + : t("course-plans-tasks-remaining", { count: tasksRemainingCount }) + + return ( + + ) +} 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 new file mode 100644 index 00000000000..e381110b134 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/CoursePlanWorkspacePage.tsx @@ -0,0 +1,626 @@ +"use client" + +import { css } from "@emotion/css" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useParams } from "next/navigation" +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 { + advanceCourseDesignerStage, + type CourseDesignerPlanStageWithTasks, + type CourseDesignerStage, + extendCourseDesignerStage, + getCourseDesignerPlan, + 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" +import { baseTheme } from "@/shared-module/common/styles" +import { respondToOrLarger } from "@/shared-module/common/styles/respond" + +const pageRootStyles = css` + padding: 2rem 0 3rem 0; + min-height: 100vh; +` + +const workspaceShellStyles = css` + width: 100%; + margin: 0 auto; + padding: 0 1.25rem 3rem; + + ${respondToOrLarger.md} { + padding: 0 1.75rem 3rem; + } + + ${respondToOrLarger.lg} { + padding: 0 2.25rem 3rem; + } + + ${respondToOrLarger.xl} { + padding: 0 2.75rem 3rem; + } + + ${respondToOrLarger.xxxl} { + padding: 0 3rem 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[900]}; + margin: 0 0 0.25rem 0; +` + +const metadataRowStyles = css` + font-size: 0.9rem; + color: ${baseTheme.colors.gray[500]}; + margin: 0; +` + +const workspaceGridStyles = css` + display: grid; + 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} { + 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; + padding: 1.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + 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; + 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]}; + line-height: 1.55; + margin: 0 0 0.75rem 0; +` + +const keyGoalsHeadingStyles = css` + font-size: 0.9rem; + font-weight: 600; + color: ${baseTheme.colors.gray[700]}; + margin: 0 0 0.35rem 0; +` + +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; +` + +function daysBetween(from: string, to: string): number { + const a = new Date(from) + const b = new Date(to) + const diff = b.getTime() - a.getTime() + return Math.ceil(diff / (1000 * 60 * 60 * 24)) +} + +export default function CoursePlanWorkspacePage() { + 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), + queryFn: () => getCourseDesignerPlan(planId), + enabled: !!planId, + }) + + const startMutation = useToastMutation( + () => startCourseDesignerPlan(planId), + { notify: true, method: "POST" }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.detail(planId) }) + }, + }, + ) + + const extendMutation = useToastMutation( + (params: { stage: CourseDesignerStage; months: number }) => + extendCourseDesignerStage(planId, params.stage, params.months), + { notify: true, method: "POST" }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.detail(planId) }) + }, + }, + ) + + const advanceMutation = useToastMutation( + () => advanceCourseDesignerStage(planId), + { notify: true, method: "POST" }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: coursePlanQueryKeys.detail(planId) }) + }, + }, + ) + + const stageLabel = useCallback( + (stage: CourseDesignerStage) => { + // eslint-disable-next-line i18next/no-literal-string + const key = `course-plans-stage-${stage.toLowerCase()}` + return t( + key as + | "course-plans-stage-analysis" + | "course-plans-stage-design" + | "course-plans-stage-development" + | "course-plans-stage-implementation" + | "course-plans-stage-evaluation", + ) + }, + [t], + ) + + if (planQuery.isError) { + return ( +
+
+ +
+
+ ) + } + + if (planQuery.isLoading || !planQuery.data) { + return ( +
+
+ +
+
+ ) + } + + const { plan, stages } = planQuery.data + + if (plan.status === "ReadyToStart" && !plan.active_stage) { + return ( +
+
+

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

+
+

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

+ +
+
+
+ ) + } + + 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 + 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) { + 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" + + 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 ( + +
    + 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. +

    +
    +
    +
    +
    +
    + ) +} 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..b99d53e240d --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/PlanOverviewPanel.tsx @@ -0,0 +1,574 @@ +"use client" + +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" + +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; +` + +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: (months: number) => 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 [isAdjustDialogOpen, setIsAdjustDialogOpen] = useState(false) + const [extendMonths, setExtendMonths] = useState(1) + + /** 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 + } + + 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 ? ( + <> +
    + +
    + {canAdjustSchedule && ( +
    + +
    + )} + + ) : ( + {t("course-plans-overview-no-actions")} + )} +
    +
    +
    + + {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, + })} +

    + )} + +
    + + +
    +
    +
    + )} +
    + ) +} + +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 new file mode 100644 index 00000000000..9af21e0e548 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/[id]/workspace/components/WorkspaceStageSection.tsx @@ -0,0 +1,427 @@ +"use client" + +import { css, cx } from "@emotion/css" +import { Trash } from "@vectopus/atlas-icons-react" +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 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 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; + 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` + 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 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]}; +` + +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 formatPhaseTimeline(startsOn: string, endsOn: string): string { + const s = new Date(startsOn) + const e = new Date(endsOn) + // eslint-disable-next-line i18next/no-literal-string + 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 { + planId: string + stage: CourseDesignerPlanStageWithTasks + stageLabel: string + isActive: boolean + onInvalidate: () => void + showStageTitle?: boolean +} + +export default function WorkspaceStageSection({ + planId, + stage, + stageLabel, + isActive, + onInvalidate, + showStageTitle = true, +}: 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 ( +
    + {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 + }%`, + }), + )} + /> +
    +
    +
    + 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} + /> + )) + )} +
    +
    +

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

    +
      +
    • {t("course-plans-phase-criteria-generic")}
    • +
    +
    +
    + ) +} + +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} + className={taskRowCheckboxWrapperStyles} + /> + + {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 new file mode 100644 index 00000000000..08e860c14a9 --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/components/CoursePlanCard.tsx @@ -0,0 +1,98 @@ +"use client" + +import { css } from "@emotion/css" +import { useRouter } from "next/navigation" +import { useTranslation } from "react-i18next" + +import { CourseDesignerPlanSummary } from "@/services/backend/courseDesigner" +import { manageCoursePlanRoute } from "@/shared-module/common/utils/routes" + +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() + 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 ( + + ) +} 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..af1b321b32a --- /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 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" +import { manageCoursePlanRoute } from "@/shared-module/common/utils/routes" + +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(manageCoursePlanRoute(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/page.tsx b/services/main-frontend/src/app/manage/course-plans/page.tsx new file mode 100644 index 00000000000..939b7663bec --- /dev/null +++ b/services/main-frontend/src/app/manage/course-plans/page.tsx @@ -0,0 +1,8 @@ +"use client" + +import CoursePlansListPage from "./components/CoursePlansListPage" + +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +export default withErrorBoundary(withSignedIn(CoursePlansListPage)) 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..722cb9a3cba --- /dev/null +++ b/services/main-frontend/src/services/backend/courseDesigner.ts @@ -0,0 +1,214 @@ +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 CourseDesignerPlanStageTask { + id: string + created_at: string + updated_at: string + course_designer_plan_stage_id: string + title: string + description: string | null + order_number: number + is_completed: boolean + completed_at: string | null + completed_by_user_id: string | null + is_auto_generated: boolean + created_by_user_id: string | null +} + +export type CourseDesignerPlanStageWithTasks = CourseDesignerPlanStage & { + tasks: Array +} + +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 +} + +export const startCourseDesignerPlan = async (planId: string): Promise => { + const response = await mainFrontendClient.post(`/course-plans/${planId}/start`) + return response.data as CourseDesignerPlan +} + +export const extendCourseDesignerStage = async ( + planId: string, + stage: CourseDesignerStage, + months: number, +): Promise => { + const stagePath = stage.toLowerCase() + const response = await mainFrontendClient.post( + `/course-plans/${planId}/stages/${stagePath}/extend`, + { months }, + ) + return response.data as CourseDesignerPlanDetails +} + +export const advanceCourseDesignerStage = async ( + planId: string, +): Promise => { + const response = await mainFrontendClient.post(`/course-plans/${planId}/stages/advance`) + return response.data as CourseDesignerPlanDetails +} + +export interface CreateCourseDesignerStageTaskRequest { + title: string + description?: string | null +} + +export const createCourseDesignerStageTask = async ( + planId: string, + stageId: string, + payload: CreateCourseDesignerStageTaskRequest, +): Promise => { + const response = await mainFrontendClient.post( + `/course-plans/${planId}/stages/${stageId}/tasks`, + payload, + ) + return response.data as CourseDesignerPlanStageTask +} + +export interface UpdateCourseDesignerStageTaskRequest { + title?: string | null + description?: string | null + is_completed?: boolean +} + +export const updateCourseDesignerStageTask = async ( + planId: string, + taskId: string, + payload: UpdateCourseDesignerStageTaskRequest, +): Promise => { + const response = await mainFrontendClient.patch( + `/course-plans/${planId}/tasks/${taskId}`, + payload, + ) + return response.data as CourseDesignerPlanStageTask +} + +export const deleteCourseDesignerStageTask = async ( + planId: string, + taskId: string, +): Promise => { + await mainFrontendClient.delete(`/course-plans/${planId}/tasks/${taskId}`) +} diff --git a/shared-module/packages/common/src/bindings.guard.ts b/shared-module/packages/common/src/bindings.guard.ts index dcd38040677..1d2985b0840 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, CourseDesignerPlanStageTask, CourseDesignerPlanStageWithTasks, 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, CreateCourseDesignerStageTaskRequest, ExtendStageRequest, SaveCourseDesignerScheduleRequest, UpdateCourseDesignerStageTaskRequest, 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,209 @@ 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) => + isCourseDesignerPlanStageWithTasks(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 isCourseDesignerPlanStageTask(obj: unknown): obj is CourseDesignerPlanStageTask { + const typedObj = obj as CourseDesignerPlanStageTask + 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["course_designer_plan_stage_id"] === "string" && + typeof typedObj["title"] === "string" && + (typedObj["description"] === null || + typeof typedObj["description"] === "string") && + typeof typedObj["order_number"] === "number" && + typeof typedObj["is_completed"] === "boolean" && + (typedObj["completed_at"] === null || + typeof typedObj["completed_at"] === "string") && + (typedObj["completed_by_user_id"] === null || + typeof typedObj["completed_by_user_id"] === "string") && + typeof typedObj["is_auto_generated"] === "boolean" && + (typedObj["created_by_user_id"] === null || + typeof typedObj["created_by_user_id"] === "string") + ) +} + +export function isCourseDesignerPlanStageWithTasks(obj: unknown): obj is CourseDesignerPlanStageWithTasks { + const typedObj = obj as CourseDesignerPlanStageWithTasks + 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") && + Array.isArray(typedObj["tasks"]) && + typedObj["tasks"].every((e: any) => + isCourseDesignerPlanStageTask(e) as boolean + ) + ) +} + +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 +2389,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 +5339,94 @@ 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 isCreateCourseDesignerStageTaskRequest(obj: unknown): obj is CreateCourseDesignerStageTaskRequest { + const typedObj = obj as CreateCourseDesignerStageTaskRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["title"] === "string" && + (typedObj["description"] === null || + typeof typedObj["description"] === "string") + ) +} + +export function isExtendStageRequest(obj: unknown): obj is ExtendStageRequest { + const typedObj = obj as ExtendStageRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + typeof typedObj["months"] === "number" + ) +} + +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 isUpdateCourseDesignerStageTaskRequest(obj: unknown): obj is UpdateCourseDesignerStageTaskRequest { + const typedObj = obj as UpdateCourseDesignerStageTaskRequest + return ( + (typedObj !== null && + typeof typedObj === "object" || + typeof typedObj === "function") && + (typedObj["title"] === null || + typeof typedObj["title"] === "string") && + (typedObj["description"] === null || + typeof typedObj["description"] === "string") && + (typedObj["is_completed"] === null || + typedObj["is_completed"] === false || + typedObj["is_completed"] === true) + ) +} + 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..fa694924505 100644 --- a/shared-module/packages/common/src/bindings.ts +++ b/shared-module/packages/common/src/bindings.ts @@ -431,6 +431,108 @@ 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 interface CourseDesignerPlanStageTask { + id: string + created_at: string + updated_at: string + course_designer_plan_stage_id: string + title: string + description: string | null + order_number: number + is_completed: boolean + completed_at: string | null + completed_by_user_id: string | null + is_auto_generated: boolean + created_by_user_id: string | null +} + +export interface CourseDesignerPlanStageWithTasks { + 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 + tasks: Array +} + +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 +2624,39 @@ 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 CreateCourseDesignerStageTaskRequest { + title: string + description: string | null +} + +export interface ExtendStageRequest { + months: number +} + +export interface SaveCourseDesignerScheduleRequest { + name: string | null + stages: Array +} + +export interface UpdateCourseDesignerStageTaskRequest { + title: string | null + description: string | null + is_completed: boolean | null +} + export interface GetFeedbackQuery { read: boolean page: number | undefined 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 866f0b0acaa..6950a3a0693 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,143 @@ "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", + "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-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", + "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-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", + "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-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", + "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.", + "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-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", + "course-plans-stage-evaluation": "Evaluation", + "course-plans-stage-implementation": "Implementation", + "course-plans-start-date-column": "Start date", + "course-plans-start-next-phase-early": "Start next phase early", + "course-plans-start-plan": "Start plan", + "course-plans-starts-on-label": "Starts on", + "course-plans-status-archived": "Archived", + "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", + "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", "course-status-summary": "Course status summary", 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..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,6 +284,122 @@ "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", + "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": "Підсумок про стан курсу", diff --git a/shared-module/packages/common/src/utils/routes.ts b/shared-module/packages/common/src/utils/routes.ts index 5ef081f1f3c..672e4e7ac60 100644 --- a/shared-module/packages/common/src/utils/routes.ts +++ b/shared-module/packages/common/src/utils/routes.ts @@ -261,6 +261,18 @@ export function manageExamQuestionsRoute(examId: string) { return `/manage/exams/${examId}/questions` } +export function manageCoursePlanRoute(planId: string) { + return `/manage/course-plans/${planId}` +} + +export function manageCoursePlanScheduleRoute(planId: string) { + return `/manage/course-plans/${planId}/schedule` +} + +export function manageCoursePlanWorkspaceRoute(planId: string) { + return `/manage/course-plans/${planId}/workspace` +} + export function testExamRoute(organizationSlug: string, examId: string) { return `/org/${organizationSlug}/exams/testexam/${examId}` }