feat(cohorts): rename Programs to Cohorts with data migration (#694)
* feat(cohorts): rename Programs to Cohorts with data migration - Add cohort database tables and Drizzle migration alongside legacy program tables - Introduce migrate-programs-to-cohorts script to copy existing program data safely - Rename API routes (/cohort), services, queries, validation, and error codes - Rename dashboard feature module, routes (/cohorts), navigation, and translations - Update docs, emails, and internal compliance cron endpoints for cohort terminology - Keep legacy program tables in place for rollback until explicitly removed Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
+14
-14
@@ -19,7 +19,7 @@
|
||||
**What it is.** ClassroomIO is an open‑source **Learning Management System (LMS) for
|
||||
companies** — compliance/employee training, customer education, and partner
|
||||
certification. It supports multi‑organization workspaces, courses/lessons/exercises,
|
||||
programs (cohorts), certificates, an AI course builder + in‑lesson tutor, a public
|
||||
cohorts (cohorts), certificates, an AI course builder + in‑lesson tutor, a public
|
||||
REST API, an MCP server, and embeddable course widgets. It can be **self‑hosted** or
|
||||
run as the **cloud** product. (`README.md` → lines 11–50 — feature summary;
|
||||
`README.md` → 84–91 — monorepo apps.)
|
||||
@@ -83,9 +83,9 @@ gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no
|
||||
| 11 | Org invites | Invite org members (EMAIL/LINK) | `apps/api/src/routes/invite/invite.ts`, `schema.ts:1384` | Complete |
|
||||
| 12 | Compliance training | Deadlines, renewals, grace, records | `apps/api/src/routes/course/compliance.ts`, `apps/api/src/routes/internal`, `schema.ts:768` (`courseCompletionRecord`) | Complete |
|
||||
| 13 | Certificates | Issue branded certificates | `packages/certificates/src/render.ts`, `schema.ts:850` (`courseCertificateIssue`) | Complete |
|
||||
| 14 | Programs (cohorts) | Group courses, goals, members, newsfeed | `apps/api/src/routes/program/program.ts`, `schema.ts:2646` | Complete |
|
||||
| 14 | Cohorts | Group courses, goals, members, newsfeed | `apps/api/src/routes/cohort/cohort.ts`, `schema.ts` cohort tables | Complete |
|
||||
| 15 | Community Q&A | Per‑course questions/answers | `apps/api/src/routes/community/community.ts`, `schema.ts:1558` | Complete |
|
||||
| 16 | Course newsfeed | Course/program announcements + comments | `apps/api/src/routes/course/newsfeed.ts`, `schema.ts:1605` | Complete |
|
||||
| 16 | Course newsfeed | Course/cohort announcements + comments | `apps/api/src/routes/course/newsfeed.ts`, `schema.ts:1605` | Complete |
|
||||
| 17 | Attendance | Track group/live‑class attendance | `apps/api/src/routes/course/attendance.ts`, `schema.ts:321` (`groupAttendance`) | Complete |
|
||||
| 18 | AI Course Assistant | In‑course AI authoring/plan/edit chat | `apps/api/src/routes/agent/*`, `packages/ai-assistant`, `schema.ts:3163` (`aiAgentRun`) | Complete (key‑gated) |
|
||||
| 19 | AI Lesson Tutor | In‑lesson learner AI helper + fair‑use caps | `apps/api/src/routes/course/ai-tutor.ts`, `apps/api/src/routes/organization/ai-tutor.ts`, `schema.ts:3013` (`aiTutorMessageCount`) | Complete (key‑gated) |
|
||||
@@ -237,16 +237,16 @@ gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no
|
||||
- **User flow:** Learner completes course → certificate issued → downloadable/branded.
|
||||
- **Edge cases:** custom certificate IDs (README 21); template selection.
|
||||
|
||||
### 3.11 Programs (Cohorts) & Goals
|
||||
### 3.11 Cohorts & Goals
|
||||
- **Purpose:** group courses into cohorts with goals, members, and progress tracking.
|
||||
- **How it works:** `apps/api/src/routes/program/program.ts`; tables `program`
|
||||
- **How it works:** `apps/api/src/routes/cohort/cohort.ts`; tables `cohort`
|
||||
(`schema.ts:2646`), `programCourse` (`2677`), `programMember` (`2704`), `programNewsfeed`
|
||||
+`Comment` (`2739`,`2767`), `programGoal`/`…Assignment` (`2829`,`2878`) with goal‑type/
|
||||
deadline/status enums (`2803–2828`), status enum `ACTIVE|INACTIVE|ARCHIVED` (`2644`).
|
||||
Middlewares `program-member.ts`, `program-team-member.ts`. PRD `programs [DONE]`.
|
||||
- **User flow:** Create program → add courses + members → set goals/deadlines → track
|
||||
progress → cohort newsfeed. `apps/dashboard/src/routes/(app)/programs/[id]`.
|
||||
- **Edge cases:** goal deadline kinds; assignment status; archived programs.
|
||||
Middlewares `cohort-member.ts`, `cohort-team-member.ts`. PRD `cohorts [DONE]`.
|
||||
- **User flow:** Create cohort → add courses + members → set goals/deadlines → track
|
||||
progress → cohort newsfeed. `apps/dashboard/src/routes/(app)/cohorts/[id]`.
|
||||
- **Edge cases:** goal deadline kinds; assignment status; archived cohorts.
|
||||
|
||||
### 3.12 Community Q&A & Newsfeed
|
||||
- **Purpose:** per‑course discussion (questions/answers) and announcements.
|
||||
@@ -254,9 +254,9 @@ gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no
|
||||
`middlewares/question-author-or-team` (`ARCHITECHTURE.md:241–252`); tables
|
||||
`communityQuestion`/`communityAnswer` (`schema.ts:1558`,`1522`), `courseNewsfeed`/
|
||||
`…Comment` (`1605`,`1632`). Comment‑author middlewares `newsfeed-comment-author-or-team.ts`,
|
||||
`program-newsfeed-comment-author-or-team.ts`.
|
||||
`cohort-newsfeed-comment-author-or-team.ts`.
|
||||
- **User flow:** Course → Community → ask/answer; team can moderate. Newsfeed posts +
|
||||
comments at course and program level.
|
||||
comments at course and cohort level.
|
||||
- **Edge cases:** author‑or‑team authorization (only author or course team can edit/delete).
|
||||
|
||||
### 3.13 AI Course Assistant (authoring chat)
|
||||
@@ -439,7 +439,7 @@ per‑request DB lookup** (`apps/api/src/middlewares/org-team-member.ts:5–53`)
|
||||
- `orgMemberMiddleware` — must be org member; `orgTeamMemberMiddleware` — ADMIN|TUTOR;
|
||||
`orgAdminMiddleware` — ADMIN only (`ARCHITECHTURE.md:233–237`).
|
||||
- `courseMemberMiddleware` / `courseTeamMemberMiddleware` — course‑level membership/team.
|
||||
- `programMemberMiddleware` / `programTeamMemberMiddleware` — program‑level.
|
||||
- `cohortMemberMiddleware` / `cohortTeamMemberMiddleware` — cohort-level.
|
||||
- `apiKeyMiddleware` / `automationKeyMiddleware` (+ `automationKeyScopesMiddleware`) —
|
||||
machine auth for webhooks/public API; `authOrApiKeyMiddleware`,
|
||||
`authOrAutomationKeyMiddleware`, and `*-or-automation-key`/`*-or-api-key` variants allow
|
||||
@@ -449,7 +449,7 @@ per‑request DB lookup** (`apps/api/src/middlewares/org-team-member.ts:5–53`)
|
||||
(`workspace-not-readonly.ts`).
|
||||
- `signupGuard` — signup rules (§3.2). `rateLimiter` — global rate limit (`app.ts:98`).
|
||||
- Route‑specific author/team guards: `question-author-or-team`,
|
||||
`newsfeed-comment-author-or-team`, `program-newsfeed-comment-author-or-team`.
|
||||
`newsfeed-comment-author-or-team`, `cohort-newsfeed-comment-author-or-team`.
|
||||
|
||||
**Public API scopes:** automation keys carry scopes like `public_api:*`, enforced per route
|
||||
(`apps/api/src/routes/v1/courses.ts:47`); usage categorized `read|write|publish`
|
||||
@@ -586,7 +586,7 @@ Upgrade or Buy tokens (api/polar/subscribe | buy-tokens)
|
||||
5. **Course templates / cohorts / import variants** — `prd/course-templates`,
|
||||
`prd/course-cohorts`, `prd/course-import` are **todo** (`PRD-TRACKER.md:10,12,13`),
|
||||
yet related schema exists (`exerciseTemplate` `schema.ts:2401`; `courseImportDraft`
|
||||
`schema.ts:2603`; programs are DONE). Scope of what's actually wired end‑to‑end vs.
|
||||
`schema.ts:2603`; cohorts are DONE). Scope of what's actually wired end‑to‑end vs.
|
||||
schema‑only should be verified per feature before writing test cases. ⚠️
|
||||
6. **Anthropic in AI picker** — code supports Anthropic, but it is "not currently in the
|
||||
picker UI" (`README.md:231`, `.env.example:112`). The picker exposes Gemini + GPT‑4o;
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
|
||||
<img alt="ClassroomIO Courses page" src="https://assets.cdn.clsrio.com/www/home/ai-course-builder.jpeg" />
|
||||
|
||||
ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification programs — all from one platform. Self-host it on your own infrastructure or use the cloud version.
|
||||
ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification cohorts — all from one platform. Self-host it on your own infrastructure or use the cloud version.
|
||||
|
||||
### Who It's For
|
||||
|
||||
| Use Case | What You Get |
|
||||
|---|---|
|
||||
| **Compliance Training** | Deadline tracking, renewals & retake intervals, grace periods, waivers, certificates with custom IDs |
|
||||
| **Customer Education** | Branded academy portal, programs (cohorts with goals), AI lesson tutor, multilingual content |
|
||||
| **Customer Education** | Branded academy portal, cohorts (cohorts with goals), AI lesson tutor, multilingual content |
|
||||
| **Partner Training** | Partner workspaces, branded certificates, custom domains, embeddable widgets, multilingual support |
|
||||
|
||||
### Key Features
|
||||
|
||||
**Course & Content**
|
||||
- **Course management** — unlimited courses, lessons, exercises, grading, and certificates
|
||||
- **Programs** — group courses into cohorts with goals, team management, and progress tracking
|
||||
- **Cohorts** — group courses into cohorts with goals, team management, and progress tracking
|
||||
- **AI Course Builder** — generate outlines, lesson content, and assignments (Gemini, GPT-4o, Claude)
|
||||
- **AI Lesson Tutor** — in-lesson AI assistant that helps learners as they study
|
||||
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ import { organizationRouter } from '@api/routes/organization';
|
||||
import { organizationSsoRouter } from '@api/routes/organization/sso';
|
||||
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import { programRouter } from '@api/routes/program';
|
||||
import { cohortRouter } from '@api/routes/cohort';
|
||||
import { publicCourseRouter } from '@api/routes/org-site';
|
||||
import { publicWidgetsRouter } from '@api/routes/widgets';
|
||||
import rateLimiter from '@api/middlewares/rate-limiter';
|
||||
@@ -232,7 +232,7 @@ export const app = new Hono()
|
||||
.route('/invite', inviteRouter)
|
||||
.route('/org-site/course', publicCourseRouter)
|
||||
.route('/public-api/v1', v1Router)
|
||||
.route('/program', programRouter)
|
||||
.route('/cohort', cohortRouter)
|
||||
.route('/unsplash', unsplashRouter)
|
||||
.route('/widgets', publicWidgetsRouter)
|
||||
.route('/internal', internalRouter)
|
||||
|
||||
+14
-14
@@ -1,13 +1,13 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isOrgAdminByProgramId, isProgramMember } from '@cio/db/queries/program';
|
||||
import { isOrgAdminByCohortId, isCohortMember } from '@cio/db/queries/cohort';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a member of the program
|
||||
* or an org admin for the program's organization.
|
||||
* Middleware to check if the authenticated user is a member of the cohort
|
||||
* or an org admin for the cohort's organization.
|
||||
*/
|
||||
export const programMemberMiddleware = async (c: Context, next: Next) => {
|
||||
export const cohortMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
@@ -21,21 +21,21 @@ export const programMemberMiddleware = async (c: Context, next: Next) => {
|
||||
);
|
||||
}
|
||||
|
||||
const programId = c.req.param('programId');
|
||||
if (!programId) {
|
||||
const cohortId = c.req.param('cohortId');
|
||||
if (!cohortId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Program ID is required',
|
||||
code: 'PROGRAM_ID_REQUIRED'
|
||||
code: 'COHORT_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const [member, isOrgAdmin] = await Promise.all([
|
||||
isProgramMember(programId, user.id),
|
||||
isOrgAdminByProgramId(programId, user.id)
|
||||
isCohortMember(cohortId, user.id),
|
||||
isOrgAdminByCohortId(cohortId, user.id)
|
||||
]);
|
||||
|
||||
if (member || isOrgAdmin) {
|
||||
@@ -45,18 +45,18 @@ export const programMemberMiddleware = async (c: Context, next: Next) => {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'You must be a member of this program to perform this action',
|
||||
code: ErrorCodes.PROGRAM_FORBIDDEN
|
||||
error: 'You must be a member of this cohort to perform this action',
|
||||
code: ErrorCodes.COHORT_FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in programMemberMiddleware:', error);
|
||||
console.error('Error in cohortMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify program membership',
|
||||
code: ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED
|
||||
error: 'Failed to verify cohort membership',
|
||||
code: ErrorCodes.COHORT_MEMBER_CHECK_FAILED
|
||||
},
|
||||
500
|
||||
);
|
||||
+18
-18
@@ -1,15 +1,15 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
getProgramMemberByProfileId,
|
||||
getProgramMemberRole,
|
||||
getProgramNewsfeedById,
|
||||
getProgramNewsfeedCommentById,
|
||||
isOrgAdminByProgramId
|
||||
} from '@cio/db/queries/program';
|
||||
getCohortMemberByProfileId,
|
||||
getCohortMemberRole,
|
||||
getCohortNewsfeedById,
|
||||
getCohortNewsfeedCommentById,
|
||||
isOrgAdminByCohortId
|
||||
} from '@cio/db/queries/cohort';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
|
||||
export const cohortNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
@@ -35,44 +35,44 @@ export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, n
|
||||
);
|
||||
}
|
||||
|
||||
const comment = await getProgramNewsfeedCommentById(Number(commentId));
|
||||
if (!comment?.programNewsfeedId) {
|
||||
const comment = await getCohortNewsfeedCommentById(Number(commentId));
|
||||
if (!comment?.cohortNewsfeedId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Comment not found',
|
||||
code: ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND
|
||||
code: ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const feed = await getProgramNewsfeedById(comment.programNewsfeedId);
|
||||
if (!feed?.programId) {
|
||||
const feed = await getCohortNewsfeedById(comment.cohortNewsfeedId);
|
||||
if (!feed?.cohortId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Program newsfeed item not found',
|
||||
code: ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND
|
||||
error: 'Cohort newsfeed item not found',
|
||||
code: ErrorCodes.COHORT_NEWSFEED_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const member = await getProgramMemberByProfileId(feed.programId, user.id);
|
||||
const member = await getCohortMemberByProfileId(feed.cohortId, user.id);
|
||||
const isAuthor = comment.authorId === member?.id;
|
||||
if (isAuthor) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const isOrgAdmin = await isOrgAdminByProgramId(feed.programId, user.id);
|
||||
const isOrgAdmin = await isOrgAdminByCohortId(feed.cohortId, user.id);
|
||||
if (isOrgAdmin) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = await getProgramMemberRole(feed.programId, user.id);
|
||||
const roleId = await getCohortMemberRole(feed.cohortId, user.id);
|
||||
const isTeamMember = roleId === ROLE.ADMIN || roleId === ROLE.TUTOR;
|
||||
if (isTeamMember) {
|
||||
await next();
|
||||
@@ -88,7 +88,7 @@ export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, n
|
||||
403
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in programNewsfeedCommentAuthorOrTeamMiddleware:', error);
|
||||
console.error('Error in cohortNewsfeedCommentAuthorOrTeamMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { getProgramMemberRole, isOrgAdminByProgramId } from '@cio/db/queries/program';
|
||||
import { getCohortMemberRole, isOrgAdminByCohortId } from '@cio/db/queries/cohort';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a program teacher or org admin.
|
||||
* Middleware to check if the authenticated user is a cohort teacher or org admin.
|
||||
* Mirrors the course-team-member pattern for program routes.
|
||||
*/
|
||||
export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
|
||||
export const cohortTeamMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
@@ -22,21 +22,21 @@ export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
|
||||
);
|
||||
}
|
||||
|
||||
const programId = c.req.param('programId');
|
||||
if (!programId) {
|
||||
const cohortId = c.req.param('cohortId');
|
||||
if (!cohortId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Program ID is required',
|
||||
code: 'PROGRAM_ID_REQUIRED'
|
||||
code: 'COHORT_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const [roleId, isOrgAdmin] = await Promise.all([
|
||||
getProgramMemberRole(programId, user.id),
|
||||
isOrgAdminByProgramId(programId, user.id)
|
||||
getCohortMemberRole(cohortId, user.id),
|
||||
isOrgAdminByCohortId(cohortId, user.id)
|
||||
]);
|
||||
|
||||
if (roleId === ROLE.TUTOR || roleId === ROLE.ADMIN || isOrgAdmin) {
|
||||
@@ -52,12 +52,12 @@ export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
|
||||
403
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in programTeamMemberMiddleware:', error);
|
||||
console.error('Error in cohortTeamMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify program team membership',
|
||||
code: 'PROGRAM_TEAM_MEMBER_CHECK_FAILED'
|
||||
code: 'COHORT_TEAM_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
@@ -0,0 +1,730 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import {
|
||||
ZAddCourseToCohort,
|
||||
ZAddCohortMembers,
|
||||
ZAssignExistingStudentsToCohort,
|
||||
ZCreateCohort,
|
||||
ZCreateCohortGoal,
|
||||
ZCreateCohortNewsfeed,
|
||||
ZCreateCohortNewsfeedComment,
|
||||
ZInviteStudentsToCohort,
|
||||
ZUpdateCohort,
|
||||
ZUpdateCohortGoal,
|
||||
ZUpdateCohortMember,
|
||||
ZUpdateCohortNewsfeed,
|
||||
ZUpdateCohortReaction
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import {
|
||||
addCourseToCohortService,
|
||||
addCohortMembers,
|
||||
createCohort,
|
||||
createCohortNewsfeedCommentService,
|
||||
createCohortNewsfeedService,
|
||||
deleteCohort,
|
||||
removeCohortMemberService,
|
||||
deleteCohortNewsfeedCommentService,
|
||||
deleteCohortNewsfeedService,
|
||||
getEnrolledCohorts,
|
||||
getCohort,
|
||||
listOrgCohorts,
|
||||
listCohortCourses,
|
||||
listCohortMembers,
|
||||
listCohortNewsfeed,
|
||||
listCohortNewsfeedComments,
|
||||
removeCourseFromCohortService,
|
||||
updateCohort,
|
||||
updateCohortMemberService,
|
||||
updateCohortNewsfeedReactionService,
|
||||
updateCohortNewsfeedService
|
||||
} from '@api/services/cohort/cohort';
|
||||
import { assignExistingStudentsToCohort, inviteStudentsToCohort } from '@api/services/cohort/invite';
|
||||
import {
|
||||
archiveGoal,
|
||||
createGoal,
|
||||
evaluateGoal,
|
||||
evaluateCohortGoals,
|
||||
getGoal,
|
||||
getMyGoals,
|
||||
getOrgGoalsOverview,
|
||||
listGoals,
|
||||
removeGoal,
|
||||
updateGoal
|
||||
} from '@api/services/cohort/goal';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
||||
import { cohortMemberMiddleware } from '@api/middlewares/cohort-member';
|
||||
import { cohortTeamMemberMiddleware } from '@api/middlewares/cohort-team-member';
|
||||
import { cohortNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/cohort-newsfeed-comment-author-or-team';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
const ZCohortParam = z.object({ cohortId: z.string().uuid() });
|
||||
const ZMemberParam = z.object({ cohortId: z.string().uuid(), memberId: z.string().uuid() });
|
||||
const ZCourseParam = z.object({ cohortId: z.string().uuid(), courseId: z.string().uuid() });
|
||||
const ZFeedParam = z.object({ cohortId: z.string().uuid(), feedId: z.string().uuid() });
|
||||
const ZCommentParam = z.object({
|
||||
cohortId: z.string().uuid(),
|
||||
feedId: z.string().uuid(),
|
||||
commentId: z.coerce.number().int()
|
||||
});
|
||||
const ZGoalParam = z.object({ cohortId: z.string().uuid(), goalId: z.string().uuid() });
|
||||
const ZListQuery = z.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(10)
|
||||
});
|
||||
const ZOrgQuery = z.object({ organizationId: z.string().uuid() });
|
||||
|
||||
export const cohortRouter = new Hono()
|
||||
// ── Enrolled Cohorts (LMS) ─── must be before /:cohortId ───────────────
|
||||
|
||||
/**
|
||||
* GET /cohort/enrolled
|
||||
* Get programs the current user is enrolled in
|
||||
*/
|
||||
.get('/enrolled', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const programs = await getEnrolledCohorts(user.id);
|
||||
return c.json({ success: true, data: programs }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get enrolled cohorts');
|
||||
}
|
||||
})
|
||||
|
||||
// ── Programs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /cohort?organizationId=...
|
||||
* List all programs for an org
|
||||
*/
|
||||
.get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => {
|
||||
try {
|
||||
const { organizationId } = c.req.valid('query');
|
||||
const programs = await listOrgCohorts(organizationId);
|
||||
return c.json({ success: true, data: programs }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list cohorts');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /cohort
|
||||
* Create a new program
|
||||
*/
|
||||
.post(
|
||||
'/',
|
||||
authMiddleware,
|
||||
zValidator('json', ZCreateCohort.extend({ organizationId: z.string().uuid() })),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { organizationId, ...data } = c.req.valid('json');
|
||||
const cohort = await createCohort(organizationId, user.id, data);
|
||||
return c.json({ success: true, data: cohort }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create cohort');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId
|
||||
* Get a cohort by ID
|
||||
*/
|
||||
.get('/:cohortId', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const cohort = await getCohort(cohortId);
|
||||
return c.json({ success: true, data: cohort }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get cohort');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /cohort/:cohortId
|
||||
* Update a cohort
|
||||
*/
|
||||
.put(
|
||||
'/:cohortId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZUpdateCohort),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const cohort = await updateCohort(cohortId, data);
|
||||
return c.json({ success: true, data: cohort }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update cohort');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /cohort/:cohortId
|
||||
* Delete a cohort
|
||||
*/
|
||||
.delete('/:cohortId', authMiddleware, cohortTeamMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const cohort = await deleteCohort(cohortId);
|
||||
return c.json({ success: true, data: cohort }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete cohort');
|
||||
}
|
||||
})
|
||||
|
||||
// ── Members ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId/members
|
||||
* List cohort members
|
||||
*/
|
||||
.get('/:cohortId/members', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const members = await listCohortMembers(cohortId);
|
||||
return c.json({ success: true, data: members }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list cohort members');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/members
|
||||
* Add members to a cohort
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/members',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZAddCohortMembers),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await addCohortMembers(cohortId, data);
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to add cohort members');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /cohort/:cohortId/members/:memberId
|
||||
* Update a cohort member's role
|
||||
*/
|
||||
.put(
|
||||
'/:cohortId/members/:memberId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZMemberParam),
|
||||
zValidator('json', ZUpdateCohortMember),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId, memberId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const member = await updateCohortMemberService(cohortId, memberId, data);
|
||||
return c.json({ success: true, data: member }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update cohort member');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /cohort/:cohortId/members/:memberId
|
||||
* Remove a member from a cohort
|
||||
*/
|
||||
.delete(
|
||||
'/:cohortId/members/:memberId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZMemberParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId, memberId } = c.req.valid('param');
|
||||
const member = await removeCohortMemberService(cohortId, memberId);
|
||||
return c.json({ success: true, data: member }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to remove program member');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Invite ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/invite
|
||||
* Invite new students to a cohort by CSV emails. Issues org invites
|
||||
* pre-tagged with this cohort so accept auto-enrolls them.
|
||||
* Gated by cohort-team-member middleware — Cohort ADMINs/TUTORs can
|
||||
* invite into their own program without org-admin rights.
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/invite',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZInviteStudentsToCohort),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await inviteStudentsToCohort(cohortId, data, user.id);
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to invite students to program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/invite/assign
|
||||
* Assign existing org-audience student profiles to a cohort.
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/invite/assign',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZAssignExistingStudentsToCohort),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await assignExistingStudentsToCohort(cohortId, data);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to assign students to program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Courses ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId/courses
|
||||
* List courses in a cohort
|
||||
*/
|
||||
.get('/:cohortId/courses', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const courses = await listCohortCourses(cohortId, user.id);
|
||||
return c.json({ success: true, data: courses }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list cohort courses');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/courses
|
||||
* Add a course to a cohort
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/courses',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZAddCourseToCohort),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await addCourseToCohortService(cohortId, data);
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to add course to program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /cohort/:cohortId/courses/:courseId
|
||||
* Remove a course from a cohort
|
||||
*/
|
||||
.delete(
|
||||
'/:cohortId/courses/:courseId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCourseParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId, courseId } = c.req.valid('param');
|
||||
const result = await removeCourseFromCohortService(cohortId, courseId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to remove course from program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Newsfeed ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId/newsfeed
|
||||
* List newsfeed for a cohort (paginated)
|
||||
*/
|
||||
.get(
|
||||
'/:cohortId/newsfeed',
|
||||
authMiddleware,
|
||||
cohortMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('query', ZListQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const { cursor, limit } = c.req.valid('query');
|
||||
const result = await listCohortNewsfeed(cohortId, { cursor, limit });
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list cohort newsfeed');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/newsfeed
|
||||
* Create a newsfeed post
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/newsfeed',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZCreateCohortNewsfeed),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const feed = await createCohortNewsfeedService(cohortId, user.id, data);
|
||||
return c.json({ success: true, data: feed }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create cohort newsfeed post');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /cohort/:cohortId/newsfeed/:feedId
|
||||
* Update a newsfeed post
|
||||
*/
|
||||
.put(
|
||||
'/:cohortId/newsfeed/:feedId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
zValidator('json', ZUpdateCohortNewsfeed),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const feed = await updateCohortNewsfeedService(feedId, data);
|
||||
return c.json({ success: true, data: feed }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update cohort newsfeed post');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /cohort/:cohortId/newsfeed/:feedId/react
|
||||
* Update reaction on a newsfeed post
|
||||
*/
|
||||
.put(
|
||||
'/:cohortId/newsfeed/:feedId/react',
|
||||
authMiddleware,
|
||||
cohortMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
zValidator('json', ZUpdateCohortReaction),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const feed = await updateCohortNewsfeedReactionService(feedId, data);
|
||||
return c.json({ success: true, data: feed }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update cohort newsfeed reaction');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /cohort/:cohortId/newsfeed/:feedId
|
||||
* Delete a newsfeed post
|
||||
*/
|
||||
.delete(
|
||||
'/:cohortId/newsfeed/:feedId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const feed = await deleteCohortNewsfeedService(feedId);
|
||||
return c.json({ success: true, data: feed }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete cohort newsfeed post');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId/newsfeed/:feedId/comments
|
||||
* List comments on a newsfeed post
|
||||
*/
|
||||
.get(
|
||||
'/:cohortId/newsfeed/:feedId/comments',
|
||||
authMiddleware,
|
||||
cohortMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const comments = await listCohortNewsfeedComments(feedId);
|
||||
return c.json({ success: true, data: comments }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list cohort newsfeed comments');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/newsfeed/:feedId/comment
|
||||
* Create a comment on a newsfeed post
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/newsfeed/:feedId/comment',
|
||||
authMiddleware,
|
||||
cohortMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
zValidator('json', ZCreateCohortNewsfeedComment),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { feedId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const comment = await createCohortNewsfeedCommentService(feedId, user.id, data);
|
||||
return c.json({ success: true, data: comment }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create cohort newsfeed comment');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /cohort/:cohortId/newsfeed/:feedId/comment/:commentId
|
||||
* Delete a comment on a newsfeed post
|
||||
*/
|
||||
.delete(
|
||||
'/:cohortId/newsfeed/:feedId/comment/:commentId',
|
||||
authMiddleware,
|
||||
cohortNewsfeedCommentAuthorOrTeamMiddleware,
|
||||
zValidator('param', ZCommentParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { commentId } = c.req.valid('param');
|
||||
const comment = await deleteCohortNewsfeedCommentService(commentId);
|
||||
return c.json({ success: true, data: comment }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete cohort newsfeed comment');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Goals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /cohort/my/goals
|
||||
* Goal assignments for the current authenticated user across all their programs.
|
||||
* Used by the LMS "Your Goals" widget.
|
||||
*/
|
||||
.get('/my/goals', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const goals = await getMyGoals(user.id);
|
||||
return c.json({ success: true, data: goals }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load goals');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /cohort/goals/overview?organizationId=...
|
||||
* Cross-cohort goal roll-up for the org owner. One entry per active goal
|
||||
* with per-status learner counts, scoped to the org via header + query.
|
||||
*/
|
||||
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
|
||||
try {
|
||||
const { organizationId } = c.req.valid('query');
|
||||
const overview = await getOrgGoalsOverview(organizationId);
|
||||
return c.json({ success: true, data: overview }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load goals overview');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId/goals
|
||||
*/
|
||||
.get('/:cohortId/goals', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const goals = await listGoals(cohortId);
|
||||
return c.json({ success: true, data: goals }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list cohort goals');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/goals
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/goals',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
zValidator('json', ZCreateCohortGoal),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const goal = await createGoal(cohortId, user.id, data);
|
||||
return c.json({ success: true, data: goal }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create cohort goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /cohort/:cohortId/goals/:goalId
|
||||
*/
|
||||
.get(
|
||||
'/:cohortId/goals/:goalId',
|
||||
authMiddleware,
|
||||
cohortMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const goal = await getGoal(goalId);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get cohort goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /cohort/:cohortId/goals/:goalId
|
||||
*/
|
||||
.put(
|
||||
'/:cohortId/goals/:goalId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
zValidator('json', ZUpdateCohortGoal),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const goal = await updateGoal(goalId, data);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update cohort goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /cohort/:cohortId/goals/:goalId
|
||||
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
|
||||
*/
|
||||
.delete(
|
||||
'/:cohortId/goals/:goalId',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const goal = await removeGoal(goalId);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete cohort goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/goals/:goalId/archive
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/goals/:goalId/archive',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const goal = await archiveGoal(goalId);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to archive cohort goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/goals/:goalId/evaluate
|
||||
* Force a re-evaluation of one goal's per-learner statuses.
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/goals/:goalId/evaluate',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const result = await evaluateGoal(goalId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to evaluate cohort goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /cohort/:cohortId/goals/evaluate-all
|
||||
* Force a re-evaluation of every active goal in the cohort.
|
||||
*/
|
||||
.post(
|
||||
'/:cohortId/goals/evaluate-all',
|
||||
authMiddleware,
|
||||
cohortTeamMemberMiddleware,
|
||||
zValidator('param', ZCohortParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { cohortId } = c.req.valid('param');
|
||||
const result = await evaluateCohortGoals(cohortId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to evaluate cohort goals');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { cohortRouter } from './cohort';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
|
||||
import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal';
|
||||
import { runCohortGoalEvaluationSweep, runCohortGoalReminderScan } from '@api/services/cohort/goal';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
||||
@@ -47,30 +47,30 @@ export const internalComplianceRouter = new Hono()
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /internal/compliance/evaluate-program-goals
|
||||
* Daily sweep: re-evaluate every active program goal and refresh per-learner
|
||||
* `program_goal_assignment` rows.
|
||||
* POST /internal/compliance/evaluate-cohort-goals
|
||||
* Daily sweep: re-evaluate every active cohort goal and refresh per-learner
|
||||
* `cohort_goal_assignment` rows.
|
||||
*/
|
||||
.post('/evaluate-program-goals', apiKeyMiddleware, async (c) => {
|
||||
.post('/evaluate-cohort-goals', apiKeyMiddleware, async (c) => {
|
||||
try {
|
||||
const data = await runProgramGoalEvaluationSweep();
|
||||
const data = await runCohortGoalEvaluationSweep();
|
||||
|
||||
return c.json({ success: true, data }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to run program goal evaluation sweep');
|
||||
return handleError(c, error, 'Failed to run cohort goal evaluation sweep');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /internal/compliance/send-program-goal-reminders
|
||||
* Sends learner reminder emails for upcoming/overdue program goals using
|
||||
* POST /internal/compliance/send-cohort-goal-reminders
|
||||
* Sends learner reminder emails for upcoming/overdue cohort goals using
|
||||
* each goal's `reminderDaysBefore` cadence.
|
||||
*/
|
||||
.post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => {
|
||||
.post('/send-cohort-goal-reminders', apiKeyMiddleware, async (c) => {
|
||||
try {
|
||||
const data = await runProgramGoalReminderScan();
|
||||
const data = await runCohortGoalReminderScan();
|
||||
|
||||
return c.json({ success: true, data }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to run program goal reminder scan');
|
||||
return handleError(c, error, 'Failed to run cohort goal reminder scan');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { programRouter } from './program';
|
||||
@@ -1,742 +0,0 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import {
|
||||
ZAddCourseToProgram,
|
||||
ZAddProgramMembers,
|
||||
ZAssignExistingStudentsToProgram,
|
||||
ZCreateProgram,
|
||||
ZCreateProgramGoal,
|
||||
ZCreateProgramNewsfeed,
|
||||
ZCreateProgramNewsfeedComment,
|
||||
ZInviteStudentsToProgram,
|
||||
ZUpdateProgram,
|
||||
ZUpdateProgramGoal,
|
||||
ZUpdateProgramMember,
|
||||
ZUpdateProgramNewsfeed,
|
||||
ZUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
import {
|
||||
addCourseToProgramService,
|
||||
addProgramMembers,
|
||||
createProgram,
|
||||
createProgramNewsfeedCommentService,
|
||||
createProgramNewsfeedService,
|
||||
deleteProgram,
|
||||
removeProgramMemberService,
|
||||
deleteProgramNewsfeedCommentService,
|
||||
deleteProgramNewsfeedService,
|
||||
getEnrolledPrograms,
|
||||
getProgram,
|
||||
listOrgPrograms,
|
||||
listProgramCourses,
|
||||
listProgramMembers,
|
||||
listProgramNewsfeed,
|
||||
listProgramNewsfeedComments,
|
||||
removeCourseFromProgramService,
|
||||
updateProgram,
|
||||
updateProgramMemberService,
|
||||
updateProgramNewsfeedReactionService,
|
||||
updateProgramNewsfeedService
|
||||
} from '@api/services/program/program';
|
||||
import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite';
|
||||
import {
|
||||
archiveGoal,
|
||||
createGoal,
|
||||
evaluateGoal,
|
||||
evaluateProgramGoals,
|
||||
getGoal,
|
||||
getMyGoals,
|
||||
getOrgGoalsOverview,
|
||||
listGoals,
|
||||
removeGoal,
|
||||
updateGoal
|
||||
} from '@api/services/program/goal';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
||||
import { programMemberMiddleware } from '@api/middlewares/program-member';
|
||||
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
|
||||
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
const ZProgramParam = z.object({ programId: z.string().uuid() });
|
||||
const ZMemberParam = z.object({ programId: z.string().uuid(), memberId: z.string().uuid() });
|
||||
const ZCourseParam = z.object({ programId: z.string().uuid(), courseId: z.string().uuid() });
|
||||
const ZFeedParam = z.object({ programId: z.string().uuid(), feedId: z.string().uuid() });
|
||||
const ZCommentParam = z.object({
|
||||
programId: z.string().uuid(),
|
||||
feedId: z.string().uuid(),
|
||||
commentId: z.coerce.number().int()
|
||||
});
|
||||
const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() });
|
||||
const ZListQuery = z.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(10)
|
||||
});
|
||||
const ZOrgQuery = z.object({ organizationId: z.string().uuid() });
|
||||
|
||||
export const programRouter = new Hono()
|
||||
// ── Enrolled Programs (LMS) ─── must be before /:programId ───────────────
|
||||
|
||||
/**
|
||||
* GET /program/enrolled
|
||||
* Get programs the current user is enrolled in
|
||||
*/
|
||||
.get('/enrolled', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const programs = await getEnrolledPrograms(user.id);
|
||||
return c.json({ success: true, data: programs }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get enrolled programs');
|
||||
}
|
||||
})
|
||||
|
||||
// ── Programs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /program?organizationId=...
|
||||
* List all programs for an org
|
||||
*/
|
||||
.get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => {
|
||||
try {
|
||||
const { organizationId } = c.req.valid('query');
|
||||
const programs = await listOrgPrograms(organizationId);
|
||||
return c.json({ success: true, data: programs }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list programs');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /program
|
||||
* Create a new program
|
||||
*/
|
||||
.post(
|
||||
'/',
|
||||
authMiddleware,
|
||||
zValidator('json', ZCreateProgram.extend({ organizationId: z.string().uuid() })),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { organizationId, ...data } = c.req.valid('json');
|
||||
const program = await createProgram(organizationId, user.id, data);
|
||||
return c.json({ success: true, data: program }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /program/:programId
|
||||
* Get a program by ID
|
||||
*/
|
||||
.get('/:programId', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const program = await getProgram(programId);
|
||||
return c.json({ success: true, data: program }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get program');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /program/:programId
|
||||
* Update a program
|
||||
*/
|
||||
.put(
|
||||
'/:programId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZUpdateProgram),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const program = await updateProgram(programId, data);
|
||||
return c.json({ success: true, data: program }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /program/:programId
|
||||
* Delete a program
|
||||
*/
|
||||
.delete('/:programId', authMiddleware, programTeamMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const program = await deleteProgram(programId);
|
||||
return c.json({ success: true, data: program }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete program');
|
||||
}
|
||||
})
|
||||
|
||||
// ── Members ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /program/:programId/members
|
||||
* List program members
|
||||
*/
|
||||
.get(
|
||||
'/:programId/members',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const members = await listProgramMembers(programId);
|
||||
return c.json({ success: true, data: members }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list program members');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/members
|
||||
* Add members to a program
|
||||
*/
|
||||
.post(
|
||||
'/:programId/members',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZAddProgramMembers),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await addProgramMembers(programId, data);
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to add program members');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /program/:programId/members/:memberId
|
||||
* Update a program member's role
|
||||
*/
|
||||
.put(
|
||||
'/:programId/members/:memberId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZMemberParam),
|
||||
zValidator('json', ZUpdateProgramMember),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId, memberId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const member = await updateProgramMemberService(programId, memberId, data);
|
||||
return c.json({ success: true, data: member }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update program member');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /program/:programId/members/:memberId
|
||||
* Remove a member from a program
|
||||
*/
|
||||
.delete(
|
||||
'/:programId/members/:memberId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZMemberParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId, memberId } = c.req.valid('param');
|
||||
const member = await removeProgramMemberService(programId, memberId);
|
||||
return c.json({ success: true, data: member }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to remove program member');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Invite ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /program/:programId/invite
|
||||
* Invite new students to a program by CSV emails. Issues org invites
|
||||
* pre-tagged with this program so accept auto-enrolls them.
|
||||
* Gated by program-team-member middleware — Program ADMINs/TUTORs can
|
||||
* invite into their own program without org-admin rights.
|
||||
*/
|
||||
.post(
|
||||
'/:programId/invite',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZInviteStudentsToProgram),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await inviteStudentsToProgram(programId, data, user.id);
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to invite students to program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/invite/assign
|
||||
* Assign existing org-audience student profiles to a program.
|
||||
*/
|
||||
.post(
|
||||
'/:programId/invite/assign',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZAssignExistingStudentsToProgram),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await assignExistingStudentsToProgram(programId, data);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to assign students to program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Courses ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /program/:programId/courses
|
||||
* List courses in a program
|
||||
*/
|
||||
.get(
|
||||
'/:programId/courses',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { programId } = c.req.valid('param');
|
||||
const courses = await listProgramCourses(programId, user.id);
|
||||
return c.json({ success: true, data: courses }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list program courses');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/courses
|
||||
* Add a course to a program
|
||||
*/
|
||||
.post(
|
||||
'/:programId/courses',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZAddCourseToProgram),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await addCourseToProgramService(programId, data);
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to add course to program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /program/:programId/courses/:courseId
|
||||
* Remove a course from a program
|
||||
*/
|
||||
.delete(
|
||||
'/:programId/courses/:courseId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZCourseParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId, courseId } = c.req.valid('param');
|
||||
const result = await removeCourseFromProgramService(programId, courseId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to remove course from program');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Newsfeed ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /program/:programId/newsfeed
|
||||
* List newsfeed for a program (paginated)
|
||||
*/
|
||||
.get(
|
||||
'/:programId/newsfeed',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('query', ZListQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const { cursor, limit } = c.req.valid('query');
|
||||
const result = await listProgramNewsfeed(programId, { cursor, limit });
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list program newsfeed');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/newsfeed
|
||||
* Create a newsfeed post
|
||||
*/
|
||||
.post(
|
||||
'/:programId/newsfeed',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZCreateProgramNewsfeed),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const feed = await createProgramNewsfeedService(programId, user.id, data);
|
||||
return c.json({ success: true, data: feed }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create program newsfeed post');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /program/:programId/newsfeed/:feedId
|
||||
* Update a newsfeed post
|
||||
*/
|
||||
.put(
|
||||
'/:programId/newsfeed/:feedId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
zValidator('json', ZUpdateProgramNewsfeed),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const feed = await updateProgramNewsfeedService(feedId, data);
|
||||
return c.json({ success: true, data: feed }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update program newsfeed post');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /program/:programId/newsfeed/:feedId/react
|
||||
* Update reaction on a newsfeed post
|
||||
*/
|
||||
.put(
|
||||
'/:programId/newsfeed/:feedId/react',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
zValidator('json', ZUpdateProgramReaction),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const feed = await updateProgramNewsfeedReactionService(feedId, data);
|
||||
return c.json({ success: true, data: feed }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update program newsfeed reaction');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /program/:programId/newsfeed/:feedId
|
||||
* Delete a newsfeed post
|
||||
*/
|
||||
.delete(
|
||||
'/:programId/newsfeed/:feedId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const feed = await deleteProgramNewsfeedService(feedId);
|
||||
return c.json({ success: true, data: feed }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete program newsfeed post');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /program/:programId/newsfeed/:feedId/comments
|
||||
* List comments on a newsfeed post
|
||||
*/
|
||||
.get(
|
||||
'/:programId/newsfeed/:feedId/comments',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { feedId } = c.req.valid('param');
|
||||
const comments = await listProgramNewsfeedComments(feedId);
|
||||
return c.json({ success: true, data: comments }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list program newsfeed comments');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/newsfeed/:feedId/comment
|
||||
* Create a comment on a newsfeed post
|
||||
*/
|
||||
.post(
|
||||
'/:programId/newsfeed/:feedId/comment',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZFeedParam),
|
||||
zValidator('json', ZCreateProgramNewsfeedComment),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { feedId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const comment = await createProgramNewsfeedCommentService(feedId, user.id, data);
|
||||
return c.json({ success: true, data: comment }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create program newsfeed comment');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /program/:programId/newsfeed/:feedId/comment/:commentId
|
||||
* Delete a comment on a newsfeed post
|
||||
*/
|
||||
.delete(
|
||||
'/:programId/newsfeed/:feedId/comment/:commentId',
|
||||
authMiddleware,
|
||||
programNewsfeedCommentAuthorOrTeamMiddleware,
|
||||
zValidator('param', ZCommentParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { commentId } = c.req.valid('param');
|
||||
const comment = await deleteProgramNewsfeedCommentService(commentId);
|
||||
return c.json({ success: true, data: comment }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete program newsfeed comment');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Goals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /program/my/goals
|
||||
* Goal assignments for the current authenticated user across all their programs.
|
||||
* Used by the LMS "Your Goals" widget.
|
||||
*/
|
||||
.get('/my/goals', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const goals = await getMyGoals(user.id);
|
||||
return c.json({ success: true, data: goals }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load goals');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /program/goals/overview?organizationId=...
|
||||
* Cross-program goal roll-up for the org owner. One entry per active goal
|
||||
* with per-status learner counts, scoped to the org via header + query.
|
||||
*/
|
||||
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
|
||||
try {
|
||||
const { organizationId } = c.req.valid('query');
|
||||
const overview = await getOrgGoalsOverview(organizationId);
|
||||
return c.json({ success: true, data: overview }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load goals overview');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /program/:programId/goals
|
||||
*/
|
||||
.get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const goals = await listGoals(programId);
|
||||
return c.json({ success: true, data: goals }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list program goals');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /program/:programId/goals
|
||||
*/
|
||||
.post(
|
||||
'/:programId/goals',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
zValidator('json', ZCreateProgramGoal),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { programId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const goal = await createGoal(programId, user.id, data);
|
||||
return c.json({ success: true, data: goal }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create program goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /program/:programId/goals/:goalId
|
||||
*/
|
||||
.get(
|
||||
'/:programId/goals/:goalId',
|
||||
authMiddleware,
|
||||
programMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const goal = await getGoal(goalId);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get program goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /program/:programId/goals/:goalId
|
||||
*/
|
||||
.put(
|
||||
'/:programId/goals/:goalId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
zValidator('json', ZUpdateProgramGoal),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const goal = await updateGoal(goalId, data);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update program goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /program/:programId/goals/:goalId
|
||||
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
|
||||
*/
|
||||
.delete(
|
||||
'/:programId/goals/:goalId',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const goal = await removeGoal(goalId);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to delete program goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/goals/:goalId/archive
|
||||
*/
|
||||
.post(
|
||||
'/:programId/goals/:goalId/archive',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const goal = await archiveGoal(goalId);
|
||||
return c.json({ success: true, data: goal }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to archive program goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/goals/:goalId/evaluate
|
||||
* Force a re-evaluation of one goal's per-learner statuses.
|
||||
*/
|
||||
.post(
|
||||
'/:programId/goals/:goalId/evaluate',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZGoalParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { goalId } = c.req.valid('param');
|
||||
const result = await evaluateGoal(goalId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to evaluate program goal');
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /program/:programId/goals/evaluate-all
|
||||
* Force a re-evaluation of every active goal in the program.
|
||||
*/
|
||||
.post(
|
||||
'/:programId/goals/evaluate-all',
|
||||
authMiddleware,
|
||||
programTeamMemberMiddleware,
|
||||
zValidator('param', ZProgramParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { programId } = c.req.valid('param');
|
||||
const result = await evaluateProgramGoals(programId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to evaluate program goals');
|
||||
}
|
||||
}
|
||||
);
|
||||
+153
-157
@@ -1,44 +1,44 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
type TCreateProgram,
|
||||
type TUpdateProgram,
|
||||
type TAddCourseToProgram,
|
||||
type TAddProgramMembers,
|
||||
type TUpdateProgramMember,
|
||||
type TCreateProgramNewsfeed,
|
||||
type TUpdateProgramNewsfeed,
|
||||
type TUpdateProgramReaction,
|
||||
type TCreateProgramNewsfeedComment
|
||||
} from '@cio/utils/validation/program';
|
||||
type TCreateCohort,
|
||||
type TUpdateCohort,
|
||||
type TAddCourseToCohort,
|
||||
type TAddCohortMembers,
|
||||
type TUpdateCohortMember,
|
||||
type TCreateCohortNewsfeed,
|
||||
type TUpdateCohortNewsfeed,
|
||||
type TUpdateCohortReaction,
|
||||
type TCreateCohortNewsfeedComment
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import {
|
||||
addCourseToProgram,
|
||||
addProgramMember,
|
||||
createProgramWithCreatorMembership,
|
||||
createProgramNewsfeed as createProgramNewsfeedQuery,
|
||||
createProgramNewsfeedComment as createProgramNewsfeedCommentQuery,
|
||||
deleteProgram as deleteProgramQuery,
|
||||
deleteProgramNewsfeed as deleteProgramNewsfeedQuery,
|
||||
deleteProgramNewsfeedComment as deleteProgramNewsfeedCommentQuery,
|
||||
getEnrolledProgramsByProfile,
|
||||
getProgramById,
|
||||
getProgramMemberByProfileId,
|
||||
getProgramMembers,
|
||||
getProgramNewsfeed,
|
||||
getProgramNewsfeedById,
|
||||
getProgramNewsfeedCommentById,
|
||||
getProgramNewsfeedComments,
|
||||
getProgramsByOrg,
|
||||
getCoursesByProgram,
|
||||
getProgramMemberRole,
|
||||
isProgramCourse,
|
||||
isProgramMember,
|
||||
removeCourseFromProgram,
|
||||
removeProgramMember,
|
||||
updateProgram as updateProgramQuery,
|
||||
updateProgramMember as updateProgramMemberQuery,
|
||||
updateProgramNewsfeed as updateProgramNewsfeedQuery,
|
||||
updateProgramNewsfeedReaction
|
||||
} from '@cio/db/queries/program';
|
||||
addCourseToCohort,
|
||||
addCohortMember,
|
||||
createCohortWithCreatorMembership,
|
||||
createCohortNewsfeed as createCohortNewsfeedQuery,
|
||||
createCohortNewsfeedComment as createCohortNewsfeedCommentQuery,
|
||||
deleteCohort as deleteCohortQuery,
|
||||
deleteCohortNewsfeed as deleteCohortNewsfeedQuery,
|
||||
deleteCohortNewsfeedComment as deleteCohortNewsfeedCommentQuery,
|
||||
getEnrolledCohortsByProfile,
|
||||
getCohortById,
|
||||
getCohortMemberByProfileId,
|
||||
getCohortMembers,
|
||||
getCohortNewsfeed,
|
||||
getCohortNewsfeedById,
|
||||
getCohortNewsfeedCommentById,
|
||||
getCohortNewsfeedComments,
|
||||
getCohortsByOrg,
|
||||
getCoursesByCohort,
|
||||
getCohortMemberRole,
|
||||
isCohortCourse,
|
||||
isCohortMember,
|
||||
removeCourseFromCohort,
|
||||
removeCohortMember,
|
||||
updateCohort as updateCohortQuery,
|
||||
updateCohortMember as updateCohortMemberQuery,
|
||||
updateCohortNewsfeed as updateCohortNewsfeedQuery,
|
||||
updateCohortNewsfeedReaction
|
||||
} from '@cio/db/queries/cohort';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
@@ -46,16 +46,16 @@ import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/or
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
|
||||
type ProgramMemberEnrollment = {
|
||||
type CohortMemberEnrollment = {
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
roleId: number;
|
||||
};
|
||||
|
||||
async function enrollProgramStudentsInGroups(
|
||||
async function enrollCohortStudentsInGroups(
|
||||
organizationId: string,
|
||||
groupIds: string[],
|
||||
members: ProgramMemberEnrollment[]
|
||||
members: CohortMemberEnrollment[]
|
||||
) {
|
||||
const studentMembers = members.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
|
||||
const uniqueGroupIds = [...new Set(groupIds)];
|
||||
@@ -91,11 +91,11 @@ async function enrollProgramStudentsInGroups(
|
||||
|
||||
// ─── Program CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProgram(organizationId: string, profileId: string, data: TCreateProgram) {
|
||||
export async function createCohort(organizationId: string, profileId: string, data: TCreateCohort) {
|
||||
try {
|
||||
const description = data.description?.trim() || `A program for ${data.name}`;
|
||||
|
||||
return createProgramWithCreatorMembership(
|
||||
return createCohortWithCreatorMembership(
|
||||
{
|
||||
organizationId,
|
||||
createdByProfileId: profileId,
|
||||
@@ -108,72 +108,72 @@ export async function createProgram(organizationId: string, profileId: string, d
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create program',
|
||||
ErrorCodes.PROGRAM_CREATE_FAILED,
|
||||
error instanceof Error ? error.message : 'Failed to create cohort',
|
||||
ErrorCodes.COHORT_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgram(programId: string) {
|
||||
export async function getCohort(cohortId: string) {
|
||||
try {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
const cohort = await getCohortById(cohortId);
|
||||
if (!cohort) {
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
return program;
|
||||
return cohort;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get program',
|
||||
ErrorCodes.PROGRAM_NOT_FOUND,
|
||||
error instanceof Error ? error.message : 'Failed to get cohort',
|
||||
ErrorCodes.COHORT_NOT_FOUND,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOrgPrograms(organizationId: string) {
|
||||
export async function listOrgCohorts(organizationId: string) {
|
||||
try {
|
||||
return getProgramsByOrg(organizationId);
|
||||
return getCohortsByOrg(organizationId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list programs',
|
||||
error instanceof Error ? error.message : 'Failed to list cohorts',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgram(programId: string, data: TUpdateProgram) {
|
||||
export async function updateCohort(cohortId: string, data: TUpdateCohort) {
|
||||
try {
|
||||
const updated = await updateProgramQuery(programId, data);
|
||||
const updated = await updateCohortQuery(cohortId, data);
|
||||
if (!updated) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program',
|
||||
ErrorCodes.PROGRAM_UPDATE_FAILED,
|
||||
error instanceof Error ? error.message : 'Failed to update cohort',
|
||||
ErrorCodes.COHORT_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgram(programId: string) {
|
||||
export async function deleteCohort(cohortId: string) {
|
||||
try {
|
||||
const deleted = await deleteProgramQuery(programId);
|
||||
const deleted = await deleteCohortQuery(cohortId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete program',
|
||||
ErrorCodes.PROGRAM_DELETE_FAILED,
|
||||
error instanceof Error ? error.message : 'Failed to delete cohort',
|
||||
ErrorCodes.COHORT_DELETE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
@@ -181,28 +181,28 @@ export async function deleteProgram(programId: string) {
|
||||
|
||||
// ─── Program Members ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramMembers(programId: string) {
|
||||
export async function listCohortMembers(cohortId: string) {
|
||||
try {
|
||||
return getProgramMembers(programId);
|
||||
return getCohortMembers(cohortId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program members',
|
||||
error instanceof Error ? error.message : 'Failed to list cohort members',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProgramMembers(programId: string, data: TAddProgramMembers) {
|
||||
export async function addCohortMembers(cohortId: string, data: TAddCohortMembers) {
|
||||
try {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
const cohort = await getCohortById(cohortId);
|
||||
if (!cohort) {
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const programCourses = await getCoursesByProgram(programId);
|
||||
const courseGroupIds = (await getCourseGroupIds(programCourses.map((course) => course.course.id))).map(
|
||||
const cohortCourses = await getCoursesByCohort(cohortId);
|
||||
const courseGroupIds = (await getCourseGroupIds(cohortCourses.map((course) => course.course.id))).map(
|
||||
(courseGroup) => courseGroup.groupId
|
||||
);
|
||||
|
||||
@@ -213,13 +213,9 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
|
||||
const normalizedEmail = email?.toLowerCase().trim() ?? profile?.email ?? null;
|
||||
|
||||
if (profileId) {
|
||||
const existing = await getProgramMemberByProfileId(programId, profileId);
|
||||
const existing = await getCohortMemberByProfileId(cohortId, profileId);
|
||||
if (existing) {
|
||||
throw new AppError(
|
||||
`${email} is already a member of this program`,
|
||||
ErrorCodes.MEMBER_ALREADY_IN_PROGRAM,
|
||||
409
|
||||
);
|
||||
throw new AppError(`${email} is already a member of this cohort`, ErrorCodes.MEMBER_ALREADY_IN_COHORT, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,9 +228,9 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const member = await addProgramMember(
|
||||
const member = await addCohortMember(
|
||||
{
|
||||
programId,
|
||||
cohortId,
|
||||
profileId,
|
||||
roleId,
|
||||
email: normalizedEmail ?? undefined
|
||||
@@ -248,7 +244,7 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
|
||||
await insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
organizationId: program.organizationId,
|
||||
organizationId: cohort.organizationId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: normalizedEmail ?? undefined,
|
||||
@@ -285,18 +281,18 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to add program members',
|
||||
error instanceof Error ? error.message : 'Failed to add cohort members',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProgramMemberService(_programId: string, memberId: string) {
|
||||
export async function removeCohortMemberService(_cohortId: string, memberId: string) {
|
||||
try {
|
||||
const deleted = await removeProgramMember(memberId);
|
||||
const deleted = await removeCohortMember(memberId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort member not found', ErrorCodes.COHORT_MEMBER_NOT_FOUND, 404);
|
||||
}
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
@@ -309,30 +305,30 @@ export async function removeProgramMemberService(_programId: string, memberId: s
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramMemberService(_programId: string, memberId: string, data: TUpdateProgramMember) {
|
||||
export async function updateCohortMemberService(_cohortId: string, memberId: string, data: TUpdateCohortMember) {
|
||||
try {
|
||||
const updated = await updateProgramMemberQuery(memberId, { roleId: data.roleId });
|
||||
const updated = await updateCohortMemberQuery(memberId, { roleId: data.roleId });
|
||||
if (!updated) {
|
||||
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort member not found', ErrorCodes.COHORT_MEMBER_NOT_FOUND, 404);
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program member',
|
||||
error instanceof Error ? error.message : 'Failed to update cohort member',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEnrolledPrograms(profileId: string) {
|
||||
export async function getEnrolledCohorts(profileId: string) {
|
||||
try {
|
||||
return getEnrolledProgramsByProfile(profileId);
|
||||
return getEnrolledCohortsByProfile(profileId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to get enrolled programs',
|
||||
error instanceof Error ? error.message : 'Failed to get enrolled cohorts',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
@@ -341,36 +337,36 @@ export async function getEnrolledPrograms(profileId: string) {
|
||||
|
||||
// ─── Program Courses ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramCourses(programId: string, profileId: string) {
|
||||
export async function listCohortCourses(cohortId: string, profileId: string) {
|
||||
try {
|
||||
const roleId = await getProgramMemberRole(programId, profileId);
|
||||
const roleId = await getCohortMemberRole(cohortId, profileId);
|
||||
const onlyPublished = roleId === ROLE.STUDENT;
|
||||
|
||||
return getCoursesByProgram(programId, onlyPublished);
|
||||
return getCoursesByCohort(cohortId, onlyPublished);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program courses',
|
||||
error instanceof Error ? error.message : 'Failed to list cohort courses',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCourseToProgramService(programId: string, data: TAddCourseToProgram) {
|
||||
export async function addCourseToCohortService(cohortId: string, data: TAddCourseToCohort) {
|
||||
try {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
const cohort = await getCohortById(cohortId);
|
||||
if (!cohort) {
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const alreadyAdded = await isProgramCourse(programId, data.courseId);
|
||||
const alreadyAdded = await isCohortCourse(cohortId, data.courseId);
|
||||
if (alreadyAdded) {
|
||||
throw new AppError('Course is already in this program', ErrorCodes.COURSE_ALREADY_IN_PROGRAM, 409);
|
||||
throw new AppError('Course is already in this cohort', ErrorCodes.COURSE_ALREADY_IN_COHORT, 409);
|
||||
}
|
||||
|
||||
const result = await addCourseToProgram(programId, data.courseId);
|
||||
const students = (await getProgramMembers(programId))
|
||||
const result = await addCourseToCohort(cohortId, data.courseId);
|
||||
const students = (await getCohortMembers(cohortId))
|
||||
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
|
||||
.map((member) => ({
|
||||
profileId: member.profileId!,
|
||||
@@ -383,7 +379,7 @@ export async function addCourseToProgramService(programId: string, data: TAddCou
|
||||
.filter((groupId): groupId is string => Boolean(groupId));
|
||||
|
||||
if (students.length > 0 && courseGroupIds.length > 0) {
|
||||
await enrollProgramStudentsInGroups(program.organizationId, courseGroupIds, students);
|
||||
await enrollCohortStudentsInGroups(cohort.organizationId, courseGroupIds, students);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -397,9 +393,9 @@ export async function addCourseToProgramService(programId: string, data: TAddCou
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCourseFromProgramService(programId: string, courseId: string) {
|
||||
export async function removeCourseFromCohortService(cohortId: string, courseId: string) {
|
||||
try {
|
||||
const deleted = await removeCourseFromProgram(programId, courseId);
|
||||
const deleted = await removeCourseFromCohort(cohortId, courseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Course not found in program', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
@@ -416,28 +412,28 @@ export async function removeCourseFromProgramService(programId: string, courseId
|
||||
|
||||
// ─── Program Newsfeed ────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramNewsfeed(programId: string, options: { cursor?: string; limit: number }) {
|
||||
export async function listCohortNewsfeed(cohortId: string, options: { cursor?: string; limit: number }) {
|
||||
try {
|
||||
return getProgramNewsfeed(programId, options);
|
||||
return getCohortNewsfeed(cohortId, options);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program newsfeed',
|
||||
error instanceof Error ? error.message : 'Failed to list cohort newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProgramNewsfeedService(programId: string, profileId: string, data: TCreateProgramNewsfeed) {
|
||||
export async function createCohortNewsfeedService(cohortId: string, profileId: string, data: TCreateCohortNewsfeed) {
|
||||
try {
|
||||
const member = await getProgramMemberByProfileId(programId, profileId);
|
||||
const member = await getCohortMemberByProfileId(cohortId, profileId);
|
||||
if (!member) {
|
||||
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
|
||||
throw new AppError('User is not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
return createProgramNewsfeedQuery({
|
||||
programId,
|
||||
return createCohortNewsfeedQuery({
|
||||
cohortId,
|
||||
authorId: member.id,
|
||||
content: data.content,
|
||||
isPinned: data.isPinned ?? false
|
||||
@@ -445,58 +441,58 @@ export async function createProgramNewsfeedService(programId: string, profileId:
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create program newsfeed',
|
||||
error instanceof Error ? error.message : 'Failed to create cohort newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramNewsfeedService(feedId: string, data: TUpdateProgramNewsfeed) {
|
||||
export async function updateCohortNewsfeedService(feedId: string, data: TUpdateCohortNewsfeed) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
const feed = await getCohortNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return updateProgramNewsfeedQuery(feedId, data);
|
||||
return updateCohortNewsfeedQuery(feedId, data);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program newsfeed',
|
||||
error instanceof Error ? error.message : 'Failed to update cohort newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramNewsfeedReactionService(feedId: string, data: TUpdateProgramReaction) {
|
||||
export async function updateCohortNewsfeedReactionService(feedId: string, data: TUpdateCohortReaction) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
const feed = await getCohortNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return updateProgramNewsfeedReaction(feedId, data.reaction);
|
||||
return updateCohortNewsfeedReaction(feedId, data.reaction);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update program newsfeed reaction',
|
||||
error instanceof Error ? error.message : 'Failed to update cohort newsfeed reaction',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramNewsfeedService(feedId: string) {
|
||||
export async function deleteCohortNewsfeedService(feedId: string) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
const feed = await getCohortNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return deleteProgramNewsfeedQuery(feedId);
|
||||
return deleteCohortNewsfeedQuery(feedId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete program newsfeed',
|
||||
error instanceof Error ? error.message : 'Failed to delete cohort newsfeed',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
@@ -505,67 +501,67 @@ export async function deleteProgramNewsfeedService(feedId: string) {
|
||||
|
||||
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
|
||||
|
||||
export async function listProgramNewsfeedComments(feedId: string) {
|
||||
export async function listCohortNewsfeedComments(feedId: string) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
const feed = await getCohortNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return getProgramNewsfeedComments(feedId);
|
||||
return getCohortNewsfeedComments(feedId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to list program newsfeed comments',
|
||||
error instanceof Error ? error.message : 'Failed to list cohort newsfeed comments',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProgramNewsfeedCommentService(
|
||||
export async function createCohortNewsfeedCommentService(
|
||||
feedId: string,
|
||||
profileId: string,
|
||||
data: TCreateProgramNewsfeedComment
|
||||
data: TCreateCohortNewsfeedComment
|
||||
) {
|
||||
try {
|
||||
const feed = await getProgramNewsfeedById(feedId);
|
||||
const feed = await getCohortNewsfeedById(feedId);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
const member = await getProgramMemberByProfileId(feed.programId!, profileId);
|
||||
const member = await getCohortMemberByProfileId(feed.cohortId!, profileId);
|
||||
if (!member) {
|
||||
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
|
||||
throw new AppError('User is not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403);
|
||||
}
|
||||
return createProgramNewsfeedCommentQuery({
|
||||
programNewsfeedId: feedId,
|
||||
return createCohortNewsfeedCommentQuery({
|
||||
cohortNewsfeedId: feedId,
|
||||
authorId: member.id,
|
||||
content: data.content
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create program newsfeed comment',
|
||||
error instanceof Error ? error.message : 'Failed to create cohort newsfeed comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramNewsfeedCommentService(commentId: number) {
|
||||
export async function deleteCohortNewsfeedCommentService(commentId: number) {
|
||||
try {
|
||||
const comment = await getProgramNewsfeedCommentById(commentId);
|
||||
const comment = await getCohortNewsfeedCommentById(commentId);
|
||||
if (!comment) {
|
||||
throw new AppError('Comment not found', ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND, 404);
|
||||
throw new AppError('Comment not found', ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND, 404);
|
||||
}
|
||||
const feed = await getProgramNewsfeedById(comment.programNewsfeedId!);
|
||||
const feed = await getCohortNewsfeedById(comment.cohortNewsfeedId!);
|
||||
if (!feed) {
|
||||
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
|
||||
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
|
||||
}
|
||||
return deleteProgramNewsfeedCommentQuery(commentId);
|
||||
return deleteCohortNewsfeedCommentQuery(commentId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to delete program newsfeed comment',
|
||||
error instanceof Error ? error.message : 'Failed to delete cohort newsfeed comment',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
@@ -574,9 +570,9 @@ export async function deleteProgramNewsfeedCommentService(commentId: number) {
|
||||
|
||||
// ─── Auth Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function assertProgramMember(programId: string, profileId: string) {
|
||||
const member = await isProgramMember(programId, profileId);
|
||||
async function assertCohortMember(cohortId: string, profileId: string) {
|
||||
const member = await isCohortMember(cohortId, profileId);
|
||||
if (!member) {
|
||||
throw new AppError('You are not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
|
||||
throw new AppError('You are not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403);
|
||||
}
|
||||
}
|
||||
@@ -4,49 +4,45 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { buildEmailBranding } from '@cio/email';
|
||||
import {
|
||||
countAssignmentsByStatus,
|
||||
createProgramGoal,
|
||||
deleteProgramGoal,
|
||||
createCohortGoal,
|
||||
deleteCohortGoal,
|
||||
getAssignmentsForProfile,
|
||||
getLatestCompletionRecordsForProfilesAndCourses,
|
||||
getMaxScoresForProfilesAndCourses,
|
||||
getNonComplianceCourseCompletions,
|
||||
getProgramById,
|
||||
getProgramGoalAssignments,
|
||||
getProgramGoalById,
|
||||
getProgramGoals,
|
||||
getProgramGoalsByOrg,
|
||||
getProgramMembers,
|
||||
listAllActiveProgramGoals,
|
||||
getCohortById,
|
||||
getCohortGoalAssignments,
|
||||
getCohortGoalById,
|
||||
getCohortGoals,
|
||||
getCohortGoalsByOrg,
|
||||
getCohortMembers,
|
||||
listAllActiveCohortGoals,
|
||||
listAssignmentsForReminderScan,
|
||||
type TNewProgramGoalAssignment,
|
||||
type TProgramGoal,
|
||||
type TProgramGoalAssignment,
|
||||
updateProgramGoal as updateProgramGoalQuery,
|
||||
upsertProgramGoalAssignments
|
||||
} from '@cio/db/queries/program';
|
||||
type TNewCohortGoalAssignment,
|
||||
type TCohortGoal,
|
||||
type TCohortGoalAssignment,
|
||||
updateCohortGoal as updateCohortGoalQuery,
|
||||
upsertCohortGoalAssignments
|
||||
} from '@cio/db/queries/cohort';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
|
||||
import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort';
|
||||
|
||||
const AT_RISK_DAYS = 3;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type GoalType = TProgramGoal['type'];
|
||||
type GoalType = TCohortGoal['type'];
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createGoal(
|
||||
programId: string,
|
||||
profileId: string,
|
||||
data: TCreateProgramGoal
|
||||
): Promise<TProgramGoal> {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
export async function createGoal(cohortId: string, profileId: string, data: TCreateCohortGoal): Promise<TCohortGoal> {
|
||||
const cohort = await getCohortById(cohortId);
|
||||
if (!cohort) {
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const goal = await createProgramGoal({
|
||||
programId,
|
||||
const goal = await createCohortGoal({
|
||||
cohortId,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
type: data.type,
|
||||
@@ -71,16 +67,16 @@ export async function createGoal(
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create goal',
|
||||
ErrorCodes.PROGRAM_GOAL_CREATE_FAILED,
|
||||
ErrorCodes.COHORT_GOAL_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGoals(
|
||||
programId: string
|
||||
): Promise<Array<TProgramGoal & { statusCounts: Record<string, number> }>> {
|
||||
const goals = await getProgramGoals(programId);
|
||||
cohortId: string
|
||||
): Promise<Array<TCohortGoal & { statusCounts: Record<string, number> }>> {
|
||||
const goals = await getCohortGoals(cohortId);
|
||||
if (goals.length === 0) return [];
|
||||
|
||||
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
|
||||
@@ -91,23 +87,23 @@ export async function listGoals(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getGoal(goalId: string): Promise<TProgramGoal> {
|
||||
const goal = await getProgramGoalById(goalId);
|
||||
export async function getGoal(goalId: string): Promise<TCohortGoal> {
|
||||
const goal = await getCohortGoalById(goalId);
|
||||
if (!goal) {
|
||||
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
|
||||
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return goal;
|
||||
}
|
||||
|
||||
export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Promise<TProgramGoal> {
|
||||
const existing = await getProgramGoalById(goalId);
|
||||
export async function updateGoal(goalId: string, data: TUpdateCohortGoal): Promise<TCohortGoal> {
|
||||
const existing = await getCohortGoalById(goalId);
|
||||
if (!existing) {
|
||||
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
|
||||
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateProgramGoalQuery(goalId, {
|
||||
const updated = await updateCohortGoalQuery(goalId, {
|
||||
title: data.title,
|
||||
description: data.description ?? undefined,
|
||||
type: data.type,
|
||||
@@ -123,7 +119,7 @@ export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Prom
|
||||
status: data.status
|
||||
});
|
||||
if (!updated) {
|
||||
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
|
||||
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await evaluateGoal(goalId).catch((error) => {
|
||||
@@ -135,20 +131,20 @@ export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Prom
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update goal',
|
||||
ErrorCodes.PROGRAM_GOAL_UPDATE_FAILED,
|
||||
ErrorCodes.COHORT_GOAL_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveGoal(goalId: string): Promise<TProgramGoal> {
|
||||
export async function archiveGoal(goalId: string): Promise<TCohortGoal> {
|
||||
return updateGoal(goalId, { status: 'archived' });
|
||||
}
|
||||
|
||||
export async function removeGoal(goalId: string): Promise<TProgramGoal> {
|
||||
const deleted = await deleteProgramGoal(goalId);
|
||||
export async function removeGoal(goalId: string): Promise<TCohortGoal> {
|
||||
const deleted = await deleteCohortGoal(goalId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
|
||||
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
@@ -164,7 +160,7 @@ export async function removeGoal(goalId: string): Promise<TProgramGoal> {
|
||||
* - relative_to_join → joinedAt + relativeDays
|
||||
* - recurring → goal.deadlineDate (current cycle); cycle advance handled elsewhere
|
||||
*/
|
||||
function computeDueDate(goal: TProgramGoal, joinedAt: Date): Date | null {
|
||||
function computeDueDate(goal: TCohortGoal, joinedAt: Date): Date | null {
|
||||
if (goal.deadlineKind === 'none') return null;
|
||||
if (goal.deadlineKind === 'absolute') {
|
||||
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
|
||||
@@ -193,8 +189,8 @@ function decideStatus(args: {
|
||||
required: number;
|
||||
dueDate: Date | null;
|
||||
now: Date;
|
||||
previousStatus: TProgramGoalAssignment['status'] | null;
|
||||
}): { status: TProgramGoalAssignment['status']; completedAt: Date | null } {
|
||||
previousStatus: TCohortGoalAssignment['status'] | null;
|
||||
}): { status: TCohortGoalAssignment['status']; completedAt: Date | null } {
|
||||
const { completed, required, dueDate, now, previousStatus } = args;
|
||||
|
||||
if (previousStatus === 'waived') {
|
||||
@@ -223,15 +219,15 @@ function decideStatus(args: {
|
||||
/**
|
||||
* Re-evaluate every assignment for one goal.
|
||||
*
|
||||
* Pulls all student program-members, computes a per-learner status, and upserts
|
||||
* Pulls all student cohort-members, computes a per-learner status, and upserts
|
||||
* `program_goal_assignment`. Designed to be safe to run repeatedly (idempotent)
|
||||
* and cheap enough for daily cron over hundreds of programs.
|
||||
*/
|
||||
export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }> {
|
||||
const goal = await getProgramGoalById(goalId);
|
||||
const goal = await getCohortGoalById(goalId);
|
||||
if (!goal) return { evaluated: 0 };
|
||||
|
||||
const members = await getProgramMembers(goal.programId);
|
||||
const members = await getCohortMembers(goal.cohortId);
|
||||
const studentMembers = members.filter((member) => member.roleId === ROLE.STUDENT && member.profileId);
|
||||
if (studentMembers.length === 0) return { evaluated: 0 };
|
||||
|
||||
@@ -244,7 +240,7 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
|
||||
goal.type === 'score' || goal.type === 'pass_rate'
|
||||
? getMaxScoresForProfilesAndCourses(profileIds, courseIds)
|
||||
: Promise.resolve(new Map<string, number>()),
|
||||
getProgramGoalAssignments(goalId)
|
||||
getCohortGoalAssignments(goalId)
|
||||
]);
|
||||
|
||||
const completionByKey = new Map<string, { status: string; score: number | null }>();
|
||||
@@ -255,13 +251,13 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
|
||||
});
|
||||
}
|
||||
|
||||
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.programMemberId, assignment]));
|
||||
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.cohortMemberId, assignment]));
|
||||
|
||||
const now = new Date();
|
||||
const requiredFor = (type: GoalType, total: number) =>
|
||||
type === 'n_of_m' && goal.requiredCount ? Math.min(goal.requiredCount, total) : total;
|
||||
|
||||
const rows: TNewProgramGoalAssignment[] = [];
|
||||
const rows: TNewCohortGoalAssignment[] = [];
|
||||
|
||||
for (const member of studentMembers) {
|
||||
if (!member.profileId) continue;
|
||||
@@ -303,7 +299,7 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
|
||||
|
||||
rows.push({
|
||||
goalId,
|
||||
programMemberId: member.id,
|
||||
cohortMemberId: member.id,
|
||||
dueDate: dueDate ? dueDate.toISOString() : null,
|
||||
status: decision.status,
|
||||
completedCount: completed,
|
||||
@@ -314,17 +310,17 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
|
||||
});
|
||||
}
|
||||
|
||||
await upsertProgramGoalAssignments(rows);
|
||||
await upsertCohortGoalAssignments(rows);
|
||||
|
||||
return { evaluated: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluate every active goal in a program. Used after material program
|
||||
* Re-evaluate every active goal in a cohort. Used after material program
|
||||
* changes (member added, course added) or by cron sweeps.
|
||||
*/
|
||||
export async function evaluateProgramGoals(programId: string): Promise<{ evaluated: number }> {
|
||||
const goals = await getProgramGoals(programId);
|
||||
export async function evaluateCohortGoals(cohortId: string): Promise<{ evaluated: number }> {
|
||||
const goals = await getCohortGoals(cohortId);
|
||||
let evaluated = 0;
|
||||
for (const goal of goals) {
|
||||
const result = await evaluateGoal(goal.id);
|
||||
@@ -338,7 +334,7 @@ export async function evaluateProgramGoals(programId: string): Promise<{ evaluat
|
||||
* Re-evaluate all goals in an organization. Used by the daily cron.
|
||||
*/
|
||||
export async function evaluateOrgGoals(organizationId: string): Promise<{ evaluated: number; goals: number }> {
|
||||
const goals = await getProgramGoalsByOrg(organizationId);
|
||||
const goals = await getCohortGoalsByOrg(organizationId);
|
||||
let evaluated = 0;
|
||||
for (const goal of goals) {
|
||||
const result = await evaluateGoal(goal.id);
|
||||
@@ -352,11 +348,11 @@ export async function evaluateOrgGoals(organizationId: string): Promise<{ evalua
|
||||
* Re-evaluate every active goal across every org. Designed for the daily cron
|
||||
* sweep — same shape as `runComplianceExpiryCheck` for the compliance feature.
|
||||
*/
|
||||
export async function runProgramGoalEvaluationSweep(): Promise<{
|
||||
export async function runCohortGoalEvaluationSweep(): Promise<{
|
||||
goalsEvaluated: number;
|
||||
assignmentsEvaluated: number;
|
||||
}> {
|
||||
const goals = await listAllActiveProgramGoals();
|
||||
const goals = await listAllActiveCohortGoals();
|
||||
let assignmentsEvaluated = 0;
|
||||
|
||||
for (const goal of goals) {
|
||||
@@ -364,7 +360,7 @@ export async function runProgramGoalEvaluationSweep(): Promise<{
|
||||
const result = await evaluateGoal(goal.id);
|
||||
assignmentsEvaluated += result.evaluated;
|
||||
} catch (error) {
|
||||
console.error(`runProgramGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
|
||||
console.error(`runCohortGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +382,7 @@ function getWholeDaysUntil(now: Date, target: Date): number {
|
||||
* Idempotency: the email worker idempotency key is keyed on assignment id +
|
||||
* the day bucket so a second run on the same day does not re-send.
|
||||
*/
|
||||
export async function runProgramGoalReminderScan(): Promise<{
|
||||
export async function runCohortGoalReminderScan(): Promise<{
|
||||
scanned: number;
|
||||
remindersEnqueued: number;
|
||||
}> {
|
||||
@@ -417,27 +413,27 @@ export async function runProgramGoalReminderScan(): Promise<{
|
||||
const dayKey = now.toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
await enqueueTransactionalEmail('programGoalReminder', {
|
||||
await enqueueTransactionalEmail('cohortGoalReminder', {
|
||||
to: row.email,
|
||||
fields: {
|
||||
orgName: row.organizationName,
|
||||
programName: row.programName,
|
||||
cohortName: row.cohortName,
|
||||
goalTitle: row.goal.title,
|
||||
daysUntilDue,
|
||||
completedCount: row.assignment.completedCount ?? 0,
|
||||
requiredCount: row.assignment.requiredCount ?? 0,
|
||||
loginUrl: `${loginUrl}/lms/programs/${row.programId}`,
|
||||
loginUrl: `${loginUrl}/lms/cohorts/${row.cohortId}`,
|
||||
branding: buildEmailBranding({
|
||||
name: row.organizationName,
|
||||
avatarUrl: row.organizationAvatarUrl,
|
||||
theme: row.organizationTheme
|
||||
})
|
||||
},
|
||||
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
|
||||
idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
|
||||
});
|
||||
remindersEnqueued += 1;
|
||||
} catch (error) {
|
||||
console.error(`runProgramGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
|
||||
console.error(`runCohortGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,11 +449,11 @@ export async function getMyGoals(profileId: string) {
|
||||
// ─── Org-wide overview ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cross-program goal roll-up for the org owner. Lists every active goal in the
|
||||
* Cross-cohort goal roll-up for the org owner. Lists every active goal in the
|
||||
* org with its per-status counts so the dashboard can group by program.
|
||||
*/
|
||||
export async function getOrgGoalsOverview(organizationId: string) {
|
||||
const goals = await getProgramGoalsByOrg(organizationId);
|
||||
const goals = await getCohortGoalsByOrg(organizationId);
|
||||
if (goals.length === 0) {
|
||||
return { goals: [] };
|
||||
}
|
||||
@@ -476,8 +472,8 @@ export async function getOrgGoalsOverview(organizationId: string) {
|
||||
|
||||
return {
|
||||
goalId: goal.id,
|
||||
programId: goal.programId,
|
||||
programName: goal.programName,
|
||||
cohortId: goal.cohortId,
|
||||
cohortName: goal.cohortName,
|
||||
title: goal.title,
|
||||
type: goal.type,
|
||||
deadlineKind: goal.deadlineKind,
|
||||
@@ -0,0 +1,52 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getCohortById } from '@cio/db/queries/cohort';
|
||||
import type { TInviteStudentsToCohort, TAssignExistingStudentsToCohort } from '@cio/utils/validation/cohort';
|
||||
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
|
||||
|
||||
/**
|
||||
* Invite students to a single program by email CSV.
|
||||
*
|
||||
* Thin wrapper that hardcodes `cohortIds = [cohortId]` and delegates to
|
||||
* `importAudienceMembers`. Lets a Cohort ADMIN/TUTOR (or org admin) issue org
|
||||
* invites scoped to *their own* program, without granting org-wide invite rights.
|
||||
*/
|
||||
export async function inviteStudentsToCohort(
|
||||
cohortId: string,
|
||||
data: TInviteStudentsToCohort,
|
||||
invitedByProfileId: string
|
||||
) {
|
||||
const cohort = await getCohortById(cohortId);
|
||||
if (!cohort) {
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return importAudienceMembers(
|
||||
cohort.organizationId,
|
||||
{
|
||||
recipientCsv: data.recipientCsv,
|
||||
cohortIds: [cohortId],
|
||||
sendEmail: data.sendEmail,
|
||||
allCourses: false,
|
||||
allCohorts: false
|
||||
},
|
||||
invitedByProfileId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign existing student profiles (already members of the org audience) to a
|
||||
* single program. Same scoping logic as `inviteStudentsToCohort` — restricted
|
||||
* to one program at a time so the caller's program-admin permission is enough.
|
||||
*/
|
||||
export async function assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort) {
|
||||
const cohort = await getCohortById(cohortId);
|
||||
if (!cohort) {
|
||||
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return assignAudienceToCourses(cohort.organizationId, {
|
||||
profileIds: data.profileIds,
|
||||
cohortIds: [cohortId],
|
||||
sendEmail: data.sendEmail
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
|
||||
import { addCohortMember, getExistingCohortMembers, getCohortsByOrg } from '@cio/db/queries/cohort';
|
||||
import {
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudits,
|
||||
@@ -132,21 +132,21 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
|
||||
return { courseIds, courseNames };
|
||||
}
|
||||
|
||||
async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
|
||||
let programIds: string[] = [];
|
||||
let programNames: string[] = [];
|
||||
async function resolveCohortIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
|
||||
let cohortIds: string[] = [];
|
||||
let cohortNames: string[] = [];
|
||||
|
||||
if (data.allPrograms) {
|
||||
const programs = await getProgramsByOrg(orgId);
|
||||
programIds = programs.map((program) => program.id);
|
||||
programNames = programs.map((program) => program.name).filter(Boolean);
|
||||
} else if (data.programIds && data.programIds.length > 0) {
|
||||
const programs = await getProgramsByOrg(orgId, data.programIds);
|
||||
programIds = programs.map((program) => program.id);
|
||||
programNames = programs.map((program) => program.name).filter(Boolean);
|
||||
if (data.allCohorts) {
|
||||
const cohorts = await getCohortsByOrg(orgId);
|
||||
cohortIds = cohorts.map((cohort) => cohort.id);
|
||||
cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean);
|
||||
} else if (data.cohortIds && data.cohortIds.length > 0) {
|
||||
const cohorts = await getCohortsByOrg(orgId, data.cohortIds);
|
||||
cohortIds = cohorts.map((cohort) => cohort.id);
|
||||
cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean);
|
||||
}
|
||||
|
||||
return { programIds, programNames };
|
||||
return { cohortIds, cohortNames };
|
||||
}
|
||||
|
||||
async function enrollAudienceStudentProfilesInCourses(
|
||||
@@ -241,14 +241,14 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
};
|
||||
}
|
||||
|
||||
async function enrollAudienceStudentProfilesInPrograms(
|
||||
async function enrollAudienceStudentProfilesInCohorts(
|
||||
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
|
||||
orgId: string,
|
||||
profileIds: string[],
|
||||
programIds: string[],
|
||||
cohortIds: string[],
|
||||
shouldSendEmail: boolean
|
||||
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
|
||||
if (programIds.length === 0 || profileIds.length === 0) {
|
||||
if (cohortIds.length === 0 || profileIds.length === 0) {
|
||||
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
}
|
||||
|
||||
@@ -260,25 +260,25 @@ async function enrollAudienceStudentProfilesInPrograms(
|
||||
studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? ''])
|
||||
);
|
||||
|
||||
const programs = await getProgramsByOrg(orgId, programIds);
|
||||
if (programs.length === 0) {
|
||||
const cohorts = await getCohortsByOrg(orgId, cohortIds);
|
||||
if (cohorts.length === 0) {
|
||||
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
}
|
||||
|
||||
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
|
||||
const cohortNameById = new Map(cohorts.map((cohort) => [cohort.id, cohort.name || 'Cohort']));
|
||||
const loginUrl = getDashboardBaseUrl(organization);
|
||||
const validProgramIds = programs.map((program) => program.id);
|
||||
const validCohortIds = cohorts.map((cohort) => cohort.id);
|
||||
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
|
||||
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
|
||||
const existingSet = await getExistingProgramMembers(pairs);
|
||||
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.programId}:${pair.profileId}`));
|
||||
const pairs = validProfiles.flatMap((profileId) => validCohortIds.map((cohortId) => ({ cohortId, profileId })));
|
||||
const existingSet = await getExistingCohortMembers(pairs);
|
||||
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.cohortId}:${pair.profileId}`));
|
||||
const alreadyEnrolled = pairs.length - toInsert.length;
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await Promise.all(
|
||||
toInsert.map((pair) =>
|
||||
addProgramMember({
|
||||
programId: pair.programId,
|
||||
addCohortMember({
|
||||
cohortId: pair.cohortId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: pair.profileId,
|
||||
email: profileEmailMap.get(pair.profileId) || undefined
|
||||
@@ -297,20 +297,20 @@ async function enrollAudienceStudentProfilesInPrograms(
|
||||
const email = profileEmailMap.get(pair.profileId)!;
|
||||
|
||||
try {
|
||||
await enqueueTransactionalEmail('studentProgramWelcome', {
|
||||
await enqueueTransactionalEmail('studentCohortWelcome', {
|
||||
to: email,
|
||||
fields: {
|
||||
orgName: organization.name,
|
||||
programName: programNameById.get(pair.programId) || 'Program',
|
||||
cohortName: cohortNameById.get(pair.cohortId) || 'Cohort',
|
||||
loginUrl,
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
|
||||
idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}`
|
||||
});
|
||||
emailsSent++;
|
||||
} catch (emailError) {
|
||||
console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError);
|
||||
console.error(`enrollAudienceStudentProfilesInCohorts enqueue error for ${email}:`, emailError);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -328,12 +328,12 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
|
||||
emails: string[];
|
||||
courseIds: string[];
|
||||
programIds: string[];
|
||||
cohortIds: string[];
|
||||
accessNamesLabel: string | undefined;
|
||||
invitedByProfileId: string;
|
||||
shouldSendEmail: boolean;
|
||||
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
|
||||
const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
|
||||
const { orgId, organization, emails, courseIds, cohortIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
|
||||
input;
|
||||
|
||||
if (emails.length === 0) {
|
||||
@@ -360,7 +360,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
metadata: {
|
||||
source: 'AUDIENCE_IMPORT',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
||||
programIds: programIds.length > 0 ? programIds : undefined
|
||||
cohortIds: cohortIds.length > 0 ? cohortIds : undefined
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -386,7 +386,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds,
|
||||
programIds
|
||||
cohortIds
|
||||
}
|
||||
};
|
||||
})
|
||||
@@ -504,8 +504,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
}
|
||||
|
||||
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
|
||||
const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
|
||||
const accessNames = [...courseNames, ...programNames];
|
||||
const { cohortIds, cohortNames } = await resolveCohortIdsAndNamesForImport(orgId, data);
|
||||
const accessNames = [...courseNames, ...cohortNames];
|
||||
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
|
||||
|
||||
const assignedToCourses = await enrollAudienceStudentProfilesInCourses(
|
||||
@@ -515,11 +515,11 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
courseIds,
|
||||
data.sendEmail
|
||||
);
|
||||
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
|
||||
const assignedToCohorts = await enrollAudienceStudentProfilesInCohorts(
|
||||
organization,
|
||||
orgId,
|
||||
existingStudentProfileIds,
|
||||
programIds,
|
||||
cohortIds,
|
||||
data.sendEmail
|
||||
);
|
||||
|
||||
@@ -554,14 +554,14 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
}
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
if (cohortIds.length > 0) {
|
||||
const profiles = await getProfilesByEmails(newEmails);
|
||||
if (profiles.length > 0) {
|
||||
await enrollAudienceStudentProfilesInPrograms(
|
||||
await enrollAudienceStudentProfilesInCohorts(
|
||||
organization,
|
||||
orgId,
|
||||
profiles.map((profile) => profile.id),
|
||||
programIds,
|
||||
cohortIds,
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -572,7 +572,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
organization,
|
||||
emails: newEmails,
|
||||
courseIds,
|
||||
programIds,
|
||||
cohortIds,
|
||||
accessNamesLabel,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
@@ -590,7 +590,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
organization,
|
||||
emails: pendingStudentEmails,
|
||||
courseIds,
|
||||
programIds,
|
||||
cohortIds,
|
||||
accessNamesLabel,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
@@ -601,12 +601,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
||||
|
||||
return {
|
||||
imported,
|
||||
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
|
||||
assigned: assignedToCourses.assigned + assignedToCohorts.assigned,
|
||||
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
|
||||
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
|
||||
alreadyEnrolledInCohorts: assignedToCohorts.alreadyEnrolled,
|
||||
pendingInvitesRenewed: pendingStudentEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToCohorts.emailsSent + importEmailsSent + pendingEmailsSent,
|
||||
emailsFailed: importEmailsFailed + pendingEmailsFailed
|
||||
};
|
||||
}
|
||||
@@ -628,9 +628,9 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; cohortIds?: string[] } | undefined) ?? {};
|
||||
const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
|
||||
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
|
||||
const cohortIdsFromMetadata = meta.cohortIds?.filter(Boolean) ?? [];
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
@@ -640,19 +640,19 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
courseNames = courses.items.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
|
||||
let programIds: string[] = [];
|
||||
let programNames: string[] = [];
|
||||
if (programIdsFromMetadata.length > 0) {
|
||||
const programs = await getProgramsByOrg(orgId, programIdsFromMetadata);
|
||||
programIds = programs.map((program) => program.id);
|
||||
programNames = programs.map((program) => program.name).filter(Boolean);
|
||||
let cohortIds: string[] = [];
|
||||
let cohortNames: string[] = [];
|
||||
if (cohortIdsFromMetadata.length > 0) {
|
||||
const cohorts = await getCohortsByOrg(orgId, cohortIdsFromMetadata);
|
||||
cohortIds = cohorts.map((cohort) => cohort.id);
|
||||
cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean);
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
const token = generateToken();
|
||||
const accessNames = [...courseNames, ...programNames];
|
||||
const accessNames = [...courseNames, ...cohortNames];
|
||||
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
|
||||
|
||||
const invite = await createOrganizationInvite({
|
||||
@@ -666,7 +666,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
metadata: {
|
||||
source: 'AUDIENCE_RESEND',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
||||
programIds: programIds.length > 0 ? programIds : undefined
|
||||
cohortIds: cohortIds.length > 0 ? cohortIds : undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -684,7 +684,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds,
|
||||
programIds
|
||||
cohortIds
|
||||
}
|
||||
}
|
||||
]);
|
||||
@@ -788,10 +788,10 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
|
||||
}
|
||||
|
||||
const courseIds = data.courseIds ?? [];
|
||||
const programIds = data.programIds ?? [];
|
||||
const cohortIds = data.cohortIds ?? [];
|
||||
|
||||
let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
let assignedToCohorts = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
|
||||
@@ -808,25 +808,25 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
|
||||
);
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
const programs = await getProgramsByOrg(orgId, programIds);
|
||||
if (programs.length === 0) {
|
||||
throw new AppError('No valid programs found', ErrorCodes.VALIDATION_ERROR, 400, 'programIds');
|
||||
if (cohortIds.length > 0) {
|
||||
const cohorts = await getCohortsByOrg(orgId, cohortIds);
|
||||
if (cohorts.length === 0) {
|
||||
throw new AppError('No valid cohorts found', ErrorCodes.VALIDATION_ERROR, 400, 'cohortIds');
|
||||
}
|
||||
|
||||
assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
|
||||
assignedToCohorts = await enrollAudienceStudentProfilesInCohorts(
|
||||
organization,
|
||||
orgId,
|
||||
data.profileIds,
|
||||
programIds,
|
||||
cohortIds,
|
||||
data.sendEmail
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
|
||||
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent
|
||||
assigned: assignedToCourses.assigned + assignedToCohorts.assigned,
|
||||
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToCohorts.alreadyEnrolled,
|
||||
emailsSent: assignedToCourses.emailsSent + assignedToCohorts.emailsSent
|
||||
};
|
||||
}
|
||||
|
||||
@@ -882,14 +882,14 @@ export async function updatePendingAudienceMemberEmail(
|
||||
}
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail);
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
|
||||
const meta = (latestInvite?.metadata as { courseIds?: string[]; cohortIds?: string[] } | undefined) ?? {};
|
||||
|
||||
await createStudentOrgInvitesAndSendEmails({
|
||||
orgId,
|
||||
organization,
|
||||
emails: [normalizedEmail],
|
||||
courseIds: meta.courseIds?.filter(Boolean) ?? [],
|
||||
programIds: meta.programIds?.filter(Boolean) ?? [],
|
||||
cohortIds: meta.cohortIds?.filter(Boolean) ?? [],
|
||||
accessNamesLabel: undefined,
|
||||
invitedByProfileId,
|
||||
shouldSendEmail: data.sendEmail
|
||||
|
||||
@@ -22,14 +22,14 @@ import {
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
|
||||
import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TNewOrganizationInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import { buildEmailBranding } from '@cio/email';
|
||||
@@ -404,7 +404,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
if (invite) {
|
||||
const metadata = invite.invite.metadata;
|
||||
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
||||
const programIds = parseProgramIdsFromInviteMetadata(metadata);
|
||||
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
@@ -422,19 +422,19 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
}
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
if (cohortIds.length > 0) {
|
||||
try {
|
||||
const existingProgramMemberships = await getExistingProgramMembers(
|
||||
programIds.map((programId) => ({ programId, profileId: user.id }))
|
||||
const existingCohortMemberships = await getExistingCohortMembers(
|
||||
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
|
||||
);
|
||||
const programIdsToInsert = programIds.filter(
|
||||
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
|
||||
const cohortIdsToInsert = cohortIds.filter(
|
||||
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
programIdsToInsert.map((programId) =>
|
||||
addProgramMember({
|
||||
programId,
|
||||
cohortIdsToInsert.map((cohortId) =>
|
||||
addCohortMember({
|
||||
cohortId,
|
||||
roleId: invite.invite.roleId,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail
|
||||
@@ -442,7 +442,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInvite program enrollment error:', error);
|
||||
console.error('acceptOrganizationInvite cohort enrollment error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -697,7 +697,7 @@ export async function acceptOrganizationInviteById(
|
||||
|
||||
const metadata = result.invite.metadata;
|
||||
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
||||
const programIds = parseProgramIdsFromInviteMetadata(metadata);
|
||||
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
@@ -714,22 +714,22 @@ export async function acceptOrganizationInviteById(
|
||||
}
|
||||
}
|
||||
|
||||
if (programIds.length > 0) {
|
||||
if (cohortIds.length > 0) {
|
||||
try {
|
||||
const existingProgramMemberships = await getExistingProgramMembers(
|
||||
programIds.map((programId) => ({ programId, profileId: user.id }))
|
||||
const existingCohortMemberships = await getExistingCohortMembers(
|
||||
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
|
||||
);
|
||||
const programIdsToInsert = programIds.filter(
|
||||
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
|
||||
const cohortIdsToInsert = cohortIds.filter(
|
||||
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
programIdsToInsert.map((programId) =>
|
||||
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
|
||||
cohortIdsToInsert.map((cohortId) =>
|
||||
addCohortMember({ cohortId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInviteById program enrollment error:', error);
|
||||
console.error('acceptOrganizationInviteById cohort enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
searchLmsCourses,
|
||||
searchLmsPrograms,
|
||||
searchLmsCohorts,
|
||||
searchOrgAudience,
|
||||
searchOrgCourses,
|
||||
searchOrgPrograms,
|
||||
searchOrgCohorts,
|
||||
searchOrgTagsAndGroups,
|
||||
searchOrgWidgets
|
||||
} from '@cio/db/queries';
|
||||
|
||||
export async function searchOrganization(orgId: string, search: string, limit: number) {
|
||||
const [courses, programs, widgets, tags, audience] = await Promise.all([
|
||||
const [courses, cohorts, widgets, tags, audience] = await Promise.all([
|
||||
searchOrgCourses(orgId, search, limit),
|
||||
searchOrgPrograms(orgId, search, limit),
|
||||
searchOrgCohorts(orgId, search, limit),
|
||||
searchOrgWidgets(orgId, search, limit),
|
||||
searchOrgTagsAndGroups(orgId, search, limit),
|
||||
searchOrgAudience(orgId, search, limit)
|
||||
@@ -19,7 +19,7 @@ export async function searchOrganization(orgId: string, search: string, limit: n
|
||||
|
||||
return {
|
||||
courses,
|
||||
programs,
|
||||
cohorts,
|
||||
widgets,
|
||||
tags,
|
||||
audience
|
||||
@@ -27,13 +27,13 @@ export async function searchOrganization(orgId: string, search: string, limit: n
|
||||
}
|
||||
|
||||
export async function searchLmsOrganization(orgId: string, profileId: string, search: string, limit: number) {
|
||||
const [courses, programs] = await Promise.all([
|
||||
const [courses, cohorts] = await Promise.all([
|
||||
searchLmsCourses(orgId, profileId, search, limit),
|
||||
searchLmsPrograms(orgId, profileId, search, limit)
|
||||
searchLmsCohorts(orgId, profileId, search, limit)
|
||||
]);
|
||||
|
||||
return {
|
||||
courses,
|
||||
programs
|
||||
cohorts
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getProgramById } from '@cio/db/queries/program';
|
||||
import type { TInviteStudentsToProgram, TAssignExistingStudentsToProgram } from '@cio/utils/validation/program';
|
||||
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
|
||||
|
||||
/**
|
||||
* Invite students to a single program by email CSV.
|
||||
*
|
||||
* Thin wrapper that hardcodes `programIds = [programId]` and delegates to
|
||||
* `importAudienceMembers`. Lets a Program ADMIN/TUTOR (or org admin) issue org
|
||||
* invites scoped to *their own* program, without granting org-wide invite rights.
|
||||
*/
|
||||
export async function inviteStudentsToProgram(
|
||||
programId: string,
|
||||
data: TInviteStudentsToProgram,
|
||||
invitedByProfileId: string
|
||||
) {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return importAudienceMembers(
|
||||
program.organizationId,
|
||||
{
|
||||
recipientCsv: data.recipientCsv,
|
||||
programIds: [programId],
|
||||
sendEmail: data.sendEmail,
|
||||
allCourses: false,
|
||||
allPrograms: false
|
||||
},
|
||||
invitedByProfileId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign existing student profiles (already members of the org audience) to a
|
||||
* single program. Same scoping logic as `inviteStudentsToProgram` — restricted
|
||||
* to one program at a time so the caller's program-admin permission is enough.
|
||||
*/
|
||||
export async function assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram) {
|
||||
const program = await getProgramById(programId);
|
||||
if (!program) {
|
||||
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return assignAudienceToCourses(program.organizationId, {
|
||||
profileIds: data.profileIds,
|
||||
programIds: [programId],
|
||||
sendEmail: data.sendEmail
|
||||
});
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function createAudienceService(orgId: string, actorId: string | nul
|
||||
recipientCsv: payload.email,
|
||||
courseIds: payload.courseIds,
|
||||
allCourses: false,
|
||||
allPrograms: false,
|
||||
allCohorts: false,
|
||||
sendEmail: payload.sendEmail
|
||||
},
|
||||
actorId
|
||||
|
||||
@@ -30,7 +30,7 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
return parseInviteIds(metadata, ['courseIds', 'course_ids']);
|
||||
}
|
||||
|
||||
/** Normalize JSONB organization-invite metadata for program ids (matches importAudienceMembers storage). */
|
||||
export function parseProgramIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
return parseInviteIds(metadata, ['programIds', 'program_ids']);
|
||||
/** Normalize JSONB organization-invite metadata for cohort ids (legacy `programIds` supported). */
|
||||
export function parseCohortIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
return parseInviteIds(metadata, ['cohortIds', 'cohort_ids', 'programIds', 'program_ids']);
|
||||
}
|
||||
|
||||
@@ -16,23 +16,23 @@
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Program {
|
||||
interface Cohort {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
programs: Program[];
|
||||
cohorts: Cohort[];
|
||||
}
|
||||
|
||||
let { courses, programs }: Props = $props();
|
||||
let { courses, cohorts }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let programAccessMode = $state('none');
|
||||
let cohortAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let selectedProgramIds = new SvelteSet<string>();
|
||||
let selectedCohortIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProgram(programId: string) {
|
||||
if (selectedProgramIds.has(programId)) {
|
||||
selectedProgramIds.delete(programId);
|
||||
function toggleCohort(cohortId: string) {
|
||||
if (selectedCohortIds.has(cohortId)) {
|
||||
selectedCohortIds.delete(cohortId);
|
||||
} else {
|
||||
selectedProgramIds.add(programId);
|
||||
selectedCohortIds.add(cohortId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
allPrograms: programAccessMode === 'all',
|
||||
allCohorts: cohortAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
|
||||
cohortIds: cohortAccessMode === 'select' ? [...selectedCohortIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
@@ -128,32 +128,32 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.cohort_access')}</Label>
|
||||
<RadioGroup.Root bind:value={cohortAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
<RadioGroup.Item value="none" id="cohort-none" />
|
||||
<Label for="cohort-none" class="font-normal">{$t('audience.import.no_cohorts')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
<RadioGroup.Item value="all" id="cohort-all" />
|
||||
<Label for="cohort-all" class="font-normal">{$t('audience.import.all_cohorts')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
<RadioGroup.Item value="select" id="cohort-select" />
|
||||
<Label for="cohort-select" class="font-normal">{$t('audience.import.select_cohorts')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if programAccessMode === 'select'}
|
||||
{#if cohortAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
heading={$t('audience.import.select_cohorts')}
|
||||
emptyMessage={$t('audience.import.select_cohorts_placeholder')}
|
||||
items={cohorts.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedCohortIds.has(id)}
|
||||
onToggle={toggleCohort}
|
||||
namePrefix="import-cohort"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { Cohort, CohortDetail, CohortMember, CohortCourse, CohortNewsfeed } from '../utils/types';
|
||||
import type {
|
||||
TCreateCohort,
|
||||
TUpdateCohort,
|
||||
TAddCohortMembers,
|
||||
TUpdateCohortMember,
|
||||
TAddCourseToCohort,
|
||||
TInviteStudentsToCohort,
|
||||
TAssignExistingStudentsToCohort
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
class CohortApi extends BaseApiWithErrors {
|
||||
cohorts = $state<Cohort[]>([]);
|
||||
cohort = $state<CohortDetail | null>(null);
|
||||
members = $state<CohortMember[]>([]);
|
||||
courses = $state<CohortCourse[]>([]);
|
||||
newsfeed = $state<CohortNewsfeed | null>(null);
|
||||
isCohortShellLoading = $state(false);
|
||||
currentCohortId = $state<string | null>(null);
|
||||
|
||||
loadedCohortId = $state<string | null>(null);
|
||||
loadedMembersCohortId = $state<string | null>(null);
|
||||
loadedCoursesCohortId = $state<string | null>(null);
|
||||
loadedCoursesStudentExperience = $state<boolean | null>(null);
|
||||
loadedNewsfeedCohortId = $state<string | null>(null);
|
||||
|
||||
syncCohortCourseCount(cohortId: string, courseCount: number) {
|
||||
this.cohorts = this.cohorts.map((item) => (item.id === cohortId ? { ...item, courseCount } : item));
|
||||
}
|
||||
|
||||
async listCohorts() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.cohort.$get>({
|
||||
requestFn: () => classroomio.cohort.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (data) => {
|
||||
this.cohorts = data.data;
|
||||
},
|
||||
logContext: 'listCohorts'
|
||||
});
|
||||
}
|
||||
|
||||
async getCohort(cohortId: string, force = false) {
|
||||
if (!force && this.loadedCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].$get({ param: { cohortId } }),
|
||||
onSuccess: (data) => {
|
||||
this.cohort = data.data;
|
||||
this.loadedCohortId = cohortId;
|
||||
},
|
||||
logContext: 'getCohort'
|
||||
});
|
||||
}
|
||||
|
||||
resetCohortShell(cohortId?: string) {
|
||||
this.currentCohortId = cohortId ?? null;
|
||||
this.cohort = null;
|
||||
this.members = [];
|
||||
this.courses = [];
|
||||
this.newsfeed = null;
|
||||
this.loadedCohortId = null;
|
||||
this.loadedMembersCohortId = null;
|
||||
this.loadedCoursesCohortId = null;
|
||||
this.loadedCoursesStudentExperience = null;
|
||||
this.loadedNewsfeedCohortId = null;
|
||||
}
|
||||
|
||||
async ensureCohortShell(cohortId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
const isShellReady =
|
||||
this.loadedCohortId === cohortId &&
|
||||
this.loadedMembersCohortId === cohortId &&
|
||||
this.loadedCoursesCohortId === cohortId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience;
|
||||
|
||||
if (!force && isShellReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCohortChanged = this.currentCohortId !== cohortId;
|
||||
if (force || isCohortChanged) {
|
||||
this.resetCohortShell(cohortId);
|
||||
}
|
||||
|
||||
this.currentCohortId = cohortId;
|
||||
this.isCohortShellLoading = true;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.getCohort(cohortId, force),
|
||||
this.listMembers(cohortId, force),
|
||||
this.listCourses(cohortId, force)
|
||||
]);
|
||||
} finally {
|
||||
this.isCohortShellLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createCohort(data: TCreateCohort) {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.cohort.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort.$post({
|
||||
json: { ...data, organizationId: org.id }
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.cohorts = [res.data, ...this.cohorts];
|
||||
snackbar.success(t.get('cohorts.create_success'));
|
||||
},
|
||||
logContext: 'createCohort'
|
||||
});
|
||||
}
|
||||
|
||||
async updateCohort(cohortId: string, data: TUpdateCohort) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['$put']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].$put({ param: { cohortId }, json: data }),
|
||||
onSuccess: (res) => {
|
||||
this.cohort = res.data;
|
||||
this.cohorts = this.cohorts.map((p) => (p.id === cohortId ? { ...p, ...res.data } : p));
|
||||
snackbar.success(t.get('cohorts.update_success'));
|
||||
},
|
||||
onError: (result) => {
|
||||
const message = typeof result === 'string' ? result : result.error;
|
||||
snackbar.error(message || 'Failed to update cohort');
|
||||
},
|
||||
logContext: 'updateCohort'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCohort(cohortId: string) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['$delete']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].$delete({ param: { cohortId } }),
|
||||
onSuccess: () => {
|
||||
this.cohorts = this.cohorts.filter((p) => p.id !== cohortId);
|
||||
snackbar.success(t.get('cohorts.delete_success'));
|
||||
},
|
||||
logContext: 'deleteCohort'
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
|
||||
async listMembers(cohortId: string, force = false) {
|
||||
if (!force && this.loadedMembersCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].members.$get({ param: { cohortId } }),
|
||||
onSuccess: (data) => {
|
||||
this.members = data.data;
|
||||
this.loadedMembersCohortId = cohortId;
|
||||
},
|
||||
logContext: 'listCohortMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async addMembers(cohortId: string, data: TAddCohortMembers) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].members.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.members_added') || 'Members added');
|
||||
},
|
||||
logContext: 'addCohortMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(cohortId: string, memberId: string, data: TUpdateCohortMember) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].members[':memberId'].$put({
|
||||
param: { cohortId, memberId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
|
||||
},
|
||||
logContext: 'updateCohortMember'
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(cohortId: string, memberId: string) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].members[':memberId'].$delete({
|
||||
param: { cohortId, memberId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.members = this.members.filter((m) => m.id !== memberId);
|
||||
snackbar.success(t.get('cohorts.member_removed') || 'Member removed');
|
||||
},
|
||||
logContext: 'removeCohortMember'
|
||||
});
|
||||
}
|
||||
|
||||
// Courses
|
||||
|
||||
async listCourses(cohortId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
|
||||
if (
|
||||
!force &&
|
||||
this.loadedCoursesCohortId === cohortId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].courses.$get({ param: { cohortId } }),
|
||||
onSuccess: (data) => {
|
||||
this.courses = data.data;
|
||||
this.syncCohortCourseCount(cohortId, data.data.length);
|
||||
this.loadedCoursesCohortId = cohortId;
|
||||
this.loadedCoursesStudentExperience = studentExperience;
|
||||
},
|
||||
logContext: 'listCohortCourses'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourse(cohortId: string, data: TAddCourseToCohort) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].courses.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listCourses(cohortId, true);
|
||||
this.syncCohortCourseCount(cohortId, this.courses.length);
|
||||
snackbar.success(t.get('cohorts.course_added') || 'Course added');
|
||||
},
|
||||
logContext: 'addCourseToCohort'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourses(cohortId: string, courseIds: string[]) {
|
||||
if (courseIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
|
||||
try {
|
||||
for (const courseId of courseIds) {
|
||||
const response = await classroomio.cohort[':cohortId'].courses.$post({
|
||||
param: { cohortId },
|
||||
json: { courseId }
|
||||
});
|
||||
const result = (await response.json()) as { success?: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
this.error = result.error ?? 'Failed to add course to cohort';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await this.listCourses(cohortId, true);
|
||||
this.syncCohortCourseCount(cohortId, this.courses.length);
|
||||
|
||||
const successMessage =
|
||||
courseIds.length === 1
|
||||
? t.get('cohorts.course_added') || 'Course added'
|
||||
: t.get('cohorts.multiple_added') || 'Courses added';
|
||||
|
||||
snackbar.success(successMessage);
|
||||
this.success = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in addCoursesToCohort:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to add courses to cohort';
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeCourse(cohortId: string, courseId: string) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses'][':courseId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].courses[':courseId'].$delete({
|
||||
param: { cohortId, courseId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.courses = this.courses.filter((c) => c.courseId !== courseId);
|
||||
this.syncCohortCourseCount(cohortId, this.courses.length);
|
||||
snackbar.success(t.get('cohorts.course_removed') || 'Course removed');
|
||||
},
|
||||
logContext: 'removeCourseFromCohort'
|
||||
});
|
||||
}
|
||||
|
||||
// Invitations (program-scoped)
|
||||
|
||||
/**
|
||||
* Invite new students to a cohort by CSV. Goes through the cohort-scoped
|
||||
* endpoint so a Cohort ADMIN/TUTOR can invite without org-admin rights.
|
||||
* Returns whether the call succeeded so callers can refresh their lists.
|
||||
*/
|
||||
async inviteStudentsToCohort(cohortId: string, data: TInviteStudentsToCohort): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].invite.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.invite_sent') || 'Invites sent');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'inviteStudentsToCohort'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign existing org audience student profiles to this cohort.
|
||||
* Same scoping as `inviteStudentsToCohort` — caller only needs program-team
|
||||
* membership for this cohort.
|
||||
*/
|
||||
async assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['assign']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].invite.assign.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.members_added') || 'Members added');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'assignExistingStudentsToCohort'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Newsfeed
|
||||
|
||||
async listNewsfeed(cohortId: string, force = false, cursor?: string) {
|
||||
if (!force && this.loadedNewsfeedCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['newsfeed']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].newsfeed.$get({
|
||||
param: { cohortId },
|
||||
query: { limit: '10', ...(cursor ? { cursor } : {}) }
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
this.newsfeed = data.data;
|
||||
this.loadedNewsfeedCohortId = cohortId;
|
||||
},
|
||||
logContext: 'listCohortNewsfeed'
|
||||
});
|
||||
}
|
||||
|
||||
// LMS — enrolled cohorts
|
||||
|
||||
async listEnrolledCohorts() {
|
||||
await this.execute<typeof classroomio.cohort.enrolled.$get>({
|
||||
requestFn: () => classroomio.cohort.enrolled.$get({}),
|
||||
onSuccess: (data) => {
|
||||
this.cohorts = data.data;
|
||||
},
|
||||
logContext: 'listEnrolledCohorts'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cohortApi = new CohortApi();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort';
|
||||
import type { GoalsOverviewRow, MyGoalAssignment, CohortGoal } from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
class CohortGoalApi extends BaseApiWithErrors {
|
||||
goalsByCohortId = $state<Record<string, CohortGoal[]>>({});
|
||||
myGoals = $state<MyGoalAssignment[]>([]);
|
||||
overviewRows = $state<GoalsOverviewRow[]>([]);
|
||||
loadedCohortId = $state<string | null>(null);
|
||||
|
||||
goalsFor(cohortId: string): CohortGoal[] {
|
||||
return this.goalsByCohortId[cohortId] ?? [];
|
||||
}
|
||||
|
||||
async listGoals(cohortId: string, force = false) {
|
||||
if (!force && this.goalsByCohortId[cohortId] && this.loadedCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].goals.$get({ param: { cohortId } }),
|
||||
onSuccess: (res) => {
|
||||
this.goalsByCohortId = { ...this.goalsByCohortId, [cohortId]: res.data };
|
||||
this.loadedCohortId = cohortId;
|
||||
},
|
||||
logContext: 'listCohortGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async createGoal(cohortId: string, data: TCreateCohortGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].goals.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.goals.created') || 'Goal created');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'createCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async updateGoal(cohortId: string, goalId: string, data: TUpdateCohortGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].goals[':goalId'].$put({
|
||||
param: { cohortId, goalId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.goals.updated') || 'Goal updated');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'updateCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async deleteGoal(cohortId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].goals[':goalId'].$delete({
|
||||
param: { cohortId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.goals.deleted') || 'Goal deleted');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'deleteCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async evaluateGoal(cohortId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['evaluate']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].goals[':goalId'].evaluate.$post({
|
||||
param: { cohortId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'evaluateCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async listMyGoals() {
|
||||
await this.execute<typeof classroomio.cohort.my.goals.$get>({
|
||||
requestFn: () => classroomio.cohort.my.goals.$get({}),
|
||||
onSuccess: (res) => {
|
||||
this.myGoals = res.data;
|
||||
},
|
||||
logContext: 'listMyGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async listOverview() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.cohort.goals.overview.$get>({
|
||||
requestFn: () => classroomio.cohort.goals.overview.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (res) => {
|
||||
this.overviewRows = res.data.goals;
|
||||
},
|
||||
logContext: 'listGoalsOverview'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cohortGoalApi = new CohortGoalApi();
|
||||
@@ -0,0 +1,3 @@
|
||||
export { cohortApi } from './cohort.svelte';
|
||||
export { cohortGoalApi } from './goal.svelte';
|
||||
export { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from './newsfeed.svelte';
|
||||
+78
-78
@@ -1,35 +1,35 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CreateProgramNewsfeedCommentRequest,
|
||||
CreateProgramNewsfeedRequest,
|
||||
DeleteProgramNewsfeedCommentRequest,
|
||||
DeleteProgramNewsfeedRequest,
|
||||
GetProgramNewsfeedCommentsRequest,
|
||||
ListProgramNewsfeed,
|
||||
ListProgramNewsfeedRequest,
|
||||
CreateCohortNewsfeedCommentRequest,
|
||||
CreateCohortNewsfeedRequest,
|
||||
DeleteCohortNewsfeedCommentRequest,
|
||||
DeleteCohortNewsfeedRequest,
|
||||
GetCohortNewsfeedCommentsRequest,
|
||||
ListCohortNewsfeed,
|
||||
ListCohortNewsfeedRequest,
|
||||
ProgramNewsfeedComment,
|
||||
ReactToProgramNewsfeedRequest,
|
||||
UpdateProgramNewsfeedRequest
|
||||
ReactToCohortNewsfeedRequest,
|
||||
UpdateCohortNewsfeedRequest
|
||||
} from '../utils/types';
|
||||
import type {
|
||||
TCreateProgramNewsfeed,
|
||||
TCreateProgramNewsfeedComment,
|
||||
TUpdateProgramNewsfeed,
|
||||
TUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
TCreateCohortNewsfeed,
|
||||
TCreateCohortNewsfeedComment,
|
||||
TUpdateCohortNewsfeed,
|
||||
TUpdateCohortReaction
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import {
|
||||
ZCreateProgramNewsfeed,
|
||||
ZCreateProgramNewsfeedComment,
|
||||
ZUpdateProgramNewsfeed,
|
||||
ZUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
ZCreateCohortNewsfeed,
|
||||
ZCreateCohortNewsfeedComment,
|
||||
ZUpdateCohortNewsfeed,
|
||||
ZUpdateCohortReaction
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { get } from 'svelte/store';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
export type ProgramNewsfeedCommentsByFeedId = {
|
||||
export type CohortNewsfeedCommentsByFeedId = {
|
||||
items: ProgramNewsfeedComment[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
@@ -37,10 +37,10 @@ export type ProgramNewsfeedCommentsByFeedId = {
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
feeds = $state<ListProgramNewsfeed>([]);
|
||||
export class CohortNewsfeedApi extends BaseApiWithErrors {
|
||||
feeds = $state<ListCohortNewsfeed>([]);
|
||||
|
||||
commentsByFeedId = $state<Record<string, ProgramNewsfeedCommentsByFeedId>>({});
|
||||
commentsByFeedId = $state<Record<string, CohortNewsfeedCommentsByFeedId>>({});
|
||||
|
||||
isNewFeedModalOpen = $state(false);
|
||||
|
||||
@@ -52,14 +52,14 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
this.isNewFeedModalOpen = false;
|
||||
}
|
||||
|
||||
async list(programId: string) {
|
||||
await this.execute<ListProgramNewsfeedRequest>({
|
||||
async list(cohortId: string) {
|
||||
await this.execute<ListCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$get({
|
||||
param: { programId },
|
||||
classroomio.cohort[':cohortId'].newsfeed.$get({
|
||||
param: { cohortId },
|
||||
query: {}
|
||||
}),
|
||||
logContext: 'listing program newsfeed',
|
||||
logContext: 'listing cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
this.feeds = response.data.items || [];
|
||||
this.commentsByFeedId = {};
|
||||
@@ -69,36 +69,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.program.error.news_feed_fail');
|
||||
snackbar.error('snackbar.cohort.error.news_feed_fail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(programId: string, fields: TCreateProgramNewsfeed) {
|
||||
async create(cohortId: string, fields: TCreateCohortNewsfeed) {
|
||||
this.success = false;
|
||||
|
||||
if (isHtmlValueEmpty(fields.content)) {
|
||||
this.errors = mapZodErrorsToTranslations(
|
||||
ZCreateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
ZCreateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
'newsfeed'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = ZCreateProgramNewsfeed.safeParse(fields);
|
||||
const validationResult = ZCreateCohortNewsfeed.safeParse(fields);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateProgramNewsfeedRequest>({
|
||||
await this.execute<CreateCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$post({
|
||||
param: { programId },
|
||||
classroomio.cohort[':cohortId'].newsfeed.$post({
|
||||
param: { cohortId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'creating program newsfeed',
|
||||
logContext: 'creating cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const author = get(profile);
|
||||
const newFeed = {
|
||||
@@ -112,36 +112,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
|
||||
this.feeds = [newFeed, ...this.feeds];
|
||||
this.initComments(newFeed.id, 0);
|
||||
snackbar.success('snackbar.program.success.post_created');
|
||||
snackbar.success('snackbar.cohort.success.post_created');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) {
|
||||
async update(cohortId: string, feedId: string, fields: TUpdateCohortNewsfeed) {
|
||||
this.success = false;
|
||||
|
||||
if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) {
|
||||
this.errors = mapZodErrorsToTranslations(
|
||||
ZUpdateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
ZUpdateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
'newsfeed'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = ZUpdateProgramNewsfeed.safeParse(fields);
|
||||
const validationResult = ZUpdateCohortNewsfeed.safeParse(fields);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateProgramNewsfeedRequest>({
|
||||
await this.execute<UpdateCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].$put({
|
||||
param: { programId, feedId },
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].$put({
|
||||
param: { cohortId, feedId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'updating program newsfeed',
|
||||
logContext: 'updating cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const index = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (index === -1) {
|
||||
@@ -159,26 +159,26 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
commentCount: existingFeed.commentCount
|
||||
};
|
||||
|
||||
snackbar.success('snackbar.program.success.post_updated');
|
||||
snackbar.success('snackbar.cohort.success.post_updated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async react(programId: string, feedId: string, reaction: TUpdateProgramReaction['reaction']) {
|
||||
const validationResult = ZUpdateProgramReaction.safeParse({ reaction });
|
||||
async react(cohortId: string, feedId: string, reaction: TUpdateCohortReaction['reaction']) {
|
||||
const validationResult = ZUpdateCohortReaction.safeParse({ reaction });
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<ReactToProgramNewsfeedRequest>({
|
||||
await this.execute<ReactToCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].react.$put({
|
||||
param: { programId, feedId },
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].react.$put({
|
||||
param: { cohortId, feedId },
|
||||
json: validationResult.data
|
||||
}),
|
||||
logContext: 'reacting to program newsfeed',
|
||||
logContext: 'reacting to cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const index = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (index === -1) {
|
||||
@@ -197,25 +197,25 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.program.error.reaction_error');
|
||||
snackbar.error('snackbar.cohort.error.reaction_error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(programId: string, feedId: string) {
|
||||
await this.execute<DeleteProgramNewsfeedRequest>({
|
||||
async delete(cohortId: string, feedId: string) {
|
||||
await this.execute<DeleteCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].$delete({
|
||||
param: { programId, feedId }
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].$delete({
|
||||
param: { cohortId, feedId }
|
||||
}),
|
||||
logContext: 'deleting program newsfeed',
|
||||
logContext: 'deleting cohort newsfeed',
|
||||
onSuccess: () => {
|
||||
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
|
||||
|
||||
const { [feedId]: _removedComments, ...remainingComments } = this.commentsByFeedId;
|
||||
this.commentsByFeedId = remainingComments;
|
||||
|
||||
snackbar.success('snackbar.program.success.feed_delete_success');
|
||||
snackbar.success('snackbar.cohort.success.feed_delete_success');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -235,19 +235,19 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
this.commentsByFeedId[feedId].totalCount = totalCount;
|
||||
}
|
||||
|
||||
async getComments(programId: string, feedId: string) {
|
||||
async getComments(cohortId: string, feedId: string) {
|
||||
if (!this.commentsByFeedId[feedId]) {
|
||||
this.initComments(feedId, 0);
|
||||
}
|
||||
|
||||
this.commentsByFeedId[feedId].isLoading = true;
|
||||
|
||||
await this.execute<GetProgramNewsfeedCommentsRequest>({
|
||||
await this.execute<GetCohortNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { programId, feedId }
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { cohortId, feedId }
|
||||
}),
|
||||
logContext: 'fetching program newsfeed comments',
|
||||
logContext: 'fetching cohort newsfeed comments',
|
||||
onSuccess: (response) => {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: response.data,
|
||||
@@ -264,27 +264,27 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
}
|
||||
|
||||
async createComment(
|
||||
programId: string,
|
||||
cohortId: string,
|
||||
feedId: string,
|
||||
content: string,
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string }
|
||||
) {
|
||||
const validationResult = ZCreateProgramNewsfeedComment.safeParse({
|
||||
const validationResult = ZCreateCohortNewsfeedComment.safeParse({
|
||||
content: getTextFromHTML(content)
|
||||
} satisfies TCreateProgramNewsfeedComment);
|
||||
} satisfies TCreateCohortNewsfeedComment);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateProgramNewsfeedCommentRequest>({
|
||||
await this.execute<CreateCohortNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comment.$post({
|
||||
param: { programId, feedId },
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment.$post({
|
||||
param: { cohortId, feedId },
|
||||
json: { content }
|
||||
}),
|
||||
logContext: 'creating program newsfeed comment',
|
||||
logContext: 'creating cohort newsfeed comment',
|
||||
onSuccess: (response) => {
|
||||
const existingCommentState = this.commentsByFeedId[feedId];
|
||||
if (!existingCommentState) {
|
||||
@@ -313,18 +313,18 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.program.success.comment_added');
|
||||
snackbar.success('snackbar.cohort.success.comment_added');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(programId: string, feedId: string, commentId: string) {
|
||||
await this.execute<DeleteProgramNewsfeedCommentRequest>({
|
||||
async deleteComment(cohortId: string, feedId: string, commentId: string) {
|
||||
await this.execute<DeleteCohortNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comment[':commentId'].$delete({
|
||||
param: { programId, feedId, commentId }
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment[':commentId'].$delete({
|
||||
param: { cohortId, feedId, commentId }
|
||||
}),
|
||||
logContext: 'deleting program newsfeed comment',
|
||||
logContext: 'deleting cohort newsfeed comment',
|
||||
onSuccess: () => {
|
||||
const existingCommentState = this.commentsByFeedId[feedId];
|
||||
if (!existingCommentState) {
|
||||
@@ -345,10 +345,10 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.program.success.comment_deleted');
|
||||
snackbar.success('snackbar.cohort.success.comment_deleted');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programNewsfeedApi = new ProgramNewsfeedApi();
|
||||
export const cohortNewsfeedApi = new CohortNewsfeedApi();
|
||||
+15
-15
@@ -6,24 +6,24 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
const COURSE_PAGE_SIZE = 20;
|
||||
|
||||
let { open = $bindable(false), programId }: Props = $props();
|
||||
let { open = $bindable(false), cohortId }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let currentPage = $state(1);
|
||||
const selectedCourseIds = new SvelteSet<string>();
|
||||
|
||||
const availableCourses = $derived.by(() => {
|
||||
const existingCourseIds = new Set(programApi.courses.map((course) => course.courseId));
|
||||
const existingCourseIds = new Set(cohortApi.courses.map((course) => course.courseId));
|
||||
|
||||
return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id));
|
||||
});
|
||||
@@ -47,8 +47,8 @@
|
||||
const canGoToNextPage = $derived(currentPage < totalPages);
|
||||
const emptyMessage = $derived(
|
||||
searchValue.trim()
|
||||
? $t('programs.courses.no_matching_courses') || 'No courses match your search.'
|
||||
: $t('programs.courses.no_available_courses') || 'No available courses to add.'
|
||||
? $t('cohorts.courses.no_matching_courses') || 'No courses match your search.'
|
||||
: $t('cohorts.courses.no_available_courses') || 'No available courses to add.'
|
||||
);
|
||||
const multiSelectItems = $derived(
|
||||
paginatedCourses.map((course) => ({
|
||||
@@ -97,7 +97,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const didAddCourses = await programApi.addCourses(programId, courseIds);
|
||||
const didAddCourses = await cohortApi.addCourses(cohortId, courseIds);
|
||||
if (!didAddCourses) {
|
||||
return;
|
||||
}
|
||||
@@ -127,7 +127,7 @@
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.courses.add_modal_title') || 'Add Course to Program'}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.courses.add_modal_title') || 'Add Course to Cohort'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
@@ -139,14 +139,14 @@
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onToggle={(id) => toggleCourse(id)}
|
||||
namePrefix="program-course"
|
||||
namePrefix="cohort-course"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t('programs.courses.search_placeholder') || 'Search courses'}
|
||||
searchPlaceholder={$t('cohorts.courses.search_placeholder') || 'Search courses'}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$t('programs.courses.select_courses') || $t('programs.courses.select_course') || 'Select course(s)'}
|
||||
{$t('cohorts.courses.select_courses') || $t('cohorts.courses.select_course') || 'Select course(s)'}
|
||||
{#if selectedCount > 0}
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('audience.selected_count', { count: selectedCount })}
|
||||
@@ -159,7 +159,7 @@
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<span class="ui:text-muted-foreground">
|
||||
{$t('programs.courses.pagination_status', {
|
||||
{$t('cohorts.courses.pagination_status', {
|
||||
page: currentPage,
|
||||
totalPages
|
||||
}) || `Page ${currentPage} of ${totalPages}`}
|
||||
@@ -188,11 +188,11 @@
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={closeModal} disabled={programApi.isLoading}>
|
||||
<Button variant="outline" onclick={closeModal} disabled={cohortApi.isLoading}>
|
||||
{$t('app.cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button onclick={handleAdd} loading={programApi.isLoading} disabled={selectedCount === 0 || programApi.isLoading}>
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
<Button onclick={handleAdd} loading={cohortApi.isLoading} disabled={selectedCount === 0 || cohortApi.isLoading}>
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
+8
-8
@@ -3,17 +3,17 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Program } from '../utils/types';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
program: Program;
|
||||
cohort: Cohort;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { program, onclick }: Props = $props();
|
||||
let { cohort, onclick }: Props = $props();
|
||||
|
||||
const courseCount = $derived(program.courseCount ?? 0);
|
||||
const studentCount = $derived(program.studentCount ?? 0);
|
||||
const courseCount = $derived(cohort.courseCount ?? 0);
|
||||
const studentCount = $derived(cohort.studentCount ?? 0);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-pointer p-4!" {onclick}>
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="flex h-full flex-col justify-between gap-5" {...props}>
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{program.name}
|
||||
{cohort.name}
|
||||
</Item.Title>
|
||||
|
||||
<Separator class="my-3" />
|
||||
@@ -29,13 +29,13 @@
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="w-[60%]">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('programs.card.courses'), courseCount, true)}
|
||||
{pluralize($t('cohorts.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('programs.card.students'), studentCount, true)}
|
||||
{pluralize($t('cohorts.card.students'), studentCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
+18
-18
@@ -7,10 +7,10 @@
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
DEFAULT_PROGRAM_SORT,
|
||||
DEFAULT_PROGRAM_SORT_ORDER,
|
||||
type ProgramSortBy,
|
||||
type ProgramSortOrder
|
||||
DEFAULT_COHORT_SORT,
|
||||
DEFAULT_COHORT_SORT_ORDER,
|
||||
type CohortSortBy,
|
||||
type CohortSortOrder
|
||||
} from '../utils/constants';
|
||||
|
||||
interface StatusOption {
|
||||
@@ -19,13 +19,13 @@
|
||||
}
|
||||
|
||||
interface SortOption {
|
||||
value: ProgramSortBy;
|
||||
value: CohortSortBy;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sortKey?: ProgramSortBy;
|
||||
selectedOrder?: ProgramSortOrder;
|
||||
sortKey?: CohortSortBy;
|
||||
selectedOrder?: CohortSortOrder;
|
||||
selectedStatuses?: string[];
|
||||
statusOptions?: StatusOption[];
|
||||
sortOptions?: SortOption[];
|
||||
@@ -34,8 +34,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
sortKey = $bindable(DEFAULT_PROGRAM_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_PROGRAM_SORT_ORDER),
|
||||
sortKey = $bindable(DEFAULT_COHORT_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_COHORT_SORT_ORDER),
|
||||
selectedStatuses = [],
|
||||
statusOptions = [],
|
||||
sortOptions = [],
|
||||
@@ -46,7 +46,7 @@
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_PROGRAM_SORT || selectedOrder !== DEFAULT_PROGRAM_SORT_ORDER || selectedStatuses.length > 0
|
||||
sortKey !== DEFAULT_COHORT_SORT || selectedOrder !== DEFAULT_COHORT_SORT_ORDER || selectedStatuses.length > 0
|
||||
);
|
||||
|
||||
function isStatusSelected(status: string) {
|
||||
@@ -58,7 +58,7 @@
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('programs.filters.filter')} tooltip={$t('programs.filters.filter')}>
|
||||
<IconButton {...props} aria-label={$t('cohorts.filters.filter')} tooltip={$t('cohorts.filters.filter')}>
|
||||
<FilterIcon size={16} />
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
@@ -74,15 +74,15 @@
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('programs.filters.popover_title')}</p>
|
||||
<p class="text-sm font-semibold">{$t('cohorts.filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('programs.filters.clear_all')}
|
||||
{$t('cohorts.filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.sort')}
|
||||
{$t('cohorts.filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.order')}
|
||||
{$t('cohorts.filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
@@ -113,14 +113,14 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value="asc">{$t('programs.filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('programs.filters.descending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="asc">{$t('cohorts.filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('cohorts.filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.status')}
|
||||
{$t('cohorts.filters.status')}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each statusOptions as option (option.value)}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{programApi.program?.name || ''}
|
||||
{cohortApi.cohort?.name || ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+12
-12
@@ -5,7 +5,7 @@
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
await programApi.createProgram({ name: name.trim(), description: description.trim() || undefined });
|
||||
await cohortApi.createCohort({ name: name.trim(), description: description.trim() || undefined });
|
||||
name = '';
|
||||
description = '';
|
||||
open = false;
|
||||
@@ -34,29 +34,29 @@
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.create_modal.title') || 'Create Program'}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.create_modal.title') || 'Create Cohort'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('programs.create_modal.description') || 'Add a new certification or compliance program.'}
|
||||
{$t('cohorts.create_modal.description') || 'Add a new certification or compliance cohort.'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="program-name">{$t('programs.create_modal.name_label') || 'Name'} *</Label>
|
||||
<Label for="program-name">{$t('cohorts.create_modal.name_label') || 'Name'} *</Label>
|
||||
<Input
|
||||
id="program-name"
|
||||
id="cohort-name"
|
||||
bind:value={name}
|
||||
placeholder={$t('programs.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
|
||||
placeholder={$t('cohorts.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
|
||||
maxlength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="program-description">{$t('programs.create_modal.description_label') || 'Description'}</Label>
|
||||
<Label for="program-description">{$t('cohorts.create_modal.description_label') || 'Description'}</Label>
|
||||
<Textarea
|
||||
id="program-description"
|
||||
id="cohort-description"
|
||||
bind:value={description}
|
||||
placeholder={$t('programs.create_modal.description_placeholder') || 'What is this program about?'}
|
||||
placeholder={$t('cohorts.create_modal.description_placeholder') || 'What is this cohort about?'}
|
||||
rows={3}
|
||||
maxlength={2000}
|
||||
/>
|
||||
@@ -65,8 +65,8 @@
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleCancel}>{$t('app.cancel') || 'Cancel'}</Button>
|
||||
<Button onclick={handleCreate} disabled={!name.trim() || programApi.isLoading}>
|
||||
{$t('programs.create_modal.submit') || 'Create'}
|
||||
<Button onclick={handleCreate} disabled={!name.trim() || cohortApi.isLoading}>
|
||||
{$t('cohorts.create_modal.submit') || 'Create'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
+45
-45
@@ -8,26 +8,26 @@
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import {
|
||||
PROGRAM_GOAL_DEADLINE_KINDS,
|
||||
PROGRAM_GOAL_TYPES,
|
||||
type TCreateProgramGoal,
|
||||
type TUpdateProgramGoal
|
||||
} from '@cio/utils/validation/program';
|
||||
COHORT_GOAL_DEADLINE_KINDS,
|
||||
COHORT_GOAL_TYPES,
|
||||
type TCreateCohortGoal,
|
||||
type TUpdateCohortGoal
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi, programGoalApi } from '$features/program/api';
|
||||
import type { ProgramGoal } from '$features/program/utils/types';
|
||||
import { cohortApi, cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
|
||||
type GoalType = (typeof PROGRAM_GOAL_TYPES)[number];
|
||||
type DeadlineKind = (typeof PROGRAM_GOAL_DEADLINE_KINDS)[number];
|
||||
type GoalType = (typeof COHORT_GOAL_TYPES)[number];
|
||||
type DeadlineKind = (typeof COHORT_GOAL_DEADLINE_KINDS)[number];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
programId: string;
|
||||
goal: ProgramGoal | null;
|
||||
cohortId: string;
|
||||
goal: CohortGoal | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), programId, goal, onClose }: Props = $props();
|
||||
let { open = $bindable(), cohortId, goal, onClose }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
@@ -45,9 +45,9 @@
|
||||
let formError = $state<string | null>(null);
|
||||
|
||||
const isEditing = $derived(goal !== null);
|
||||
const availableCourses = $derived(programApi.courses);
|
||||
const availableCourses = $derived(cohortApi.courses);
|
||||
|
||||
function resetFromGoal(source: ProgramGoal | null) {
|
||||
function resetFromGoal(source: CohortGoal | null) {
|
||||
if (source) {
|
||||
title = source.title;
|
||||
description = source.description ?? '';
|
||||
@@ -105,14 +105,14 @@
|
||||
.filter((value) => Number.isFinite(value) && value >= 0 && value <= 365);
|
||||
}
|
||||
|
||||
function buildPayload(): TCreateProgramGoal | null {
|
||||
function buildPayload(): TCreateCohortGoal | null {
|
||||
if (!title.trim()) {
|
||||
formError = $t('programs.goals.modal.title_label') + ' is required';
|
||||
formError = $t('cohorts.goals.modal.title_label') + ' is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (courseIds.length === 0) {
|
||||
formError = $t('programs.goals.modal.courses_label') + ' is required';
|
||||
formError = $t('cohorts.goals.modal.courses_label') + ' is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -141,10 +141,10 @@
|
||||
try {
|
||||
const ok =
|
||||
isEditing && goal
|
||||
? await programGoalApi.updateGoal(programId, goal.id, payload as TUpdateProgramGoal)
|
||||
: await programGoalApi.createGoal(programId, payload);
|
||||
? await cohortGoalApi.updateGoal(cohortId, goal.id, payload as TUpdateCohortGoal)
|
||||
: await cohortGoalApi.createGoal(cohortId, payload);
|
||||
if (ok) onClose();
|
||||
else formError = programGoalApi.error ?? 'Failed to save';
|
||||
else formError = cohortGoalApi.error ?? 'Failed to save';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
@@ -159,40 +159,40 @@
|
||||
<Dialog.Content class="max-h-[90vh] w-[96vw] max-w-2xl overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{isEditing ? $t('programs.goals.modal.edit_title') : $t('programs.goals.modal.create_title')}
|
||||
{isEditing ? $t('cohorts.goals.modal.edit_title') : $t('cohorts.goals.modal.create_title')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-title">{$t('programs.goals.modal.title_label')} *</Label>
|
||||
<Label for="goal-title">{$t('cohorts.goals.modal.title_label')} *</Label>
|
||||
<Input
|
||||
id="goal-title"
|
||||
bind:value={title}
|
||||
maxlength={255}
|
||||
placeholder={$t('programs.goals.modal.title_placeholder')}
|
||||
placeholder={$t('cohorts.goals.modal.title_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-description">{$t('programs.goals.modal.description_label')}</Label>
|
||||
<Label for="goal-description">{$t('cohorts.goals.modal.description_label')}</Label>
|
||||
<Textarea
|
||||
id="goal-description"
|
||||
bind:value={description}
|
||||
rows={2}
|
||||
maxlength={2000}
|
||||
placeholder={$t('programs.goals.modal.description_placeholder')}
|
||||
placeholder={$t('cohorts.goals.modal.description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.goals.modal.type_label')}</Label>
|
||||
<Label>{$t('cohorts.goals.modal.type_label')}</Label>
|
||||
<Select.Root type="single" bind:value={type}>
|
||||
<Select.Trigger>{$t(`programs.goals.type.${type}`)}</Select.Trigger>
|
||||
<Select.Trigger>{$t(`cohorts.goals.type.${type}`)}</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each PROGRAM_GOAL_TYPES as goalType (goalType)}
|
||||
{#each COHORT_GOAL_TYPES as goalType (goalType)}
|
||||
<Select.Item value={goalType}>
|
||||
{$t(`programs.goals.type.${goalType}`)}
|
||||
{$t(`cohorts.goals.type.${goalType}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
@@ -200,9 +200,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.goals.modal.courses_label')} *</Label>
|
||||
<Label>{$t('cohorts.goals.modal.courses_label')} *</Label>
|
||||
{#if availableCourses.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">No courses in this program yet.</p>
|
||||
<p class="ui:text-muted-foreground text-sm">No courses in this cohort yet.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3">
|
||||
{#each availableCourses as item (item.courseId)}
|
||||
@@ -220,7 +220,7 @@
|
||||
|
||||
{#if type === 'n_of_m'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-required-count">{$t('programs.goals.modal.required_count_label')} *</Label>
|
||||
<Label for="goal-required-count">{$t('cohorts.goals.modal.required_count_label')} *</Label>
|
||||
<Input
|
||||
id="goal-required-count"
|
||||
type="number"
|
||||
@@ -237,7 +237,7 @@
|
||||
|
||||
{#if type === 'score' || type === 'pass_rate'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-score">{$t('programs.goals.modal.score_threshold_label')} *</Label>
|
||||
<Label for="goal-score">{$t('cohorts.goals.modal.score_threshold_label')} *</Label>
|
||||
<Input
|
||||
id="goal-score"
|
||||
type="number"
|
||||
@@ -254,7 +254,7 @@
|
||||
|
||||
{#if type === 'pass_rate'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-team-rate">{$t('programs.goals.modal.team_pass_rate_label')} *</Label>
|
||||
<Label for="goal-team-rate">{$t('cohorts.goals.modal.team_pass_rate_label')} *</Label>
|
||||
<Input
|
||||
id="goal-team-rate"
|
||||
type="number"
|
||||
@@ -270,13 +270,13 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.goals.modal.deadline_label')}</Label>
|
||||
<Label>{$t('cohorts.goals.modal.deadline_label')}</Label>
|
||||
<Select.Root type="single" bind:value={deadlineKind} disabled={type === 'readiness'}>
|
||||
<Select.Trigger>{$t(`programs.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
|
||||
<Select.Trigger>{$t(`cohorts.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each PROGRAM_GOAL_DEADLINE_KINDS as kind (kind)}
|
||||
{#each COHORT_GOAL_DEADLINE_KINDS as kind (kind)}
|
||||
<Select.Item value={kind}>
|
||||
{$t(`programs.goals.deadline_kind.${kind}`)}
|
||||
{$t(`cohorts.goals.deadline_kind.${kind}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
@@ -285,14 +285,14 @@
|
||||
|
||||
{#if deadlineKind === 'absolute'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-deadline-date">{$t('programs.goals.modal.deadline_date_label')} *</Label>
|
||||
<Label for="goal-deadline-date">{$t('cohorts.goals.modal.deadline_date_label')} *</Label>
|
||||
<Input id="goal-deadline-date" type="date" bind:value={deadlineDate} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deadlineKind === 'relative_to_join'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-relative-days">{$t('programs.goals.modal.relative_days_label')} *</Label>
|
||||
<Label for="goal-relative-days">{$t('cohorts.goals.modal.relative_days_label')} *</Label>
|
||||
<Input
|
||||
id="goal-relative-days"
|
||||
type="number"
|
||||
@@ -309,7 +309,7 @@
|
||||
|
||||
{#if deadlineKind === 'recurring'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-recurring-months">{$t('programs.goals.modal.recurring_months_label')} *</Label>
|
||||
<Label for="goal-recurring-months">{$t('cohorts.goals.modal.recurring_months_label')} *</Label>
|
||||
<Input
|
||||
id="goal-recurring-months"
|
||||
type="number"
|
||||
@@ -326,9 +326,9 @@
|
||||
|
||||
{#if deadlineKind !== 'none'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-reminders">{$t('programs.goals.modal.reminders_label')}</Label>
|
||||
<Label for="goal-reminders">{$t('cohorts.goals.modal.reminders_label')}</Label>
|
||||
<Input id="goal-reminders" bind:value={reminderDaysBeforeText} placeholder="7, 3, 1" />
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.modal.reminders_helper')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.modal.reminders_helper')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -338,10 +338,10 @@
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<Button variant="ghost" onclick={onClose} disabled={isSaving}>
|
||||
{$t('programs.goals.modal.cancel')}
|
||||
{$t('cohorts.goals.modal.cancel')}
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleSave} loading={isSaving} disabled={isSaving}>
|
||||
{$t('programs.goals.modal.save')}
|
||||
{$t('cohorts.goals.modal.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
+15
-15
@@ -2,30 +2,30 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { ProgramGoal } from '$features/program/utils/types';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
const goals = $derived(programGoalApi.goalsFor(programId));
|
||||
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listGoals(programId);
|
||||
cohortGoalApi.listGoals(cohortId);
|
||||
});
|
||||
|
||||
function totalLearners(goal: ProgramGoal): number {
|
||||
function totalLearners(goal: CohortGoal): number {
|
||||
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function statusValue(goal: ProgramGoal, status: string): number {
|
||||
function statusValue(goal: CohortGoal, status: string): number {
|
||||
return goal.statusCounts?.[status] ?? 0;
|
||||
}
|
||||
|
||||
function onTrackPct(goal: ProgramGoal): number {
|
||||
function onTrackPct(goal: CohortGoal): number {
|
||||
const total = totalLearners(goal);
|
||||
if (total === 0) return 0;
|
||||
const onTrack =
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
{#if goals.length > 0}
|
||||
<section class="mb-4">
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('programs.goals.heading')}</h3>
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('cohorts.goals.heading')}</h3>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each goals as goal (goal.id)}
|
||||
{@const total = totalLearners(goal)}
|
||||
@@ -46,27 +46,27 @@
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm font-medium">{goal.title}</p>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-lg font-semibold">{onTrackPct(goal)}%</span>
|
||||
<span class="ui:text-muted-foreground text-xs">{$t('programs.goals.tile.on_track')}</span>
|
||||
<span class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.tile.on_track')}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-xs">
|
||||
<span>
|
||||
<span class="font-medium">{completed}/{total}</span>
|
||||
{' '}{$t('programs.goals.tile.completed_short')}
|
||||
{' '}{$t('cohorts.goals.tile.completed_short')}
|
||||
</span>
|
||||
{#if atRisk > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRisk}</span>
|
||||
{' '}{$t('programs.goals.tile.at_risk_short')}
|
||||
{' '}{$t('cohorts.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdue > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdue}</span>
|
||||
{' '}{$t('programs.goals.tile.overdue_short')}
|
||||
{' '}{$t('cohorts.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
+27
-27
@@ -7,24 +7,24 @@
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { ProgramGoal } from '$features/program/utils/types';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
import GoalFormModal from './goal-form-modal.svelte';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
let { programId, canManage }: Props = $props();
|
||||
let { cohortId, canManage }: Props = $props();
|
||||
|
||||
let isModalOpen = $state(false);
|
||||
let editingGoal = $state<ProgramGoal | null>(null);
|
||||
let editingGoal = $state<CohortGoal | null>(null);
|
||||
|
||||
const goals = $derived(programGoalApi.goalsFor(programId));
|
||||
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listGoals(programId);
|
||||
cohortGoalApi.listGoals(cohortId);
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
@@ -32,20 +32,20 @@
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(goal: ProgramGoal) {
|
||||
function openEdit(goal: CohortGoal) {
|
||||
editingGoal = goal;
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
async function deleteGoal(goal: ProgramGoal) {
|
||||
const confirmMessage = $t('programs.goals.delete_confirm');
|
||||
async function deleteGoal(goal: CohortGoal) {
|
||||
const confirmMessage = $t('cohorts.goals.delete_confirm');
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
await programGoalApi.deleteGoal(programId, goal.id);
|
||||
await cohortGoalApi.deleteGoal(cohortId, goal.id);
|
||||
}
|
||||
|
||||
function summarizeDeadline(goal: ProgramGoal) {
|
||||
if (goal.deadlineKind === 'none') return $t('programs.goals.lms.no_deadline');
|
||||
function summarizeDeadline(goal: CohortGoal) {
|
||||
if (goal.deadlineKind === 'none') return $t('cohorts.goals.lms.no_deadline');
|
||||
if (goal.deadlineKind === 'absolute' && goal.deadlineDate) {
|
||||
return new Date(goal.deadlineDate).toLocaleDateString();
|
||||
}
|
||||
@@ -58,14 +58,14 @@
|
||||
return `Repeats every ${goal.recurringMonths}mo`;
|
||||
}
|
||||
|
||||
return $t('programs.goals.lms.no_deadline');
|
||||
return $t('cohorts.goals.lms.no_deadline');
|
||||
}
|
||||
|
||||
function totalLearners(goal: ProgramGoal): number {
|
||||
function totalLearners(goal: CohortGoal): number {
|
||||
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function statusValue(goal: ProgramGoal, status: string): number {
|
||||
function statusValue(goal: CohortGoal, status: string): number {
|
||||
return goal.statusCounts?.[status] ?? 0;
|
||||
}
|
||||
</script>
|
||||
@@ -73,21 +73,21 @@
|
||||
<section>
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">{$t('programs.goals.heading')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('programs.goals.description')}</p>
|
||||
<h2 class="text-sm font-semibold">{$t('cohorts.goals.heading')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('cohorts.goals.description')}</p>
|
||||
</div>
|
||||
{#if canManage}
|
||||
<Button variant="secondary" onclick={openCreate}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('programs.goals.add')}
|
||||
{$t('cohorts.goals.add')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if goals.length === 0}
|
||||
<div class="rounded-md border p-6 text-center">
|
||||
<p class="text-sm font-medium">{$t('programs.goals.empty_title')}</p>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('programs.goals.empty_description')}</p>
|
||||
<p class="text-sm font-medium">{$t('cohorts.goals.empty_title')}</p>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('cohorts.goals.empty_description')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-medium">{goal.title}</p>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</div>
|
||||
{#if goal.description}
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">{goal.description}</p>
|
||||
@@ -124,19 +124,19 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div class="rounded border p-2 text-center">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.completed')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.completed')}</p>
|
||||
<p class="text-base font-semibold">{completed}/{total}</p>
|
||||
</div>
|
||||
<div class="rounded border p-2 text-center">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.in_progress')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.in_progress')}</p>
|
||||
<p class="text-base font-semibold">{statusValue(goal, 'in_progress')}</p>
|
||||
</div>
|
||||
<div class="rounded border p-2 text-center {atRisk > 0 ? 'bg-amber-50' : ''}">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.at_risk')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.at_risk')}</p>
|
||||
<p class="text-base font-semibold">{atRisk}</p>
|
||||
</div>
|
||||
<div class="rounded border p-2 text-center {overdue > 0 ? 'bg-red-50' : ''}">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.overdue')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.overdue')}</p>
|
||||
<p class="text-base font-semibold">{overdue}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,4 +146,4 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<GoalFormModal bind:open={isModalOpen} {programId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
|
||||
<GoalFormModal bind:open={isModalOpen} {cohortId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
|
||||
+17
-19
@@ -2,38 +2,36 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
|
||||
interface Props {
|
||||
/** When set, only show goals from this program. */
|
||||
programId?: string;
|
||||
/** When set, only show goals from this cohort. */
|
||||
cohortId?: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listMyGoals();
|
||||
cohortGoalApi.listMyGoals();
|
||||
});
|
||||
|
||||
const myGoals = $derived(
|
||||
programGoalApi.myGoals.filter((assignment) => !programId || assignment.programId === programId)
|
||||
);
|
||||
const myGoals = $derived(cohortGoalApi.myGoals.filter((assignment) => !cohortId || assignment.cohortId === cohortId));
|
||||
|
||||
function formatDue(assignment: (typeof programGoalApi.myGoals)[number]): string {
|
||||
if (!assignment.dueDate) return $t('programs.goals.lms.no_deadline');
|
||||
function formatDue(assignment: (typeof cohortGoalApi.myGoals)[number]): string {
|
||||
if (!assignment.dueDate) return $t('cohorts.goals.lms.no_deadline');
|
||||
|
||||
const dueDate = new Date(assignment.dueDate);
|
||||
const now = new Date();
|
||||
const days = Math.ceil((dueDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (days < 0) return $t('programs.goals.lms.overdue');
|
||||
if (days === 0) return $t('programs.goals.lms.due_today');
|
||||
if (days === 1) return $t('programs.goals.lms.due_tomorrow');
|
||||
if (days < 0) return $t('cohorts.goals.lms.overdue');
|
||||
if (days === 0) return $t('cohorts.goals.lms.due_today');
|
||||
if (days === 1) return $t('cohorts.goals.lms.due_tomorrow');
|
||||
|
||||
return $t('programs.goals.lms.due_in_days').replace('{days}', String(days));
|
||||
return $t('cohorts.goals.lms.due_in_days').replace('{days}', String(days));
|
||||
}
|
||||
|
||||
function progressPct(assignment: (typeof programGoalApi.myGoals)[number]): number {
|
||||
function progressPct(assignment: (typeof cohortGoalApi.myGoals)[number]): number {
|
||||
if (assignment.requiredCount <= 0) return 0;
|
||||
return Math.min(100, Math.round((assignment.completedCount / assignment.requiredCount) * 100));
|
||||
}
|
||||
@@ -49,19 +47,19 @@
|
||||
|
||||
{#if myGoals.length > 0}
|
||||
<section class="mb-4">
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('programs.goals.lms.heading')}</h3>
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('cohorts.goals.lms.heading')}</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each myGoals as assignment (assignment.id)}
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">{assignment.goal.title}</p>
|
||||
{#if !programId}
|
||||
<p class="ui:text-muted-foreground text-xs">{assignment.programName}</p>
|
||||
{#if !cohortId}
|
||||
<p class="ui:text-muted-foreground text-xs">{assignment.cohortName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Badge variant={statusVariant(assignment.status)}>
|
||||
{$t(`programs.goals.status.${assignment.status}`)}
|
||||
{$t(`cohorts.goals.status.${assignment.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
+15
-15
@@ -14,13 +14,13 @@
|
||||
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -30,12 +30,12 @@
|
||||
const addPeopleParam = $derived(new URLSearchParams(page.url.search).get('add'));
|
||||
const isOpen = $derived(addPeopleParam === 'true');
|
||||
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, programApi.members));
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, cohortApi.members));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutorIds = new Set(
|
||||
programApi.members
|
||||
cohortApi.members
|
||||
.filter((member) => Number(member.roleId) === ROLE.TUTOR || Number(member.roleId) === ROLE.ADMIN)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
@@ -52,9 +52,9 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], programMembers: typeof programApi.members) {
|
||||
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
|
||||
const existingStudentIds = new Set(
|
||||
programMembers
|
||||
cohortMembers
|
||||
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
@@ -133,9 +133,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
programApi.success = false;
|
||||
cohortApi.success = false;
|
||||
|
||||
await programApi.addMembers(programId, {
|
||||
await cohortApi.addMembers(cohortId, {
|
||||
members: selectedTutors.map((tutor) => ({
|
||||
profileId: tutor.profileId,
|
||||
roleId: ROLE.TUTOR,
|
||||
@@ -144,17 +144,17 @@
|
||||
}))
|
||||
});
|
||||
|
||||
if (programApi.success) {
|
||||
if (cohortApi.success) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
await programApi.assignExistingStudentsToProgram(programId, { profileIds, sendEmail });
|
||||
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
await programApi.inviteStudentsToProgram(programId, { recipientCsv, sendEmail });
|
||||
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -162,7 +162,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen || !programId) {
|
||||
if (!isOpen || !cohortId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.people.invite_modal_title')}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeTab}>
|
||||
@@ -205,7 +205,7 @@
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={programApi.isLoading}>
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
|
||||
{$t(`${INVITE_MODAL}.finish`)}
|
||||
</Button>
|
||||
</div>
|
||||
+18
-18
@@ -5,18 +5,18 @@
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Program } from '../utils/types';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
program: Program;
|
||||
cohort: Cohort;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
let { program, isLMS = false, href }: Props = $props();
|
||||
let { cohort, isLMS = false, href }: Props = $props();
|
||||
|
||||
const courseCount = $derived(program.courseCount ?? 0);
|
||||
const studentCount = $derived(program.studentCount ?? 0);
|
||||
const courseCount = $derived(cohort.courseCount ?? 0);
|
||||
const studentCount = $derived(cohort.studentCount ?? 0);
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
@@ -24,12 +24,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRoute = isLMS ? `/programs/${program.id}/newsfeed?source=lms` : `/programs/${program.id}/newsfeed`;
|
||||
const nextRoute = isLMS ? `/cohorts/${cohort.id}/newsfeed?source=lms` : `/cohorts/${cohort.id}/newsfeed`;
|
||||
|
||||
goto(resolve(nextRoute, {}));
|
||||
}
|
||||
|
||||
function getStatusVariant(status: Program['status']) {
|
||||
function getStatusVariant(status: Cohort['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'success';
|
||||
}
|
||||
@@ -41,16 +41,16 @@
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function getStatusLabelKey(status: Program['status']) {
|
||||
function getStatusLabelKey(status: Cohort['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'programs.status.active';
|
||||
return 'cohorts.status.active';
|
||||
}
|
||||
|
||||
if (status === 'INACTIVE') {
|
||||
return 'programs.status.inactive';
|
||||
return 'cohorts.status.inactive';
|
||||
}
|
||||
|
||||
return 'programs.status.archived';
|
||||
return 'cohorts.status.archived';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,27 +63,27 @@
|
||||
>
|
||||
<div class="ui:flex ui:w-full ui:items-start ui:gap-6">
|
||||
<div class="ui:min-w-0 ui:flex-1">
|
||||
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{program.name}</p>
|
||||
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{cohort.name}</p>
|
||||
|
||||
{#if program.description}
|
||||
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{program.description}</p>
|
||||
{#if cohort.description}
|
||||
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{cohort.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:shrink-0 ui:items-start">
|
||||
<Badge variant={getStatusVariant(program.status)}>
|
||||
{$t(getStatusLabelKey(program.status))}
|
||||
<Badge variant={getStatusVariant(cohort.status)}>
|
||||
{$t(getStatusLabelKey(cohort.status))}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:shrink-0 ui:flex-col ui:items-start ui:gap-1">
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('programs.card.courses'), courseCount, true)}
|
||||
{pluralize(t.get('cohorts.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
|
||||
{#if !isLMS}
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('programs.card.students'), studentCount, true)}
|
||||
{pluralize(t.get('cohorts.card.students'), studentCount, true)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
|
||||
import {
|
||||
getNewsfeedReactionCounts,
|
||||
getSelectedNewsfeedReactionType,
|
||||
@@ -12,10 +12,10 @@
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
feed: ProgramNewsfeedItem;
|
||||
comments?: ProgramNewsfeedCommentsByFeedId;
|
||||
programId?: string;
|
||||
editFeed: ProgramNewsfeedItem | null;
|
||||
feed: CohortNewsfeedItem;
|
||||
comments?: CohortNewsfeedCommentsByFeedId;
|
||||
cohortId?: string;
|
||||
editFeed: CohortNewsfeedItem | null;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -30,7 +30,7 @@
|
||||
deleteComment?: (feedId: string, commentId: string) => void;
|
||||
addNewComment?: (content: string, feedId: string) => Promise<void> | void;
|
||||
addNewReaction?: (reactionType: string, feedId: string, authorId: string) => Promise<void> | void;
|
||||
onPin?: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
|
||||
onPin?: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
|
||||
isActive?: boolean;
|
||||
isReacting?: boolean;
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
let {
|
||||
feed,
|
||||
comments,
|
||||
programId,
|
||||
cohortId,
|
||||
editFeed = $bindable(),
|
||||
author,
|
||||
reactionAuthorId,
|
||||
@@ -63,7 +63,7 @@
|
||||
editFeed = feed;
|
||||
edit = true;
|
||||
await tick();
|
||||
programNewsfeedApi.openNewFeedModal();
|
||||
cohortNewsfeedApi.openNewFeedModal();
|
||||
};
|
||||
|
||||
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, reactionAuthorId);
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<Comments
|
||||
{programId}
|
||||
{cohortId}
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
+17
-17
@@ -8,15 +8,15 @@
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programNewsfeedApi } from '$features/program/api';
|
||||
import { cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import type { CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
|
||||
|
||||
interface Props {
|
||||
programId?: string;
|
||||
feed: ProgramNewsfeedItem;
|
||||
cohortId?: string;
|
||||
feed: CohortNewsfeedItem;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -24,12 +24,12 @@
|
||||
avatarUrl: string;
|
||||
};
|
||||
canComment?: boolean;
|
||||
comments?: ProgramNewsfeedCommentsByFeedId;
|
||||
comments?: CohortNewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number) => void;
|
||||
}
|
||||
|
||||
let { programId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
let { cohortId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
@@ -45,14 +45,14 @@
|
||||
const shownCount = $derived(Math.min(visibleCount, loadedCount));
|
||||
|
||||
const bootstrap = async () => {
|
||||
if (!programId || !comments) return;
|
||||
if (!cohortId || !comments) return;
|
||||
if (didBootstrap) return;
|
||||
|
||||
didBootstrap = true;
|
||||
isBootstrapping = true;
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.getComments(programId, feed.id);
|
||||
await cohortNewsfeedApi.getComments(cohortId, feed.id);
|
||||
} finally {
|
||||
isBootstrapping = false;
|
||||
}
|
||||
@@ -119,18 +119,18 @@
|
||||
class="justify-start"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
<p class="py-2 text-sm">{pluralize($t('programs.newsfeed.comments.comment'), totalCount, true)}</p>
|
||||
<p class="py-2 text-sm">{pluralize($t('cohorts.newsfeed.comments.comment'), totalCount, true)}</p>
|
||||
|
||||
{#if totalCount > shownCount}
|
||||
<span class="ml-auto text-xs text-gray-600">
|
||||
{$t('programs.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
|
||||
{$t('cohorts.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if shownCount > 1}
|
||||
<Button variant="ghost" size="sm" onclick={minimize}>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('programs.newsfeed.comments.hide')}</span>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('cohorts.newsfeed.comments.hide')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={commentItem.authorAvatarUrl || ''}
|
||||
alt={$t('programs.newsfeed.user_avatar_alt')}
|
||||
alt={$t('cohorts.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -166,7 +166,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
|
||||
{$t('programs.newsfeed.comments.delete')}
|
||||
{$t('cohorts.newsfeed.comments.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -179,13 +179,13 @@
|
||||
|
||||
{#if canComment}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('programs.newsfeed.user_avatar_alt')} class="size-7" />
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('cohorts.newsfeed.user_avatar_alt')} class="size-7" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
placeholder={$t('programs.newsfeed.comments.placeholder')}
|
||||
placeholder={$t('cohorts.newsfeed.comments.placeholder')}
|
||||
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
@@ -197,7 +197,7 @@
|
||||
class="rounded-full"
|
||||
loading={isSubmitting}
|
||||
onclick={handleAddNewComment}
|
||||
aria-label={$t('programs.newsfeed.comments.submit_aria')}
|
||||
aria-label={$t('cohorts.newsfeed.comments.submit_aria')}
|
||||
>
|
||||
<SendHorizontalIcon size={16} />
|
||||
</Button>
|
||||
+4
-4
@@ -19,17 +19,17 @@
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.newsfeed.delete_feed_title')}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.newsfeed.delete_feed_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="text-lg">{$t('programs.newsfeed.delete_feed_description')}</p>
|
||||
<p class="text-lg">{$t('cohorts.newsfeed.delete_feed_description')}</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('programs.newsfeed.cancel')}
|
||||
{$t('cohorts.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('programs.newsfeed.confirm_delete')}
|
||||
{$t('cohorts.newsfeed.confirm_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
+7
-7
@@ -5,13 +5,13 @@
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { HTMLRender } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
feed: ProgramNewsfeedItem;
|
||||
feed: CohortNewsfeedItem;
|
||||
canManageFeed?: boolean;
|
||||
onPin: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
|
||||
onPin: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
|
||||
onEdit: () => void;
|
||||
onRequestDelete: () => void;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
alt={$t('programs.newsfeed.user_avatar_alt')}
|
||||
alt={$t('cohorts.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -44,11 +44,11 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
|
||||
{feed.isPinned ? $t('programs.newsfeed.card.unpin') : $t('programs.newsfeed.card.pin')}
|
||||
{feed.isPinned ? $t('cohorts.newsfeed.card.unpin') : $t('cohorts.newsfeed.card.pin')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('programs.newsfeed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('cohorts.newsfeed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}>
|
||||
{$t('programs.newsfeed.card.delete')}
|
||||
{$t('cohorts.newsfeed.card.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
+20
-20
@@ -1,26 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { getErrorKey } from '$features/program/utils/error';
|
||||
import { programNewsfeedApi } from '$features/program/api';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { getErrorKey } from '$features/cohort/utils/error';
|
||||
import { cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextEditor } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
programId?: string;
|
||||
cohortId?: string;
|
||||
edit?: boolean;
|
||||
editFeed: ProgramNewsfeedItem | null;
|
||||
editFeed: CohortNewsfeedItem | null;
|
||||
}
|
||||
|
||||
let { programId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
|
||||
let { cohortId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let newPost = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!programNewsfeedApi.isNewFeedModalOpen) {
|
||||
if (!cohortNewsfeedApi.isNewFeedModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
newPost = '';
|
||||
edit = false;
|
||||
editFeed = null;
|
||||
programNewsfeedApi.closeNewFeedModal();
|
||||
cohortNewsfeedApi.closeNewFeedModal();
|
||||
};
|
||||
|
||||
const onPost = async () => {
|
||||
@@ -44,27 +44,27 @@
|
||||
|
||||
try {
|
||||
if (edit && editFeed) {
|
||||
await programNewsfeedApi.update(programId, editFeed.id, { content: newPost ?? '' });
|
||||
await cohortNewsfeedApi.update(cohortId, editFeed.id, { content: newPost ?? '' });
|
||||
|
||||
if (programNewsfeedApi.success) {
|
||||
if (cohortNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, 'update'));
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'update'));
|
||||
}
|
||||
} else {
|
||||
await programNewsfeedApi.create(programId, {
|
||||
await cohortNewsfeedApi.create(cohortId, {
|
||||
content: newPost ?? '',
|
||||
isPinned: false
|
||||
});
|
||||
|
||||
if (programNewsfeedApi.success) {
|
||||
if (cohortNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, 'create'));
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'create'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, edit ? 'update' : 'create'));
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, edit ? 'update' : 'create'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
@@ -72,7 +72,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={programNewsfeedApi.isNewFeedModalOpen}
|
||||
bind:open={cohortNewsfeedApi.isNewFeedModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetEditor();
|
||||
@@ -82,7 +82,7 @@
|
||||
<Dialog.Content class="h-fit! w-fit! max-w-none!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{edit ? $t('programs.newsfeed.edit_post') : $t('programs.newsfeed.make_a_post')}
|
||||
{edit ? $t('cohorts.newsfeed.edit_post') : $t('cohorts.newsfeed.make_a_post')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section class="max-w-lg">
|
||||
@@ -93,16 +93,16 @@
|
||||
newPost = text;
|
||||
}}
|
||||
editorClass="max-h-[400px]"
|
||||
placeholder={$t('programs.newsfeed.editor_placeholder')}
|
||||
placeholder={$t('cohorts.newsfeed.editor_placeholder')}
|
||||
/>
|
||||
{/key}
|
||||
</section>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={resetEditor}>
|
||||
{$t('programs.newsfeed.cancel')}
|
||||
{$t('cohorts.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
{$t('programs.newsfeed.post_submit')}
|
||||
{$t('cohorts.newsfeed.post_submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
+14
-14
@@ -8,7 +8,7 @@
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -19,10 +19,10 @@
|
||||
let { path, id }: Props = $props();
|
||||
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
const isLmsProgramContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
|
||||
const programsListPath = $derived(isLmsProgramContext ? '/lms/programs' : `${$currentOrgPath}/programs`);
|
||||
const isLmsCohortContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
|
||||
const cohortsListPath = $derived(isLmsCohortContext ? '/lms/cohorts' : `${$currentOrgPath}/cohorts`);
|
||||
|
||||
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const currentMemberRole = $derived(cohortApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
|
||||
// Admin + Tutor can manage members; org admins always can
|
||||
const canManageMembers = $derived(
|
||||
@@ -36,32 +36,32 @@
|
||||
[
|
||||
{
|
||||
id: 'newsfeed',
|
||||
title: $t('programs.sidebar.newsfeed') || 'Newsfeed',
|
||||
url: `/programs/${id}/newsfeed`,
|
||||
title: $t('cohorts.sidebar.newsfeed') || 'Newsfeed',
|
||||
url: `/cohorts/${id}/newsfeed`,
|
||||
isActive: currentPath.endsWith('/newsfeed'),
|
||||
icon: NewsFeedIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'courses',
|
||||
title: $t('programs.sidebar.courses') || 'Courses',
|
||||
url: `/programs/${id}/courses`,
|
||||
title: $t('cohorts.sidebar.courses') || 'Courses',
|
||||
url: `/cohorts/${id}/courses`,
|
||||
isActive: currentPath.endsWith('/courses'),
|
||||
icon: CourseIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
title: $t('programs.sidebar.people') || 'People',
|
||||
url: `/programs/${id}/people`,
|
||||
title: $t('cohorts.sidebar.people') || 'People',
|
||||
url: `/cohorts/${id}/people`,
|
||||
isActive: currentPath.endsWith('/people'),
|
||||
icon: PeopleIcon,
|
||||
show: () => canManageMembers
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: $t('programs.sidebar.settings') || 'Settings',
|
||||
url: `/programs/${id}/settings`,
|
||||
title: $t('cohorts.sidebar.settings') || 'Settings',
|
||||
url: `/cohorts/${id}/settings`,
|
||||
isActive: currentPath.endsWith('/settings'),
|
||||
icon: SettingsIcon,
|
||||
show: () => canManageSettings
|
||||
@@ -72,8 +72,8 @@
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<BackButton
|
||||
href={resolve(programsListPath, {})}
|
||||
label={$t(isLmsProgramContext ? 'lms_navigation.programs' : 'org_navigation.programs') || 'Programs'}
|
||||
href={resolve(cohortsListPath, {})}
|
||||
label={$t(isLmsCohortContext ? 'lms_navigation.cohorts' : 'org_navigation.cohorts') || 'Cohorts'}
|
||||
class="px-2! py-2!"
|
||||
/>
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
|
||||
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.svelte';
|
||||
import Navigation from './program-sidebar-navigation.svelte';
|
||||
import Navigation from './cohort-sidebar-navigation.svelte';
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
import PoweredBy from '$features/ui/powered-by.svelte';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
@@ -16,10 +16,10 @@
|
||||
interface Props {
|
||||
path: string;
|
||||
id: string;
|
||||
isProgramReady?: boolean;
|
||||
isCohortReady?: boolean;
|
||||
}
|
||||
|
||||
let { path, id, isProgramReady = true }: Props = $props();
|
||||
let { path, id, isCohortReady = true }: Props = $props();
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
const sidebar = useSidebar();
|
||||
@@ -35,7 +35,7 @@
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
{#if isProgramReady}
|
||||
{#if isCohortReady}
|
||||
<Navigation path={currentPath} {id} />
|
||||
{:else}
|
||||
<Sidebar.Group class="pt-0!">
|
||||
@@ -62,7 +62,7 @@
|
||||
<Sidebar.Footer>
|
||||
<PoweredBy
|
||||
variant="sidebar"
|
||||
sidebarUtmSource="lms-program-sidebar"
|
||||
sidebarUtmSource="lms-cohort-sidebar"
|
||||
showOnlyLogo={!sidebar.open || sidebar.isMobile}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CohortSidebar } from './cohort-sidebar.svelte';
|
||||
+10
-10
@@ -7,11 +7,11 @@
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ProgramList from '../components/list.svelte';
|
||||
import type { Program } from '../utils/types';
|
||||
import CohortList from '../components/list.svelte';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
programs?: Program[];
|
||||
cohorts?: Cohort[];
|
||||
isLMS?: boolean;
|
||||
isLoading?: boolean;
|
||||
searchValue?: string;
|
||||
@@ -22,13 +22,13 @@
|
||||
}
|
||||
|
||||
let {
|
||||
programs = [],
|
||||
cohorts = [],
|
||||
isLMS = false,
|
||||
isLoading = false,
|
||||
searchValue = $bindable(''),
|
||||
emptyTitle = $t('programs.empty_title'),
|
||||
emptyDescription = $t('programs.empty_description'),
|
||||
searchPlaceholder = $t('programs.search_placeholder'),
|
||||
emptyTitle = $t('cohorts.empty_title'),
|
||||
emptyDescription = $t('cohorts.empty_description'),
|
||||
searchPlaceholder = $t('cohorts.search_placeholder'),
|
||||
filterControls
|
||||
}: Props = $props();
|
||||
</script>
|
||||
@@ -43,12 +43,12 @@
|
||||
<div class="flex h-64 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if !programs.length}
|
||||
{:else if !cohorts.length}
|
||||
<Empty title={emptyTitle} description={emptyDescription} icon={GoalIcon} variant="page" />
|
||||
{:else}
|
||||
<ResourceListRow.Group class="w-full">
|
||||
{#each programs as program (program.id)}
|
||||
<ProgramList {program} {isLMS} />
|
||||
{#each cohorts as cohort (cohort.id)}
|
||||
<CohortList {cohort} {isLMS} />
|
||||
{/each}
|
||||
</ResourceListRow.Group>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CohortsPage } from './cohorts.svelte';
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CohortSortBy = 'name' | 'courses' | 'students' | 'status';
|
||||
export type CohortSortOrder = 'asc' | 'desc';
|
||||
export const DEFAULT_COHORT_SORT: CohortSortBy = 'name';
|
||||
export const DEFAULT_COHORT_SORT_ORDER: CohortSortOrder = 'asc';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
|
||||
|
||||
const COHORT_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
|
||||
[ErrorCodes.COHORT_FORBIDDEN]: 'snackbar.cohort.error.code.cohort_forbidden',
|
||||
[ErrorCodes.COHORT_MEMBER_CHECK_FAILED]: 'snackbar.cohort.error.code.cohort_member_check_failed',
|
||||
[ErrorCodes.COHORT_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_not_found',
|
||||
[ErrorCodes.COHORT_CREATE_FAILED]: 'snackbar.cohort.error.code.cohort_create_failed',
|
||||
[ErrorCodes.COHORT_UPDATE_FAILED]: 'snackbar.cohort.error.code.cohort_update_failed',
|
||||
[ErrorCodes.COHORT_DELETE_FAILED]: 'snackbar.cohort.error.code.cohort_delete_failed',
|
||||
[ErrorCodes.COHORT_MEMBER_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_member_not_found',
|
||||
[ErrorCodes.COHORT_NEWSFEED_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_newsfeed_not_found',
|
||||
[ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_newsfeed_comment_not_found'
|
||||
};
|
||||
|
||||
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
|
||||
const parsed = tryParseApiErrorBody(rawError);
|
||||
const mapped = parsed?.code ? COHORT_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return kind === 'update' ? 'snackbar.cohort.error.post_update_failed' : 'snackbar.cohort.error.post_create_failed';
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type CohortListResponse = InferResponseType<typeof classroomio.cohort.$get>;
|
||||
type CohortListSuccess = Extract<CohortListResponse, { success: true }>;
|
||||
export type Cohort = CohortListSuccess['data'][number];
|
||||
|
||||
type CohortResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['$get']>;
|
||||
type CohortSuccess = Extract<CohortResponse, { success: true }>;
|
||||
export type CohortDetail = CohortSuccess['data'];
|
||||
|
||||
type CohortMembersResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['members']['$get']>;
|
||||
type CohortMembersSuccess = Extract<CohortMembersResponse, { success: true }>;
|
||||
export type CohortMember = CohortMembersSuccess['data'][number];
|
||||
|
||||
type CohortCoursesResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>;
|
||||
type CohortCoursesSuccess = Extract<CohortCoursesResponse, { success: true }>;
|
||||
export type CohortCourse = CohortCoursesSuccess['data'][number];
|
||||
|
||||
export type ListCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed']['$get'];
|
||||
type ListCohortNewsfeedResponse = InferResponseType<ListCohortNewsfeedRequest>;
|
||||
type ListCohortNewsfeedSuccess = Extract<ListCohortNewsfeedResponse, { success: true }>;
|
||||
export type CohortNewsfeed = ListCohortNewsfeedSuccess['data'];
|
||||
export type ListCohortNewsfeed = CohortNewsfeed['items'];
|
||||
export type CohortNewsfeedItem = ListCohortNewsfeed[number];
|
||||
|
||||
export type CreateCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed']['$post'];
|
||||
export type UpdateCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['$put'];
|
||||
export type ReactToCohortNewsfeedRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['react']['$put'];
|
||||
export type DeleteCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['$delete'];
|
||||
|
||||
export type GetCohortNewsfeedCommentsRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comments']['$get'];
|
||||
type GetCohortNewsfeedCommentsResponse = InferResponseType<GetCohortNewsfeedCommentsRequest>;
|
||||
type GetCohortNewsfeedCommentsSuccess = Extract<GetCohortNewsfeedCommentsResponse, { success: true }>;
|
||||
export type CohortNewsfeedCommentsResponse = GetCohortNewsfeedCommentsSuccess['data'];
|
||||
export type CohortNewsfeedComment = CohortNewsfeedCommentsResponse[number];
|
||||
|
||||
export type CreateCohortNewsfeedCommentRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comment']['$post'];
|
||||
export type DeleteCohortNewsfeedCommentRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
|
||||
|
||||
// ─── Goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ListCohortGoalsRequest = (typeof classroomio.cohort)[':cohortId']['goals']['$get'];
|
||||
type ListCohortGoalsResponse = InferResponseType<ListCohortGoalsRequest>;
|
||||
type ListCohortGoalsSuccess = Extract<ListCohortGoalsResponse, { success: true }>;
|
||||
export type CohortGoal = ListCohortGoalsSuccess['data'][number];
|
||||
export type CohortGoalStatusCounts = CohortGoal['statusCounts'];
|
||||
|
||||
export type CreateCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals']['$post'];
|
||||
export type UpdateCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put'];
|
||||
export type DeleteCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete'];
|
||||
|
||||
export type MyGoalsRequest = typeof classroomio.cohort.my.goals.$get;
|
||||
type MyGoalsResponse = InferResponseType<MyGoalsRequest>;
|
||||
type MyGoalsSuccess = Extract<MyGoalsResponse, { success: true }>;
|
||||
export type MyGoalAssignment = MyGoalsSuccess['data'][number];
|
||||
|
||||
export type GoalsOverviewRequest = typeof classroomio.cohort.goals.overview.$get;
|
||||
type GoalsOverviewResponse = InferResponseType<GoalsOverviewRequest>;
|
||||
type GoalsOverviewSuccess = Extract<GoalsOverviewResponse, { success: true }>;
|
||||
export type GoalsOverview = GoalsOverviewSuccess['data'];
|
||||
export type GoalsOverviewRow = GoalsOverview['goals'][number];
|
||||
@@ -1,129 +0,0 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
|
||||
import type { GoalsOverviewRow, MyGoalAssignment, ProgramGoal } from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
class ProgramGoalApi extends BaseApiWithErrors {
|
||||
goalsByProgramId = $state<Record<string, ProgramGoal[]>>({});
|
||||
myGoals = $state<MyGoalAssignment[]>([]);
|
||||
overviewRows = $state<GoalsOverviewRow[]>([]);
|
||||
loadedProgramId = $state<string | null>(null);
|
||||
|
||||
goalsFor(programId: string): ProgramGoal[] {
|
||||
return this.goalsByProgramId[programId] ?? [];
|
||||
}
|
||||
|
||||
async listGoals(programId: string, force = false) {
|
||||
if (!force && this.goalsByProgramId[programId] && this.loadedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].goals.$get({ param: { programId } }),
|
||||
onSuccess: (res) => {
|
||||
this.goalsByProgramId = { ...this.goalsByProgramId, [programId]: res.data };
|
||||
this.loadedProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async createGoal(programId: string, data: TCreateProgramGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].goals.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
snackbar.success(t.get('programs.goals.created') || 'Goal created');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'createProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async updateGoal(programId: string, goalId: string, data: TUpdateProgramGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].goals[':goalId'].$put({
|
||||
param: { programId, goalId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
snackbar.success(t.get('programs.goals.updated') || 'Goal updated');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'updateProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async deleteGoal(programId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].goals[':goalId'].$delete({
|
||||
param: { programId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
snackbar.success(t.get('programs.goals.deleted') || 'Goal deleted');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'deleteProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async evaluateGoal(programId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['evaluate']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].goals[':goalId'].evaluate.$post({
|
||||
param: { programId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'evaluateProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async listMyGoals() {
|
||||
await this.execute<typeof classroomio.program.my.goals.$get>({
|
||||
requestFn: () => classroomio.program.my.goals.$get({}),
|
||||
onSuccess: (res) => {
|
||||
this.myGoals = res.data;
|
||||
},
|
||||
logContext: 'listMyGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async listOverview() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.goals.overview.$get>({
|
||||
requestFn: () => classroomio.program.goals.overview.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (res) => {
|
||||
this.overviewRows = res.data.goals;
|
||||
},
|
||||
logContext: 'listGoalsOverview'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programGoalApi = new ProgramGoalApi();
|
||||
@@ -1,3 +0,0 @@
|
||||
export { programApi } from './program.svelte';
|
||||
export { programGoalApi } from './goal.svelte';
|
||||
export { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from './newsfeed.svelte';
|
||||
@@ -1,376 +0,0 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { Program, ProgramDetail, ProgramMember, ProgramCourse, ProgramNewsfeed } from '../utils/types';
|
||||
import type {
|
||||
TCreateProgram,
|
||||
TUpdateProgram,
|
||||
TAddProgramMembers,
|
||||
TUpdateProgramMember,
|
||||
TAddCourseToProgram,
|
||||
TInviteStudentsToProgram,
|
||||
TAssignExistingStudentsToProgram
|
||||
} from '@cio/utils/validation/program';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
class ProgramApi extends BaseApiWithErrors {
|
||||
programs = $state<Program[]>([]);
|
||||
program = $state<ProgramDetail | null>(null);
|
||||
members = $state<ProgramMember[]>([]);
|
||||
courses = $state<ProgramCourse[]>([]);
|
||||
newsfeed = $state<ProgramNewsfeed | null>(null);
|
||||
isProgramShellLoading = $state(false);
|
||||
currentProgramId = $state<string | null>(null);
|
||||
|
||||
loadedProgramId = $state<string | null>(null);
|
||||
loadedMembersProgramId = $state<string | null>(null);
|
||||
loadedCoursesProgramId = $state<string | null>(null);
|
||||
loadedCoursesStudentExperience = $state<boolean | null>(null);
|
||||
loadedNewsfeedProgramId = $state<string | null>(null);
|
||||
|
||||
syncProgramCourseCount(programId: string, courseCount: number) {
|
||||
this.programs = this.programs.map((program) => (program.id === programId ? { ...program, courseCount } : program));
|
||||
}
|
||||
|
||||
async listPrograms() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.$get>({
|
||||
requestFn: () => classroomio.program.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (data) => {
|
||||
this.programs = data.data;
|
||||
},
|
||||
logContext: 'listPrograms'
|
||||
});
|
||||
}
|
||||
|
||||
async getProgram(programId: string, force = false) {
|
||||
if (!force && this.loadedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.program = data.data;
|
||||
this.loadedProgramId = programId;
|
||||
},
|
||||
logContext: 'getProgram'
|
||||
});
|
||||
}
|
||||
|
||||
resetProgramShell(programId?: string) {
|
||||
this.currentProgramId = programId ?? null;
|
||||
this.program = null;
|
||||
this.members = [];
|
||||
this.courses = [];
|
||||
this.newsfeed = null;
|
||||
this.loadedProgramId = null;
|
||||
this.loadedMembersProgramId = null;
|
||||
this.loadedCoursesProgramId = null;
|
||||
this.loadedCoursesStudentExperience = null;
|
||||
this.loadedNewsfeedProgramId = null;
|
||||
}
|
||||
|
||||
async ensureProgramShell(programId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
const isShellReady =
|
||||
this.loadedProgramId === programId &&
|
||||
this.loadedMembersProgramId === programId &&
|
||||
this.loadedCoursesProgramId === programId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience;
|
||||
|
||||
if (!force && isShellReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProgramChanged = this.currentProgramId !== programId;
|
||||
if (force || isProgramChanged) {
|
||||
this.resetProgramShell(programId);
|
||||
}
|
||||
|
||||
this.currentProgramId = programId;
|
||||
this.isProgramShellLoading = true;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.getProgram(programId, force),
|
||||
this.listMembers(programId, force),
|
||||
this.listCourses(programId, force)
|
||||
]);
|
||||
} finally {
|
||||
this.isProgramShellLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createProgram(data: TCreateProgram) {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.program.$post({
|
||||
json: { ...data, organizationId: org.id }
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.programs = [res.data, ...this.programs];
|
||||
snackbar.success(t.get('programs.create_success') || 'Program created');
|
||||
},
|
||||
logContext: 'createProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async updateProgram(programId: string, data: TUpdateProgram) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$put']>({
|
||||
requestFn: () => classroomio.program[':programId'].$put({ param: { programId }, json: data }),
|
||||
onSuccess: (res) => {
|
||||
this.program = res.data;
|
||||
this.programs = this.programs.map((p) => (p.id === programId ? { ...p, ...res.data } : p));
|
||||
snackbar.success(t.get('programs.update_success') || 'Program updated');
|
||||
},
|
||||
onError: (result) => {
|
||||
const message = typeof result === 'string' ? result : result.error;
|
||||
snackbar.error(message || 'Failed to update program');
|
||||
},
|
||||
logContext: 'updateProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProgram(programId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$delete']>({
|
||||
requestFn: () => classroomio.program[':programId'].$delete({ param: { programId } }),
|
||||
onSuccess: () => {
|
||||
this.programs = this.programs.filter((p) => p.id !== programId);
|
||||
snackbar.success(t.get('programs.delete_success') || 'Program deleted');
|
||||
},
|
||||
logContext: 'deleteProgram'
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
|
||||
async listMembers(programId: string, force = false) {
|
||||
if (!force && this.loadedMembersProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].members.$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.members = data.data;
|
||||
this.loadedMembersProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async addMembers(programId: string, data: TAddProgramMembers) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].members.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.members_added') || 'Members added');
|
||||
},
|
||||
logContext: 'addProgramMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(programId: string, memberId: string, data: TUpdateProgramMember) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].members[':memberId'].$put({
|
||||
param: { programId, memberId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
|
||||
},
|
||||
logContext: 'updateProgramMember'
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(programId: string, memberId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].members[':memberId'].$delete({
|
||||
param: { programId, memberId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.members = this.members.filter((m) => m.id !== memberId);
|
||||
snackbar.success(t.get('programs.member_removed') || 'Member removed');
|
||||
},
|
||||
logContext: 'removeProgramMember'
|
||||
});
|
||||
}
|
||||
|
||||
// Courses
|
||||
|
||||
async listCourses(programId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
|
||||
if (
|
||||
!force &&
|
||||
this.loadedCoursesProgramId === programId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].courses.$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.courses = data.data;
|
||||
this.syncProgramCourseCount(programId, data.data.length);
|
||||
this.loadedCoursesProgramId = programId;
|
||||
this.loadedCoursesStudentExperience = studentExperience;
|
||||
},
|
||||
logContext: 'listProgramCourses'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourse(programId: string, data: TAddCourseToProgram) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].courses.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listCourses(programId, true);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
snackbar.success(t.get('programs.course_added') || 'Course added');
|
||||
},
|
||||
logContext: 'addCourseToProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourses(programId: string, courseIds: string[]) {
|
||||
if (courseIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
|
||||
try {
|
||||
for (const courseId of courseIds) {
|
||||
const response = await classroomio.program[':programId'].courses.$post({
|
||||
param: { programId },
|
||||
json: { courseId }
|
||||
});
|
||||
const result = (await response.json()) as { success?: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
this.error = result.error ?? 'Failed to add course to program';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await this.listCourses(programId, true);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
|
||||
const successMessage =
|
||||
courseIds.length === 1
|
||||
? t.get('programs.course_added') || 'Course added'
|
||||
: t.get('programs.multiple_added') || 'Courses added';
|
||||
|
||||
snackbar.success(successMessage);
|
||||
this.success = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in addCoursesToProgram:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to add courses to program';
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeCourse(programId: string, courseId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses'][':courseId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].courses[':courseId'].$delete({
|
||||
param: { programId, courseId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.courses = this.courses.filter((c) => c.courseId !== courseId);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
snackbar.success(t.get('programs.course_removed') || 'Course removed');
|
||||
},
|
||||
logContext: 'removeCourseFromProgram'
|
||||
});
|
||||
}
|
||||
|
||||
// Invitations (program-scoped)
|
||||
|
||||
/**
|
||||
* Invite new students to a program by CSV. Goes through the program-scoped
|
||||
* endpoint so a Program ADMIN/TUTOR can invite without org-admin rights.
|
||||
* Returns whether the call succeeded so callers can refresh their lists.
|
||||
*/
|
||||
async inviteStudentsToProgram(programId: string, data: TInviteStudentsToProgram): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['invite']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].invite.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.invite_sent') || 'Invites sent');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'inviteStudentsToProgram'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign existing org audience student profiles to this program.
|
||||
* Same scoping as `inviteStudentsToProgram` — caller only needs program-team
|
||||
* membership for this program.
|
||||
*/
|
||||
async assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['invite']['assign']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].invite.assign.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.members_added') || 'Members added');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'assignExistingStudentsToProgram'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Newsfeed
|
||||
|
||||
async listNewsfeed(programId: string, force = false, cursor?: string) {
|
||||
if (!force && this.loadedNewsfeedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['newsfeed']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$get({
|
||||
param: { programId },
|
||||
query: { limit: '10', ...(cursor ? { cursor } : {}) }
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
this.newsfeed = data.data;
|
||||
this.loadedNewsfeedProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramNewsfeed'
|
||||
});
|
||||
}
|
||||
|
||||
// LMS — enrolled programs
|
||||
|
||||
async listEnrolledPrograms() {
|
||||
await this.execute<typeof classroomio.program.enrolled.$get>({
|
||||
requestFn: () => classroomio.program.enrolled.$get({}),
|
||||
onSuccess: (data) => {
|
||||
this.programs = data.data;
|
||||
},
|
||||
logContext: 'listEnrolledPrograms'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programApi = new ProgramApi();
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ProgramSidebar } from './program-sidebar.svelte';
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ProgramsPage } from './programs.svelte';
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ProgramSortBy = 'name' | 'courses' | 'students' | 'status';
|
||||
export type ProgramSortOrder = 'asc' | 'desc';
|
||||
export const DEFAULT_PROGRAM_SORT: ProgramSortBy = 'name';
|
||||
export const DEFAULT_PROGRAM_SORT_ORDER: ProgramSortOrder = 'asc';
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
|
||||
|
||||
const PROGRAM_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
|
||||
[ErrorCodes.PROGRAM_FORBIDDEN]: 'snackbar.program.error.code.program_forbidden',
|
||||
[ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED]: 'snackbar.program.error.code.program_member_check_failed',
|
||||
[ErrorCodes.PROGRAM_NOT_FOUND]: 'snackbar.program.error.code.program_not_found',
|
||||
[ErrorCodes.PROGRAM_CREATE_FAILED]: 'snackbar.program.error.code.program_create_failed',
|
||||
[ErrorCodes.PROGRAM_UPDATE_FAILED]: 'snackbar.program.error.code.program_update_failed',
|
||||
[ErrorCodes.PROGRAM_DELETE_FAILED]: 'snackbar.program.error.code.program_delete_failed',
|
||||
[ErrorCodes.PROGRAM_MEMBER_NOT_FOUND]: 'snackbar.program.error.code.program_member_not_found',
|
||||
[ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_not_found',
|
||||
[ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_comment_not_found'
|
||||
};
|
||||
|
||||
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
|
||||
const parsed = tryParseApiErrorBody(rawError);
|
||||
const mapped = parsed?.code ? PROGRAM_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return kind === 'update' ? 'snackbar.program.error.post_update_failed' : 'snackbar.program.error.post_create_failed';
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type ProgramListResponse = InferResponseType<typeof classroomio.program.$get>;
|
||||
type ProgramListSuccess = Extract<ProgramListResponse, { success: true }>;
|
||||
export type Program = ProgramListSuccess['data'][number];
|
||||
|
||||
type ProgramResponse = InferResponseType<(typeof classroomio.program)[':programId']['$get']>;
|
||||
type ProgramSuccess = Extract<ProgramResponse, { success: true }>;
|
||||
export type ProgramDetail = ProgramSuccess['data'];
|
||||
|
||||
type ProgramMembersResponse = InferResponseType<(typeof classroomio.program)[':programId']['members']['$get']>;
|
||||
type ProgramMembersSuccess = Extract<ProgramMembersResponse, { success: true }>;
|
||||
export type ProgramMember = ProgramMembersSuccess['data'][number];
|
||||
|
||||
type ProgramCoursesResponse = InferResponseType<(typeof classroomio.program)[':programId']['courses']['$get']>;
|
||||
type ProgramCoursesSuccess = Extract<ProgramCoursesResponse, { success: true }>;
|
||||
export type ProgramCourse = ProgramCoursesSuccess['data'][number];
|
||||
|
||||
export type ListProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$get'];
|
||||
type ListProgramNewsfeedResponse = InferResponseType<ListProgramNewsfeedRequest>;
|
||||
type ListProgramNewsfeedSuccess = Extract<ListProgramNewsfeedResponse, { success: true }>;
|
||||
export type ProgramNewsfeed = ListProgramNewsfeedSuccess['data'];
|
||||
export type ListProgramNewsfeed = ProgramNewsfeed['items'];
|
||||
export type ProgramNewsfeedItem = ListProgramNewsfeed[number];
|
||||
|
||||
export type CreateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$post'];
|
||||
export type UpdateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$put'];
|
||||
export type ReactToProgramNewsfeedRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['react']['$put'];
|
||||
export type DeleteProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$delete'];
|
||||
|
||||
export type GetProgramNewsfeedCommentsRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comments']['$get'];
|
||||
type GetProgramNewsfeedCommentsResponse = InferResponseType<GetProgramNewsfeedCommentsRequest>;
|
||||
type GetProgramNewsfeedCommentsSuccess = Extract<GetProgramNewsfeedCommentsResponse, { success: true }>;
|
||||
export type ProgramNewsfeedCommentsResponse = GetProgramNewsfeedCommentsSuccess['data'];
|
||||
export type ProgramNewsfeedComment = ProgramNewsfeedCommentsResponse[number];
|
||||
|
||||
export type CreateProgramNewsfeedCommentRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment']['$post'];
|
||||
export type DeleteProgramNewsfeedCommentRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
|
||||
|
||||
// ─── Goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ListProgramGoalsRequest = (typeof classroomio.program)[':programId']['goals']['$get'];
|
||||
type ListProgramGoalsResponse = InferResponseType<ListProgramGoalsRequest>;
|
||||
type ListProgramGoalsSuccess = Extract<ListProgramGoalsResponse, { success: true }>;
|
||||
export type ProgramGoal = ListProgramGoalsSuccess['data'][number];
|
||||
export type ProgramGoalStatusCounts = ProgramGoal['statusCounts'];
|
||||
|
||||
export type CreateProgramGoalRequest = (typeof classroomio.program)[':programId']['goals']['$post'];
|
||||
export type UpdateProgramGoalRequest = (typeof classroomio.program)[':programId']['goals'][':goalId']['$put'];
|
||||
export type DeleteProgramGoalRequest = (typeof classroomio.program)[':programId']['goals'][':goalId']['$delete'];
|
||||
|
||||
export type MyGoalsRequest = typeof classroomio.program.my.goals.$get;
|
||||
type MyGoalsResponse = InferResponseType<MyGoalsRequest>;
|
||||
type MyGoalsSuccess = Extract<MyGoalsResponse, { success: true }>;
|
||||
export type MyGoalAssignment = MyGoalsSuccess['data'][number];
|
||||
|
||||
export type GoalsOverviewRequest = typeof classroomio.program.goals.overview.$get;
|
||||
type GoalsOverviewResponse = InferResponseType<GoalsOverviewRequest>;
|
||||
type GoalsOverviewSuccess = Extract<GoalsOverviewResponse, { success: true }>;
|
||||
export type GoalsOverview = GoalsOverviewSuccess['data'];
|
||||
export type GoalsOverviewRow = GoalsOverview['goals'][number];
|
||||
@@ -14,7 +14,7 @@ const SEARCH_DEBOUNCE_MS = 200;
|
||||
function emptyResults(): GroupedSearchResults {
|
||||
return {
|
||||
course: [],
|
||||
program: [],
|
||||
cohort: [],
|
||||
widget: [],
|
||||
tag: [],
|
||||
audience: [],
|
||||
@@ -137,13 +137,13 @@ function mapSearchResults(data: SearchOrgData, currentOrgPath: string): GroupedS
|
||||
})
|
||||
);
|
||||
|
||||
results.program = data.programs.map(
|
||||
(program): SearchResultItem => ({
|
||||
kind: 'program',
|
||||
id: program.id,
|
||||
title: program.name,
|
||||
subtitle: summarize(program.description),
|
||||
url: `/programs/${program.id}/courses`
|
||||
results.cohort = data.cohorts.map(
|
||||
(cohort): SearchResultItem => ({
|
||||
kind: 'cohort',
|
||||
id: cohort.id,
|
||||
title: cohort.name,
|
||||
subtitle: summarize(cohort.description),
|
||||
url: `/cohorts/${cohort.id}/courses`
|
||||
})
|
||||
);
|
||||
|
||||
@@ -193,13 +193,13 @@ function mapLmsSearchResults(data: SearchLmsData): GroupedSearchResults {
|
||||
})
|
||||
);
|
||||
|
||||
results.program = data.programs.map(
|
||||
(program): SearchResultItem => ({
|
||||
kind: 'program',
|
||||
id: program.id,
|
||||
title: program.name,
|
||||
subtitle: summarize(program.description),
|
||||
url: `/programs/${program.id}/courses`
|
||||
results.cohort = data.cohorts.map(
|
||||
(cohort): SearchResultItem => ({
|
||||
kind: 'cohort',
|
||||
id: cohort.id,
|
||||
title: cohort.name,
|
||||
subtitle: summarize(cohort.description),
|
||||
url: `/cohorts/${cohort.id}/courses`
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
const hasQuery = $derived(query.trim().length > 0);
|
||||
const hasServerResults = $derived(
|
||||
searchApi.results.course.length > 0 ||
|
||||
searchApi.results.program.length > 0 ||
|
||||
searchApi.results.cohort.length > 0 ||
|
||||
searchApi.results.widget.length > 0 ||
|
||||
searchApi.results.tag.length > 0 ||
|
||||
searchApi.results.audience.length > 0
|
||||
@@ -133,8 +133,8 @@
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.programs')}
|
||||
items={groupItems('program')}
|
||||
heading={$t('app.search.command_palette.groups.cohorts')}
|
||||
items={groupItems('cohort')}
|
||||
fallbackIcon={Goal}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ export type SearchLmsResponse = InferResponseType<SearchLmsRequest>;
|
||||
export type SearchLmsSuccess = Extract<SearchLmsResponse, { success: true }>;
|
||||
export type SearchLmsData = SearchLmsSuccess['data'];
|
||||
|
||||
export type SearchResultKind = 'course' | 'program' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
|
||||
export type SearchResultKind = 'course' | 'cohort' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
|
||||
export type SearchScope = 'org' | 'lms';
|
||||
|
||||
export interface SearchResultItem {
|
||||
|
||||
@@ -73,10 +73,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
matchPattern: '^/lms/explore(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'lms_navigation.programs',
|
||||
path: '/programs',
|
||||
titleKey: 'lms_navigation.cohorts',
|
||||
path: '/cohorts',
|
||||
icon: GoalIcon,
|
||||
matchPattern: '^/lms/programs(/.*)?$'
|
||||
matchPattern: '^/lms/cohorts(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'lms_navigation.exercise',
|
||||
|
||||
@@ -117,10 +117,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.programs',
|
||||
path: '/programs',
|
||||
titleKey: 'org_navigation.cohorts',
|
||||
path: '/cohorts',
|
||||
icon: GoalIcon,
|
||||
matchPattern: '^/org/[^/]+/programs(/.*)?$'
|
||||
matchPattern: '^/org/[^/]+/cohorts(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Automatiseringsnøglen drejet. Gem den nye hemmelighed nu, fordi den ikke vil blive vist igen.",
|
||||
"revoked": "Automatiseringsnøgle tilbagekaldt."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Der opstod en fejl under reaktion på indlægget",
|
||||
"news_feed_fail": "Kunne ikke hente programmets nyhedsfeed",
|
||||
"post_create_failed": "Kunne ikke oprette indlæg",
|
||||
"post_update_failed": "Kunne ikke opdatere indlægget",
|
||||
"code": {
|
||||
"program_forbidden": "Du skal være medlem af dette program for at udføre denne handling.",
|
||||
"program_member_check_failed": "Vi kunne ikke bekræfte dit programmedlemskab. Prøv igen.",
|
||||
"program_not_found": "Dette program kunne ikke findes.",
|
||||
"program_create_failed": "Kunne ikke oprette programmet.",
|
||||
"program_update_failed": "Kunne ikke opdatere programmet.",
|
||||
"program_delete_failed": "Kunne ikke slette programmet.",
|
||||
"program_member_not_found": "Dette programmedlem kunne ikke findes.",
|
||||
"program_newsfeed_not_found": "Det indlæg kunne ikke findes.",
|
||||
"program_newsfeed_comment_not_found": "Den kommentar kunne ikke findes."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Kommentar slettet",
|
||||
"comment_added": "Kommentar tilføjet",
|
||||
"feed_delete_success": "Indlægget blev slettet",
|
||||
"post_created": "Indlægget er oprettet",
|
||||
"post_updated": "Opslaget blev opdateret"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "AI credit pack tilføjet til din saldo.",
|
||||
"checkout_failed": "Kunne ikke starte betaling. Prøv igen."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "1080p gengivelse tilføjet",
|
||||
"generate_1080_failed": "Kunne ikke generere 1080p gengivelse"
|
||||
},
|
||||
"social_auth_not_enabled": "Denne godkendelsesudbyder er ikke aktiveret."
|
||||
"social_auth_not_enabled": "Denne godkendelsesudbyder er ikke aktiveret.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Der opstod en fejl under reaktion på indlægget",
|
||||
"news_feed_fail": "Kunne ikke hente programmets nyhedsfeed",
|
||||
"post_create_failed": "Kunne ikke oprette indlæg",
|
||||
"post_update_failed": "Kunne ikke opdatere indlægget",
|
||||
"code": {
|
||||
"cohort_forbidden": "Du skal være medlem af dette cohort for at udføre denne handling.",
|
||||
"cohort_member_check_failed": "Vi kunne ikke bekræfte dit programmedlemskab. Prøv igen.",
|
||||
"cohort_not_found": "Dette cohort kunne ikke findes.",
|
||||
"cohort_create_failed": "Kunne ikke oprette programmet.",
|
||||
"cohort_update_failed": "Kunne ikke opdatere programmet.",
|
||||
"cohort_delete_failed": "Kunne ikke slette programmet.",
|
||||
"cohort_member_not_found": "Dette programmedlem kunne ikke findes.",
|
||||
"cohort_newsfeed_not_found": "Det indlæg kunne ikke findes.",
|
||||
"cohort_newsfeed_comment_not_found": "Den kommentar kunne ikke findes."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Kommentar slettet",
|
||||
"comment_added": "Kommentar tilføjet",
|
||||
"feed_delete_success": "Indlægget blev slettet",
|
||||
"post_created": "Indlægget er oprettet",
|
||||
"post_updated": "Opslaget blev opdateret"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Udforsk vores kurser",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Udforsk",
|
||||
"help": "Hjælp",
|
||||
"settings": "Indstillinger",
|
||||
"programs": "Programmer"
|
||||
"cohorts": "Programmer"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Opgrader din plan for at generere certifikater",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Alle kurser",
|
||||
"select_courses": "Vælg specifikke kurser",
|
||||
"select_courses_placeholder": "Vælg kurser...",
|
||||
"program_access": "Programadgang",
|
||||
"no_programs": "Ingen programtildeling",
|
||||
"all_programs": "Alle programmer",
|
||||
"select_programs": "Vælg specifikke programmer",
|
||||
"select_programs_placeholder": "Vælg programmer...",
|
||||
"send_email": "Send invitationsmail til importerede brugere",
|
||||
"submit": "Importer brugere",
|
||||
"back": "Tilbage til Publikum",
|
||||
"snackbar_success": "{imported} nye invitationer, {assigned} eksisterende studerende med kursusadgang, {pendingInvitesRenewed} afventende invitationer fornyet ({emailsSent} e-mails sendt)",
|
||||
"snackbar_no_emails": "Indtast venligst mindst én e-mailadresse",
|
||||
"snackbar_failed": "Kunne ikke importere brugere",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Programadgang",
|
||||
"no_cohorts": "Ingen programtildeling",
|
||||
"all_cohorts": "Alle programmer",
|
||||
"select_cohorts": "Vælg specifikke programmer",
|
||||
"select_cohorts_placeholder": "Vælg programmer..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Opgrader nu",
|
||||
"tags": "Tags",
|
||||
"automation": "Automatisering",
|
||||
"programs": "Programmer",
|
||||
"widgets": "Widgets",
|
||||
"stats": "Statistik",
|
||||
"analytics": "Analytics",
|
||||
"compliance": "Overholdelse"
|
||||
"compliance": "Overholdelse",
|
||||
"cohorts": "Programmer"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Media Manager - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programmer",
|
||||
"page_title": "Programmer",
|
||||
"create": "Nyt program",
|
||||
"create_success": "Program oprettet",
|
||||
"update_success": "Program opdateret",
|
||||
"delete_success": "Program slettet",
|
||||
"members_added": "Medlemmer tilføjet",
|
||||
"member_removed": "Medlem fjernet",
|
||||
"course_added": "Kursus tilføjet",
|
||||
"course_removed": "Kursus fjernet",
|
||||
"empty_title": "Ingen programmer endnu",
|
||||
"empty_description": "Opret dit første certificerings- eller overholdelsesprogram.",
|
||||
"settings": {
|
||||
"general": "Generelt",
|
||||
"name_label": "Navn",
|
||||
"description_label": "Beskrivelse",
|
||||
"status_label": "Status",
|
||||
"save": "Gem ændringer",
|
||||
"danger_zone": "Farezone",
|
||||
"delete": "Slet program",
|
||||
"delete_description": "Når det er slettet, kan dette program og alle dets data ikke gendannes.",
|
||||
"confirm_delete": "Er du sikker på, at du vil slette dette program? Dette kan ikke fortrydes."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Ingen programmer endnu",
|
||||
"empty_description": "Du er ikke blevet tilmeldt nogen programmer."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Inviter",
|
||||
"invite_modal_title": "Inviter medlemmer",
|
||||
"invite_modal_description": "Tilføj personer til dette program via e-mail.",
|
||||
"email_label": "E-mail",
|
||||
"role_label": "Rolle",
|
||||
"add_another": "Tilføj en anden",
|
||||
"invite_submit": "Send invitationer",
|
||||
"empty_title": "Ingen medlemmer endnu",
|
||||
"empty_description": "Inviter folk til at deltage i dette program."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Tilføj kursus",
|
||||
"add_modal_title": "Føj kursus til programmet",
|
||||
"select_course": "Vælg et kursus",
|
||||
"select_placeholder": "Vælg et kursus...",
|
||||
"empty_title": "Ingen kurser endnu",
|
||||
"empty_description": "Tilføj kurser til dette program.",
|
||||
"select_courses": "Vælg kurser",
|
||||
"search_placeholder": "Søg efter kurser",
|
||||
"no_available_courses": "Ingen tilgængelige kurser at tilføje.",
|
||||
"no_matching_courses": "Ingen kurser matcher din søgning.",
|
||||
"no_matching_program_courses": "Ingen programkurser matcher din søgning.",
|
||||
"pagination_status": "Side {page} af {totalPages}",
|
||||
"published_access_callout_title": "Studerendes adgang",
|
||||
"published_access_callout_description": "Studerende har kun adgang til offentliggjorte kurser. Kurser kan være i dette program for dit team, mens de stadig er upublicerede, mens de forbliver skjulte for eleverne, indtil du udgiver dem."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nyt indlæg",
|
||||
"post_placeholder": "Hvad tænker du på?",
|
||||
"post_submit": "Indlæg",
|
||||
"empty_title": "Ingen indlæg endnu",
|
||||
"empty_description": "Vær den første til at poste noget.",
|
||||
"heading": "Nyhedsfeed",
|
||||
"make_a_post": "Lav et indlæg",
|
||||
"edit_post": "Rediger indlæg",
|
||||
"editor_placeholder": "Del en opdatering med dit program",
|
||||
"cancel": "Annuller",
|
||||
"pinned": "Fastgjort",
|
||||
"delete_feed_title": "Slet indlæg",
|
||||
"delete_feed_description": "Er du sikker på, at du vil slette dette indlæg?",
|
||||
"confirm_delete": "Slet",
|
||||
"user_avatar_alt": "Bruger avatar",
|
||||
"comments": {
|
||||
"comment": "kommentar",
|
||||
"showing_of": "Viser {shown} af {total}",
|
||||
"hide": "Skjul kommentarer",
|
||||
"placeholder": "Tilføj programkommentar",
|
||||
"submit_aria": "Send kommentar",
|
||||
"delete": "Slet"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Frigør",
|
||||
"edit": "Rediger",
|
||||
"delete": "Slet"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Opret program",
|
||||
"description": "Tilføj et nyt certificerings- eller overholdelsesprogram.",
|
||||
"name_label": "Navn",
|
||||
"name_placeholder": "f.eks. OSHA sikkerhedscertificering",
|
||||
"description_label": "Beskrivelse",
|
||||
"description_placeholder": "Hvad handler dette program om?",
|
||||
"submit": "Opret"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Nyhedsfeed",
|
||||
"courses": "Kurser",
|
||||
"people": "Mennesker",
|
||||
"settings": "Indstillinger"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"archived": "Arkiveret"
|
||||
},
|
||||
"description": "Giv en gruppe mennesker adgang til flere kurser, f.eks. en klasse",
|
||||
"search_placeholder": "Søg programmer",
|
||||
"multiple_added": "Kurser tilføjet",
|
||||
"no_matching_programs": "Ingen programmer matcher dine filtre.",
|
||||
"card": {
|
||||
"courses": "Kurser",
|
||||
"students": "studerende"
|
||||
},
|
||||
"table": {
|
||||
"name": "Navn",
|
||||
"courses": "Kurser",
|
||||
"students": "studerende",
|
||||
"status": "Status",
|
||||
"description": "Beskrivelse"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filter",
|
||||
"clear_all": "Ryd alle",
|
||||
"popover_title": "Filtre",
|
||||
"sort": "Sorter",
|
||||
"order": "Bestil",
|
||||
"status": "Status",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"sort_name": "Navn",
|
||||
"sort_courses": "Kurser",
|
||||
"sort_students": "studerende",
|
||||
"sort_status": "Status"
|
||||
},
|
||||
"invite_sent": "Invitationer sendt",
|
||||
"goals": {
|
||||
"heading": "Læringsmål",
|
||||
"description": "Sæt målbare mål, der spænder over flere kurser for alle i dette program.",
|
||||
"add": "Nyt mål",
|
||||
"empty_title": "Ingen mål endnu",
|
||||
"empty_description": "Opret et mål for at spore teamfremskridt på tværs af flere kurser.",
|
||||
"created": "Mål skabt",
|
||||
"updated": "Mål opdateret",
|
||||
"deleted": "Mål slettet",
|
||||
"delete_confirm": "Vil du slette dette mål? Per-elev statushistorik vil blive fjernet.",
|
||||
"lms": {
|
||||
"heading": "Dine mål",
|
||||
"empty_title": "Ingen mål tildelt",
|
||||
"empty_description": "Når dine instruktører sætter sig mål, dukker de op her.",
|
||||
"due_in_days": "Forfalder om {days} dage",
|
||||
"due_tomorrow": "Afleveres i morgen",
|
||||
"due_today": "Forfalder i dag",
|
||||
"overdue": "Forfalden",
|
||||
"no_deadline": "Ingen deadline"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "på sporet",
|
||||
"completed_short": "afsluttet",
|
||||
"overdue_short": "forfalden",
|
||||
"at_risk_short": "i fare"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Afsluttet",
|
||||
"in_progress": "I gang",
|
||||
"at_risk": "I fare",
|
||||
"overdue": "Forfalden",
|
||||
"not_started": "Ikke startet",
|
||||
"waived": "Frafaldet"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolut dato",
|
||||
"relative_to_join": "I forhold til elevtilslutning",
|
||||
"recurring": "Tilbagevendende",
|
||||
"none": "Ingen deadline"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Gennemfør alle udvalgte kurser",
|
||||
"n_of_m": "Gennemfør N af M udvalgte kurser",
|
||||
"score": "Score over tærsklen på hvert kursus",
|
||||
"pass_rate": "Teamets beståelsesgrænse",
|
||||
"readiness": "Beredskab (ingen deadline)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nyt mål",
|
||||
"edit_title": "Rediger mål",
|
||||
"title_label": "Titel",
|
||||
"title_placeholder": "f.eks. Onboarding rampe",
|
||||
"description_label": "Beskrivelse",
|
||||
"description_placeholder": "Valgfri kontekst for holdet",
|
||||
"type_label": "Type",
|
||||
"courses_label": "Kurser",
|
||||
"required_count_label": "Antal nødvendige kurser",
|
||||
"score_threshold_label": "Beståelsesscore pr. elev (%)",
|
||||
"team_pass_rate_label": "Holdbeståelsesprocent (%)",
|
||||
"deadline_label": "Deadline",
|
||||
"deadline_date_label": "Deadline dato",
|
||||
"relative_days_label": "Dage fra eleven tiltræder",
|
||||
"recurring_months_label": "Gentag hver (måned)",
|
||||
"reminders_label": "Påmindelser (dage før termin)",
|
||||
"reminders_helper": "Kommasepareret. Eksempel: 7, 3, 1",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Bed mig om at hjælpe med dit kursusindhold - lav lektioner, generer spørgsmål, eller planlæg et kursus ud fra et dokument.",
|
||||
"input_placeholder": "Spørg AI-assistenten...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Klar",
|
||||
"failed": "Upload mislykkedes"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Programmer",
|
||||
"page_title": "Programmer",
|
||||
"create": "Nyt cohort",
|
||||
"create_success": "Cohort oprettet",
|
||||
"update_success": "Cohort opdateret",
|
||||
"delete_success": "Cohort slettet",
|
||||
"members_added": "Medlemmer tilføjet",
|
||||
"member_removed": "Medlem fjernet",
|
||||
"course_added": "Kursus tilføjet",
|
||||
"course_removed": "Kursus fjernet",
|
||||
"empty_title": "Ingen programmer endnu",
|
||||
"empty_description": "Opret dit første certificerings- eller overholdelsesprogram.",
|
||||
"settings": {
|
||||
"general": "Generelt",
|
||||
"name_label": "Navn",
|
||||
"description_label": "Beskrivelse",
|
||||
"status_label": "Status",
|
||||
"save": "Gem ændringer",
|
||||
"danger_zone": "Farezone",
|
||||
"delete": "Slet cohort",
|
||||
"delete_description": "Når det er slettet, kan dette cohort og alle dets data ikke gendannes.",
|
||||
"confirm_delete": "Er du sikker på, at du vil slette dette cohort? Dette kan ikke fortrydes."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Ingen programmer endnu",
|
||||
"empty_description": "Du er ikke blevet tilmeldt nogen programmer."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Inviter",
|
||||
"invite_modal_title": "Inviter medlemmer",
|
||||
"invite_modal_description": "Tilføj personer til dette cohort via e-mail.",
|
||||
"email_label": "E-mail",
|
||||
"role_label": "Rolle",
|
||||
"add_another": "Tilføj en anden",
|
||||
"invite_submit": "Send invitationer",
|
||||
"empty_title": "Ingen medlemmer endnu",
|
||||
"empty_description": "Inviter folk til at deltage i dette cohort."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Tilføj kursus",
|
||||
"add_modal_title": "Føj kursus til programmet",
|
||||
"select_course": "Vælg et kursus",
|
||||
"select_placeholder": "Vælg et kursus...",
|
||||
"empty_title": "Ingen kurser endnu",
|
||||
"empty_description": "Tilføj kurser til dette cohort.",
|
||||
"select_courses": "Vælg kurser",
|
||||
"search_placeholder": "Søg efter kurser",
|
||||
"no_available_courses": "Ingen tilgængelige kurser at tilføje.",
|
||||
"no_matching_courses": "Ingen kurser matcher din søgning.",
|
||||
"no_matching_cohort_courses": "Ingen programkurser matcher din søgning.",
|
||||
"pagination_status": "Side {page} af {totalPages}",
|
||||
"published_access_callout_title": "Studerendes adgang",
|
||||
"published_access_callout_description": "Studerende har kun adgang til offentliggjorte kurser. Kurser kan være i dette cohort for dit team, mens de stadig er upublicerede, mens de forbliver skjulte for eleverne, indtil du udgiver dem."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nyt indlæg",
|
||||
"post_placeholder": "Hvad tænker du på?",
|
||||
"post_submit": "Indlæg",
|
||||
"empty_title": "Ingen indlæg endnu",
|
||||
"empty_description": "Vær den første til at poste noget.",
|
||||
"heading": "Nyhedsfeed",
|
||||
"make_a_post": "Lav et indlæg",
|
||||
"edit_post": "Rediger indlæg",
|
||||
"editor_placeholder": "Del en opdatering med dit cohort",
|
||||
"cancel": "Annuller",
|
||||
"pinned": "Fastgjort",
|
||||
"delete_feed_title": "Slet indlæg",
|
||||
"delete_feed_description": "Er du sikker på, at du vil slette dette indlæg?",
|
||||
"confirm_delete": "Slet",
|
||||
"user_avatar_alt": "Bruger avatar",
|
||||
"comments": {
|
||||
"comment": "kommentar",
|
||||
"showing_of": "Viser {shown} af {total}",
|
||||
"hide": "Skjul kommentarer",
|
||||
"placeholder": "Tilføj programkommentar",
|
||||
"submit_aria": "Send kommentar",
|
||||
"delete": "Slet"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Frigør",
|
||||
"edit": "Rediger",
|
||||
"delete": "Slet"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Opret cohort",
|
||||
"description": "Tilføj et nyt certificerings- eller overholdelsesprogram.",
|
||||
"name_label": "Navn",
|
||||
"name_placeholder": "f.eks. OSHA sikkerhedscertificering",
|
||||
"description_label": "Beskrivelse",
|
||||
"description_placeholder": "Hvad handler dette cohort om?",
|
||||
"submit": "Opret"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Nyhedsfeed",
|
||||
"courses": "Kurser",
|
||||
"people": "Mennesker",
|
||||
"settings": "Indstillinger"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"archived": "Arkiveret"
|
||||
},
|
||||
"description": "Giv en gruppe mennesker adgang til flere kurser, f.eks. en klasse",
|
||||
"search_placeholder": "Søg programmer",
|
||||
"multiple_added": "Kurser tilføjet",
|
||||
"no_matching_cohorts": "Ingen programmer matcher dine filtre.",
|
||||
"card": {
|
||||
"courses": "Kurser",
|
||||
"students": "studerende"
|
||||
},
|
||||
"table": {
|
||||
"name": "Navn",
|
||||
"courses": "Kurser",
|
||||
"students": "studerende",
|
||||
"status": "Status",
|
||||
"description": "Beskrivelse"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filter",
|
||||
"clear_all": "Ryd alle",
|
||||
"popover_title": "Filtre",
|
||||
"sort": "Sorter",
|
||||
"order": "Bestil",
|
||||
"status": "Status",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"sort_name": "Navn",
|
||||
"sort_courses": "Kurser",
|
||||
"sort_students": "studerende",
|
||||
"sort_status": "Status"
|
||||
},
|
||||
"invite_sent": "Invitationer sendt",
|
||||
"goals": {
|
||||
"heading": "Læringsmål",
|
||||
"description": "Sæt målbare mål, der spænder over flere kurser for alle i dette cohort.",
|
||||
"add": "Nyt mål",
|
||||
"empty_title": "Ingen mål endnu",
|
||||
"empty_description": "Opret et mål for at spore teamfremskridt på tværs af flere kurser.",
|
||||
"created": "Mål skabt",
|
||||
"updated": "Mål opdateret",
|
||||
"deleted": "Mål slettet",
|
||||
"delete_confirm": "Vil du slette dette mål? Per-elev statushistorik vil blive fjernet.",
|
||||
"lms": {
|
||||
"heading": "Dine mål",
|
||||
"empty_title": "Ingen mål tildelt",
|
||||
"empty_description": "Når dine instruktører sætter sig mål, dukker de op her.",
|
||||
"due_in_days": "Forfalder om {days} dage",
|
||||
"due_tomorrow": "Afleveres i morgen",
|
||||
"due_today": "Forfalder i dag",
|
||||
"overdue": "Forfalden",
|
||||
"no_deadline": "Ingen deadline"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "på sporet",
|
||||
"completed_short": "afsluttet",
|
||||
"overdue_short": "forfalden",
|
||||
"at_risk_short": "i fare"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Afsluttet",
|
||||
"in_progress": "I gang",
|
||||
"at_risk": "I fare",
|
||||
"overdue": "Forfalden",
|
||||
"not_started": "Ikke startet",
|
||||
"waived": "Frafaldet"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolut dato",
|
||||
"relative_to_join": "I forhold til elevtilslutning",
|
||||
"recurring": "Tilbagevendende",
|
||||
"none": "Ingen deadline"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Gennemfør alle udvalgte kurser",
|
||||
"n_of_m": "Gennemfør N af M udvalgte kurser",
|
||||
"score": "Score over tærsklen på hvert kursus",
|
||||
"pass_rate": "Teamets beståelsesgrænse",
|
||||
"readiness": "Beredskab (ingen deadline)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nyt mål",
|
||||
"edit_title": "Rediger mål",
|
||||
"title_label": "Titel",
|
||||
"title_placeholder": "f.eks. Onboarding rampe",
|
||||
"description_label": "Beskrivelse",
|
||||
"description_placeholder": "Valgfri kontekst for holdet",
|
||||
"type_label": "Type",
|
||||
"courses_label": "Kurser",
|
||||
"required_count_label": "Antal nødvendige kurser",
|
||||
"score_threshold_label": "Beståelsesscore pr. elev (%)",
|
||||
"team_pass_rate_label": "Holdbeståelsesprocent (%)",
|
||||
"deadline_label": "Deadline",
|
||||
"deadline_date_label": "Deadline dato",
|
||||
"relative_days_label": "Dage fra eleven tiltræder",
|
||||
"recurring_months_label": "Gentag hver (måned)",
|
||||
"reminders_label": "Påmindelser (dage før termin)",
|
||||
"reminders_helper": "Kommasepareret. Eksempel: 7, 3, 1",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Automatisierungsschlüssel gedreht. Speichern Sie das neue Geheimnis jetzt, da es nicht mehr angezeigt wird.",
|
||||
"revoked": "Automatisierungsschlüssel widerrufen."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Beim Reagieren auf den Beitrag ist ein Fehler aufgetreten",
|
||||
"news_feed_fail": "Der Programm-Newsfeed konnte nicht abgerufen werden",
|
||||
"post_create_failed": "Beitrag konnte nicht erstellt werden",
|
||||
"post_update_failed": "Beitrag konnte nicht aktualisiert werden",
|
||||
"code": {
|
||||
"program_forbidden": "Sie müssen Mitglied dieses Programms sein, um diese Aktion ausführen zu können.",
|
||||
"program_member_check_failed": "Wir konnten Ihre Programmmitgliedschaft nicht bestätigen. Versuchen Sie es erneut.",
|
||||
"program_not_found": "Dieses Programm konnte nicht gefunden werden.",
|
||||
"program_create_failed": "Das Programm konnte nicht erstellt werden.",
|
||||
"program_update_failed": "Das Programm konnte nicht aktualisiert werden.",
|
||||
"program_delete_failed": "Das Programm konnte nicht gelöscht werden.",
|
||||
"program_member_not_found": "Dieses Programmmitglied konnte nicht gefunden werden.",
|
||||
"program_newsfeed_not_found": "Dieser Beitrag konnte nicht gefunden werden.",
|
||||
"program_newsfeed_comment_not_found": "Dieser Kommentar konnte nicht gefunden werden."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Kommentar gelöscht",
|
||||
"comment_added": "Kommentar hinzugefügt",
|
||||
"feed_delete_success": "Beitrag erfolgreich gelöscht",
|
||||
"post_created": "Beitrag erfolgreich erstellt",
|
||||
"post_updated": "Beitrag erfolgreich aktualisiert"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "AI credit pack zu Ihrem Guthaben hinzugefügt.",
|
||||
"checkout_failed": "Checkout konnte nicht gestartet werden. Bitte versuchen Sie es erneut."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "1080p-Wiedergabe hinzugefügt",
|
||||
"generate_1080_failed": "Die 1080p-Wiedergabe konnte nicht generiert werden"
|
||||
},
|
||||
"social_auth_not_enabled": "Dieser Authentifizierungsanbieter ist nicht aktiviert."
|
||||
"social_auth_not_enabled": "Dieser Authentifizierungsanbieter ist nicht aktiviert.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Beim Reagieren auf den Beitrag ist ein Fehler aufgetreten",
|
||||
"news_feed_fail": "Der Programm-Newsfeed konnte nicht abgerufen werden",
|
||||
"post_create_failed": "Beitrag konnte nicht erstellt werden",
|
||||
"post_update_failed": "Beitrag konnte nicht aktualisiert werden",
|
||||
"code": {
|
||||
"cohort_forbidden": "Sie müssen Mitglied dieses Programms sein, um diese Aktion ausführen zu können.",
|
||||
"cohort_member_check_failed": "Wir konnten Ihre Programmmitgliedschaft nicht bestätigen. Versuchen Sie es erneut.",
|
||||
"cohort_not_found": "Dieses Programm konnte nicht gefunden werden.",
|
||||
"cohort_create_failed": "Das Programm konnte nicht erstellt werden.",
|
||||
"cohort_update_failed": "Das Programm konnte nicht aktualisiert werden.",
|
||||
"cohort_delete_failed": "Das Programm konnte nicht gelöscht werden.",
|
||||
"cohort_member_not_found": "Dieses Programmmitglied konnte nicht gefunden werden.",
|
||||
"cohort_newsfeed_not_found": "Dieser Beitrag konnte nicht gefunden werden.",
|
||||
"cohort_newsfeed_comment_not_found": "Dieser Kommentar konnte nicht gefunden werden."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Kommentar gelöscht",
|
||||
"comment_added": "Kommentar hinzugefügt",
|
||||
"feed_delete_success": "Beitrag erfolgreich gelöscht",
|
||||
"post_created": "Beitrag erfolgreich erstellt",
|
||||
"post_updated": "Beitrag erfolgreich aktualisiert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Entdecken Sie unsere Kurse",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Entdecken",
|
||||
"help": "Hilfe",
|
||||
"settings": "Einstellungen",
|
||||
"programs": "Programme"
|
||||
"cohorts": "Programme"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Aktualisieren Sie Ihren Plan, um Zertifikate zu generieren",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Alle Kurse",
|
||||
"select_courses": "Wählen Sie bestimmte Kurse aus",
|
||||
"select_courses_placeholder": "Kurse auswählen...",
|
||||
"program_access": "Programmzugriff",
|
||||
"no_programs": "Keine Programmzuweisung",
|
||||
"all_programs": "Alle Programme",
|
||||
"select_programs": "Bestimmte Programme auswählen",
|
||||
"select_programs_placeholder": "Programme auswählen...",
|
||||
"send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer",
|
||||
"submit": "Benutzer importieren",
|
||||
"back": "Zurück zum Publikum",
|
||||
"snackbar_success": "{imported} neue Einladungen, {assigned} bestehende Studierende mit Kurszugang, {pendingInvitesRenewed} ausstehende Einladungen erneuert ({emailsSent} E-Mails gesendet)",
|
||||
"snackbar_no_emails": "Bitte geben Sie mindestens eine E-Mail-Adresse ein",
|
||||
"snackbar_failed": "Benutzer konnten nicht importiert werden",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Programmzugriff",
|
||||
"no_cohorts": "Keine Programmzuweisung",
|
||||
"all_cohorts": "Alle Programme",
|
||||
"select_cohorts": "Bestimmte Programme auswählen",
|
||||
"select_cohorts_placeholder": "Programme auswählen..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Jetzt upgraden",
|
||||
"tags": "Schlagworte",
|
||||
"automation": "Automatisierung",
|
||||
"programs": "Programme",
|
||||
"widgets": "Widgets",
|
||||
"stats": "Statistiken",
|
||||
"analytics": "Analytik",
|
||||
"compliance": "Compliance"
|
||||
"compliance": "Compliance",
|
||||
"cohorts": "Programme"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Medienmanager - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programme",
|
||||
"page_title": "Programme",
|
||||
"create": "Neues Programm",
|
||||
"create_success": "Programm erstellt",
|
||||
"update_success": "Programm aktualisiert",
|
||||
"delete_success": "Programm gelöscht",
|
||||
"members_added": "Mitglieder hinzugefügt",
|
||||
"member_removed": "Mitglied entfernt",
|
||||
"course_added": "Kurs hinzugefügt",
|
||||
"course_removed": "Kurs entfernt",
|
||||
"empty_title": "Noch keine Programme",
|
||||
"empty_description": "Erstellen Sie Ihr erstes Zertifizierungs- oder Compliance-Programm.",
|
||||
"settings": {
|
||||
"general": "Allgemein",
|
||||
"name_label": "Name",
|
||||
"description_label": "Beschreibung",
|
||||
"status_label": "Status",
|
||||
"save": "Änderungen speichern",
|
||||
"danger_zone": "Gefahrenzone",
|
||||
"delete": "Programm löschen",
|
||||
"delete_description": "Nach dem Löschen können dieses Programm und alle seine Daten nicht wiederhergestellt werden.",
|
||||
"confirm_delete": "Sind Sie sicher, dass Sie dieses Programm löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Noch keine Programme",
|
||||
"empty_description": "Sie sind in keinem Programm eingeschrieben."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Einladen",
|
||||
"invite_modal_title": "Mitglieder einladen",
|
||||
"invite_modal_description": "Fügen Sie Personen per E-Mail zu diesem Programm hinzu.",
|
||||
"email_label": "E-Mail",
|
||||
"role_label": "Rolle",
|
||||
"add_another": "Fügen Sie einen weiteren hinzu",
|
||||
"invite_submit": "Senden Sie Einladungen",
|
||||
"empty_title": "Noch keine Mitglieder",
|
||||
"empty_description": "Laden Sie Menschen ein, an diesem Programm teilzunehmen."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Kurs hinzufügen",
|
||||
"add_modal_title": "Kurs zum Programm hinzufügen",
|
||||
"select_course": "Wählen Sie einen Kurs aus",
|
||||
"select_placeholder": "Wählen Sie einen Kurs…",
|
||||
"empty_title": "Noch keine Kurse",
|
||||
"empty_description": "Fügen Sie Kurse zu diesem Programm hinzu.",
|
||||
"select_courses": "Kurse auswählen",
|
||||
"search_placeholder": "Kurse suchen",
|
||||
"no_available_courses": "Keine Kurse zum Hinzufügen verfügbar.",
|
||||
"no_matching_courses": "Zu Ihrer Suche passen keine Kurse.",
|
||||
"no_matching_program_courses": "Zu Ihrer Suche passen keine Studiengänge.",
|
||||
"pagination_status": "Seite {page} von {totalPages}",
|
||||
"published_access_callout_title": "Zugang für Studierende",
|
||||
"published_access_callout_description": "Studierende haben nur Zugriff auf veröffentlichte Kurse. Kurse können für Ihr Team in diesem Programm enthalten sein, solange sie noch unveröffentlicht sind, während sie den Studierenden verborgen bleiben, bis Sie sie veröffentlichen."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Neuer Beitrag",
|
||||
"post_placeholder": "Was geht Ihnen durch den Kopf?",
|
||||
"post_submit": "Beitrag",
|
||||
"empty_title": "Noch keine Beiträge",
|
||||
"empty_description": "Seien Sie der Erste, der etwas postet.",
|
||||
"heading": "Newsfeed",
|
||||
"make_a_post": "Machen Sie einen Beitrag",
|
||||
"edit_post": "Beitrag bearbeiten",
|
||||
"editor_placeholder": "Teilen Sie ein Update mit Ihrem Programm",
|
||||
"cancel": "Abbrechen",
|
||||
"pinned": "Angepinnt",
|
||||
"delete_feed_title": "Beitrag löschen",
|
||||
"delete_feed_description": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
|
||||
"confirm_delete": "Löschen",
|
||||
"user_avatar_alt": "Benutzer-Avatar",
|
||||
"comments": {
|
||||
"comment": "Kommentar",
|
||||
"showing_of": "Es werden {shown} von {total} angezeigt",
|
||||
"hide": "Kommentare ausblenden",
|
||||
"placeholder": "Programmkommentar hinzufügen",
|
||||
"submit_aria": "Kommentar abgeben",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Lösen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Programm erstellen",
|
||||
"description": "Fügen Sie ein neues Zertifizierungs- oder Compliance-Programm hinzu.",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "z.B. OSHA-Sicherheitszertifizierung",
|
||||
"description_label": "Beschreibung",
|
||||
"description_placeholder": "Worum geht es in diesem Programm?",
|
||||
"submit": "Erstellen"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Newsfeed",
|
||||
"courses": "Kurse",
|
||||
"people": "Menschen",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"archived": "Archiviert"
|
||||
},
|
||||
"description": "Gewähren Sie einer Gruppe von Personen Zugriff auf mehrere Kurse, z. B. einen Kurs",
|
||||
"search_placeholder": "Suchprogramme",
|
||||
"multiple_added": "Kurse hinzugefügt",
|
||||
"no_matching_programs": "Keine Programme entsprechen Ihren Filtern.",
|
||||
"card": {
|
||||
"courses": "Kurse",
|
||||
"students": "Studenten"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"courses": "Kurse",
|
||||
"students": "Studenten",
|
||||
"status": "Status",
|
||||
"description": "Beschreibung"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtern",
|
||||
"clear_all": "Alles löschen",
|
||||
"popover_title": "Filter",
|
||||
"sort": "Sortieren",
|
||||
"order": "Bestellen",
|
||||
"status": "Status",
|
||||
"ascending": "Aufstieg",
|
||||
"descending": "Beschr",
|
||||
"sort_name": "Name",
|
||||
"sort_courses": "Kurse",
|
||||
"sort_students": "Studenten",
|
||||
"sort_status": "Status"
|
||||
},
|
||||
"invite_sent": "Einladungen gesendet",
|
||||
"goals": {
|
||||
"heading": "Lernziele",
|
||||
"description": "Legen Sie messbare Ziele fest, die sich über mehrere Kurse für alle Teilnehmer dieses Programms erstrecken.",
|
||||
"add": "Neues Ziel",
|
||||
"empty_title": "Noch keine Ziele",
|
||||
"empty_description": "Erstellen Sie ein Ziel, um den Teamfortschritt über mehrere Kurse hinweg zu verfolgen.",
|
||||
"created": "Ziel erstellt",
|
||||
"updated": "Ziel aktualisiert",
|
||||
"deleted": "Ziel gelöscht",
|
||||
"delete_confirm": "Dieses Ziel löschen? Der Statusverlauf pro Schüler wird entfernt.",
|
||||
"lms": {
|
||||
"heading": "Ihre Ziele",
|
||||
"empty_title": "Keine Ziele zugewiesen",
|
||||
"empty_description": "Wenn Ihre Kursleiter Ziele setzen, werden sie hier angezeigt.",
|
||||
"due_in_days": "Fällig in {days} Tagen",
|
||||
"due_tomorrow": "Fällig morgen",
|
||||
"due_today": "Heute fällig",
|
||||
"overdue": "Überfällig",
|
||||
"no_deadline": "Keine Frist"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "auf Kurs",
|
||||
"completed_short": "abgeschlossen",
|
||||
"overdue_short": "überfällig",
|
||||
"at_risk_short": "gefährdet"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Abgeschlossen",
|
||||
"in_progress": "In Bearbeitung",
|
||||
"at_risk": "In Gefahr",
|
||||
"overdue": "Überfällig",
|
||||
"not_started": "Nicht gestartet",
|
||||
"waived": "Verzichtet"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolutes Datum",
|
||||
"relative_to_join": "Bezogen auf den Beitritt von Studierenden",
|
||||
"recurring": "Wiederkehrend",
|
||||
"none": "Keine Frist"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Absolvieren Sie alle ausgewählten Kurse",
|
||||
"n_of_m": "Absolvieren Sie N von M ausgewählten Kursen",
|
||||
"score": "Erreichen Sie in jedem Kurs eine Punktzahl über dem Schwellenwert",
|
||||
"pass_rate": "Schwellenwert für die Erfolgsquote des Teams",
|
||||
"readiness": "Bereitschaft (keine Frist)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Neues Ziel",
|
||||
"edit_title": "Ziel bearbeiten",
|
||||
"title_label": "Titel",
|
||||
"title_placeholder": "z.B. Onboarding-Rampe",
|
||||
"description_label": "Beschreibung",
|
||||
"description_placeholder": "Optionaler Kontext für das Team",
|
||||
"type_label": "Typ",
|
||||
"courses_label": "Kurse",
|
||||
"required_count_label": "Anzahl der erforderlichen Kurse",
|
||||
"score_threshold_label": "Bestehenspunktzahl pro Schüler (%)",
|
||||
"team_pass_rate_label": "Erfolgsquote der Mannschaft (%)",
|
||||
"deadline_label": "Frist",
|
||||
"deadline_date_label": "Deadline-Datum",
|
||||
"relative_days_label": "Tage ab dem Beitritt des Schülers",
|
||||
"recurring_months_label": "Wiederholen Sie alle (Monate)",
|
||||
"reminders_label": "Erinnerungen (Tage vor Fälligkeit)",
|
||||
"reminders_helper": "Durch Kommas getrennt. Beispiel: 7, 3, 1",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Bitten Sie mich, Ihnen bei den Inhalten Ihres Kurses zu helfen – Lektionen zu entwerfen, Fragen zu generieren oder einen Kurs anhand eines Dokuments zu planen.",
|
||||
"input_placeholder": "Fragen Sie den KI-Assistenten...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Bereit",
|
||||
"failed": "Der Upload ist fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Programme",
|
||||
"page_title": "Programme",
|
||||
"create": "Neues Programm",
|
||||
"create_success": "Programm erstellt",
|
||||
"update_success": "Programm aktualisiert",
|
||||
"delete_success": "Programm gelöscht",
|
||||
"members_added": "Mitglieder hinzugefügt",
|
||||
"member_removed": "Mitglied entfernt",
|
||||
"course_added": "Kurs hinzugefügt",
|
||||
"course_removed": "Kurs entfernt",
|
||||
"empty_title": "Noch keine Programme",
|
||||
"empty_description": "Erstellen Sie Ihr erstes Zertifizierungs- oder Compliance-Programm.",
|
||||
"settings": {
|
||||
"general": "Allgemein",
|
||||
"name_label": "Name",
|
||||
"description_label": "Beschreibung",
|
||||
"status_label": "Status",
|
||||
"save": "Änderungen speichern",
|
||||
"danger_zone": "Gefahrenzone",
|
||||
"delete": "Programm löschen",
|
||||
"delete_description": "Nach dem Löschen können dieses Programm und alle seine Daten nicht wiederhergestellt werden.",
|
||||
"confirm_delete": "Sind Sie sicher, dass Sie dieses Programm löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Noch keine Programme",
|
||||
"empty_description": "Sie sind in keinem Programm eingeschrieben."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Einladen",
|
||||
"invite_modal_title": "Mitglieder einladen",
|
||||
"invite_modal_description": "Fügen Sie Personen per E-Mail zu diesem Programm hinzu.",
|
||||
"email_label": "E-Mail",
|
||||
"role_label": "Rolle",
|
||||
"add_another": "Fügen Sie einen weiteren hinzu",
|
||||
"invite_submit": "Senden Sie Einladungen",
|
||||
"empty_title": "Noch keine Mitglieder",
|
||||
"empty_description": "Laden Sie Menschen ein, an diesem Programm teilzunehmen."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Kurs hinzufügen",
|
||||
"add_modal_title": "Kurs zum Programm hinzufügen",
|
||||
"select_course": "Wählen Sie einen Kurs aus",
|
||||
"select_placeholder": "Wählen Sie einen Kurs…",
|
||||
"empty_title": "Noch keine Kurse",
|
||||
"empty_description": "Fügen Sie Kurse zu diesem Programm hinzu.",
|
||||
"select_courses": "Kurse auswählen",
|
||||
"search_placeholder": "Kurse suchen",
|
||||
"no_available_courses": "Keine Kurse zum Hinzufügen verfügbar.",
|
||||
"no_matching_courses": "Zu Ihrer Suche passen keine Kurse.",
|
||||
"no_matching_cohort_courses": "Zu Ihrer Suche passen keine Studiengänge.",
|
||||
"pagination_status": "Seite {page} von {totalPages}",
|
||||
"published_access_callout_title": "Zugang für Studierende",
|
||||
"published_access_callout_description": "Studierende haben nur Zugriff auf veröffentlichte Kurse. Kurse können für Ihr Team in diesem Programm enthalten sein, solange sie noch unveröffentlicht sind, während sie den Studierenden verborgen bleiben, bis Sie sie veröffentlichen."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Neuer Beitrag",
|
||||
"post_placeholder": "Was geht Ihnen durch den Kopf?",
|
||||
"post_submit": "Beitrag",
|
||||
"empty_title": "Noch keine Beiträge",
|
||||
"empty_description": "Seien Sie der Erste, der etwas postet.",
|
||||
"heading": "Newsfeed",
|
||||
"make_a_post": "Machen Sie einen Beitrag",
|
||||
"edit_post": "Beitrag bearbeiten",
|
||||
"editor_placeholder": "Teilen Sie ein Update mit Ihrem Programm",
|
||||
"cancel": "Abbrechen",
|
||||
"pinned": "Angepinnt",
|
||||
"delete_feed_title": "Beitrag löschen",
|
||||
"delete_feed_description": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
|
||||
"confirm_delete": "Löschen",
|
||||
"user_avatar_alt": "Benutzer-Avatar",
|
||||
"comments": {
|
||||
"comment": "Kommentar",
|
||||
"showing_of": "Es werden {shown} von {total} angezeigt",
|
||||
"hide": "Kommentare ausblenden",
|
||||
"placeholder": "Programmkommentar hinzufügen",
|
||||
"submit_aria": "Kommentar abgeben",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Lösen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Programm erstellen",
|
||||
"description": "Fügen Sie ein neues Zertifizierungs- oder Compliance-Programm hinzu.",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "z.B. OSHA-Sicherheitszertifizierung",
|
||||
"description_label": "Beschreibung",
|
||||
"description_placeholder": "Worum geht es in diesem Programm?",
|
||||
"submit": "Erstellen"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Newsfeed",
|
||||
"courses": "Kurse",
|
||||
"people": "Menschen",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"archived": "Archiviert"
|
||||
},
|
||||
"description": "Gewähren Sie einer Gruppe von Personen Zugriff auf mehrere Kurse, z. B. einen Kurs",
|
||||
"search_placeholder": "Suchprogramme",
|
||||
"multiple_added": "Kurse hinzugefügt",
|
||||
"no_matching_cohorts": "Keine Programme entsprechen Ihren Filtern.",
|
||||
"card": {
|
||||
"courses": "Kurse",
|
||||
"students": "Studenten"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"courses": "Kurse",
|
||||
"students": "Studenten",
|
||||
"status": "Status",
|
||||
"description": "Beschreibung"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtern",
|
||||
"clear_all": "Alles löschen",
|
||||
"popover_title": "Filter",
|
||||
"sort": "Sortieren",
|
||||
"order": "Bestellen",
|
||||
"status": "Status",
|
||||
"ascending": "Aufstieg",
|
||||
"descending": "Beschr",
|
||||
"sort_name": "Name",
|
||||
"sort_courses": "Kurse",
|
||||
"sort_students": "Studenten",
|
||||
"sort_status": "Status"
|
||||
},
|
||||
"invite_sent": "Einladungen gesendet",
|
||||
"goals": {
|
||||
"heading": "Lernziele",
|
||||
"description": "Legen Sie messbare Ziele fest, die sich über mehrere Kurse für alle Teilnehmer dieses Programms erstrecken.",
|
||||
"add": "Neues Ziel",
|
||||
"empty_title": "Noch keine Ziele",
|
||||
"empty_description": "Erstellen Sie ein Ziel, um den Teamfortschritt über mehrere Kurse hinweg zu verfolgen.",
|
||||
"created": "Ziel erstellt",
|
||||
"updated": "Ziel aktualisiert",
|
||||
"deleted": "Ziel gelöscht",
|
||||
"delete_confirm": "Dieses Ziel löschen? Der Statusverlauf pro Schüler wird entfernt.",
|
||||
"lms": {
|
||||
"heading": "Ihre Ziele",
|
||||
"empty_title": "Keine Ziele zugewiesen",
|
||||
"empty_description": "Wenn Ihre Kursleiter Ziele setzen, werden sie hier angezeigt.",
|
||||
"due_in_days": "Fällig in {days} Tagen",
|
||||
"due_tomorrow": "Fällig morgen",
|
||||
"due_today": "Heute fällig",
|
||||
"overdue": "Überfällig",
|
||||
"no_deadline": "Keine Frist"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "auf Kurs",
|
||||
"completed_short": "abgeschlossen",
|
||||
"overdue_short": "überfällig",
|
||||
"at_risk_short": "gefährdet"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Abgeschlossen",
|
||||
"in_progress": "In Bearbeitung",
|
||||
"at_risk": "In Gefahr",
|
||||
"overdue": "Überfällig",
|
||||
"not_started": "Nicht gestartet",
|
||||
"waived": "Verzichtet"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolutes Datum",
|
||||
"relative_to_join": "Bezogen auf den Beitritt von Studierenden",
|
||||
"recurring": "Wiederkehrend",
|
||||
"none": "Keine Frist"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Absolvieren Sie alle ausgewählten Kurse",
|
||||
"n_of_m": "Absolvieren Sie N von M ausgewählten Kursen",
|
||||
"score": "Erreichen Sie in jedem Kurs eine Punktzahl über dem Schwellenwert",
|
||||
"pass_rate": "Schwellenwert für die Erfolgsquote des Teams",
|
||||
"readiness": "Bereitschaft (keine Frist)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Neues Ziel",
|
||||
"edit_title": "Ziel bearbeiten",
|
||||
"title_label": "Titel",
|
||||
"title_placeholder": "z.B. Onboarding-Rampe",
|
||||
"description_label": "Beschreibung",
|
||||
"description_placeholder": "Optionaler Kontext für das Team",
|
||||
"type_label": "Typ",
|
||||
"courses_label": "Kurse",
|
||||
"required_count_label": "Anzahl der erforderlichen Kurse",
|
||||
"score_threshold_label": "Bestehenspunktzahl pro Schüler (%)",
|
||||
"team_pass_rate_label": "Erfolgsquote der Mannschaft (%)",
|
||||
"deadline_label": "Frist",
|
||||
"deadline_date_label": "Deadline-Datum",
|
||||
"relative_days_label": "Tage ab dem Beitritt des Schülers",
|
||||
"recurring_months_label": "Wiederholen Sie alle (Monate)",
|
||||
"reminders_label": "Erinnerungen (Tage vor Fälligkeit)",
|
||||
"reminders_helper": "Durch Kommas getrennt. Beispiel: 7, 3, 1",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,9 +264,9 @@
|
||||
"command_palette": {
|
||||
"title": "Command palette",
|
||||
"description": "Search and navigate across your organization",
|
||||
"placeholder": "Search courses, programs, widgets, students, tags, settings...",
|
||||
"org_placeholder": "Search courses, programs, widgets, students, tags, settings...",
|
||||
"lms_placeholder": "Search your courses, programs, and learning pages...",
|
||||
"placeholder": "Search courses, cohorts, widgets, students, tags, settings...",
|
||||
"org_placeholder": "Search courses, cohorts, widgets, students, tags, settings...",
|
||||
"lms_placeholder": "Search your courses, cohorts, and learning pages...",
|
||||
"lms_description": "Search and navigate your learning workspace",
|
||||
"empty": "No results found",
|
||||
"loading": "Searching...",
|
||||
@@ -275,7 +275,7 @@
|
||||
"recent": "Recent",
|
||||
"suggested": "Suggested",
|
||||
"courses": "Courses",
|
||||
"programs": "Programs",
|
||||
"cohorts": "Cohorts",
|
||||
"widgets": "Widgets",
|
||||
"tags": "Tags",
|
||||
"students": "Students",
|
||||
@@ -1243,7 +1243,6 @@
|
||||
"analytics": "Analytics",
|
||||
"compliance": "Compliance",
|
||||
"courses": "Courses",
|
||||
"programs": "Programs",
|
||||
"tags": "Tags",
|
||||
"community": "Community",
|
||||
"audience": "Audience",
|
||||
@@ -1255,7 +1254,8 @@
|
||||
"settings": "Settings",
|
||||
"early_adopter": "Become an Early Adopter",
|
||||
"unlock": "Unlock unlimited features and invest in our future",
|
||||
"upgrade": "Upgrade Now"
|
||||
"upgrade": "Upgrade Now",
|
||||
"cohorts": "Cohorts"
|
||||
},
|
||||
"pricing": {
|
||||
"plan_names": {
|
||||
@@ -1750,212 +1750,6 @@
|
||||
"insert": "Insert",
|
||||
"can_you": "Can you please rephrase this"
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programs",
|
||||
"description": "Give a group of people access to multiple courses, like a class",
|
||||
"page_title": "Programs",
|
||||
"search_placeholder": "Search programs",
|
||||
"create": "New Program",
|
||||
"create_success": "Program created",
|
||||
"update_success": "Program updated",
|
||||
"delete_success": "Program deleted",
|
||||
"members_added": "Members added",
|
||||
"member_removed": "Member removed",
|
||||
"invite_sent": "Invites sent",
|
||||
"course_added": "Course added",
|
||||
"multiple_added": "Courses added",
|
||||
"course_removed": "Course removed",
|
||||
"empty_title": "No programs yet",
|
||||
"empty_description": "Create your first certification or compliance program.",
|
||||
"no_matching_programs": "No programs match your filters.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filter",
|
||||
"clear_all": "Clear all",
|
||||
"popover_title": "Filters",
|
||||
"sort": "Sort",
|
||||
"order": "Order",
|
||||
"status": "Status",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"sort_name": "Name",
|
||||
"sort_courses": "Courses",
|
||||
"sort_students": "Students",
|
||||
"sort_status": "Status"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"courses": "Courses",
|
||||
"students": "Students",
|
||||
"status": "Status",
|
||||
"description": "Description"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Newsfeed",
|
||||
"courses": "Courses",
|
||||
"people": "People",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Create Program",
|
||||
"description": "Add a new certification or compliance program.",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "e.g. OSHA Safety Certification",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "What is this program about?",
|
||||
"submit": "Create"
|
||||
},
|
||||
"card": {
|
||||
"courses": "Courses",
|
||||
"students": "Students"
|
||||
},
|
||||
"newsfeed": {
|
||||
"heading": "Newsfeed",
|
||||
"post": "New Post",
|
||||
"post_placeholder": "What's on your mind?",
|
||||
"make_a_post": "Make a Post",
|
||||
"edit_post": "Edit Post",
|
||||
"editor_placeholder": "Share an update with your program",
|
||||
"cancel": "Cancel",
|
||||
"post_submit": "Post",
|
||||
"pinned": "Pinned",
|
||||
"delete_feed_title": "Delete post",
|
||||
"delete_feed_description": "Are you sure you want to delete this post?",
|
||||
"confirm_delete": "Delete",
|
||||
"user_avatar_alt": "User avatar",
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"comments": {
|
||||
"comment": "comment",
|
||||
"showing_of": "Showing {shown} of {total}",
|
||||
"hide": "Hide comments",
|
||||
"placeholder": "Add program comment",
|
||||
"submit_aria": "Submit comment",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"empty_title": "No posts yet",
|
||||
"empty_description": "Be the first to post something."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Add Course",
|
||||
"add_modal_title": "Add Course to Program",
|
||||
"select_course": "Select a course",
|
||||
"select_courses": "Select courses",
|
||||
"select_placeholder": "Choose a course…",
|
||||
"search_placeholder": "Search courses",
|
||||
"no_available_courses": "No available courses to add.",
|
||||
"no_matching_courses": "No courses match your search.",
|
||||
"no_matching_program_courses": "No program courses match your search.",
|
||||
"pagination_status": "Page {page} of {totalPages}",
|
||||
"empty_title": "No courses yet",
|
||||
"empty_description": "Add courses to this program.",
|
||||
"published_access_callout_title": "Student access",
|
||||
"published_access_callout_description": "Students only have access to published courses. Courses can be in this program for your team while still unpublished while those stay hidden from students until you publish them."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Invite",
|
||||
"invite_modal_title": "Invite Members",
|
||||
"invite_modal_description": "Add people to this program by email.",
|
||||
"email_label": "Email",
|
||||
"role_label": "Role",
|
||||
"add_another": "Add another",
|
||||
"invite_submit": "Send Invites",
|
||||
"empty_title": "No members yet",
|
||||
"empty_description": "Invite people to join this program."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "No programs yet",
|
||||
"empty_description": "You haven't been enrolled in any programs."
|
||||
},
|
||||
"settings": {
|
||||
"general": "General",
|
||||
"name_label": "Name",
|
||||
"description_label": "Description",
|
||||
"status_label": "Status",
|
||||
"save": "Save Changes",
|
||||
"danger_zone": "Danger Zone",
|
||||
"delete": "Delete Program",
|
||||
"delete_description": "Once deleted, this program and all its data cannot be recovered.",
|
||||
"confirm_delete": "Are you sure you want to delete this program? This cannot be undone."
|
||||
},
|
||||
"goals": {
|
||||
"heading": "Learning Goals",
|
||||
"description": "Set measurable targets that span multiple courses for everyone in this program.",
|
||||
"add": "New Goal",
|
||||
"empty_title": "No goals yet",
|
||||
"empty_description": "Create a goal to track team progress across multiple courses.",
|
||||
"created": "Goal created",
|
||||
"updated": "Goal updated",
|
||||
"deleted": "Goal deleted",
|
||||
"delete_confirm": "Delete this goal? Per-student status history will be removed.",
|
||||
"modal": {
|
||||
"create_title": "New goal",
|
||||
"edit_title": "Edit goal",
|
||||
"title_label": "Title",
|
||||
"title_placeholder": "e.g. Onboarding ramp",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Optional context for the team",
|
||||
"type_label": "Type",
|
||||
"courses_label": "Courses",
|
||||
"required_count_label": "Number of courses required",
|
||||
"score_threshold_label": "Per-student pass score (%)",
|
||||
"team_pass_rate_label": "Team pass rate (%)",
|
||||
"deadline_label": "Deadline",
|
||||
"deadline_date_label": "Deadline date",
|
||||
"relative_days_label": "Days from when the student joins",
|
||||
"recurring_months_label": "Repeat every (months)",
|
||||
"reminders_label": "Reminders (days before due)",
|
||||
"reminders_helper": "Comma-separated. Example: 7, 3, 1",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Complete all selected courses",
|
||||
"n_of_m": "Complete N of M selected courses",
|
||||
"score": "Score above threshold on every course",
|
||||
"pass_rate": "Team pass-rate threshold",
|
||||
"readiness": "Readiness (no deadline)"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolute date",
|
||||
"relative_to_join": "Relative to student joining",
|
||||
"recurring": "Recurring",
|
||||
"none": "No deadline"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Completed",
|
||||
"in_progress": "In progress",
|
||||
"at_risk": "At risk",
|
||||
"overdue": "Overdue",
|
||||
"not_started": "Not started",
|
||||
"waived": "Waived"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "on track",
|
||||
"completed_short": "completed",
|
||||
"overdue_short": "overdue",
|
||||
"at_risk_short": "at risk"
|
||||
},
|
||||
"lms": {
|
||||
"heading": "Your Goals",
|
||||
"empty_title": "No goals assigned",
|
||||
"empty_description": "When your instructors set goals, they'll show up here.",
|
||||
"due_in_days": "Due in {days} days",
|
||||
"due_tomorrow": "Due tomorrow",
|
||||
"due_today": "Due today",
|
||||
"overdue": "Overdue",
|
||||
"no_deadline": "No deadline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
"heading": "Courses",
|
||||
"page_subtitle": "Where lessons turn into courses and courses turn into enrollments.",
|
||||
@@ -3580,7 +3374,7 @@
|
||||
"description": "Course Description",
|
||||
"learn": "What you'll learn",
|
||||
"certificate": "Certificate",
|
||||
"certificate_text": "When you complete all of the courses in the program, you'll earn a Certificate to share with your professional network.",
|
||||
"certificate_text": "When you complete all of the courses in the cohort, you'll earn a Certificate to share with your professional network.",
|
||||
"content": "Course Content",
|
||||
"slide": "1 slide",
|
||||
"note": "note",
|
||||
@@ -3772,7 +3566,7 @@
|
||||
"delete": {
|
||||
"action": "Delete",
|
||||
"title": "Delete member",
|
||||
"description": "This removes the member from your organization and revokes their access to all assigned courses and programs.",
|
||||
"description": "This removes the member from your organization and revokes their access to all assigned courses and cohorts.",
|
||||
"confirmation": "Are you sure you want to delete {name} ({email})? This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete",
|
||||
@@ -3783,9 +3577,9 @@
|
||||
"page_subtitle": "Progress, grades, and activity across their courses."
|
||||
},
|
||||
"import": {
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or cohorts if you like.",
|
||||
"title": "Import Users",
|
||||
"description": "Add students to your organization by entering their email addresses. Optionally assign them to courses or programs and send invitation emails.",
|
||||
"description": "Add students to your organization by entering their email addresses. Optionally assign them to courses or cohorts and send invitation emails.",
|
||||
"emails_label": "Email Addresses",
|
||||
"emails_placeholder": "Enter emails separated by commas, newlines, or semicolons\ne.g. alice@example.com, bob@example.com",
|
||||
"course_access": "Course Access",
|
||||
@@ -3793,17 +3587,17 @@
|
||||
"all_courses": "All courses",
|
||||
"select_courses": "Select specific courses",
|
||||
"select_courses_placeholder": "Choose courses...",
|
||||
"program_access": "Program Access",
|
||||
"no_programs": "No program assignment",
|
||||
"all_programs": "All programs",
|
||||
"select_programs": "Select specific programs",
|
||||
"select_programs_placeholder": "Choose programs...",
|
||||
"select_cohorts_placeholder": "Choose cohorts...",
|
||||
"send_email": "Send invitation email to imported users",
|
||||
"submit": "Import Users",
|
||||
"back": "Back to Audience",
|
||||
"snackbar_success": "{imported} new invites, {assigned} existing students given access, {pendingInvitesRenewed} pending invites renewed ({emailsSent} emails sent)",
|
||||
"snackbar_no_emails": "Please enter at least one email address",
|
||||
"snackbar_failed": "Failed to import users"
|
||||
"snackbar_failed": "Failed to import users",
|
||||
"cohort_access": "Cohort Access",
|
||||
"no_cohorts": "No cohort assignment",
|
||||
"all_cohorts": "All cohorts",
|
||||
"select_cohorts": "Select specific cohorts"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Assign to Courses",
|
||||
@@ -4075,12 +3869,12 @@
|
||||
"home": "Home",
|
||||
"my_learning": "My Learning",
|
||||
"certificates": "Certificates",
|
||||
"programs": "Programs",
|
||||
"exercise": "Exercise",
|
||||
"community": "Community",
|
||||
"explore": "Explore",
|
||||
"help": "Help",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"cohorts": "Cohorts"
|
||||
},
|
||||
"my_learning": {
|
||||
"heading": "My Learning",
|
||||
@@ -4313,32 +4107,6 @@
|
||||
"complete": "Complete :)"
|
||||
}
|
||||
},
|
||||
"program": {
|
||||
"success": {
|
||||
"comment_deleted": "Comment deleted",
|
||||
"comment_added": "Comment added",
|
||||
"feed_delete_success": "Post deleted successfully",
|
||||
"post_created": "Post created successfully",
|
||||
"post_updated": "Post updated successfully"
|
||||
},
|
||||
"error": {
|
||||
"reaction_error": "An error occurred while reacting to the post",
|
||||
"news_feed_fail": "Failed to fetch program newsfeed",
|
||||
"post_create_failed": "Failed to create post",
|
||||
"post_update_failed": "Failed to update post",
|
||||
"code": {
|
||||
"program_forbidden": "You must be a member of this program to perform this action.",
|
||||
"program_member_check_failed": "We could not verify your program membership. Try again.",
|
||||
"program_not_found": "This program could not be found.",
|
||||
"program_create_failed": "Could not create the program.",
|
||||
"program_update_failed": "Could not update the program.",
|
||||
"program_delete_failed": "Could not delete the program.",
|
||||
"program_member_not_found": "That program member could not be found.",
|
||||
"program_newsfeed_not_found": "That post could not be found.",
|
||||
"program_newsfeed_comment_not_found": "That comment could not be found."
|
||||
}
|
||||
}
|
||||
},
|
||||
"attendance": {
|
||||
"error": {
|
||||
"reload": "Something isn't right. Please reload and take attendance again"
|
||||
@@ -4414,6 +4182,32 @@
|
||||
"generic": {
|
||||
"success_delete": "Deleted successfully",
|
||||
"try_again": "Error - Please try again later"
|
||||
},
|
||||
"cohort": {
|
||||
"success": {
|
||||
"comment_deleted": "Comment deleted",
|
||||
"comment_added": "Comment added",
|
||||
"feed_delete_success": "Post deleted successfully",
|
||||
"post_created": "Post created successfully",
|
||||
"post_updated": "Post updated successfully"
|
||||
},
|
||||
"error": {
|
||||
"reaction_error": "An error occurred while reacting to the post",
|
||||
"news_feed_fail": "Failed to fetch cohort newsfeed",
|
||||
"post_create_failed": "Failed to create post",
|
||||
"post_update_failed": "Failed to update post",
|
||||
"code": {
|
||||
"cohort_forbidden": "You must be a member of this cohort to perform this action.",
|
||||
"cohort_member_check_failed": "We could not verify your cohort membership. Try again.",
|
||||
"cohort_not_found": "This cohort could not be found.",
|
||||
"cohort_create_failed": "Could not create the cohort.",
|
||||
"cohort_update_failed": "Could not update the cohort.",
|
||||
"cohort_delete_failed": "Could not delete the cohort.",
|
||||
"cohort_member_not_found": "That cohort member could not be found.",
|
||||
"cohort_newsfeed_not_found": "That post could not be found.",
|
||||
"cohort_newsfeed_comment_not_found": "That comment could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"specialization": {
|
||||
@@ -4784,5 +4578,211 @@
|
||||
"ai_assistant": "AI Assistant",
|
||||
"transcript": "Transcript"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Cohorts",
|
||||
"description": "Give a group of people access to multiple courses, like a class",
|
||||
"page_title": "Cohorts",
|
||||
"search_placeholder": "Search cohorts",
|
||||
"create": "New Cohort",
|
||||
"create_success": "Cohort created",
|
||||
"update_success": "Cohort updated",
|
||||
"delete_success": "Cohort deleted",
|
||||
"members_added": "Members added",
|
||||
"member_removed": "Member removed",
|
||||
"invite_sent": "Invites sent",
|
||||
"course_added": "Course added",
|
||||
"multiple_added": "Courses added",
|
||||
"course_removed": "Course removed",
|
||||
"empty_title": "No cohorts yet",
|
||||
"empty_description": "Create your first certification or compliance cohort.",
|
||||
"no_matching_cohorts": "No cohorts match your filters.",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filter",
|
||||
"clear_all": "Clear all",
|
||||
"popover_title": "Filters",
|
||||
"sort": "Sort",
|
||||
"order": "Order",
|
||||
"status": "Status",
|
||||
"ascending": "Asc",
|
||||
"descending": "Desc",
|
||||
"sort_name": "Name",
|
||||
"sort_courses": "Courses",
|
||||
"sort_students": "Students",
|
||||
"sort_status": "Status"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"courses": "Courses",
|
||||
"students": "Students",
|
||||
"status": "Status",
|
||||
"description": "Description"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Newsfeed",
|
||||
"courses": "Courses",
|
||||
"people": "People",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Create Cohort",
|
||||
"description": "Add a new certification or compliance cohort.",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "e.g. OSHA Safety Certification",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "What is this cohort about?",
|
||||
"submit": "Create"
|
||||
},
|
||||
"card": {
|
||||
"courses": "Courses",
|
||||
"students": "Students"
|
||||
},
|
||||
"newsfeed": {
|
||||
"heading": "Newsfeed",
|
||||
"post": "New Post",
|
||||
"post_placeholder": "What's on your mind?",
|
||||
"make_a_post": "Make a Post",
|
||||
"edit_post": "Edit Post",
|
||||
"editor_placeholder": "Share an update with your cohort",
|
||||
"cancel": "Cancel",
|
||||
"post_submit": "Post",
|
||||
"pinned": "Pinned",
|
||||
"delete_feed_title": "Delete post",
|
||||
"delete_feed_description": "Are you sure you want to delete this post?",
|
||||
"confirm_delete": "Delete",
|
||||
"user_avatar_alt": "User avatar",
|
||||
"card": {
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"comments": {
|
||||
"comment": "comment",
|
||||
"showing_of": "Showing {shown} of {total}",
|
||||
"hide": "Hide comments",
|
||||
"placeholder": "Add cohort comment",
|
||||
"submit_aria": "Submit comment",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"empty_title": "No posts yet",
|
||||
"empty_description": "Be the first to post something."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Add Course",
|
||||
"add_modal_title": "Add Course to Cohort",
|
||||
"select_course": "Select a course",
|
||||
"select_courses": "Select courses",
|
||||
"select_placeholder": "Choose a course…",
|
||||
"search_placeholder": "Search courses",
|
||||
"no_available_courses": "No available courses to add.",
|
||||
"no_matching_courses": "No courses match your search.",
|
||||
"no_matching_cohort_courses": "No cohort courses match your search.",
|
||||
"pagination_status": "Page {page} of {totalPages}",
|
||||
"empty_title": "No courses yet",
|
||||
"empty_description": "Add courses to this cohort.",
|
||||
"published_access_callout_title": "Student access",
|
||||
"published_access_callout_description": "Students only have access to published courses. Courses can be in this cohort for your team while still unpublished while those stay hidden from students until you publish them."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Invite",
|
||||
"invite_modal_title": "Invite Members",
|
||||
"invite_modal_description": "Add people to this cohort by email.",
|
||||
"email_label": "Email",
|
||||
"role_label": "Role",
|
||||
"add_another": "Add another",
|
||||
"invite_submit": "Send Invites",
|
||||
"empty_title": "No members yet",
|
||||
"empty_description": "Invite people to join this cohort."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "No cohorts yet",
|
||||
"empty_description": "You haven't been enrolled in any cohorts."
|
||||
},
|
||||
"settings": {
|
||||
"general": "General",
|
||||
"name_label": "Name",
|
||||
"description_label": "Description",
|
||||
"status_label": "Status",
|
||||
"save": "Save Changes",
|
||||
"danger_zone": "Danger Zone",
|
||||
"delete": "Delete Cohort",
|
||||
"delete_description": "Once deleted, this cohort and all its data cannot be recovered.",
|
||||
"confirm_delete": "Are you sure you want to delete this cohort? This cannot be undone."
|
||||
},
|
||||
"goals": {
|
||||
"heading": "Learning Goals",
|
||||
"description": "Set measurable targets that span multiple courses for everyone in this cohort.",
|
||||
"add": "New Goal",
|
||||
"empty_title": "No goals yet",
|
||||
"empty_description": "Create a goal to track team progress across multiple courses.",
|
||||
"created": "Goal created",
|
||||
"updated": "Goal updated",
|
||||
"deleted": "Goal deleted",
|
||||
"delete_confirm": "Delete this goal? Per-student status history will be removed.",
|
||||
"modal": {
|
||||
"create_title": "New goal",
|
||||
"edit_title": "Edit goal",
|
||||
"title_label": "Title",
|
||||
"title_placeholder": "e.g. Onboarding ramp",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Optional context for the team",
|
||||
"type_label": "Type",
|
||||
"courses_label": "Courses",
|
||||
"required_count_label": "Number of courses required",
|
||||
"score_threshold_label": "Per-student pass score (%)",
|
||||
"team_pass_rate_label": "Team pass rate (%)",
|
||||
"deadline_label": "Deadline",
|
||||
"deadline_date_label": "Deadline date",
|
||||
"relative_days_label": "Days from when the student joins",
|
||||
"recurring_months_label": "Repeat every (months)",
|
||||
"reminders_label": "Reminders (days before due)",
|
||||
"reminders_helper": "Comma-separated. Example: 7, 3, 1",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Complete all selected courses",
|
||||
"n_of_m": "Complete N of M selected courses",
|
||||
"score": "Score above threshold on every course",
|
||||
"pass_rate": "Team pass-rate threshold",
|
||||
"readiness": "Readiness (no deadline)"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolute date",
|
||||
"relative_to_join": "Relative to student joining",
|
||||
"recurring": "Recurring",
|
||||
"none": "No deadline"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Completed",
|
||||
"in_progress": "In progress",
|
||||
"at_risk": "At risk",
|
||||
"overdue": "Overdue",
|
||||
"not_started": "Not started",
|
||||
"waived": "Waived"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "on track",
|
||||
"completed_short": "completed",
|
||||
"overdue_short": "overdue",
|
||||
"at_risk_short": "at risk"
|
||||
},
|
||||
"lms": {
|
||||
"heading": "Your Goals",
|
||||
"empty_title": "No goals assigned",
|
||||
"empty_description": "When your instructors set goals, they'll show up here.",
|
||||
"due_in_days": "Due in {days} days",
|
||||
"due_tomorrow": "Due tomorrow",
|
||||
"due_today": "Due today",
|
||||
"overdue": "Overdue",
|
||||
"no_deadline": "No deadline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Tecla de automatización girada. Guarde el nuevo secreto ahora porque no se volverá a mostrar.",
|
||||
"revoked": "Clave de automatización revocada."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Se produjo un error al reaccionar a la publicación.",
|
||||
"news_feed_fail": "No se pudo recuperar el suministro de noticias del programa",
|
||||
"post_create_failed": "No se pudo crear la publicación",
|
||||
"post_update_failed": "No se pudo actualizar la publicación",
|
||||
"code": {
|
||||
"program_forbidden": "Debes ser miembro de este programa para realizar esta acción.",
|
||||
"program_member_check_failed": "No pudimos verificar su membresía en el programa. Intentar otra vez.",
|
||||
"program_not_found": "No se pudo encontrar este programa.",
|
||||
"program_create_failed": "No se pudo crear el programa.",
|
||||
"program_update_failed": "No se pudo actualizar el programa.",
|
||||
"program_delete_failed": "No se pudo eliminar el programa.",
|
||||
"program_member_not_found": "No se pudo encontrar ese miembro del programa.",
|
||||
"program_newsfeed_not_found": "Esa publicación no se pudo encontrar.",
|
||||
"program_newsfeed_comment_not_found": "Ese comentario no se pudo encontrar."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Comentario eliminado",
|
||||
"comment_added": "Comentario añadido",
|
||||
"feed_delete_success": "Publicación eliminada exitosamente",
|
||||
"post_created": "Publicación creada exitosamente",
|
||||
"post_updated": "Publicación actualizada exitosamente"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "Paquete de AI credits añadido a su saldo.",
|
||||
"checkout_failed": "No se pudo iniciar el pago. Inténtelo de nuevo."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "Se agregó una reproducción de 1080p",
|
||||
"generate_1080_failed": "No se pudo generar una reproducción de 1080p"
|
||||
},
|
||||
"social_auth_not_enabled": "Este proveedor de autenticación no está habilitado."
|
||||
"social_auth_not_enabled": "Este proveedor de autenticación no está habilitado.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Se produjo un error al reaccionar a la publicación.",
|
||||
"news_feed_fail": "No se pudo recuperar el suministro de noticias del programa",
|
||||
"post_create_failed": "No se pudo crear la publicación",
|
||||
"post_update_failed": "No se pudo actualizar la publicación",
|
||||
"code": {
|
||||
"cohort_forbidden": "Debes ser miembro de este programa para realizar esta acción.",
|
||||
"cohort_member_check_failed": "No pudimos verificar su membresía en el programa. Intentar otra vez.",
|
||||
"cohort_not_found": "No se pudo encontrar este programa.",
|
||||
"cohort_create_failed": "No se pudo crear el programa.",
|
||||
"cohort_update_failed": "No se pudo actualizar el programa.",
|
||||
"cohort_delete_failed": "No se pudo eliminar el programa.",
|
||||
"cohort_member_not_found": "No se pudo encontrar ese miembro del programa.",
|
||||
"cohort_newsfeed_not_found": "Esa publicación no se pudo encontrar.",
|
||||
"cohort_newsfeed_comment_not_found": "Ese comentario no se pudo encontrar."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Comentario eliminado",
|
||||
"comment_added": "Comentario añadido",
|
||||
"feed_delete_success": "Publicación eliminada exitosamente",
|
||||
"post_created": "Publicación creada exitosamente",
|
||||
"post_updated": "Publicación actualizada exitosamente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Explora nuestros cursos",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Explorar",
|
||||
"help": "Ayuda",
|
||||
"settings": "Configuración",
|
||||
"programs": "Programas"
|
||||
"cohorts": "Programas"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Actualice su plan para generar certificados",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Todos los cursos",
|
||||
"select_courses": "Seleccionar cursos específicos",
|
||||
"select_courses_placeholder": "Elige cursos...",
|
||||
"program_access": "Acceso al programa",
|
||||
"no_programs": "Sin asignación de programa",
|
||||
"all_programs": "Todos los programas",
|
||||
"select_programs": "Seleccionar programas específicos",
|
||||
"select_programs_placeholder": "Elige programas...",
|
||||
"send_email": "Enviar correo electrónico de invitación a usuarios importados",
|
||||
"submit": "Importar usuarios",
|
||||
"back": "Volver a la audiencia",
|
||||
"snackbar_success": "{imported} invitaciones nuevas, {assigned} estudiantes existentes con acceso al curso, {pendingInvitesRenewed} invitaciones pendientes renovadas ({emailsSent} correos enviados)",
|
||||
"snackbar_no_emails": "Por favor ingrese al menos una dirección de correo electrónico",
|
||||
"snackbar_failed": "No se pudieron importar usuarios",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Acceso al programa",
|
||||
"no_cohorts": "Sin asignación de programa",
|
||||
"all_cohorts": "Todos los programas",
|
||||
"select_cohorts": "Seleccionar programas específicos",
|
||||
"select_cohorts_placeholder": "Elige programas..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Actualiza ahora",
|
||||
"tags": "Etiquetas",
|
||||
"automation": "Automatización",
|
||||
"programs": "Programas",
|
||||
"widgets": "widgets",
|
||||
"stats": "Estadísticas",
|
||||
"analytics": "Analítica",
|
||||
"compliance": "Cumplimiento"
|
||||
"compliance": "Cumplimiento",
|
||||
"cohorts": "Programas"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Administrador de medios - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programas",
|
||||
"page_title": "Programas",
|
||||
"create": "Nuevo programa",
|
||||
"create_success": "Programa creado",
|
||||
"update_success": "Programa actualizado",
|
||||
"delete_success": "Programa eliminado",
|
||||
"members_added": "Miembros agregados",
|
||||
"member_removed": "Miembro eliminado",
|
||||
"course_added": "Curso agregado",
|
||||
"course_removed": "Curso eliminado",
|
||||
"empty_title": "Aún no hay programas",
|
||||
"empty_description": "Cree su primera certificación o programa de cumplimiento.",
|
||||
"settings": {
|
||||
"general": "generales",
|
||||
"name_label": "Nombre",
|
||||
"description_label": "Descripción",
|
||||
"status_label": "Estado",
|
||||
"save": "Guardar cambios",
|
||||
"danger_zone": "Zona de peligro",
|
||||
"delete": "Eliminar programa",
|
||||
"delete_description": "Una vez eliminado, este programa y todos sus datos no se pueden recuperar.",
|
||||
"confirm_delete": "¿Estás seguro de que deseas eliminar este programa? Esto no se puede deshacer."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Aún no hay programas",
|
||||
"empty_description": "No has estado inscrito en ningún programa."
|
||||
},
|
||||
"people": {
|
||||
"invite": "invitar",
|
||||
"invite_modal_title": "Invitar miembros",
|
||||
"invite_modal_description": "Agregue personas a este programa por correo electrónico.",
|
||||
"email_label": "Correo electrónico",
|
||||
"role_label": "Rol",
|
||||
"add_another": "agregar otro",
|
||||
"invite_submit": "Enviar invitaciones",
|
||||
"empty_title": "Aún no hay miembros",
|
||||
"empty_description": "Invita a personas a unirse a este programa."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Agregar curso",
|
||||
"add_modal_title": "Agregar curso al programa",
|
||||
"select_course": "Seleccione un curso",
|
||||
"select_placeholder": "Elige un curso…",
|
||||
"empty_title": "Aún no hay cursos",
|
||||
"empty_description": "Agregue cursos a este programa.",
|
||||
"select_courses": "Seleccionar cursos",
|
||||
"search_placeholder": "Buscar cursos",
|
||||
"no_available_courses": "No hay cursos disponibles para agregar.",
|
||||
"no_matching_courses": "Ningún curso coincide con tu búsqueda.",
|
||||
"no_matching_program_courses": "Ningún programa de cursos coincide con su búsqueda.",
|
||||
"pagination_status": "Página {page} de {totalPages}",
|
||||
"published_access_callout_title": "Acceso de estudiantes",
|
||||
"published_access_callout_description": "Los estudiantes solo tienen acceso a los cursos publicados. Los cursos pueden estar en este programa para su equipo mientras aún no están publicados, mientras que permanecen ocultos para los estudiantes hasta que los publique."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nueva publicación",
|
||||
"post_placeholder": "¿Qué tienes en mente?",
|
||||
"post_submit": "Publicar",
|
||||
"empty_title": "Aún no hay publicaciones",
|
||||
"empty_description": "Sé el primero en publicar algo.",
|
||||
"heading": "suministro de noticias",
|
||||
"make_a_post": "Hacer una publicación",
|
||||
"edit_post": "Editar publicación",
|
||||
"editor_placeholder": "Comparte una actualización con tu programa",
|
||||
"cancel": "Cancelar",
|
||||
"pinned": "Fijado",
|
||||
"delete_feed_title": "Eliminar publicación",
|
||||
"delete_feed_description": "¿Estás seguro de que deseas eliminar esta publicación?",
|
||||
"confirm_delete": "Eliminar",
|
||||
"user_avatar_alt": "avatar de usuario",
|
||||
"comments": {
|
||||
"comment": "comentario",
|
||||
"showing_of": "Mostrando {shown} de {total}",
|
||||
"hide": "Ocultar comentarios",
|
||||
"placeholder": "Agregar comentario del programa",
|
||||
"submit_aria": "Enviar comentario",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Alfiler",
|
||||
"unpin": "Desanclar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Crear programa",
|
||||
"description": "Agregue una nueva certificación o programa de cumplimiento.",
|
||||
"name_label": "Nombre",
|
||||
"name_placeholder": "por ej. Certificación de seguridad OSHA",
|
||||
"description_label": "Descripción",
|
||||
"description_placeholder": "¿De qué se trata este programa?",
|
||||
"submit": "crear"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "suministro de noticias",
|
||||
"courses": "Cursos",
|
||||
"people": "gente",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"archived": "Archivado"
|
||||
},
|
||||
"description": "Dar a un grupo de personas acceso a múltiples cursos, como una clase",
|
||||
"search_placeholder": "Programas de búsqueda",
|
||||
"multiple_added": "Cursos agregados",
|
||||
"no_matching_programs": "Ningún programa coincide con tus filtros.",
|
||||
"card": {
|
||||
"courses": "Cursos",
|
||||
"students": "estudiantes"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nombre",
|
||||
"courses": "Cursos",
|
||||
"students": "estudiantes",
|
||||
"status": "Estado",
|
||||
"description": "Descripción"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtrar",
|
||||
"clear_all": "Borrar todo",
|
||||
"popover_title": "Filtros",
|
||||
"sort": "ordenar",
|
||||
"order": "Orden",
|
||||
"status": "Estado",
|
||||
"ascending": "asc",
|
||||
"descending": "Descripción",
|
||||
"sort_name": "Nombre",
|
||||
"sort_courses": "Cursos",
|
||||
"sort_students": "estudiantes",
|
||||
"sort_status": "Estado"
|
||||
},
|
||||
"invite_sent": "Invitaciones enviadas",
|
||||
"goals": {
|
||||
"heading": "Metas de aprendizaje",
|
||||
"description": "Establezca objetivos mensurables que abarquen varios cursos para todos los participantes de este programa.",
|
||||
"add": "Nuevo objetivo",
|
||||
"empty_title": "Aún no hay goles",
|
||||
"empty_description": "Cree un objetivo para realizar un seguimiento del progreso del equipo en varios cursos.",
|
||||
"created": "Objetivo creado",
|
||||
"updated": "Objetivo actualizado",
|
||||
"deleted": "Objetivo eliminado",
|
||||
"delete_confirm": "¿Eliminar este objetivo? Se eliminará el historial de estado por estudiante.",
|
||||
"lms": {
|
||||
"heading": "Tus objetivos",
|
||||
"empty_title": "No hay objetivos asignados",
|
||||
"empty_description": "Cuando sus instructores establezcan objetivos, aparecerán aquí.",
|
||||
"due_in_days": "Vencimiento en {days} días",
|
||||
"due_tomorrow": "Vencimiento mañana",
|
||||
"due_today": "Vencimiento hoy",
|
||||
"overdue": "Vencido",
|
||||
"no_deadline": "Sin fecha límite"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "en camino",
|
||||
"completed_short": "completado",
|
||||
"overdue_short": "vencido",
|
||||
"at_risk_short": "en riesgo"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Completado",
|
||||
"in_progress": "En progreso",
|
||||
"at_risk": "En riesgo",
|
||||
"overdue": "Vencido",
|
||||
"not_started": "No iniciado",
|
||||
"waived": "Renunciado"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "fecha absoluta",
|
||||
"relative_to_join": "En relación con la incorporación de estudiantes",
|
||||
"recurring": "recurrente",
|
||||
"none": "Sin fecha límite"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Completa todos los cursos seleccionados",
|
||||
"n_of_m": "Completa N de M cursos seleccionados",
|
||||
"score": "Puntuación por encima del umbral en cada curso",
|
||||
"pass_rate": "Umbral de tasa de aprobación del equipo",
|
||||
"readiness": "Preparación (sin fecha límite)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nuevo objetivo",
|
||||
"edit_title": "Editar objetivo",
|
||||
"title_label": "Título",
|
||||
"title_placeholder": "por ej. Rampa de embarque",
|
||||
"description_label": "Descripción",
|
||||
"description_placeholder": "Contexto opcional para el equipo.",
|
||||
"type_label": "Tipo",
|
||||
"courses_label": "Cursos",
|
||||
"required_count_label": "Número de cursos requeridos",
|
||||
"score_threshold_label": "Puntaje aprobado por estudiante (%)",
|
||||
"team_pass_rate_label": "Tasa de aprobación del equipo (%)",
|
||||
"deadline_label": "Fecha límite",
|
||||
"deadline_date_label": "Fecha límite",
|
||||
"relative_days_label": "Días desde que se incorpora el estudiante",
|
||||
"recurring_months_label": "Repetir cada (meses)",
|
||||
"reminders_label": "Recordatorios (días antes del vencimiento)",
|
||||
"reminders_helper": "Separados por comas. Ejemplo: 7, 3, 1",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Pídeme que te ayude con el contenido de tu curso: redacta lecciones, genera preguntas o planifica un curso a partir de un documento.",
|
||||
"input_placeholder": "Pregúntale al asistente de IA...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Listo",
|
||||
"failed": "Error al subir"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Programas",
|
||||
"page_title": "Programas",
|
||||
"create": "Nuevo programa",
|
||||
"create_success": "Programa creado",
|
||||
"update_success": "Programa actualizado",
|
||||
"delete_success": "Programa eliminado",
|
||||
"members_added": "Miembros agregados",
|
||||
"member_removed": "Miembro eliminado",
|
||||
"course_added": "Curso agregado",
|
||||
"course_removed": "Curso eliminado",
|
||||
"empty_title": "Aún no hay programas",
|
||||
"empty_description": "Cree su primera certificación o programa de cumplimiento.",
|
||||
"settings": {
|
||||
"general": "generales",
|
||||
"name_label": "Nombre",
|
||||
"description_label": "Descripción",
|
||||
"status_label": "Estado",
|
||||
"save": "Guardar cambios",
|
||||
"danger_zone": "Zona de peligro",
|
||||
"delete": "Eliminar programa",
|
||||
"delete_description": "Una vez eliminado, este programa y todos sus datos no se pueden recuperar.",
|
||||
"confirm_delete": "¿Estás seguro de que deseas eliminar este programa? Esto no se puede deshacer."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Aún no hay programas",
|
||||
"empty_description": "No has estado inscrito en ningún programa."
|
||||
},
|
||||
"people": {
|
||||
"invite": "invitar",
|
||||
"invite_modal_title": "Invitar miembros",
|
||||
"invite_modal_description": "Agregue personas a este programa por correo electrónico.",
|
||||
"email_label": "Correo electrónico",
|
||||
"role_label": "Rol",
|
||||
"add_another": "agregar otro",
|
||||
"invite_submit": "Enviar invitaciones",
|
||||
"empty_title": "Aún no hay miembros",
|
||||
"empty_description": "Invita a personas a unirse a este programa."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Agregar curso",
|
||||
"add_modal_title": "Agregar curso al programa",
|
||||
"select_course": "Seleccione un curso",
|
||||
"select_placeholder": "Elige un curso…",
|
||||
"empty_title": "Aún no hay cursos",
|
||||
"empty_description": "Agregue cursos a este programa.",
|
||||
"select_courses": "Seleccionar cursos",
|
||||
"search_placeholder": "Buscar cursos",
|
||||
"no_available_courses": "No hay cursos disponibles para agregar.",
|
||||
"no_matching_courses": "Ningún curso coincide con tu búsqueda.",
|
||||
"no_matching_cohort_courses": "Ningún programa de cursos coincide con su búsqueda.",
|
||||
"pagination_status": "Página {page} de {totalPages}",
|
||||
"published_access_callout_title": "Acceso de estudiantes",
|
||||
"published_access_callout_description": "Los estudiantes solo tienen acceso a los cursos publicados. Los cursos pueden estar en este programa para su equipo mientras aún no están publicados, mientras que permanecen ocultos para los estudiantes hasta que los publique."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nueva publicación",
|
||||
"post_placeholder": "¿Qué tienes en mente?",
|
||||
"post_submit": "Publicar",
|
||||
"empty_title": "Aún no hay publicaciones",
|
||||
"empty_description": "Sé el primero en publicar algo.",
|
||||
"heading": "suministro de noticias",
|
||||
"make_a_post": "Hacer una publicación",
|
||||
"edit_post": "Editar publicación",
|
||||
"editor_placeholder": "Comparte una actualización con tu programa",
|
||||
"cancel": "Cancelar",
|
||||
"pinned": "Fijado",
|
||||
"delete_feed_title": "Eliminar publicación",
|
||||
"delete_feed_description": "¿Estás seguro de que deseas eliminar esta publicación?",
|
||||
"confirm_delete": "Eliminar",
|
||||
"user_avatar_alt": "avatar de usuario",
|
||||
"comments": {
|
||||
"comment": "comentario",
|
||||
"showing_of": "Mostrando {shown} de {total}",
|
||||
"hide": "Ocultar comentarios",
|
||||
"placeholder": "Agregar comentario del programa",
|
||||
"submit_aria": "Enviar comentario",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Alfiler",
|
||||
"unpin": "Desanclar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Crear programa",
|
||||
"description": "Agregue una nueva certificación o programa de cumplimiento.",
|
||||
"name_label": "Nombre",
|
||||
"name_placeholder": "por ej. Certificación de seguridad OSHA",
|
||||
"description_label": "Descripción",
|
||||
"description_placeholder": "¿De qué se trata este programa?",
|
||||
"submit": "crear"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "suministro de noticias",
|
||||
"courses": "Cursos",
|
||||
"people": "gente",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"archived": "Archivado"
|
||||
},
|
||||
"description": "Dar a un grupo de personas acceso a múltiples cursos, como una clase",
|
||||
"search_placeholder": "Programas de búsqueda",
|
||||
"multiple_added": "Cursos agregados",
|
||||
"no_matching_cohorts": "Ningún programa coincide con tus filtros.",
|
||||
"card": {
|
||||
"courses": "Cursos",
|
||||
"students": "estudiantes"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nombre",
|
||||
"courses": "Cursos",
|
||||
"students": "estudiantes",
|
||||
"status": "Estado",
|
||||
"description": "Descripción"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtrar",
|
||||
"clear_all": "Borrar todo",
|
||||
"popover_title": "Filtros",
|
||||
"sort": "ordenar",
|
||||
"order": "Orden",
|
||||
"status": "Estado",
|
||||
"ascending": "asc",
|
||||
"descending": "Descripción",
|
||||
"sort_name": "Nombre",
|
||||
"sort_courses": "Cursos",
|
||||
"sort_students": "estudiantes",
|
||||
"sort_status": "Estado"
|
||||
},
|
||||
"invite_sent": "Invitaciones enviadas",
|
||||
"goals": {
|
||||
"heading": "Metas de aprendizaje",
|
||||
"description": "Establezca objetivos mensurables que abarquen varios cursos para todos los participantes de este programa.",
|
||||
"add": "Nuevo objetivo",
|
||||
"empty_title": "Aún no hay goles",
|
||||
"empty_description": "Cree un objetivo para realizar un seguimiento del progreso del equipo en varios cursos.",
|
||||
"created": "Objetivo creado",
|
||||
"updated": "Objetivo actualizado",
|
||||
"deleted": "Objetivo eliminado",
|
||||
"delete_confirm": "¿Eliminar este objetivo? Se eliminará el historial de estado por estudiante.",
|
||||
"lms": {
|
||||
"heading": "Tus objetivos",
|
||||
"empty_title": "No hay objetivos asignados",
|
||||
"empty_description": "Cuando sus instructores establezcan objetivos, aparecerán aquí.",
|
||||
"due_in_days": "Vencimiento en {days} días",
|
||||
"due_tomorrow": "Vencimiento mañana",
|
||||
"due_today": "Vencimiento hoy",
|
||||
"overdue": "Vencido",
|
||||
"no_deadline": "Sin fecha límite"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "en camino",
|
||||
"completed_short": "completado",
|
||||
"overdue_short": "vencido",
|
||||
"at_risk_short": "en riesgo"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Completado",
|
||||
"in_progress": "En progreso",
|
||||
"at_risk": "En riesgo",
|
||||
"overdue": "Vencido",
|
||||
"not_started": "No iniciado",
|
||||
"waived": "Renunciado"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "fecha absoluta",
|
||||
"relative_to_join": "En relación con la incorporación de estudiantes",
|
||||
"recurring": "recurrente",
|
||||
"none": "Sin fecha límite"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Completa todos los cursos seleccionados",
|
||||
"n_of_m": "Completa N de M cursos seleccionados",
|
||||
"score": "Puntuación por encima del umbral en cada curso",
|
||||
"pass_rate": "Umbral de tasa de aprobación del equipo",
|
||||
"readiness": "Preparación (sin fecha límite)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nuevo objetivo",
|
||||
"edit_title": "Editar objetivo",
|
||||
"title_label": "Título",
|
||||
"title_placeholder": "por ej. Rampa de embarque",
|
||||
"description_label": "Descripción",
|
||||
"description_placeholder": "Contexto opcional para el equipo.",
|
||||
"type_label": "Tipo",
|
||||
"courses_label": "Cursos",
|
||||
"required_count_label": "Número de cursos requeridos",
|
||||
"score_threshold_label": "Puntaje aprobado por estudiante (%)",
|
||||
"team_pass_rate_label": "Tasa de aprobación del equipo (%)",
|
||||
"deadline_label": "Fecha límite",
|
||||
"deadline_date_label": "Fecha límite",
|
||||
"relative_days_label": "Días desde que se incorpora el estudiante",
|
||||
"recurring_months_label": "Repetir cada (meses)",
|
||||
"reminders_label": "Recordatorios (días antes del vencimiento)",
|
||||
"reminders_helper": "Separados por comas. Ejemplo: 7, 3, 1",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Clé d'automatisation tournée. Enregistrez le nouveau secret maintenant car il ne sera plus affiché.",
|
||||
"revoked": "Clé d'automatisation révoquée."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Une erreur s'est produite lors de la réaction au message",
|
||||
"news_feed_fail": "Échec de la récupération du fil d'actualité du programme",
|
||||
"post_create_failed": "Échec de la création du message",
|
||||
"post_update_failed": "Échec de la mise à jour du message",
|
||||
"code": {
|
||||
"program_forbidden": "Vous devez être membre de ce programme pour effectuer cette action.",
|
||||
"program_member_check_failed": "Nous n'avons pas pu vérifier votre adhésion au programme. Essayer à nouveau.",
|
||||
"program_not_found": "Ce programme est introuvable.",
|
||||
"program_create_failed": "Impossible de créer le programme.",
|
||||
"program_update_failed": "Impossible de mettre à jour le programme.",
|
||||
"program_delete_failed": "Impossible de supprimer le programme.",
|
||||
"program_member_not_found": "Ce membre du programme est introuvable.",
|
||||
"program_newsfeed_not_found": "Ce message est introuvable.",
|
||||
"program_newsfeed_comment_not_found": "Ce commentaire n'a pas pu être trouvé."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Commentaire supprimé",
|
||||
"comment_added": "Commentaire ajouté",
|
||||
"feed_delete_success": "Message supprimé avec succès",
|
||||
"post_created": "Message créé avec succès",
|
||||
"post_updated": "Message mis à jour avec succès"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "Pack d'AI credits ajouté à votre solde.",
|
||||
"checkout_failed": "Impossible de démarrer le paiement. Veuillez réessayer."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "Rendu 1080p ajouté",
|
||||
"generate_1080_failed": "Impossible de générer un rendu 1080p"
|
||||
},
|
||||
"social_auth_not_enabled": "Ce fournisseur d'authentification n'est pas activé."
|
||||
"social_auth_not_enabled": "Ce fournisseur d'authentification n'est pas activé.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Une erreur s'est produite lors de la réaction au message",
|
||||
"news_feed_fail": "Échec de la récupération du fil d'actualité du programme",
|
||||
"post_create_failed": "Échec de la création du message",
|
||||
"post_update_failed": "Échec de la mise à jour du message",
|
||||
"code": {
|
||||
"cohort_forbidden": "Vous devez être membre de ce programme pour effectuer cette action.",
|
||||
"cohort_member_check_failed": "Nous n'avons pas pu vérifier votre adhésion au programme. Essayer à nouveau.",
|
||||
"cohort_not_found": "Ce programme est introuvable.",
|
||||
"cohort_create_failed": "Impossible de créer le programme.",
|
||||
"cohort_update_failed": "Impossible de mettre à jour le programme.",
|
||||
"cohort_delete_failed": "Impossible de supprimer le programme.",
|
||||
"cohort_member_not_found": "Ce membre du programme est introuvable.",
|
||||
"cohort_newsfeed_not_found": "Ce message est introuvable.",
|
||||
"cohort_newsfeed_comment_not_found": "Ce commentaire n'a pas pu être trouvé."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Commentaire supprimé",
|
||||
"comment_added": "Commentaire ajouté",
|
||||
"feed_delete_success": "Message supprimé avec succès",
|
||||
"post_created": "Message créé avec succès",
|
||||
"post_updated": "Message mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Découvrez nos cours",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Explorer",
|
||||
"help": "Aide",
|
||||
"settings": "Paramètres",
|
||||
"programs": "Programmes"
|
||||
"cohorts": "Programmes"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Mettez à niveau votre plan pour générer des certificats",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Tous les cours",
|
||||
"select_courses": "Sélectionnez des cours spécifiques",
|
||||
"select_courses_placeholder": "Choisissez des cours...",
|
||||
"program_access": "Accès aux programmes",
|
||||
"no_programs": "Aucune attribution de programme",
|
||||
"all_programs": "Tous les programmes",
|
||||
"select_programs": "Sélectionnez des programmes spécifiques",
|
||||
"select_programs_placeholder": "Choisissez des programmes...",
|
||||
"send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés",
|
||||
"submit": "Importer des utilisateurs",
|
||||
"back": "Retour au public",
|
||||
"snackbar_success": "{imported} nouvelles invitations, {assigned} étudiants existants avec accès au cours, {pendingInvitesRenewed} invitations en attente renouvelées ({emailsSent} e-mails envoyés)",
|
||||
"snackbar_no_emails": "Veuillez saisir au moins une adresse e-mail",
|
||||
"snackbar_failed": "Échec de l'importation des utilisateurs",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Accès aux programmes",
|
||||
"no_cohorts": "Aucune attribution de programme",
|
||||
"all_cohorts": "Tous les programmes",
|
||||
"select_cohorts": "Sélectionnez des programmes spécifiques",
|
||||
"select_cohorts_placeholder": "Choisissez des programmes..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Mettre à niveau maintenant",
|
||||
"tags": "Balises",
|
||||
"automation": "Automation",
|
||||
"programs": "Programmes",
|
||||
"widgets": "Widgets",
|
||||
"stats": "Statistiques",
|
||||
"analytics": "Analyse",
|
||||
"compliance": "Conformité"
|
||||
"compliance": "Conformité",
|
||||
"cohorts": "Programmes"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Gestionnaire de médias - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programmes",
|
||||
"page_title": "Programmes",
|
||||
"create": "Nouveau programme",
|
||||
"create_success": "Programme créé",
|
||||
"update_success": "Programme mis à jour",
|
||||
"delete_success": "Programme supprimé",
|
||||
"members_added": "Membres ajoutés",
|
||||
"member_removed": "Membre supprimé",
|
||||
"course_added": "Cours ajouté",
|
||||
"course_removed": "Cours supprimé",
|
||||
"empty_title": "Aucun programme pour l'instant",
|
||||
"empty_description": "Créez votre premier programme de certification ou de conformité.",
|
||||
"settings": {
|
||||
"general": "Général",
|
||||
"name_label": "Nom",
|
||||
"description_label": "Descriptif",
|
||||
"status_label": "Statut",
|
||||
"save": "Enregistrer les modifications",
|
||||
"danger_zone": "Zone dangereuse",
|
||||
"delete": "Supprimer le programme",
|
||||
"delete_description": "Une fois supprimé, ce programme et toutes ses données ne peuvent pas être récupérés.",
|
||||
"confirm_delete": "Êtes-vous sûr de vouloir supprimer ce programme ? Cela ne peut pas être annulé."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Aucun programme pour l'instant",
|
||||
"empty_description": "Vous n'êtes inscrit à aucun programme."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Inviter",
|
||||
"invite_modal_title": "Inviter des membres",
|
||||
"invite_modal_description": "Ajoutez des personnes à ce programme par e-mail.",
|
||||
"email_label": "Courriel",
|
||||
"role_label": "Rôle",
|
||||
"add_another": "Ajoutez-en un autre",
|
||||
"invite_submit": "Envoyer des invitations",
|
||||
"empty_title": "Pas encore de membres",
|
||||
"empty_description": "Invitez les gens à rejoindre ce programme."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Ajouter un cours",
|
||||
"add_modal_title": "Ajouter un cours au programme",
|
||||
"select_course": "Sélectionnez un cours",
|
||||
"select_placeholder": "Choisissez un cours…",
|
||||
"empty_title": "Pas encore de cours",
|
||||
"empty_description": "Ajoutez des cours à ce programme.",
|
||||
"select_courses": "Sélectionner des cours",
|
||||
"search_placeholder": "Rechercher des cours",
|
||||
"no_available_courses": "Aucun cours disponible à ajouter.",
|
||||
"no_matching_courses": "Aucun cours ne correspond à votre recherche.",
|
||||
"no_matching_program_courses": "Aucun cours du programme ne correspond à votre recherche.",
|
||||
"pagination_status": "Page {page} sur {totalPages}",
|
||||
"published_access_callout_title": "Accès étudiant",
|
||||
"published_access_callout_description": "Les étudiants n'ont accès qu'aux cours publiés. Les cours peuvent figurer dans ce programme pour votre équipe alors qu'ils ne sont pas encore publiés, tandis qu'ils restent cachés aux étudiants jusqu'à ce que vous les publiiez."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nouveau message",
|
||||
"post_placeholder": "Qu'avez-vous en tête ?",
|
||||
"post_submit": "Publier",
|
||||
"empty_title": "Pas encore de messages",
|
||||
"empty_description": "Soyez le premier à publier quelque chose.",
|
||||
"heading": "Fil d'actualité",
|
||||
"make_a_post": "Créer un message",
|
||||
"edit_post": "Modifier le message",
|
||||
"editor_placeholder": "Partager une mise à jour avec votre programme",
|
||||
"cancel": "Annuler",
|
||||
"pinned": "Épinglé",
|
||||
"delete_feed_title": "Supprimer le message",
|
||||
"delete_feed_description": "Êtes-vous sûr de vouloir supprimer ce message ?",
|
||||
"confirm_delete": "Supprimer",
|
||||
"user_avatar_alt": "Avatar de l'utilisateur",
|
||||
"comments": {
|
||||
"comment": "commentaire",
|
||||
"showing_of": "Affichage de {shown} sur {total}",
|
||||
"hide": "Masquer les commentaires",
|
||||
"placeholder": "Ajouter un commentaire sur le programme",
|
||||
"submit_aria": "Soumettre un commentaire",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Épingle",
|
||||
"unpin": "Désépingler",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Créer un programme",
|
||||
"description": "Ajoutez un nouveau programme de certification ou de conformité.",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "par ex. Certification de sécurité OSHA",
|
||||
"description_label": "Descriptif",
|
||||
"description_placeholder": "De quoi parle ce programme ?",
|
||||
"submit": "Créer"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Fil d'actualité",
|
||||
"courses": "Cours",
|
||||
"people": "Les gens",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"status": {
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif",
|
||||
"archived": "Archivé"
|
||||
},
|
||||
"description": "Donner à un groupe de personnes l'accès à plusieurs cours, comme un cours",
|
||||
"search_placeholder": "Programmes de recherche",
|
||||
"multiple_added": "Cours ajoutés",
|
||||
"no_matching_programs": "Aucun programme ne correspond à vos filtres.",
|
||||
"card": {
|
||||
"courses": "Cours",
|
||||
"students": "Étudiants"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"courses": "Cours",
|
||||
"students": "Étudiants",
|
||||
"status": "Statut",
|
||||
"description": "Descriptif"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtrer",
|
||||
"clear_all": "Tout effacer",
|
||||
"popover_title": "Filtres",
|
||||
"sort": "Trier",
|
||||
"order": "Commande",
|
||||
"status": "Statut",
|
||||
"ascending": "Asc.",
|
||||
"descending": "Description",
|
||||
"sort_name": "Nom",
|
||||
"sort_courses": "Cours",
|
||||
"sort_students": "Étudiants",
|
||||
"sort_status": "Statut"
|
||||
},
|
||||
"invite_sent": "Invitations envoyées",
|
||||
"goals": {
|
||||
"heading": "Objectifs d'apprentissage",
|
||||
"description": "Fixez des objectifs mesurables couvrant plusieurs cours pour tous les participants à ce programme.",
|
||||
"add": "Nouvel objectif",
|
||||
"empty_title": "Pas encore de but",
|
||||
"empty_description": "Créez un objectif pour suivre les progrès de l’équipe sur plusieurs cours.",
|
||||
"created": "Objectif créé",
|
||||
"updated": "Objectif mis à jour",
|
||||
"deleted": "Objectif supprimé",
|
||||
"delete_confirm": "Supprimer cet objectif ? L’historique du statut de chaque étudiant sera supprimé.",
|
||||
"lms": {
|
||||
"heading": "Vos objectifs",
|
||||
"empty_title": "Aucun objectif assigné",
|
||||
"empty_description": "Lorsque vos instructeurs fixent des objectifs, ils s'affichent ici.",
|
||||
"due_in_days": "À rendre dans {days} jours",
|
||||
"due_tomorrow": "À rendre demain",
|
||||
"due_today": "À rendre aujourd'hui",
|
||||
"overdue": "En retard",
|
||||
"no_deadline": "Pas de date limite"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "en bonne voie",
|
||||
"completed_short": "terminé",
|
||||
"overdue_short": "en retard",
|
||||
"at_risk_short": "à risque"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Terminé",
|
||||
"in_progress": "En cours",
|
||||
"at_risk": "À risque",
|
||||
"overdue": "En retard",
|
||||
"not_started": "Pas commencé",
|
||||
"waived": "Renoncé"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Date absolue",
|
||||
"relative_to_join": "Par rapport à l'adhésion des étudiants",
|
||||
"recurring": "Récurrent",
|
||||
"none": "Pas de date limite"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Terminez tous les cours sélectionnés",
|
||||
"n_of_m": "Terminer les cours sélectionnés par N sur M",
|
||||
"score": "Score supérieur au seuil sur chaque cours",
|
||||
"pass_rate": "Seuil de réussite de l'équipe",
|
||||
"readiness": "Préparation (pas de date limite)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nouvel objectif",
|
||||
"edit_title": "Modifier l'objectif",
|
||||
"title_label": "Titre",
|
||||
"title_placeholder": "par ex. Rampe d'embarquement",
|
||||
"description_label": "Descriptif",
|
||||
"description_placeholder": "Contexte facultatif pour l'équipe",
|
||||
"type_label": "Tapez",
|
||||
"courses_label": "Cours",
|
||||
"required_count_label": "Nombre de cours requis",
|
||||
"score_threshold_label": "Note de réussite par élève (%)",
|
||||
"team_pass_rate_label": "Taux de réussite des équipes (%)",
|
||||
"deadline_label": "Date limite",
|
||||
"deadline_date_label": "Date limite",
|
||||
"relative_days_label": "Jours à compter de l'arrivée de l'étudiant",
|
||||
"recurring_months_label": "Répéter tous les (mois)",
|
||||
"reminders_label": "Rappels (jours avant l'échéance)",
|
||||
"reminders_helper": "Séparés par des virgules. Exemple : 7, 3, 1",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Demandez-moi de vous aider avec le contenu de votre cours : rédigez des leçons, générez des questions ou planifiez un cours à partir d'un document.",
|
||||
"input_placeholder": "Demandez à l'assistant IA...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Prêt",
|
||||
"failed": "Échec du téléchargement"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Programmes",
|
||||
"page_title": "Programmes",
|
||||
"create": "Nouveau programme",
|
||||
"create_success": "Programme créé",
|
||||
"update_success": "Programme mis à jour",
|
||||
"delete_success": "Programme supprimé",
|
||||
"members_added": "Membres ajoutés",
|
||||
"member_removed": "Membre supprimé",
|
||||
"course_added": "Cours ajouté",
|
||||
"course_removed": "Cours supprimé",
|
||||
"empty_title": "Aucun programme pour l'instant",
|
||||
"empty_description": "Créez votre premier programme de certification ou de conformité.",
|
||||
"settings": {
|
||||
"general": "Général",
|
||||
"name_label": "Nom",
|
||||
"description_label": "Descriptif",
|
||||
"status_label": "Statut",
|
||||
"save": "Enregistrer les modifications",
|
||||
"danger_zone": "Zone dangereuse",
|
||||
"delete": "Supprimer le programme",
|
||||
"delete_description": "Une fois supprimé, ce programme et toutes ses données ne peuvent pas être récupérés.",
|
||||
"confirm_delete": "Êtes-vous sûr de vouloir supprimer ce programme ? Cela ne peut pas être annulé."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Aucun programme pour l'instant",
|
||||
"empty_description": "Vous n'êtes inscrit à aucun programme."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Inviter",
|
||||
"invite_modal_title": "Inviter des membres",
|
||||
"invite_modal_description": "Ajoutez des personnes à ce programme par e-mail.",
|
||||
"email_label": "Courriel",
|
||||
"role_label": "Rôle",
|
||||
"add_another": "Ajoutez-en un autre",
|
||||
"invite_submit": "Envoyer des invitations",
|
||||
"empty_title": "Pas encore de membres",
|
||||
"empty_description": "Invitez les gens à rejoindre ce programme."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Ajouter un cours",
|
||||
"add_modal_title": "Ajouter un cours au programme",
|
||||
"select_course": "Sélectionnez un cours",
|
||||
"select_placeholder": "Choisissez un cours…",
|
||||
"empty_title": "Pas encore de cours",
|
||||
"empty_description": "Ajoutez des cours à ce programme.",
|
||||
"select_courses": "Sélectionner des cours",
|
||||
"search_placeholder": "Rechercher des cours",
|
||||
"no_available_courses": "Aucun cours disponible à ajouter.",
|
||||
"no_matching_courses": "Aucun cours ne correspond à votre recherche.",
|
||||
"no_matching_cohort_courses": "Aucun cours du programme ne correspond à votre recherche.",
|
||||
"pagination_status": "Page {page} sur {totalPages}",
|
||||
"published_access_callout_title": "Accès étudiant",
|
||||
"published_access_callout_description": "Les étudiants n'ont accès qu'aux cours publiés. Les cours peuvent figurer dans ce programme pour votre équipe alors qu'ils ne sont pas encore publiés, tandis qu'ils restent cachés aux étudiants jusqu'à ce que vous les publiiez."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nouveau message",
|
||||
"post_placeholder": "Qu'avez-vous en tête ?",
|
||||
"post_submit": "Publier",
|
||||
"empty_title": "Pas encore de messages",
|
||||
"empty_description": "Soyez le premier à publier quelque chose.",
|
||||
"heading": "Fil d'actualité",
|
||||
"make_a_post": "Créer un message",
|
||||
"edit_post": "Modifier le message",
|
||||
"editor_placeholder": "Partager une mise à jour avec votre programme",
|
||||
"cancel": "Annuler",
|
||||
"pinned": "Épinglé",
|
||||
"delete_feed_title": "Supprimer le message",
|
||||
"delete_feed_description": "Êtes-vous sûr de vouloir supprimer ce message ?",
|
||||
"confirm_delete": "Supprimer",
|
||||
"user_avatar_alt": "Avatar de l'utilisateur",
|
||||
"comments": {
|
||||
"comment": "commentaire",
|
||||
"showing_of": "Affichage de {shown} sur {total}",
|
||||
"hide": "Masquer les commentaires",
|
||||
"placeholder": "Ajouter un commentaire sur le programme",
|
||||
"submit_aria": "Soumettre un commentaire",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Épingle",
|
||||
"unpin": "Désépingler",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Créer un programme",
|
||||
"description": "Ajoutez un nouveau programme de certification ou de conformité.",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "par ex. Certification de sécurité OSHA",
|
||||
"description_label": "Descriptif",
|
||||
"description_placeholder": "De quoi parle ce programme ?",
|
||||
"submit": "Créer"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Fil d'actualité",
|
||||
"courses": "Cours",
|
||||
"people": "Les gens",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"status": {
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif",
|
||||
"archived": "Archivé"
|
||||
},
|
||||
"description": "Donner à un groupe de personnes l'accès à plusieurs cours, comme un cours",
|
||||
"search_placeholder": "Programmes de recherche",
|
||||
"multiple_added": "Cours ajoutés",
|
||||
"no_matching_cohorts": "Aucun programme ne correspond à vos filtres.",
|
||||
"card": {
|
||||
"courses": "Cours",
|
||||
"students": "Étudiants"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"courses": "Cours",
|
||||
"students": "Étudiants",
|
||||
"status": "Statut",
|
||||
"description": "Descriptif"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtrer",
|
||||
"clear_all": "Tout effacer",
|
||||
"popover_title": "Filtres",
|
||||
"sort": "Trier",
|
||||
"order": "Commande",
|
||||
"status": "Statut",
|
||||
"ascending": "Asc.",
|
||||
"descending": "Description",
|
||||
"sort_name": "Nom",
|
||||
"sort_courses": "Cours",
|
||||
"sort_students": "Étudiants",
|
||||
"sort_status": "Statut"
|
||||
},
|
||||
"invite_sent": "Invitations envoyées",
|
||||
"goals": {
|
||||
"heading": "Objectifs d'apprentissage",
|
||||
"description": "Fixez des objectifs mesurables couvrant plusieurs cours pour tous les participants à ce programme.",
|
||||
"add": "Nouvel objectif",
|
||||
"empty_title": "Pas encore de but",
|
||||
"empty_description": "Créez un objectif pour suivre les progrès de l’équipe sur plusieurs cours.",
|
||||
"created": "Objectif créé",
|
||||
"updated": "Objectif mis à jour",
|
||||
"deleted": "Objectif supprimé",
|
||||
"delete_confirm": "Supprimer cet objectif ? L’historique du statut de chaque étudiant sera supprimé.",
|
||||
"lms": {
|
||||
"heading": "Vos objectifs",
|
||||
"empty_title": "Aucun objectif assigné",
|
||||
"empty_description": "Lorsque vos instructeurs fixent des objectifs, ils s'affichent ici.",
|
||||
"due_in_days": "À rendre dans {days} jours",
|
||||
"due_tomorrow": "À rendre demain",
|
||||
"due_today": "À rendre aujourd'hui",
|
||||
"overdue": "En retard",
|
||||
"no_deadline": "Pas de date limite"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "en bonne voie",
|
||||
"completed_short": "terminé",
|
||||
"overdue_short": "en retard",
|
||||
"at_risk_short": "à risque"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Terminé",
|
||||
"in_progress": "En cours",
|
||||
"at_risk": "À risque",
|
||||
"overdue": "En retard",
|
||||
"not_started": "Pas commencé",
|
||||
"waived": "Renoncé"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Date absolue",
|
||||
"relative_to_join": "Par rapport à l'adhésion des étudiants",
|
||||
"recurring": "Récurrent",
|
||||
"none": "Pas de date limite"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Terminez tous les cours sélectionnés",
|
||||
"n_of_m": "Terminer les cours sélectionnés par N sur M",
|
||||
"score": "Score supérieur au seuil sur chaque cours",
|
||||
"pass_rate": "Seuil de réussite de l'équipe",
|
||||
"readiness": "Préparation (pas de date limite)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nouvel objectif",
|
||||
"edit_title": "Modifier l'objectif",
|
||||
"title_label": "Titre",
|
||||
"title_placeholder": "par ex. Rampe d'embarquement",
|
||||
"description_label": "Descriptif",
|
||||
"description_placeholder": "Contexte facultatif pour l'équipe",
|
||||
"type_label": "Tapez",
|
||||
"courses_label": "Cours",
|
||||
"required_count_label": "Nombre de cours requis",
|
||||
"score_threshold_label": "Note de réussite par élève (%)",
|
||||
"team_pass_rate_label": "Taux de réussite des équipes (%)",
|
||||
"deadline_label": "Date limite",
|
||||
"deadline_date_label": "Date limite",
|
||||
"relative_days_label": "Jours à compter de l'arrivée de l'étudiant",
|
||||
"recurring_months_label": "Répéter tous les (mois)",
|
||||
"reminders_label": "Rappels (jours avant l'échéance)",
|
||||
"reminders_helper": "Séparés par des virgules. Exemple : 7, 3, 1",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "स्वचालन कुंजी घुमाई गई. नया रहस्य अभी सहेजें क्योंकि यह दोबारा नहीं दिखाया जाएगा।",
|
||||
"revoked": "स्वचालन कुंजी निरस्त कर दी गई."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "पोस्ट पर प्रतिक्रिया करते समय एक त्रुटि उत्पन्न हुई",
|
||||
"news_feed_fail": "प्रोग्राम न्यूज़फ़ीड लाने में विफल",
|
||||
"post_create_failed": "पोस्ट बनाने में विफल",
|
||||
"post_update_failed": "पोस्ट अपडेट करने में विफल",
|
||||
"code": {
|
||||
"program_forbidden": "इस क्रिया को करने के लिए आपको इस कार्यक्रम का सदस्य होना चाहिए।",
|
||||
"program_member_check_failed": "हम आपके कार्यक्रम की सदस्यता सत्यापित नहीं कर सके. पुनः प्रयास करें।",
|
||||
"program_not_found": "यह प्रोग्राम नहीं मिल सका.",
|
||||
"program_create_failed": "प्रोग्राम नहीं बनाया जा सका.",
|
||||
"program_update_failed": "प्रोग्राम अद्यतन नहीं किया जा सका.",
|
||||
"program_delete_failed": "प्रोग्राम को हटाया नहीं जा सका.",
|
||||
"program_member_not_found": "वह प्रोग्राम सदस्य नहीं मिल सका.",
|
||||
"program_newsfeed_not_found": "वह पोस्ट नहीं मिल सकी.",
|
||||
"program_newsfeed_comment_not_found": "वह टिप्पणी नहीं मिल सकी."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "टिप्पणी हटा दी गई",
|
||||
"comment_added": "टिप्पणी जोड़ी गई",
|
||||
"feed_delete_success": "पोस्ट सफलतापूर्वक हटा दी गई",
|
||||
"post_created": "पोस्ट सफलतापूर्वक बनाई गई",
|
||||
"post_updated": "पोस्ट सफलतापूर्वक अपडेट की गई"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "AI credit pack आपके balance में जोड़ा गया।",
|
||||
"checkout_failed": "Checkout शुरू नहीं हो सका। कृपया पुनः प्रयास करें।"
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "1080p प्रस्तुतिकरण जोड़ा गया",
|
||||
"generate_1080_failed": "1080p प्रस्तुतिकरण उत्पन्न नहीं हो सका"
|
||||
},
|
||||
"social_auth_not_enabled": "यह प्रमाणीकरण प्रदाता सक्षम नहीं है."
|
||||
"social_auth_not_enabled": "यह प्रमाणीकरण प्रदाता सक्षम नहीं है.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "पोस्ट पर प्रतिक्रिया करते समय एक त्रुटि उत्पन्न हुई",
|
||||
"news_feed_fail": "प्रोग्राम न्यूज़फ़ीड लाने में विफल",
|
||||
"post_create_failed": "पोस्ट बनाने में विफल",
|
||||
"post_update_failed": "पोस्ट अपडेट करने में विफल",
|
||||
"code": {
|
||||
"cohort_forbidden": "इस क्रिया को करने के लिए आपको इस कार्यक्रम का सदस्य होना चाहिए।",
|
||||
"cohort_member_check_failed": "हम आपके कार्यक्रम की सदस्यता सत्यापित नहीं कर सके. पुनः प्रयास करें।",
|
||||
"cohort_not_found": "यह प्रोग्राम नहीं मिल सका.",
|
||||
"cohort_create_failed": "प्रोग्राम नहीं बनाया जा सका.",
|
||||
"cohort_update_failed": "प्रोग्राम अद्यतन नहीं किया जा सका.",
|
||||
"cohort_delete_failed": "प्रोग्राम को हटाया नहीं जा सका.",
|
||||
"cohort_member_not_found": "वह प्रोग्राम सदस्य नहीं मिल सका.",
|
||||
"cohort_newsfeed_not_found": "वह पोस्ट नहीं मिल सकी.",
|
||||
"cohort_newsfeed_comment_not_found": "वह टिप्पणी नहीं मिल सकी."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "टिप्पणी हटा दी गई",
|
||||
"comment_added": "टिप्पणी जोड़ी गई",
|
||||
"feed_delete_success": "पोस्ट सफलतापूर्वक हटा दी गई",
|
||||
"post_created": "पोस्ट सफलतापूर्वक बनाई गई",
|
||||
"post_updated": "पोस्ट सफलतापूर्वक अपडेट की गई"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "हमारे पाठ्यक्रमों का अन्वेषण करें",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "अन्वेषण करें",
|
||||
"help": "मदद",
|
||||
"settings": "सेटिंग्स",
|
||||
"programs": "कार्यक्रम"
|
||||
"cohorts": "कार्यक्रम"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "प्रमाणपत्र बनाने की अपनी योजना को अपग्रेड करें",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "सभी पाठ्यक्रम",
|
||||
"select_courses": "विशिष्ट पाठ्यक्रम चुनें",
|
||||
"select_courses_placeholder": "पाठ्यक्रम चुनें...",
|
||||
"program_access": "प्रोग्राम एक्सेस",
|
||||
"no_programs": "कोई प्रोग्राम असाइनमेंट नहीं",
|
||||
"all_programs": "सभी प्रोग्राम",
|
||||
"select_programs": "विशिष्ट प्रोग्राम चुनें",
|
||||
"select_programs_placeholder": "प्रोग्राम चुनें...",
|
||||
"send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें",
|
||||
"submit": "उपयोगकर्ता आयात करें",
|
||||
"back": "दर्शकों के पास वापस जाएँ",
|
||||
"snackbar_success": "{imported} नए निमंत्रण, {assigned} मौजूदा छात्रों को पाठ्यक्रम पहुंच, {pendingInvitesRenewed} लंबित निमंत्रण नवीकृत ({emailsSent} ईमेल भेजे गए)",
|
||||
"snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें",
|
||||
"snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "प्रोग्राम एक्सेस",
|
||||
"no_cohorts": "कोई प्रोग्राम असाइनमेंट नहीं",
|
||||
"all_cohorts": "सभी प्रोग्राम",
|
||||
"select_cohorts": "विशिष्ट प्रोग्राम चुनें",
|
||||
"select_cohorts_placeholder": "प्रोग्राम चुनें..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "अभी अपग्रेड करें",
|
||||
"tags": "टैग",
|
||||
"automation": "स्वचालन",
|
||||
"programs": "कार्यक्रम",
|
||||
"widgets": "विजेट्स",
|
||||
"stats": "आँकड़े",
|
||||
"analytics": "विश्लेषिकी",
|
||||
"compliance": "अनुपालन"
|
||||
"compliance": "अनुपालन",
|
||||
"cohorts": "कार्यक्रम"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "मीडिया मैनेजर - क्लासरूमआईओ",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "कार्यक्रम",
|
||||
"page_title": "कार्यक्रम",
|
||||
"create": "नया कार्यक्रम",
|
||||
"create_success": "प्रोग्राम बनाया गया",
|
||||
"update_success": "कार्यक्रम अद्यतन किया गया",
|
||||
"delete_success": "प्रोग्राम हटा दिया गया",
|
||||
"members_added": "सदस्य जोड़े गए",
|
||||
"member_removed": "सदस्य हटा दिया गया",
|
||||
"course_added": "पाठ्यक्रम जोड़ा गया",
|
||||
"course_removed": "पाठ्यक्रम हटा दिया गया",
|
||||
"empty_title": "अभी तक कोई कार्यक्रम नहीं",
|
||||
"empty_description": "अपना पहला प्रमाणन या अनुपालन कार्यक्रम बनाएं।",
|
||||
"settings": {
|
||||
"general": "सामान्य",
|
||||
"name_label": "नाम",
|
||||
"description_label": "विवरण",
|
||||
"status_label": "स्थिति",
|
||||
"save": "परिवर्तन सहेजें",
|
||||
"danger_zone": "ख़तरे का क्षेत्र",
|
||||
"delete": "प्रोग्राम हटाएँ",
|
||||
"delete_description": "एक बार डिलीट होने के बाद, यह प्रोग्राम और इसका सारा डेटा पुनर्प्राप्त नहीं किया जा सकता है।",
|
||||
"confirm_delete": "क्या आप वाकई इस प्रोग्राम को हटाना चाहते हैं? इसे असंपादित नहीं किया जा सकता है।"
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "अभी तक कोई कार्यक्रम नहीं",
|
||||
"empty_description": "आपको किसी भी कार्यक्रम में नामांकित नहीं किया गया है."
|
||||
},
|
||||
"people": {
|
||||
"invite": "आमंत्रित करें",
|
||||
"invite_modal_title": "सदस्यों को आमंत्रित करें",
|
||||
"invite_modal_description": "लोगों को ईमेल द्वारा इस कार्यक्रम में जोड़ें.",
|
||||
"email_label": "ईमेल",
|
||||
"role_label": "भूमिका",
|
||||
"add_another": "दूसरा जोड़ें",
|
||||
"invite_submit": "निमंत्रण भेजें",
|
||||
"empty_title": "अभी तक कोई सदस्य नहीं",
|
||||
"empty_description": "लोगों को इस कार्यक्रम में शामिल होने के लिए आमंत्रित करें."
|
||||
},
|
||||
"courses": {
|
||||
"add": "पाठ्यक्रम जोड़ें",
|
||||
"add_modal_title": "कार्यक्रम में पाठ्यक्रम जोड़ें",
|
||||
"select_course": "एक पाठ्यक्रम चुनें",
|
||||
"select_placeholder": "एक कोर्स चुनें...",
|
||||
"empty_title": "अभी तक कोई पाठ्यक्रम नहीं",
|
||||
"empty_description": "इस कार्यक्रम में पाठ्यक्रम जोड़ें.",
|
||||
"select_courses": "पाठ्यक्रम चुनें",
|
||||
"search_placeholder": "पाठ्यक्रम खोजें",
|
||||
"no_available_courses": "जोड़ने के लिए कोई पाठ्यक्रम उपलब्ध नहीं है.",
|
||||
"no_matching_courses": "कोई भी पाठ्यक्रम आपकी खोज से मेल नहीं खाता.",
|
||||
"no_matching_program_courses": "कोई भी प्रोग्राम पाठ्यक्रम आपकी खोज से मेल नहीं खाता।",
|
||||
"pagination_status": "{page} का पेज {totalPages}",
|
||||
"published_access_callout_title": "छात्र प्रवेश",
|
||||
"published_access_callout_description": "छात्रों को केवल प्रकाशित पाठ्यक्रमों तक ही पहुंच है। पाठ्यक्रम अप्रकाशित रहते हुए भी आपकी टीम के लिए इस कार्यक्रम में हो सकते हैं, जबकि जब तक आप उन्हें प्रकाशित नहीं करते तब तक वे छात्रों से छिपे रहते हैं।"
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "नई पोस्ट",
|
||||
"post_placeholder": "आपके मन में क्या है?",
|
||||
"post_submit": "पोस्ट",
|
||||
"empty_title": "अभी तक कोई पोस्ट नहीं",
|
||||
"empty_description": "कुछ पोस्ट करने वाले पहले व्यक्ति बनें.",
|
||||
"heading": "न्यूज़फीड",
|
||||
"make_a_post": "एक पोस्ट बनाएं",
|
||||
"edit_post": "पोस्ट संपादित करें",
|
||||
"editor_placeholder": "अपने प्रोग्राम के साथ एक अपडेट साझा करें",
|
||||
"cancel": "रद्द करें",
|
||||
"pinned": "पिन किया गया",
|
||||
"delete_feed_title": "पोस्ट हटाएं",
|
||||
"delete_feed_description": "क्या आप वाकई इस पोस्ट को हटाना चाहते हैं?",
|
||||
"confirm_delete": "हटाएँ",
|
||||
"user_avatar_alt": "उपयोगकर्ता अवतार",
|
||||
"comments": {
|
||||
"comment": "टिप्पणी",
|
||||
"showing_of": "{shown} में से {total}",
|
||||
"hide": "टिप्पणियाँ छिपाएँ",
|
||||
"placeholder": "प्रोग्राम टिप्पणी जोड़ें",
|
||||
"submit_aria": "टिप्पणी सबमिट करें",
|
||||
"delete": "हटाएँ"
|
||||
},
|
||||
"card": {
|
||||
"pin": "पिन",
|
||||
"unpin": "अनपिन करें",
|
||||
"edit": "संपादित करें",
|
||||
"delete": "हटाएँ"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "प्रोग्राम बनाएं",
|
||||
"description": "एक नया प्रमाणन या अनुपालन कार्यक्रम जोड़ें.",
|
||||
"name_label": "नाम",
|
||||
"name_placeholder": "जैसे OSHA सुरक्षा प्रमाणन",
|
||||
"description_label": "विवरण",
|
||||
"description_placeholder": "यह कार्यक्रम किस बारे में है?",
|
||||
"submit": "बनाएँ"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "न्यूज़फीड",
|
||||
"courses": "पाठ्यक्रम",
|
||||
"people": "लोग",
|
||||
"settings": "सेटिंग्स"
|
||||
},
|
||||
"status": {
|
||||
"active": "सक्रिय",
|
||||
"inactive": "निष्क्रिय",
|
||||
"archived": "संग्रहीत"
|
||||
},
|
||||
"description": "लोगों के एक समूह को एक कक्षा जैसे कई पाठ्यक्रमों तक पहुंच प्रदान करें",
|
||||
"search_placeholder": "प्रोग्राम खोजें",
|
||||
"multiple_added": "पाठ्यक्रम जोड़े गए",
|
||||
"no_matching_programs": "कोई भी प्रोग्राम आपके फ़िल्टर से मेल नहीं खाता.",
|
||||
"card": {
|
||||
"courses": "पाठ्यक्रम",
|
||||
"students": "छात्र"
|
||||
},
|
||||
"table": {
|
||||
"name": "नाम",
|
||||
"courses": "पाठ्यक्रम",
|
||||
"students": "छात्र",
|
||||
"status": "स्थिति",
|
||||
"description": "विवरण"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "फ़िल्टर करें",
|
||||
"clear_all": "सब साफ़ करें",
|
||||
"popover_title": "फिल्टर",
|
||||
"sort": "क्रमबद्ध करें",
|
||||
"order": "आदेश",
|
||||
"status": "स्थिति",
|
||||
"ascending": "ए.एस.सी",
|
||||
"descending": "वर्णन",
|
||||
"sort_name": "नाम",
|
||||
"sort_courses": "पाठ्यक्रम",
|
||||
"sort_students": "छात्र",
|
||||
"sort_status": "स्थिति"
|
||||
},
|
||||
"invite_sent": "निमंत्रण भेजा गया",
|
||||
"goals": {
|
||||
"heading": "सीखने के लक्ष्य",
|
||||
"description": "मापने योग्य लक्ष्य निर्धारित करें जो इस कार्यक्रम में सभी के लिए कई पाठ्यक्रमों तक फैले हों।",
|
||||
"add": "नया लक्ष्य",
|
||||
"empty_title": "अभी तक कोई लक्ष्य नहीं",
|
||||
"empty_description": "अनेक पाठ्यक्रमों में टीम की प्रगति को ट्रैक करने के लिए एक लक्ष्य बनाएं।",
|
||||
"created": "लक्ष्य बनाया गया",
|
||||
"updated": "लक्ष्य अद्यतन किया गया",
|
||||
"deleted": "लक्ष्य हटा दिया गया",
|
||||
"delete_confirm": "यह लक्ष्य हटाएं? प्रति-छात्र स्थिति इतिहास हटा दिया जाएगा.",
|
||||
"lms": {
|
||||
"heading": "आपके लक्ष्य",
|
||||
"empty_title": "कोई लक्ष्य नहीं सौंपा गया",
|
||||
"empty_description": "जब आपके प्रशिक्षक लक्ष्य निर्धारित करेंगे, तो वे यहां दिखाई देंगे।",
|
||||
"due_in_days": "{days} दिनों में देय",
|
||||
"due_tomorrow": "कल देय",
|
||||
"due_today": "आज देय",
|
||||
"overdue": "अतिदेय",
|
||||
"no_deadline": "कोई समयसीमा नहीं"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "ट्रैक पर",
|
||||
"completed_short": "पूरा हुआ",
|
||||
"overdue_short": "अतिदेय",
|
||||
"at_risk_short": "ख़तरे में"
|
||||
},
|
||||
"status": {
|
||||
"completed": "पूरा हुआ",
|
||||
"in_progress": "प्रगति पर है",
|
||||
"at_risk": "ख़तरे में",
|
||||
"overdue": "अतिदेय",
|
||||
"not_started": "शुरू नहीं हुआ",
|
||||
"waived": "माफ़ कर दिया गया"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "पूर्ण तिथि",
|
||||
"relative_to_join": "विद्यार्थियों के शामिल होने के संबंध में",
|
||||
"recurring": "आवर्ती",
|
||||
"none": "कोई समयसीमा नहीं"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "सभी चयनित पाठ्यक्रम पूर्ण करें",
|
||||
"n_of_m": "एम चयनित पाठ्यक्रमों का एन पूरा करें",
|
||||
"score": "प्रत्येक पाठ्यक्रम पर सीमा से अधिक अंक प्राप्त करें",
|
||||
"pass_rate": "टीम पास-रेट सीमा",
|
||||
"readiness": "तैयारी (कोई समय सीमा नहीं)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "नया लक्ष्य",
|
||||
"edit_title": "लक्ष्य संपादित करें",
|
||||
"title_label": "शीर्षक",
|
||||
"title_placeholder": "जैसे ऑनबोर्डिंग रैंप",
|
||||
"description_label": "विवरण",
|
||||
"description_placeholder": "टीम के लिए वैकल्पिक संदर्भ",
|
||||
"type_label": "प्रकार",
|
||||
"courses_label": "पाठ्यक्रम",
|
||||
"required_count_label": "आवश्यक पाठ्यक्रमों की संख्या",
|
||||
"score_threshold_label": "प्रति छात्र उत्तीर्णांक (%)",
|
||||
"team_pass_rate_label": "टीम पास दर (%)",
|
||||
"deadline_label": "समयसीमा",
|
||||
"deadline_date_label": "अंतिम तिथि",
|
||||
"relative_days_label": "छात्र के शामिल होने के दिन से दिन",
|
||||
"recurring_months_label": "प्रत्येक (महीने) दोहराएँ",
|
||||
"reminders_label": "अनुस्मारक (नियत तिथि से कुछ दिन पहले)",
|
||||
"reminders_helper": "अल्पविराम से अलग किया गया. उदाहरण: 7, 3, 1",
|
||||
"save": "सहेजें",
|
||||
"cancel": "रद्द करें"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "मुझसे अपने पाठ्यक्रम की सामग्री में मदद करने के लिए कहें - पाठों का मसौदा तैयार करें, प्रश्न तैयार करें, या किसी दस्तावेज़ से पाठ्यक्रम की योजना बनाएं।",
|
||||
"input_placeholder": "एआई सहायक से पूछें...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "तैयार",
|
||||
"failed": "अपलोड विफल रहा"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "कार्यक्रम",
|
||||
"page_title": "कार्यक्रम",
|
||||
"create": "नया कार्यक्रम",
|
||||
"create_success": "प्रोग्राम बनाया गया",
|
||||
"update_success": "कार्यक्रम अद्यतन किया गया",
|
||||
"delete_success": "प्रोग्राम हटा दिया गया",
|
||||
"members_added": "सदस्य जोड़े गए",
|
||||
"member_removed": "सदस्य हटा दिया गया",
|
||||
"course_added": "पाठ्यक्रम जोड़ा गया",
|
||||
"course_removed": "पाठ्यक्रम हटा दिया गया",
|
||||
"empty_title": "अभी तक कोई कार्यक्रम नहीं",
|
||||
"empty_description": "अपना पहला प्रमाणन या अनुपालन कार्यक्रम बनाएं।",
|
||||
"settings": {
|
||||
"general": "सामान्य",
|
||||
"name_label": "नाम",
|
||||
"description_label": "विवरण",
|
||||
"status_label": "स्थिति",
|
||||
"save": "परिवर्तन सहेजें",
|
||||
"danger_zone": "ख़तरे का क्षेत्र",
|
||||
"delete": "प्रोग्राम हटाएँ",
|
||||
"delete_description": "एक बार डिलीट होने के बाद, यह प्रोग्राम और इसका सारा डेटा पुनर्प्राप्त नहीं किया जा सकता है।",
|
||||
"confirm_delete": "क्या आप वाकई इस प्रोग्राम को हटाना चाहते हैं? इसे असंपादित नहीं किया जा सकता है।"
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "अभी तक कोई कार्यक्रम नहीं",
|
||||
"empty_description": "आपको किसी भी कार्यक्रम में नामांकित नहीं किया गया है."
|
||||
},
|
||||
"people": {
|
||||
"invite": "आमंत्रित करें",
|
||||
"invite_modal_title": "सदस्यों को आमंत्रित करें",
|
||||
"invite_modal_description": "लोगों को ईमेल द्वारा इस कार्यक्रम में जोड़ें.",
|
||||
"email_label": "ईमेल",
|
||||
"role_label": "भूमिका",
|
||||
"add_another": "दूसरा जोड़ें",
|
||||
"invite_submit": "निमंत्रण भेजें",
|
||||
"empty_title": "अभी तक कोई सदस्य नहीं",
|
||||
"empty_description": "लोगों को इस कार्यक्रम में शामिल होने के लिए आमंत्रित करें."
|
||||
},
|
||||
"courses": {
|
||||
"add": "पाठ्यक्रम जोड़ें",
|
||||
"add_modal_title": "कार्यक्रम में पाठ्यक्रम जोड़ें",
|
||||
"select_course": "एक पाठ्यक्रम चुनें",
|
||||
"select_placeholder": "एक कोर्स चुनें...",
|
||||
"empty_title": "अभी तक कोई पाठ्यक्रम नहीं",
|
||||
"empty_description": "इस कार्यक्रम में पाठ्यक्रम जोड़ें.",
|
||||
"select_courses": "पाठ्यक्रम चुनें",
|
||||
"search_placeholder": "पाठ्यक्रम खोजें",
|
||||
"no_available_courses": "जोड़ने के लिए कोई पाठ्यक्रम उपलब्ध नहीं है.",
|
||||
"no_matching_courses": "कोई भी पाठ्यक्रम आपकी खोज से मेल नहीं खाता.",
|
||||
"no_matching_cohort_courses": "कोई भी प्रोग्राम पाठ्यक्रम आपकी खोज से मेल नहीं खाता।",
|
||||
"pagination_status": "{page} का पेज {totalPages}",
|
||||
"published_access_callout_title": "छात्र प्रवेश",
|
||||
"published_access_callout_description": "छात्रों को केवल प्रकाशित पाठ्यक्रमों तक ही पहुंच है। पाठ्यक्रम अप्रकाशित रहते हुए भी आपकी टीम के लिए इस कार्यक्रम में हो सकते हैं, जबकि जब तक आप उन्हें प्रकाशित नहीं करते तब तक वे छात्रों से छिपे रहते हैं।"
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "नई पोस्ट",
|
||||
"post_placeholder": "आपके मन में क्या है?",
|
||||
"post_submit": "पोस्ट",
|
||||
"empty_title": "अभी तक कोई पोस्ट नहीं",
|
||||
"empty_description": "कुछ पोस्ट करने वाले पहले व्यक्ति बनें.",
|
||||
"heading": "न्यूज़फीड",
|
||||
"make_a_post": "एक पोस्ट बनाएं",
|
||||
"edit_post": "पोस्ट संपादित करें",
|
||||
"editor_placeholder": "अपने प्रोग्राम के साथ एक अपडेट साझा करें",
|
||||
"cancel": "रद्द करें",
|
||||
"pinned": "पिन किया गया",
|
||||
"delete_feed_title": "पोस्ट हटाएं",
|
||||
"delete_feed_description": "क्या आप वाकई इस पोस्ट को हटाना चाहते हैं?",
|
||||
"confirm_delete": "हटाएँ",
|
||||
"user_avatar_alt": "उपयोगकर्ता अवतार",
|
||||
"comments": {
|
||||
"comment": "टिप्पणी",
|
||||
"showing_of": "{shown} में से {total}",
|
||||
"hide": "टिप्पणियाँ छिपाएँ",
|
||||
"placeholder": "प्रोग्राम टिप्पणी जोड़ें",
|
||||
"submit_aria": "टिप्पणी सबमिट करें",
|
||||
"delete": "हटाएँ"
|
||||
},
|
||||
"card": {
|
||||
"pin": "पिन",
|
||||
"unpin": "अनपिन करें",
|
||||
"edit": "संपादित करें",
|
||||
"delete": "हटाएँ"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "प्रोग्राम बनाएं",
|
||||
"description": "एक नया प्रमाणन या अनुपालन कार्यक्रम जोड़ें.",
|
||||
"name_label": "नाम",
|
||||
"name_placeholder": "जैसे OSHA सुरक्षा प्रमाणन",
|
||||
"description_label": "विवरण",
|
||||
"description_placeholder": "यह कार्यक्रम किस बारे में है?",
|
||||
"submit": "बनाएँ"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "न्यूज़फीड",
|
||||
"courses": "पाठ्यक्रम",
|
||||
"people": "लोग",
|
||||
"settings": "सेटिंग्स"
|
||||
},
|
||||
"status": {
|
||||
"active": "सक्रिय",
|
||||
"inactive": "निष्क्रिय",
|
||||
"archived": "संग्रहीत"
|
||||
},
|
||||
"description": "लोगों के एक समूह को एक कक्षा जैसे कई पाठ्यक्रमों तक पहुंच प्रदान करें",
|
||||
"search_placeholder": "प्रोग्राम खोजें",
|
||||
"multiple_added": "पाठ्यक्रम जोड़े गए",
|
||||
"no_matching_cohorts": "कोई भी प्रोग्राम आपके फ़िल्टर से मेल नहीं खाता.",
|
||||
"card": {
|
||||
"courses": "पाठ्यक्रम",
|
||||
"students": "छात्र"
|
||||
},
|
||||
"table": {
|
||||
"name": "नाम",
|
||||
"courses": "पाठ्यक्रम",
|
||||
"students": "छात्र",
|
||||
"status": "स्थिति",
|
||||
"description": "विवरण"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "फ़िल्टर करें",
|
||||
"clear_all": "सब साफ़ करें",
|
||||
"popover_title": "फिल्टर",
|
||||
"sort": "क्रमबद्ध करें",
|
||||
"order": "आदेश",
|
||||
"status": "स्थिति",
|
||||
"ascending": "ए.एस.सी",
|
||||
"descending": "वर्णन",
|
||||
"sort_name": "नाम",
|
||||
"sort_courses": "पाठ्यक्रम",
|
||||
"sort_students": "छात्र",
|
||||
"sort_status": "स्थिति"
|
||||
},
|
||||
"invite_sent": "निमंत्रण भेजा गया",
|
||||
"goals": {
|
||||
"heading": "सीखने के लक्ष्य",
|
||||
"description": "मापने योग्य लक्ष्य निर्धारित करें जो इस कार्यक्रम में सभी के लिए कई पाठ्यक्रमों तक फैले हों।",
|
||||
"add": "नया लक्ष्य",
|
||||
"empty_title": "अभी तक कोई लक्ष्य नहीं",
|
||||
"empty_description": "अनेक पाठ्यक्रमों में टीम की प्रगति को ट्रैक करने के लिए एक लक्ष्य बनाएं।",
|
||||
"created": "लक्ष्य बनाया गया",
|
||||
"updated": "लक्ष्य अद्यतन किया गया",
|
||||
"deleted": "लक्ष्य हटा दिया गया",
|
||||
"delete_confirm": "यह लक्ष्य हटाएं? प्रति-छात्र स्थिति इतिहास हटा दिया जाएगा.",
|
||||
"lms": {
|
||||
"heading": "आपके लक्ष्य",
|
||||
"empty_title": "कोई लक्ष्य नहीं सौंपा गया",
|
||||
"empty_description": "जब आपके प्रशिक्षक लक्ष्य निर्धारित करेंगे, तो वे यहां दिखाई देंगे।",
|
||||
"due_in_days": "{days} दिनों में देय",
|
||||
"due_tomorrow": "कल देय",
|
||||
"due_today": "आज देय",
|
||||
"overdue": "अतिदेय",
|
||||
"no_deadline": "कोई समयसीमा नहीं"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "ट्रैक पर",
|
||||
"completed_short": "पूरा हुआ",
|
||||
"overdue_short": "अतिदेय",
|
||||
"at_risk_short": "ख़तरे में"
|
||||
},
|
||||
"status": {
|
||||
"completed": "पूरा हुआ",
|
||||
"in_progress": "प्रगति पर है",
|
||||
"at_risk": "ख़तरे में",
|
||||
"overdue": "अतिदेय",
|
||||
"not_started": "शुरू नहीं हुआ",
|
||||
"waived": "माफ़ कर दिया गया"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "पूर्ण तिथि",
|
||||
"relative_to_join": "विद्यार्थियों के शामिल होने के संबंध में",
|
||||
"recurring": "आवर्ती",
|
||||
"none": "कोई समयसीमा नहीं"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "सभी चयनित पाठ्यक्रम पूर्ण करें",
|
||||
"n_of_m": "एम चयनित पाठ्यक्रमों का एन पूरा करें",
|
||||
"score": "प्रत्येक पाठ्यक्रम पर सीमा से अधिक अंक प्राप्त करें",
|
||||
"pass_rate": "टीम पास-रेट सीमा",
|
||||
"readiness": "तैयारी (कोई समय सीमा नहीं)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "नया लक्ष्य",
|
||||
"edit_title": "लक्ष्य संपादित करें",
|
||||
"title_label": "शीर्षक",
|
||||
"title_placeholder": "जैसे ऑनबोर्डिंग रैंप",
|
||||
"description_label": "विवरण",
|
||||
"description_placeholder": "टीम के लिए वैकल्पिक संदर्भ",
|
||||
"type_label": "प्रकार",
|
||||
"courses_label": "पाठ्यक्रम",
|
||||
"required_count_label": "आवश्यक पाठ्यक्रमों की संख्या",
|
||||
"score_threshold_label": "प्रति छात्र उत्तीर्णांक (%)",
|
||||
"team_pass_rate_label": "टीम पास दर (%)",
|
||||
"deadline_label": "समयसीमा",
|
||||
"deadline_date_label": "अंतिम तिथि",
|
||||
"relative_days_label": "छात्र के शामिल होने के दिन से दिन",
|
||||
"recurring_months_label": "प्रत्येक (महीने) दोहराएँ",
|
||||
"reminders_label": "अनुस्मारक (नियत तिथि से कुछ दिन पहले)",
|
||||
"reminders_helper": "अल्पविराम से अलग किया गया. उदाहरण: 7, 3, 1",
|
||||
"save": "सहेजें",
|
||||
"cancel": "रद्द करें"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Klucz automatyki obrócony. Zapisz teraz nowy sekret, ponieważ nie będzie on wyświetlany ponownie.",
|
||||
"revoked": "Klucz automatyzacji unieważniony."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Wystąpił błąd podczas odpowiadania na post",
|
||||
"news_feed_fail": "Nie udało się pobrać kanału informacyjnego programu",
|
||||
"post_create_failed": "Nie udało się utworzyć posta",
|
||||
"post_update_failed": "Nie udało się zaktualizować wpisu",
|
||||
"code": {
|
||||
"program_forbidden": "Aby wykonać tę czynność, musisz być członkiem tego programu.",
|
||||
"program_member_check_failed": "Nie mogliśmy zweryfikować Twojego członkostwa w programie. Spróbuj ponownie.",
|
||||
"program_not_found": "Nie można znaleźć tego programu.",
|
||||
"program_create_failed": "Nie można utworzyć programu.",
|
||||
"program_update_failed": "Nie można zaktualizować programu.",
|
||||
"program_delete_failed": "Nie można usunąć programu.",
|
||||
"program_member_not_found": "Nie można znaleźć tego członka programu.",
|
||||
"program_newsfeed_not_found": "Nie udało się znaleźć tego wpisu.",
|
||||
"program_newsfeed_comment_not_found": "Nie udało się znaleźć tego komentarza."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Komentarz usunięty",
|
||||
"comment_added": "Komentarz dodany",
|
||||
"feed_delete_success": "Post został pomyślnie usunięty",
|
||||
"post_created": "Post został utworzony pomyślnie",
|
||||
"post_updated": "Post został pomyślnie zaktualizowany"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "Pakiet AI credits dodany do salda.",
|
||||
"checkout_failed": "Nie udało się rozpocząć płatności. Spróbuj ponownie."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "Dodano wersję 1080p",
|
||||
"generate_1080_failed": "Nie można wygenerować wersji 1080p"
|
||||
},
|
||||
"social_auth_not_enabled": "Ten dostawca uwierzytelniania nie jest włączony."
|
||||
"social_auth_not_enabled": "Ten dostawca uwierzytelniania nie jest włączony.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Wystąpił błąd podczas odpowiadania na post",
|
||||
"news_feed_fail": "Nie udało się pobrać kanału informacyjnego programu",
|
||||
"post_create_failed": "Nie udało się utworzyć posta",
|
||||
"post_update_failed": "Nie udało się zaktualizować wpisu",
|
||||
"code": {
|
||||
"cohort_forbidden": "Aby wykonać tę czynność, musisz być członkiem tego programu.",
|
||||
"cohort_member_check_failed": "Nie mogliśmy zweryfikować Twojego członkostwa w programie. Spróbuj ponownie.",
|
||||
"cohort_not_found": "Nie można znaleźć tego programu.",
|
||||
"cohort_create_failed": "Nie można utworzyć programu.",
|
||||
"cohort_update_failed": "Nie można zaktualizować programu.",
|
||||
"cohort_delete_failed": "Nie można usunąć programu.",
|
||||
"cohort_member_not_found": "Nie można znaleźć tego członka programu.",
|
||||
"cohort_newsfeed_not_found": "Nie udało się znaleźć tego wpisu.",
|
||||
"cohort_newsfeed_comment_not_found": "Nie udało się znaleźć tego komentarza."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Komentarz usunięty",
|
||||
"comment_added": "Komentarz dodany",
|
||||
"feed_delete_success": "Post został pomyślnie usunięty",
|
||||
"post_created": "Post został utworzony pomyślnie",
|
||||
"post_updated": "Post został pomyślnie zaktualizowany"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Poznaj nasze kursy",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Eksploruj",
|
||||
"help": "Pomoc",
|
||||
"settings": "Ustawienia",
|
||||
"programs": "Programy"
|
||||
"cohorts": "Programy"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Uaktualnij swój plan, aby generować certyfikaty",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Wszystkie kursy",
|
||||
"select_courses": "Wybierz konkretne kursy",
|
||||
"select_courses_placeholder": "Wybierz kursy...",
|
||||
"program_access": "Dostęp do programów",
|
||||
"no_programs": "Bez przypisania do programu",
|
||||
"all_programs": "Wszystkie programy",
|
||||
"select_programs": "Wybierz konkretne programy",
|
||||
"select_programs_placeholder": "Wybierz programy...",
|
||||
"send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników",
|
||||
"submit": "Importuj użytkowników",
|
||||
"back": "Powrót do Publiczności",
|
||||
"snackbar_success": "{imported} nowych zaproszeń, {assigned} istniejących studentów z dostępem do kursu, {pendingInvitesRenewed} odnowionych oczekujących zaproszeń (wysłano e-maile: {emailsSent})",
|
||||
"snackbar_no_emails": "Proszę podać przynajmniej jeden adres e-mail",
|
||||
"snackbar_failed": "Nie udało się zaimportować użytkowników",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Dostęp do cohortów",
|
||||
"no_cohorts": "Bez przypisania do programu",
|
||||
"all_cohorts": "Wszystkie programy",
|
||||
"select_cohorts": "Wybierz konkretne programy",
|
||||
"select_cohorts_placeholder": "Wybierz programy..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Uaktualnij teraz",
|
||||
"tags": "Tagi",
|
||||
"automation": "Automatyzacja",
|
||||
"programs": "Programy",
|
||||
"widgets": "Widżety",
|
||||
"stats": "Statystyki",
|
||||
"analytics": "Analityka",
|
||||
"compliance": "Zgodność"
|
||||
"compliance": "Zgodność",
|
||||
"cohorts": "Programy"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Menedżer multimediów - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programy",
|
||||
"page_title": "Programy",
|
||||
"create": "Nowy program",
|
||||
"create_success": "Program stworzony",
|
||||
"update_success": "Program zaktualizowany",
|
||||
"delete_success": "Program usunięty",
|
||||
"members_added": "Dodano członków",
|
||||
"member_removed": "Członek usunięty",
|
||||
"course_added": "Kurs dodany",
|
||||
"course_removed": "Kurs usunięty",
|
||||
"empty_title": "Nie ma jeszcze programów",
|
||||
"empty_description": "Utwórz swój pierwszy program certyfikacji lub zgodności.",
|
||||
"settings": {
|
||||
"general": "Generał",
|
||||
"name_label": "Imię",
|
||||
"description_label": "Opis",
|
||||
"status_label": "Stan",
|
||||
"save": "Zapisz zmiany",
|
||||
"danger_zone": "Strefa zagrożenia",
|
||||
"delete": "Usuń program",
|
||||
"delete_description": "Po usunięciu tego programu i wszystkich jego danych nie można odzyskać.",
|
||||
"confirm_delete": "Czy na pewno chcesz usunąć ten program? Tego nie można cofnąć."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Nie ma jeszcze programów",
|
||||
"empty_description": "Nie jesteś zarejestrowany w żadnym programie."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Zaproś",
|
||||
"invite_modal_title": "Zaproś członków",
|
||||
"invite_modal_description": "Dodaj osoby do tego programu przez e-mail.",
|
||||
"email_label": "E-mail",
|
||||
"role_label": "Rola",
|
||||
"add_another": "Dodaj kolejny",
|
||||
"invite_submit": "Wyślij zaproszenia",
|
||||
"empty_title": "Nie ma jeszcze członków",
|
||||
"empty_description": "Zaproś ludzi do przyłączenia się do tego programu."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Dodaj kurs",
|
||||
"add_modal_title": "Dodaj kurs do programu",
|
||||
"select_course": "Wybierz kurs",
|
||||
"select_placeholder": "Wybierz kurs…",
|
||||
"empty_title": "Nie ma jeszcze żadnych kursów",
|
||||
"empty_description": "Dodaj kursy do tego programu.",
|
||||
"select_courses": "Wybierz kursy",
|
||||
"search_placeholder": "Wyszukaj kursy",
|
||||
"no_available_courses": "Brak dostępnych kursów do dodania.",
|
||||
"no_matching_courses": "Żaden kurs nie pasuje do Twojego wyszukiwania.",
|
||||
"no_matching_program_courses": "Żaden kurs programowy nie pasuje do Twojego wyszukiwania.",
|
||||
"pagination_status": "Strona {page} z {totalPages}",
|
||||
"published_access_callout_title": "Dostęp studencki",
|
||||
"published_access_callout_description": "Studenci mają dostęp wyłącznie do opublikowanych kursów. Kursy mogą znajdować się w tym programie dla Twojego zespołu, gdy nie są jeszcze opublikowane, ale pozostają ukryte przed uczniami, dopóki ich nie opublikujesz."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nowy post",
|
||||
"post_placeholder": "Co masz na myśli?",
|
||||
"post_submit": "Opublikuj",
|
||||
"empty_title": "Nie ma jeszcze żadnych postów",
|
||||
"empty_description": "Bądź pierwszą osobą, która coś opublikuje.",
|
||||
"heading": "Kanał informacyjny",
|
||||
"make_a_post": "Napisz post",
|
||||
"edit_post": "Edytuj post",
|
||||
"editor_placeholder": "Udostępnij aktualizację swojemu programowi",
|
||||
"cancel": "Anuluj",
|
||||
"pinned": "Przypięty",
|
||||
"delete_feed_title": "Usuń wpis",
|
||||
"delete_feed_description": "Czy na pewno chcesz usunąć ten post?",
|
||||
"confirm_delete": "Usuń",
|
||||
"user_avatar_alt": "Awatar użytkownika",
|
||||
"comments": {
|
||||
"comment": "komentarz",
|
||||
"showing_of": "Wyświetlanie {shown} z {total}",
|
||||
"hide": "Ukryj komentarze",
|
||||
"placeholder": "Dodaj komentarz do programu",
|
||||
"submit_aria": "Prześlij komentarz",
|
||||
"delete": "Usuń"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Przypnij",
|
||||
"unpin": "Odepnij",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Utwórz program",
|
||||
"description": "Dodaj nowy program certyfikacji lub zgodności.",
|
||||
"name_label": "Imię",
|
||||
"name_placeholder": "np. Certyfikat bezpieczeństwa OSHA",
|
||||
"description_label": "Opis",
|
||||
"description_placeholder": "O czym jest ten program?",
|
||||
"submit": "Utwórz"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Kanał informacyjny",
|
||||
"courses": "Kursy",
|
||||
"people": "Ludzie",
|
||||
"settings": "Ustawienia"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktywny",
|
||||
"inactive": "Nieaktywny",
|
||||
"archived": "Zarchiwizowane"
|
||||
},
|
||||
"description": "Daj grupie osób dostęp do wielu kursów, np. zajęć",
|
||||
"search_placeholder": "Wyszukaj programy",
|
||||
"multiple_added": "Dodano kursy",
|
||||
"no_matching_programs": "Żaden program nie pasuje do Twoich filtrów.",
|
||||
"card": {
|
||||
"courses": "Kursy",
|
||||
"students": "Studenci"
|
||||
},
|
||||
"table": {
|
||||
"name": "Imię",
|
||||
"courses": "Kursy",
|
||||
"students": "Studenci",
|
||||
"status": "Stan",
|
||||
"description": "Opis"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtruj",
|
||||
"clear_all": "Wyczyść wszystko",
|
||||
"popover_title": "Filtry",
|
||||
"sort": "Sortuj",
|
||||
"order": "Zamów",
|
||||
"status": "Stan",
|
||||
"ascending": "rosnąco",
|
||||
"descending": "Opis",
|
||||
"sort_name": "Imię",
|
||||
"sort_courses": "Kursy",
|
||||
"sort_students": "Studenci",
|
||||
"sort_status": "Stan"
|
||||
},
|
||||
"invite_sent": "Zaproszenia wysłane",
|
||||
"goals": {
|
||||
"heading": "Cele uczenia się",
|
||||
"description": "Wyznaczaj mierzalne cele obejmujące wiele kursów dla każdego uczestnika tego programu.",
|
||||
"add": "Nowy cel",
|
||||
"empty_title": "Nie ma jeszcze żadnych celów",
|
||||
"empty_description": "Utwórz cel, aby śledzić postępy zespołu na wielu kursach.",
|
||||
"created": "Cel stworzony",
|
||||
"updated": "Cel zaktualizowany",
|
||||
"deleted": "Cel usunięty",
|
||||
"delete_confirm": "Usunąć ten cel? Historia statusu poszczególnych uczniów zostanie usunięta.",
|
||||
"lms": {
|
||||
"heading": "Twoje cele",
|
||||
"empty_title": "Brak przypisanych celów",
|
||||
"empty_description": "Kiedy Twoi instruktorzy wyznaczą cele, pojawią się tutaj.",
|
||||
"due_in_days": "Termin za {days} dni",
|
||||
"due_tomorrow": "Termin na jutro",
|
||||
"due_today": "Termin na dzisiaj",
|
||||
"overdue": "Zaległe",
|
||||
"no_deadline": "Brak terminu"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "na torze",
|
||||
"completed_short": "ukończone",
|
||||
"overdue_short": "spóźnione",
|
||||
"at_risk_short": "zagrożone"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Ukończono",
|
||||
"in_progress": "W toku",
|
||||
"at_risk": "Ryzykowne",
|
||||
"overdue": "Zaległe",
|
||||
"not_started": "Nie rozpoczęte",
|
||||
"waived": "Zrzeczenie się"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolutna data",
|
||||
"relative_to_join": "Dotyczy dołączania uczniów",
|
||||
"recurring": "Powtarzające się",
|
||||
"none": "Brak terminu"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Ukończ wszystkie wybrane kursy",
|
||||
"n_of_m": "Ukończ N z M wybranych kursów",
|
||||
"score": "Zdobądź wynik powyżej progu na każdym kursie",
|
||||
"pass_rate": "Próg przepustowości zespołu",
|
||||
"readiness": "Gotowość (bez terminu)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nowy cel",
|
||||
"edit_title": "Edytuj cel",
|
||||
"title_label": "Tytuł",
|
||||
"title_placeholder": "np. Rampa pokładowa",
|
||||
"description_label": "Opis",
|
||||
"description_placeholder": "Opcjonalny kontekst dla zespołu",
|
||||
"type_label": "Wpisz",
|
||||
"courses_label": "Kursy",
|
||||
"required_count_label": "Wymagana liczba kursów",
|
||||
"score_threshold_label": "Wynik zaliczenia na ucznia (%)",
|
||||
"team_pass_rate_label": "Wskaźnik zdawalności zespołu (%)",
|
||||
"deadline_label": "Termin",
|
||||
"deadline_date_label": "Termin ostateczny",
|
||||
"relative_days_label": "Dni od momentu dołączenia ucznia",
|
||||
"recurring_months_label": "Powtarzaj co (miesiące)",
|
||||
"reminders_label": "Przypomnienia (dni przed terminem)",
|
||||
"reminders_helper": "Oddzielone przecinkami. Przykład: 7, 3, 1",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Poproś mnie o pomoc w przygotowaniu treści kursu — szkicuj lekcje, generuj pytania lub planuj kurs na podstawie dokumentu.",
|
||||
"input_placeholder": "Zapytaj asystenta AI...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Gotowy",
|
||||
"failed": "Przesyłanie nie powiodło się"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Programy",
|
||||
"page_title": "Programy",
|
||||
"create": "Nowy cohort",
|
||||
"create_success": "Cohort stworzony",
|
||||
"update_success": "Cohort zaktualizowany",
|
||||
"delete_success": "Cohort usunięty",
|
||||
"members_added": "Dodano członków",
|
||||
"member_removed": "Członek usunięty",
|
||||
"course_added": "Kurs dodany",
|
||||
"course_removed": "Kurs usunięty",
|
||||
"empty_title": "Nie ma jeszcze cohortów",
|
||||
"empty_description": "Utwórz swój pierwszy cohort certyfikacji lub zgodności.",
|
||||
"settings": {
|
||||
"general": "Generał",
|
||||
"name_label": "Imię",
|
||||
"description_label": "Opis",
|
||||
"status_label": "Stan",
|
||||
"save": "Zapisz zmiany",
|
||||
"danger_zone": "Strefa zagrożenia",
|
||||
"delete": "Usuń cohort",
|
||||
"delete_description": "Po usunięciu tego programu i wszystkich jego danych nie można odzyskać.",
|
||||
"confirm_delete": "Czy na pewno chcesz usunąć ten cohort? Tego nie można cofnąć."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Nie ma jeszcze cohortów",
|
||||
"empty_description": "Nie jesteś zarejestrowany w żadnym programie."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Zaproś",
|
||||
"invite_modal_title": "Zaproś członków",
|
||||
"invite_modal_description": "Dodaj osoby do tego programu przez e-mail.",
|
||||
"email_label": "E-mail",
|
||||
"role_label": "Rola",
|
||||
"add_another": "Dodaj kolejny",
|
||||
"invite_submit": "Wyślij zaproszenia",
|
||||
"empty_title": "Nie ma jeszcze członków",
|
||||
"empty_description": "Zaproś ludzi do przyłączenia się do tego programu."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Dodaj kurs",
|
||||
"add_modal_title": "Dodaj kurs do programu",
|
||||
"select_course": "Wybierz kurs",
|
||||
"select_placeholder": "Wybierz kurs…",
|
||||
"empty_title": "Nie ma jeszcze żadnych kursów",
|
||||
"empty_description": "Dodaj kursy do tego programu.",
|
||||
"select_courses": "Wybierz kursy",
|
||||
"search_placeholder": "Wyszukaj kursy",
|
||||
"no_available_courses": "Brak dostępnych kursów do dodania.",
|
||||
"no_matching_courses": "Żaden kurs nie pasuje do Twojego wyszukiwania.",
|
||||
"no_matching_cohort_courses": "Żaden kurs programowy nie pasuje do Twojego wyszukiwania.",
|
||||
"pagination_status": "Strona {page} z {totalPages}",
|
||||
"published_access_callout_title": "Dostęp studencki",
|
||||
"published_access_callout_description": "Studenci mają dostęp wyłącznie do opublikowanych kursów. Kursy mogą znajdować się w tym programie dla Twojego zespołu, gdy nie są jeszcze opublikowane, ale pozostają ukryte przed uczniami, dopóki ich nie opublikujesz."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nowy post",
|
||||
"post_placeholder": "Co masz na myśli?",
|
||||
"post_submit": "Opublikuj",
|
||||
"empty_title": "Nie ma jeszcze żadnych postów",
|
||||
"empty_description": "Bądź pierwszą osobą, która coś opublikuje.",
|
||||
"heading": "Kanał informacyjny",
|
||||
"make_a_post": "Napisz post",
|
||||
"edit_post": "Edytuj post",
|
||||
"editor_placeholder": "Udostępnij aktualizację swojemu programowi",
|
||||
"cancel": "Anuluj",
|
||||
"pinned": "Przypięty",
|
||||
"delete_feed_title": "Usuń wpis",
|
||||
"delete_feed_description": "Czy na pewno chcesz usunąć ten post?",
|
||||
"confirm_delete": "Usuń",
|
||||
"user_avatar_alt": "Awatar użytkownika",
|
||||
"comments": {
|
||||
"comment": "komentarz",
|
||||
"showing_of": "Wyświetlanie {shown} z {total}",
|
||||
"hide": "Ukryj komentarze",
|
||||
"placeholder": "Dodaj komentarz do programu",
|
||||
"submit_aria": "Prześlij komentarz",
|
||||
"delete": "Usuń"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Przypnij",
|
||||
"unpin": "Odepnij",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Utwórz cohort",
|
||||
"description": "Dodaj nowy cohort certyfikacji lub zgodności.",
|
||||
"name_label": "Imię",
|
||||
"name_placeholder": "np. Certyfikat bezpieczeństwa OSHA",
|
||||
"description_label": "Opis",
|
||||
"description_placeholder": "O czym jest ten cohort?",
|
||||
"submit": "Utwórz"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Kanał informacyjny",
|
||||
"courses": "Kursy",
|
||||
"people": "Ludzie",
|
||||
"settings": "Ustawienia"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktywny",
|
||||
"inactive": "Nieaktywny",
|
||||
"archived": "Zarchiwizowane"
|
||||
},
|
||||
"description": "Daj grupie osób dostęp do wielu kursów, np. zajęć",
|
||||
"search_placeholder": "Wyszukaj programy",
|
||||
"multiple_added": "Dodano kursy",
|
||||
"no_matching_cohorts": "Żaden cohort nie pasuje do Twoich filtrów.",
|
||||
"card": {
|
||||
"courses": "Kursy",
|
||||
"students": "Studenci"
|
||||
},
|
||||
"table": {
|
||||
"name": "Imię",
|
||||
"courses": "Kursy",
|
||||
"students": "Studenci",
|
||||
"status": "Stan",
|
||||
"description": "Opis"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtruj",
|
||||
"clear_all": "Wyczyść wszystko",
|
||||
"popover_title": "Filtry",
|
||||
"sort": "Sortuj",
|
||||
"order": "Zamów",
|
||||
"status": "Stan",
|
||||
"ascending": "rosnąco",
|
||||
"descending": "Opis",
|
||||
"sort_name": "Imię",
|
||||
"sort_courses": "Kursy",
|
||||
"sort_students": "Studenci",
|
||||
"sort_status": "Stan"
|
||||
},
|
||||
"invite_sent": "Zaproszenia wysłane",
|
||||
"goals": {
|
||||
"heading": "Cele uczenia się",
|
||||
"description": "Wyznaczaj mierzalne cele obejmujące wiele kursów dla każdego uczestnika tego programu.",
|
||||
"add": "Nowy cel",
|
||||
"empty_title": "Nie ma jeszcze żadnych celów",
|
||||
"empty_description": "Utwórz cel, aby śledzić postępy zespołu na wielu kursach.",
|
||||
"created": "Cel stworzony",
|
||||
"updated": "Cel zaktualizowany",
|
||||
"deleted": "Cel usunięty",
|
||||
"delete_confirm": "Usunąć ten cel? Historia statusu poszczególnych uczniów zostanie usunięta.",
|
||||
"lms": {
|
||||
"heading": "Twoje cele",
|
||||
"empty_title": "Brak przypisanych celów",
|
||||
"empty_description": "Kiedy Twoi instruktorzy wyznaczą cele, pojawią się tutaj.",
|
||||
"due_in_days": "Termin za {days} dni",
|
||||
"due_tomorrow": "Termin na jutro",
|
||||
"due_today": "Termin na dzisiaj",
|
||||
"overdue": "Zaległe",
|
||||
"no_deadline": "Brak terminu"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "na torze",
|
||||
"completed_short": "ukończone",
|
||||
"overdue_short": "spóźnione",
|
||||
"at_risk_short": "zagrożone"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Ukończono",
|
||||
"in_progress": "W toku",
|
||||
"at_risk": "Ryzykowne",
|
||||
"overdue": "Zaległe",
|
||||
"not_started": "Nie rozpoczęte",
|
||||
"waived": "Zrzeczenie się"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Absolutna data",
|
||||
"relative_to_join": "Dotyczy dołączania uczniów",
|
||||
"recurring": "Powtarzające się",
|
||||
"none": "Brak terminu"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Ukończ wszystkie wybrane kursy",
|
||||
"n_of_m": "Ukończ N z M wybranych kursów",
|
||||
"score": "Zdobądź wynik powyżej progu na każdym kursie",
|
||||
"pass_rate": "Próg przepustowości zespołu",
|
||||
"readiness": "Gotowość (bez terminu)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Nowy cel",
|
||||
"edit_title": "Edytuj cel",
|
||||
"title_label": "Tytuł",
|
||||
"title_placeholder": "np. Rampa pokładowa",
|
||||
"description_label": "Opis",
|
||||
"description_placeholder": "Opcjonalny kontekst dla zespołu",
|
||||
"type_label": "Wpisz",
|
||||
"courses_label": "Kursy",
|
||||
"required_count_label": "Wymagana liczba kursów",
|
||||
"score_threshold_label": "Wynik zaliczenia na ucznia (%)",
|
||||
"team_pass_rate_label": "Wskaźnik zdawalności zespołu (%)",
|
||||
"deadline_label": "Termin",
|
||||
"deadline_date_label": "Termin ostateczny",
|
||||
"relative_days_label": "Dni od momentu dołączenia ucznia",
|
||||
"recurring_months_label": "Powtarzaj co (miesiące)",
|
||||
"reminders_label": "Przypomnienia (dni przed terminem)",
|
||||
"reminders_helper": "Oddzielone przecinkami. Przykład: 7, 3, 1",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Chave de automação girada. Salve o novo segredo agora porque ele não será mostrado novamente.",
|
||||
"revoked": "Chave de automação revogada."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Ocorreu um erro ao reagir à postagem",
|
||||
"news_feed_fail": "Falha ao buscar feed de notícias do programa",
|
||||
"post_create_failed": "Falha ao criar postagem",
|
||||
"post_update_failed": "Falha ao atualizar a postagem",
|
||||
"code": {
|
||||
"program_forbidden": "Você deve ser membro deste programa para executar esta ação.",
|
||||
"program_member_check_failed": "Não foi possível verificar sua associação ao programa. Tente novamente.",
|
||||
"program_not_found": "Este programa não foi encontrado.",
|
||||
"program_create_failed": "Não foi possível criar o programa.",
|
||||
"program_update_failed": "Não foi possível atualizar o programa.",
|
||||
"program_delete_failed": "Não foi possível excluir o programa.",
|
||||
"program_member_not_found": "Esse membro do programa não foi encontrado.",
|
||||
"program_newsfeed_not_found": "Essa postagem não foi encontrada.",
|
||||
"program_newsfeed_comment_not_found": "Esse comentário não foi encontrado."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Comentário excluído",
|
||||
"comment_added": "Comentário adicionado",
|
||||
"feed_delete_success": "Postagem excluída com sucesso",
|
||||
"post_created": "Postagem criada com sucesso",
|
||||
"post_updated": "Postagem atualizada com sucesso"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "Pacote de AI credits adicionado ao seu saldo.",
|
||||
"checkout_failed": "Não foi possível iniciar o checkout. Tente novamente."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "Versão 1080p adicionada",
|
||||
"generate_1080_failed": "Não foi possível gerar a renderização em 1080p"
|
||||
},
|
||||
"social_auth_not_enabled": "Este provedor de autenticação não está habilitado."
|
||||
"social_auth_not_enabled": "Este provedor de autenticação não está habilitado.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Ocorreu um erro ao reagir à postagem",
|
||||
"news_feed_fail": "Falha ao buscar feed de notícias do programa",
|
||||
"post_create_failed": "Falha ao criar postagem",
|
||||
"post_update_failed": "Falha ao atualizar a postagem",
|
||||
"code": {
|
||||
"cohort_forbidden": "Você deve ser membro deste programa para executar esta ação.",
|
||||
"cohort_member_check_failed": "Não foi possível verificar sua associação ao programa. Tente novamente.",
|
||||
"cohort_not_found": "Este programa não foi encontrado.",
|
||||
"cohort_create_failed": "Não foi possível criar o programa.",
|
||||
"cohort_update_failed": "Não foi possível atualizar o programa.",
|
||||
"cohort_delete_failed": "Não foi possível excluir o programa.",
|
||||
"cohort_member_not_found": "Esse membro do programa não foi encontrado.",
|
||||
"cohort_newsfeed_not_found": "Essa postagem não foi encontrada.",
|
||||
"cohort_newsfeed_comment_not_found": "Esse comentário não foi encontrado."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Comentário excluído",
|
||||
"comment_added": "Comentário adicionado",
|
||||
"feed_delete_success": "Postagem excluída com sucesso",
|
||||
"post_created": "Postagem criada com sucesso",
|
||||
"post_updated": "Postagem atualizada com sucesso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Explore nossos cursos",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Explorar",
|
||||
"help": "Ajuda",
|
||||
"settings": "Configurações",
|
||||
"programs": "Programas"
|
||||
"cohorts": "Programas"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Atualize seu plano para gerar certificados",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Todos os cursos",
|
||||
"select_courses": "Selecione cursos específicos",
|
||||
"select_courses_placeholder": "Escolha cursos...",
|
||||
"program_access": "Acesso ao programa",
|
||||
"no_programs": "Sem atribuição de programa",
|
||||
"all_programs": "Todos os programas",
|
||||
"select_programs": "Selecione programas específicos",
|
||||
"select_programs_placeholder": "Escolha programas...",
|
||||
"send_email": "Enviar e-mail de convite para usuários importados",
|
||||
"submit": "Importar usuários",
|
||||
"back": "Voltar ao público",
|
||||
"snackbar_success": "{imported} novos convites, {assigned} estudantes existentes com acesso ao curso, {pendingInvitesRenewed} convites pendentes renovados ({emailsSent} e-mails enviados)",
|
||||
"snackbar_no_emails": "Insira pelo menos um endereço de e-mail",
|
||||
"snackbar_failed": "Falha ao importar usuários",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Acesso ao programa",
|
||||
"no_cohorts": "Sem atribuição de programa",
|
||||
"all_cohorts": "Todos os programas",
|
||||
"select_cohorts": "Selecione programas específicos",
|
||||
"select_cohorts_placeholder": "Escolha programas..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Atualize agora",
|
||||
"tags": "Etiquetas",
|
||||
"automation": "Automação",
|
||||
"programs": "Programas",
|
||||
"widgets": "Widgets",
|
||||
"stats": "Estatísticas",
|
||||
"analytics": "Análise",
|
||||
"compliance": "Conformidade"
|
||||
"compliance": "Conformidade",
|
||||
"cohorts": "Programas"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Gerenciador de mídia - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Programas",
|
||||
"page_title": "Programas",
|
||||
"create": "Novo programa",
|
||||
"create_success": "Programa criado",
|
||||
"update_success": "Programa atualizado",
|
||||
"delete_success": "Programa excluído",
|
||||
"members_added": "Membros adicionados",
|
||||
"member_removed": "Membro removido",
|
||||
"course_added": "Curso adicionado",
|
||||
"course_removed": "Curso removido",
|
||||
"empty_title": "Ainda não há programas",
|
||||
"empty_description": "Crie seu primeiro programa de certificação ou conformidade.",
|
||||
"settings": {
|
||||
"general": "Geral",
|
||||
"name_label": "Nome",
|
||||
"description_label": "Descrição",
|
||||
"status_label": "Estado",
|
||||
"save": "Salvar alterações",
|
||||
"danger_zone": "Zona de perigo",
|
||||
"delete": "Excluir programa",
|
||||
"delete_description": "Uma vez excluído, este programa e todos os seus dados não poderão ser recuperados.",
|
||||
"confirm_delete": "Tem certeza de que deseja excluir este programa? Isto não pode ser desfeito."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Ainda não há programas",
|
||||
"empty_description": "Você não está inscrito em nenhum programa."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Convidar",
|
||||
"invite_modal_title": "Convidar membros",
|
||||
"invite_modal_description": "Adicione pessoas a este programa por e-mail.",
|
||||
"email_label": "E-mail",
|
||||
"role_label": "Função",
|
||||
"add_another": "Adicionar outro",
|
||||
"invite_submit": "Enviar convites",
|
||||
"empty_title": "Ainda não há membros",
|
||||
"empty_description": "Convide pessoas para participar deste programa."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Adicionar curso",
|
||||
"add_modal_title": "Adicionar curso ao programa",
|
||||
"select_course": "Selecione um curso",
|
||||
"select_placeholder": "Escolha um curso…",
|
||||
"empty_title": "Ainda não há cursos",
|
||||
"empty_description": "Adicione cursos a este programa.",
|
||||
"select_courses": "Selecione os cursos",
|
||||
"search_placeholder": "Pesquisar cursos",
|
||||
"no_available_courses": "Não há cursos disponíveis para adicionar.",
|
||||
"no_matching_courses": "Nenhum curso corresponde à sua pesquisa.",
|
||||
"no_matching_program_courses": "Nenhum curso do programa corresponde à sua pesquisa.",
|
||||
"pagination_status": "Página {page} de {totalPages}",
|
||||
"published_access_callout_title": "Acesso de estudante",
|
||||
"published_access_callout_description": "Os alunos só têm acesso aos cursos publicados. Os cursos podem estar neste programa para sua equipe enquanto ainda não foram publicados, enquanto permanecem ocultos aos alunos até que você os publique."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nova postagem",
|
||||
"post_placeholder": "O que você está pensando?",
|
||||
"post_submit": "Postar",
|
||||
"empty_title": "Nenhuma postagem ainda",
|
||||
"empty_description": "Seja o primeiro a postar algo.",
|
||||
"heading": "Feed de notícias",
|
||||
"make_a_post": "Faça uma postagem",
|
||||
"edit_post": "Editar postagem",
|
||||
"editor_placeholder": "Compartilhe uma atualização com seu programa",
|
||||
"cancel": "Cancelar",
|
||||
"pinned": "Fixado",
|
||||
"delete_feed_title": "Excluir postagem",
|
||||
"delete_feed_description": "Tem certeza de que deseja excluir esta postagem?",
|
||||
"confirm_delete": "Excluir",
|
||||
"user_avatar_alt": "Avatar do usuário",
|
||||
"comments": {
|
||||
"comment": "comentar",
|
||||
"showing_of": "Exibindo {shown} de {total}",
|
||||
"hide": "Ocultar comentários",
|
||||
"placeholder": "Adicionar comentário do programa",
|
||||
"submit_aria": "Enviar comentário",
|
||||
"delete": "Excluir"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Fixar",
|
||||
"unpin": "Liberar",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Criar programa",
|
||||
"description": "Adicione um novo programa de certificação ou conformidade.",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "por exemplo Certificação de Segurança OSHA",
|
||||
"description_label": "Descrição",
|
||||
"description_placeholder": "Sobre o que é este programa?",
|
||||
"submit": "Criar"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Feed de notícias",
|
||||
"courses": "Cursos",
|
||||
"people": "Pessoas",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"status": {
|
||||
"active": "Ativo",
|
||||
"inactive": "Inativo",
|
||||
"archived": "Arquivado"
|
||||
},
|
||||
"description": "Conceda a um grupo de pessoas acesso a vários cursos, como uma aula",
|
||||
"search_placeholder": "Programas de pesquisa",
|
||||
"multiple_added": "Cursos adicionados",
|
||||
"no_matching_programs": "Nenhum programa corresponde aos seus filtros.",
|
||||
"card": {
|
||||
"courses": "Cursos",
|
||||
"students": "Alunos"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nome",
|
||||
"courses": "Cursos",
|
||||
"students": "Alunos",
|
||||
"status": "Estado",
|
||||
"description": "Descrição"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtro",
|
||||
"clear_all": "Limpar tudo",
|
||||
"popover_title": "Filtros",
|
||||
"sort": "Classificar",
|
||||
"order": "Pedido",
|
||||
"status": "Estado",
|
||||
"ascending": "Asc",
|
||||
"descending": "Descrição",
|
||||
"sort_name": "Nome",
|
||||
"sort_courses": "Cursos",
|
||||
"sort_students": "Alunos",
|
||||
"sort_status": "Estado"
|
||||
},
|
||||
"invite_sent": "Convites enviados",
|
||||
"goals": {
|
||||
"heading": "Metas de aprendizagem",
|
||||
"description": "Defina metas mensuráveis que abrangem vários cursos para todos neste programa.",
|
||||
"add": "Nova meta",
|
||||
"empty_title": "Ainda não há metas",
|
||||
"empty_description": "Crie uma meta para acompanhar o progresso da equipe em vários cursos.",
|
||||
"created": "Meta criada",
|
||||
"updated": "Meta atualizada",
|
||||
"deleted": "Meta excluída",
|
||||
"delete_confirm": "Excluir esta meta? O histórico de status por aluno será removido.",
|
||||
"lms": {
|
||||
"heading": "Seus objetivos",
|
||||
"empty_title": "Nenhuma meta atribuída",
|
||||
"empty_description": "Quando seus instrutores definirem metas, elas aparecerão aqui.",
|
||||
"due_in_days": "Vencimento em {days} dias",
|
||||
"due_tomorrow": "Vencimento amanhã",
|
||||
"due_today": "Vencimento hoje",
|
||||
"overdue": "Atrasado",
|
||||
"no_deadline": "Sem prazo"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "no caminho certo",
|
||||
"completed_short": "concluído",
|
||||
"overdue_short": "atrasado",
|
||||
"at_risk_short": "em risco"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Concluído",
|
||||
"in_progress": "Em andamento",
|
||||
"at_risk": "Em risco",
|
||||
"overdue": "Atrasado",
|
||||
"not_started": "Não iniciado",
|
||||
"waived": "Dispensado"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Data absoluta",
|
||||
"relative_to_join": "Em relação à adesão do aluno",
|
||||
"recurring": "Recorrente",
|
||||
"none": "Sem prazo"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Conclua todos os cursos selecionados",
|
||||
"n_of_m": "Conclua N de M cursos selecionados",
|
||||
"score": "Pontuação acima do limite em todos os cursos",
|
||||
"pass_rate": "Limite de taxa de aprovação da equipe",
|
||||
"readiness": "Prontidão (sem prazo)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Novo objetivo",
|
||||
"edit_title": "Editar meta",
|
||||
"title_label": "Título",
|
||||
"title_placeholder": "por exemplo Rampa de integração",
|
||||
"description_label": "Descrição",
|
||||
"description_placeholder": "Contexto opcional para a equipe",
|
||||
"type_label": "Tipo",
|
||||
"courses_label": "Cursos",
|
||||
"required_count_label": "Número de cursos necessários",
|
||||
"score_threshold_label": "Pontuação de aprovação por aluno (%)",
|
||||
"team_pass_rate_label": "Taxa de aprovação da equipe (%)",
|
||||
"deadline_label": "Prazo",
|
||||
"deadline_date_label": "Data limite",
|
||||
"relative_days_label": "Dias a partir da entrada do aluno",
|
||||
"recurring_months_label": "Repita a cada (meses)",
|
||||
"reminders_label": "Lembretes (dias antes do vencimento)",
|
||||
"reminders_helper": "Separados por vírgula. Exemplo: 7, 3, 1",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Peça-me para ajudar com o conteúdo do seu curso – redigir lições, gerar perguntas ou planejar um curso a partir de um documento.",
|
||||
"input_placeholder": "Pergunte ao assistente de IA...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Pronto",
|
||||
"failed": "Falha no upload"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Programas",
|
||||
"page_title": "Programas",
|
||||
"create": "Novo programa",
|
||||
"create_success": "Programa criado",
|
||||
"update_success": "Programa atualizado",
|
||||
"delete_success": "Programa excluído",
|
||||
"members_added": "Membros adicionados",
|
||||
"member_removed": "Membro removido",
|
||||
"course_added": "Curso adicionado",
|
||||
"course_removed": "Curso removido",
|
||||
"empty_title": "Ainda não há programas",
|
||||
"empty_description": "Crie seu primeiro programa de certificação ou conformidade.",
|
||||
"settings": {
|
||||
"general": "Geral",
|
||||
"name_label": "Nome",
|
||||
"description_label": "Descrição",
|
||||
"status_label": "Estado",
|
||||
"save": "Salvar alterações",
|
||||
"danger_zone": "Zona de perigo",
|
||||
"delete": "Excluir programa",
|
||||
"delete_description": "Uma vez excluído, este programa e todos os seus dados não poderão ser recuperados.",
|
||||
"confirm_delete": "Tem certeza de que deseja excluir este programa? Isto não pode ser desfeito."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Ainda não há programas",
|
||||
"empty_description": "Você não está inscrito em nenhum programa."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Convidar",
|
||||
"invite_modal_title": "Convidar membros",
|
||||
"invite_modal_description": "Adicione pessoas a este programa por e-mail.",
|
||||
"email_label": "E-mail",
|
||||
"role_label": "Função",
|
||||
"add_another": "Adicionar outro",
|
||||
"invite_submit": "Enviar convites",
|
||||
"empty_title": "Ainda não há membros",
|
||||
"empty_description": "Convide pessoas para participar deste programa."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Adicionar curso",
|
||||
"add_modal_title": "Adicionar curso ao programa",
|
||||
"select_course": "Selecione um curso",
|
||||
"select_placeholder": "Escolha um curso…",
|
||||
"empty_title": "Ainda não há cursos",
|
||||
"empty_description": "Adicione cursos a este programa.",
|
||||
"select_courses": "Selecione os cursos",
|
||||
"search_placeholder": "Pesquisar cursos",
|
||||
"no_available_courses": "Não há cursos disponíveis para adicionar.",
|
||||
"no_matching_courses": "Nenhum curso corresponde à sua pesquisa.",
|
||||
"no_matching_cohort_courses": "Nenhum curso do programa corresponde à sua pesquisa.",
|
||||
"pagination_status": "Página {page} de {totalPages}",
|
||||
"published_access_callout_title": "Acesso de estudante",
|
||||
"published_access_callout_description": "Os alunos só têm acesso aos cursos publicados. Os cursos podem estar neste programa para sua equipe enquanto ainda não foram publicados, enquanto permanecem ocultos aos alunos até que você os publique."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Nova postagem",
|
||||
"post_placeholder": "O que você está pensando?",
|
||||
"post_submit": "Postar",
|
||||
"empty_title": "Nenhuma postagem ainda",
|
||||
"empty_description": "Seja o primeiro a postar algo.",
|
||||
"heading": "Feed de notícias",
|
||||
"make_a_post": "Faça uma postagem",
|
||||
"edit_post": "Editar postagem",
|
||||
"editor_placeholder": "Compartilhe uma atualização com seu programa",
|
||||
"cancel": "Cancelar",
|
||||
"pinned": "Fixado",
|
||||
"delete_feed_title": "Excluir postagem",
|
||||
"delete_feed_description": "Tem certeza de que deseja excluir esta postagem?",
|
||||
"confirm_delete": "Excluir",
|
||||
"user_avatar_alt": "Avatar do usuário",
|
||||
"comments": {
|
||||
"comment": "comentar",
|
||||
"showing_of": "Exibindo {shown} de {total}",
|
||||
"hide": "Ocultar comentários",
|
||||
"placeholder": "Adicionar comentário do programa",
|
||||
"submit_aria": "Enviar comentário",
|
||||
"delete": "Excluir"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Fixar",
|
||||
"unpin": "Liberar",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Criar programa",
|
||||
"description": "Adicione um novo programa de certificação ou conformidade.",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "por exemplo Certificação de Segurança OSHA",
|
||||
"description_label": "Descrição",
|
||||
"description_placeholder": "Sobre o que é este programa?",
|
||||
"submit": "Criar"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Feed de notícias",
|
||||
"courses": "Cursos",
|
||||
"people": "Pessoas",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"status": {
|
||||
"active": "Ativo",
|
||||
"inactive": "Inativo",
|
||||
"archived": "Arquivado"
|
||||
},
|
||||
"description": "Conceda a um grupo de pessoas acesso a vários cursos, como uma aula",
|
||||
"search_placeholder": "Programas de pesquisa",
|
||||
"multiple_added": "Cursos adicionados",
|
||||
"no_matching_cohorts": "Nenhum programa corresponde aos seus filtros.",
|
||||
"card": {
|
||||
"courses": "Cursos",
|
||||
"students": "Alunos"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nome",
|
||||
"courses": "Cursos",
|
||||
"students": "Alunos",
|
||||
"status": "Estado",
|
||||
"description": "Descrição"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Filtro",
|
||||
"clear_all": "Limpar tudo",
|
||||
"popover_title": "Filtros",
|
||||
"sort": "Classificar",
|
||||
"order": "Pedido",
|
||||
"status": "Estado",
|
||||
"ascending": "Asc",
|
||||
"descending": "Descrição",
|
||||
"sort_name": "Nome",
|
||||
"sort_courses": "Cursos",
|
||||
"sort_students": "Alunos",
|
||||
"sort_status": "Estado"
|
||||
},
|
||||
"invite_sent": "Convites enviados",
|
||||
"goals": {
|
||||
"heading": "Metas de aprendizagem",
|
||||
"description": "Defina metas mensuráveis que abrangem vários cursos para todos neste programa.",
|
||||
"add": "Nova meta",
|
||||
"empty_title": "Ainda não há metas",
|
||||
"empty_description": "Crie uma meta para acompanhar o progresso da equipe em vários cursos.",
|
||||
"created": "Meta criada",
|
||||
"updated": "Meta atualizada",
|
||||
"deleted": "Meta excluída",
|
||||
"delete_confirm": "Excluir esta meta? O histórico de status por aluno será removido.",
|
||||
"lms": {
|
||||
"heading": "Seus objetivos",
|
||||
"empty_title": "Nenhuma meta atribuída",
|
||||
"empty_description": "Quando seus instrutores definirem metas, elas aparecerão aqui.",
|
||||
"due_in_days": "Vencimento em {days} dias",
|
||||
"due_tomorrow": "Vencimento amanhã",
|
||||
"due_today": "Vencimento hoje",
|
||||
"overdue": "Atrasado",
|
||||
"no_deadline": "Sem prazo"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "no caminho certo",
|
||||
"completed_short": "concluído",
|
||||
"overdue_short": "atrasado",
|
||||
"at_risk_short": "em risco"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Concluído",
|
||||
"in_progress": "Em andamento",
|
||||
"at_risk": "Em risco",
|
||||
"overdue": "Atrasado",
|
||||
"not_started": "Não iniciado",
|
||||
"waived": "Dispensado"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Data absoluta",
|
||||
"relative_to_join": "Em relação à adesão do aluno",
|
||||
"recurring": "Recorrente",
|
||||
"none": "Sem prazo"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Conclua todos os cursos selecionados",
|
||||
"n_of_m": "Conclua N de M cursos selecionados",
|
||||
"score": "Pontuação acima do limite em todos os cursos",
|
||||
"pass_rate": "Limite de taxa de aprovação da equipe",
|
||||
"readiness": "Prontidão (sem prazo)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Novo objetivo",
|
||||
"edit_title": "Editar meta",
|
||||
"title_label": "Título",
|
||||
"title_placeholder": "por exemplo Rampa de integração",
|
||||
"description_label": "Descrição",
|
||||
"description_placeholder": "Contexto opcional para a equipe",
|
||||
"type_label": "Tipo",
|
||||
"courses_label": "Cursos",
|
||||
"required_count_label": "Número de cursos necessários",
|
||||
"score_threshold_label": "Pontuação de aprovação por aluno (%)",
|
||||
"team_pass_rate_label": "Taxa de aprovação da equipe (%)",
|
||||
"deadline_label": "Prazo",
|
||||
"deadline_date_label": "Data limite",
|
||||
"relative_days_label": "Dias a partir da entrada do aluno",
|
||||
"recurring_months_label": "Repita a cada (meses)",
|
||||
"reminders_label": "Lembretes (dias antes do vencimento)",
|
||||
"reminders_helper": "Separados por vírgula. Exemplo: 7, 3, 1",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Ключ автоматики повернут. Сохраните новый секрет сейчас, потому что он больше не будет отображаться.",
|
||||
"revoked": "Ключ автоматизации отозван."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Произошла ошибка при реакции на сообщение",
|
||||
"news_feed_fail": "Не удалось получить ленту новостей программы.",
|
||||
"post_create_failed": "Не удалось создать публикацию",
|
||||
"post_update_failed": "Не удалось обновить публикацию",
|
||||
"code": {
|
||||
"program_forbidden": "Для выполнения этого действия вы должны быть участником этой программы.",
|
||||
"program_member_check_failed": "Нам не удалось подтвердить ваше участие в программе. Попробуйте еще раз.",
|
||||
"program_not_found": "Эту программу не удалось найти.",
|
||||
"program_create_failed": "Не удалось создать программу.",
|
||||
"program_update_failed": "Не удалось обновить программу.",
|
||||
"program_delete_failed": "Не удалось удалить программу.",
|
||||
"program_member_not_found": "Этот участник программы не найден.",
|
||||
"program_newsfeed_not_found": "Этот пост не найден.",
|
||||
"program_newsfeed_comment_not_found": "Этот комментарий не найден."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Комментарий удален",
|
||||
"comment_added": "Комментарий добавлен",
|
||||
"feed_delete_success": "Сообщение успешно удалено",
|
||||
"post_created": "Сообщение успешно создано",
|
||||
"post_updated": "Сообщение успешно обновлено"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "Пакет AI credits добавлен на баланс.",
|
||||
"checkout_failed": "Не удалось начать оплату. Попробуйте снова."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "добавлено воспроизведение 1080p",
|
||||
"generate_1080_failed": "Не удалось создать воспроизведение 1080p."
|
||||
},
|
||||
"social_auth_not_enabled": "Этот поставщик аутентификации не включен."
|
||||
"social_auth_not_enabled": "Этот поставщик аутентификации не включен.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Произошла ошибка при реакции на сообщение",
|
||||
"news_feed_fail": "Не удалось получить ленту новостей программы.",
|
||||
"post_create_failed": "Не удалось создать публикацию",
|
||||
"post_update_failed": "Не удалось обновить публикацию",
|
||||
"code": {
|
||||
"cohort_forbidden": "Для выполнения этого действия вы должны быть участником этой программы.",
|
||||
"cohort_member_check_failed": "Нам не удалось подтвердить ваше участие в программе. Попробуйте еще раз.",
|
||||
"cohort_not_found": "Эту программу не удалось найти.",
|
||||
"cohort_create_failed": "Не удалось создать программу.",
|
||||
"cohort_update_failed": "Не удалось обновить программу.",
|
||||
"cohort_delete_failed": "Не удалось удалить программу.",
|
||||
"cohort_member_not_found": "Этот участник программы не найден.",
|
||||
"cohort_newsfeed_not_found": "Этот пост не найден.",
|
||||
"cohort_newsfeed_comment_not_found": "Этот комментарий не найден."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Комментарий удален",
|
||||
"comment_added": "Комментарий добавлен",
|
||||
"feed_delete_success": "Сообщение успешно удалено",
|
||||
"post_created": "Сообщение успешно создано",
|
||||
"post_updated": "Сообщение успешно обновлено"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Ознакомьтесь с нашими курсами",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Исследуйте",
|
||||
"help": "Помощь",
|
||||
"settings": "Настройки",
|
||||
"programs": "Программы"
|
||||
"cohorts": "Программы"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Обновите свой план для создания сертификатов",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Все курсы",
|
||||
"select_courses": "Выберите конкретные курсы",
|
||||
"select_courses_placeholder": "Выбирайте курсы...",
|
||||
"program_access": "Доступ к программам",
|
||||
"no_programs": "Без назначения программы",
|
||||
"all_programs": "Все программы",
|
||||
"select_programs": "Выберите конкретные программы",
|
||||
"select_programs_placeholder": "Выберите программы...",
|
||||
"send_email": "Отправить электронное письмо с приглашением импортированным пользователям",
|
||||
"submit": "Импортировать пользователей",
|
||||
"back": "Вернуться к аудитории",
|
||||
"snackbar_success": "{imported} новых приглашений, {assigned} существующих студентов с доступом к курсу, {pendingInvitesRenewed} обновлённых ожидающих приглашений (отправлено писем: {emailsSent})",
|
||||
"snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты",
|
||||
"snackbar_failed": "Не удалось импортировать пользователей.",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Доступ к программам",
|
||||
"no_cohorts": "Без назначения программы",
|
||||
"all_cohorts": "Все программы",
|
||||
"select_cohorts": "Выберите конкретные программы",
|
||||
"select_cohorts_placeholder": "Выберите программы..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Обновите сейчас",
|
||||
"tags": "Теги",
|
||||
"automation": "Автоматизация",
|
||||
"programs": "Программы",
|
||||
"widgets": "Виджеты",
|
||||
"stats": "Статистика",
|
||||
"analytics": "Аналитика",
|
||||
"compliance": "Соответствие"
|
||||
"compliance": "Соответствие",
|
||||
"cohorts": "Программы"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Медиа-менеджер — ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Программы",
|
||||
"page_title": "Программы",
|
||||
"create": "Новая программа",
|
||||
"create_success": "Программа создана",
|
||||
"update_success": "Программа обновлена",
|
||||
"delete_success": "Программа удалена",
|
||||
"members_added": "Добавлены участники",
|
||||
"member_removed": "Участник удален",
|
||||
"course_added": "Курс добавлен",
|
||||
"course_removed": "Курс удален.",
|
||||
"empty_title": "Пока нет программ",
|
||||
"empty_description": "Создайте свою первую программу сертификации или соответствия требованиям.",
|
||||
"settings": {
|
||||
"general": "Общий",
|
||||
"name_label": "Имя",
|
||||
"description_label": "Описание",
|
||||
"status_label": "Статус",
|
||||
"save": "Сохранить изменения",
|
||||
"danger_zone": "Опасная зона",
|
||||
"delete": "Удалить программу",
|
||||
"delete_description": "После удаления эту программу и все ее данные невозможно восстановить.",
|
||||
"confirm_delete": "Вы уверены, что хотите удалить эту программу? Это невозможно отменить."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Пока нет программ",
|
||||
"empty_description": "Вы не были зарегистрированы ни в одной программе."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Пригласить",
|
||||
"invite_modal_title": "Пригласить участников",
|
||||
"invite_modal_description": "Добавляйте людей в эту программу по электронной почте.",
|
||||
"email_label": "электронная почта",
|
||||
"role_label": "Роль",
|
||||
"add_another": "Добавить еще",
|
||||
"invite_submit": "Отправить приглашения",
|
||||
"empty_title": "Пока нет участников",
|
||||
"empty_description": "Пригласите людей присоединиться к этой программе."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Добавить курс",
|
||||
"add_modal_title": "Добавить курс в программу",
|
||||
"select_course": "Выберите курс",
|
||||
"select_placeholder": "Выбрать курс…",
|
||||
"empty_title": "Курсов пока нет",
|
||||
"empty_description": "Добавьте курсы в эту программу.",
|
||||
"select_courses": "Выберите курсы",
|
||||
"search_placeholder": "Поиск курсов",
|
||||
"no_available_courses": "Нет доступных курсов для добавления.",
|
||||
"no_matching_courses": "Нет курсов, соответствующих вашему запросу.",
|
||||
"no_matching_program_courses": "Никакие курсы программы не соответствуют вашему запросу.",
|
||||
"pagination_status": "Страница {page} из {totalPages}",
|
||||
"published_access_callout_title": "Доступ для студентов",
|
||||
"published_access_callout_description": "Студенты имеют доступ только к опубликованным курсам. Курсы могут быть включены в эту программу для вашей команды, будучи еще неопубликованными, и оставаться скрытыми от учащихся, пока вы их не опубликуете."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Новое сообщение",
|
||||
"post_placeholder": "Что у тебя на уме?",
|
||||
"post_submit": "Сообщение",
|
||||
"empty_title": "Пока нет сообщений",
|
||||
"empty_description": "Будьте первым, кто опубликует что-либо.",
|
||||
"heading": "Лента новостей",
|
||||
"make_a_post": "Сделать публикацию",
|
||||
"edit_post": "Редактировать сообщение",
|
||||
"editor_placeholder": "Поделитесь обновлением своей программы",
|
||||
"cancel": "Отмена",
|
||||
"pinned": "Закреплено",
|
||||
"delete_feed_title": "Удалить сообщение",
|
||||
"delete_feed_description": "Вы уверены, что хотите удалить этот пост?",
|
||||
"confirm_delete": "Удалить",
|
||||
"user_avatar_alt": "Аватар пользователя",
|
||||
"comments": {
|
||||
"comment": "комментарий",
|
||||
"showing_of": "Показаны {shown} из {total}",
|
||||
"hide": "Скрыть комментарии",
|
||||
"placeholder": "Добавить комментарий к программе",
|
||||
"submit_aria": "Отправить комментарий",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Пин-код",
|
||||
"unpin": "Открепить",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Создать программу",
|
||||
"description": "Добавьте новую программу сертификации или соответствия.",
|
||||
"name_label": "Имя",
|
||||
"name_placeholder": "например Сертификация безопасности OSHA",
|
||||
"description_label": "Описание",
|
||||
"description_placeholder": "О чем эта программа?",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Лента новостей",
|
||||
"courses": "Курсы",
|
||||
"people": "Люди",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"status": {
|
||||
"active": "Активный",
|
||||
"inactive": "Неактивный",
|
||||
"archived": "В архиве"
|
||||
},
|
||||
"description": "Предоставьте группе людей доступ к нескольким курсам, например к классу.",
|
||||
"search_placeholder": "Поиск программ",
|
||||
"multiple_added": "Курсы добавлены",
|
||||
"no_matching_programs": "Ни одна программа не соответствует вашим фильтрам.",
|
||||
"card": {
|
||||
"courses": "Курсы",
|
||||
"students": "Студенты"
|
||||
},
|
||||
"table": {
|
||||
"name": "Имя",
|
||||
"courses": "Курсы",
|
||||
"students": "Студенты",
|
||||
"status": "Статус",
|
||||
"description": "Описание"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Фильтр",
|
||||
"clear_all": "Очистить все",
|
||||
"popover_title": "Фильтры",
|
||||
"sort": "Сортировать",
|
||||
"order": "Заказать",
|
||||
"status": "Статус",
|
||||
"ascending": "по возрастанию",
|
||||
"descending": "Описание",
|
||||
"sort_name": "Имя",
|
||||
"sort_courses": "Курсы",
|
||||
"sort_students": "Студенты",
|
||||
"sort_status": "Статус"
|
||||
},
|
||||
"invite_sent": "Приглашения отправлены",
|
||||
"goals": {
|
||||
"heading": "Цели обучения",
|
||||
"description": "Установите измеримые цели, охватывающие несколько курсов для всех участников этой программы.",
|
||||
"add": "Новая цель",
|
||||
"empty_title": "Целей пока нет",
|
||||
"empty_description": "Создайте цель для отслеживания прогресса команды на нескольких курсах.",
|
||||
"created": "Цель создана",
|
||||
"updated": "Цель обновлена",
|
||||
"deleted": "Цель удалена",
|
||||
"delete_confirm": "Удалить эту цель? История статуса каждого учащегося будет удалена.",
|
||||
"lms": {
|
||||
"heading": "Ваши цели",
|
||||
"empty_title": "Цели не назначены",
|
||||
"empty_description": "Когда ваши преподаватели поставят цели, они появятся здесь.",
|
||||
"due_in_days": "Срок оплаты: {days} дн.",
|
||||
"due_tomorrow": "Срок завтра",
|
||||
"due_today": "Срок оплаты сегодня",
|
||||
"overdue": "Просрочено",
|
||||
"no_deadline": "Нет срока"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "на ходу",
|
||||
"completed_short": "завершено",
|
||||
"overdue_short": "просроченный",
|
||||
"at_risk_short": "в опасности"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Завершено",
|
||||
"in_progress": "В процессе",
|
||||
"at_risk": "В зоне риска",
|
||||
"overdue": "Просрочено",
|
||||
"not_started": "Не запущено",
|
||||
"waived": "Отказано"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Абсолютная дата",
|
||||
"relative_to_join": "Относительно присоединения студента",
|
||||
"recurring": "повторяющийся",
|
||||
"none": "Нет срока"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Завершить все выбранные курсы",
|
||||
"n_of_m": "Завершить выбранные курсы N из M",
|
||||
"score": "Оценка выше порога на каждом курсе",
|
||||
"pass_rate": "Порог проходимости команды",
|
||||
"readiness": "Готовность (без сроков)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Новая цель",
|
||||
"edit_title": "Изменить цель",
|
||||
"title_label": "Название",
|
||||
"title_placeholder": "например Посадочная рампа",
|
||||
"description_label": "Описание",
|
||||
"description_placeholder": "Дополнительный контекст для команды",
|
||||
"type_label": "Тип",
|
||||
"courses_label": "Курсы",
|
||||
"required_count_label": "Необходимое количество курсов",
|
||||
"score_threshold_label": "Проходной балл на одного учащегося (%)",
|
||||
"team_pass_rate_label": "Процент проходов команды (%)",
|
||||
"deadline_label": "Крайний срок",
|
||||
"deadline_date_label": "Крайний срок",
|
||||
"relative_days_label": "Дни с момента присоединения студента",
|
||||
"recurring_months_label": "Повторять каждые (месяцев)",
|
||||
"reminders_label": "Напоминания (за несколько дней до наступления срока)",
|
||||
"reminders_helper": "Через запятую. Пример: 7, 3, 1",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Попросите меня помочь с содержанием вашего курса: набросайте уроки, сформулируйте вопросы или спланируйте курс на основе документа.",
|
||||
"input_placeholder": "Спросите ИИ-помощника...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Готово",
|
||||
"failed": "Загрузка не удалась"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Программы",
|
||||
"page_title": "Программы",
|
||||
"create": "Новая программа",
|
||||
"create_success": "Программа создана",
|
||||
"update_success": "Программа обновлена",
|
||||
"delete_success": "Программа удалена",
|
||||
"members_added": "Добавлены участники",
|
||||
"member_removed": "Участник удален",
|
||||
"course_added": "Курс добавлен",
|
||||
"course_removed": "Курс удален.",
|
||||
"empty_title": "Пока нет программ",
|
||||
"empty_description": "Создайте свою первую программу сертификации или соответствия требованиям.",
|
||||
"settings": {
|
||||
"general": "Общий",
|
||||
"name_label": "Имя",
|
||||
"description_label": "Описание",
|
||||
"status_label": "Статус",
|
||||
"save": "Сохранить изменения",
|
||||
"danger_zone": "Опасная зона",
|
||||
"delete": "Удалить программу",
|
||||
"delete_description": "После удаления эту программу и все ее данные невозможно восстановить.",
|
||||
"confirm_delete": "Вы уверены, что хотите удалить эту программу? Это невозможно отменить."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Пока нет программ",
|
||||
"empty_description": "Вы не были зарегистрированы ни в одной программе."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Пригласить",
|
||||
"invite_modal_title": "Пригласить участников",
|
||||
"invite_modal_description": "Добавляйте людей в эту программу по электронной почте.",
|
||||
"email_label": "электронная почта",
|
||||
"role_label": "Роль",
|
||||
"add_another": "Добавить еще",
|
||||
"invite_submit": "Отправить приглашения",
|
||||
"empty_title": "Пока нет участников",
|
||||
"empty_description": "Пригласите людей присоединиться к этой программе."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Добавить курс",
|
||||
"add_modal_title": "Добавить курс в программу",
|
||||
"select_course": "Выберите курс",
|
||||
"select_placeholder": "Выбрать курс…",
|
||||
"empty_title": "Курсов пока нет",
|
||||
"empty_description": "Добавьте курсы в эту программу.",
|
||||
"select_courses": "Выберите курсы",
|
||||
"search_placeholder": "Поиск курсов",
|
||||
"no_available_courses": "Нет доступных курсов для добавления.",
|
||||
"no_matching_courses": "Нет курсов, соответствующих вашему запросу.",
|
||||
"no_matching_cohort_courses": "Никакие курсы программы не соответствуют вашему запросу.",
|
||||
"pagination_status": "Страница {page} из {totalPages}",
|
||||
"published_access_callout_title": "Доступ для студентов",
|
||||
"published_access_callout_description": "Студенты имеют доступ только к опубликованным курсам. Курсы могут быть включены в эту программу для вашей команды, будучи еще неопубликованными, и оставаться скрытыми от учащихся, пока вы их не опубликуете."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Новое сообщение",
|
||||
"post_placeholder": "Что у тебя на уме?",
|
||||
"post_submit": "Сообщение",
|
||||
"empty_title": "Пока нет сообщений",
|
||||
"empty_description": "Будьте первым, кто опубликует что-либо.",
|
||||
"heading": "Лента новостей",
|
||||
"make_a_post": "Сделать публикацию",
|
||||
"edit_post": "Редактировать сообщение",
|
||||
"editor_placeholder": "Поделитесь обновлением своей программы",
|
||||
"cancel": "Отмена",
|
||||
"pinned": "Закреплено",
|
||||
"delete_feed_title": "Удалить сообщение",
|
||||
"delete_feed_description": "Вы уверены, что хотите удалить этот пост?",
|
||||
"confirm_delete": "Удалить",
|
||||
"user_avatar_alt": "Аватар пользователя",
|
||||
"comments": {
|
||||
"comment": "комментарий",
|
||||
"showing_of": "Показаны {shown} из {total}",
|
||||
"hide": "Скрыть комментарии",
|
||||
"placeholder": "Добавить комментарий к программе",
|
||||
"submit_aria": "Отправить комментарий",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Пин-код",
|
||||
"unpin": "Открепить",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Создать программу",
|
||||
"description": "Добавьте новую программу сертификации или соответствия.",
|
||||
"name_label": "Имя",
|
||||
"name_placeholder": "например Сертификация безопасности OSHA",
|
||||
"description_label": "Описание",
|
||||
"description_placeholder": "О чем эта программа?",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Лента новостей",
|
||||
"courses": "Курсы",
|
||||
"people": "Люди",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"status": {
|
||||
"active": "Активный",
|
||||
"inactive": "Неактивный",
|
||||
"archived": "В архиве"
|
||||
},
|
||||
"description": "Предоставьте группе людей доступ к нескольким курсам, например к классу.",
|
||||
"search_placeholder": "Поиск программ",
|
||||
"multiple_added": "Курсы добавлены",
|
||||
"no_matching_cohorts": "Ни одна программа не соответствует вашим фильтрам.",
|
||||
"card": {
|
||||
"courses": "Курсы",
|
||||
"students": "Студенты"
|
||||
},
|
||||
"table": {
|
||||
"name": "Имя",
|
||||
"courses": "Курсы",
|
||||
"students": "Студенты",
|
||||
"status": "Статус",
|
||||
"description": "Описание"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Фильтр",
|
||||
"clear_all": "Очистить все",
|
||||
"popover_title": "Фильтры",
|
||||
"sort": "Сортировать",
|
||||
"order": "Заказать",
|
||||
"status": "Статус",
|
||||
"ascending": "по возрастанию",
|
||||
"descending": "Описание",
|
||||
"sort_name": "Имя",
|
||||
"sort_courses": "Курсы",
|
||||
"sort_students": "Студенты",
|
||||
"sort_status": "Статус"
|
||||
},
|
||||
"invite_sent": "Приглашения отправлены",
|
||||
"goals": {
|
||||
"heading": "Цели обучения",
|
||||
"description": "Установите измеримые цели, охватывающие несколько курсов для всех участников этой программы.",
|
||||
"add": "Новая цель",
|
||||
"empty_title": "Целей пока нет",
|
||||
"empty_description": "Создайте цель для отслеживания прогресса команды на нескольких курсах.",
|
||||
"created": "Цель создана",
|
||||
"updated": "Цель обновлена",
|
||||
"deleted": "Цель удалена",
|
||||
"delete_confirm": "Удалить эту цель? История статуса каждого учащегося будет удалена.",
|
||||
"lms": {
|
||||
"heading": "Ваши цели",
|
||||
"empty_title": "Цели не назначены",
|
||||
"empty_description": "Когда ваши преподаватели поставят цели, они появятся здесь.",
|
||||
"due_in_days": "Срок оплаты: {days} дн.",
|
||||
"due_tomorrow": "Срок завтра",
|
||||
"due_today": "Срок оплаты сегодня",
|
||||
"overdue": "Просрочено",
|
||||
"no_deadline": "Нет срока"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "на ходу",
|
||||
"completed_short": "завершено",
|
||||
"overdue_short": "просроченный",
|
||||
"at_risk_short": "в опасности"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Завершено",
|
||||
"in_progress": "В процессе",
|
||||
"at_risk": "В зоне риска",
|
||||
"overdue": "Просрочено",
|
||||
"not_started": "Не запущено",
|
||||
"waived": "Отказано"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Абсолютная дата",
|
||||
"relative_to_join": "Относительно присоединения студента",
|
||||
"recurring": "повторяющийся",
|
||||
"none": "Нет срока"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Завершить все выбранные курсы",
|
||||
"n_of_m": "Завершить выбранные курсы N из M",
|
||||
"score": "Оценка выше порога на каждом курсе",
|
||||
"pass_rate": "Порог проходимости команды",
|
||||
"readiness": "Готовность (без сроков)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Новая цель",
|
||||
"edit_title": "Изменить цель",
|
||||
"title_label": "Название",
|
||||
"title_placeholder": "например Посадочная рампа",
|
||||
"description_label": "Описание",
|
||||
"description_placeholder": "Дополнительный контекст для команды",
|
||||
"type_label": "Тип",
|
||||
"courses_label": "Курсы",
|
||||
"required_count_label": "Необходимое количество курсов",
|
||||
"score_threshold_label": "Проходной балл на одного учащегося (%)",
|
||||
"team_pass_rate_label": "Процент проходов команды (%)",
|
||||
"deadline_label": "Крайний срок",
|
||||
"deadline_date_label": "Крайний срок",
|
||||
"relative_days_label": "Дни с момента присоединения студента",
|
||||
"recurring_months_label": "Повторять каждые (месяцев)",
|
||||
"reminders_label": "Напоминания (за несколько дней до наступления срока)",
|
||||
"reminders_helper": "Через запятую. Пример: 7, 3, 1",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,32 +386,6 @@
|
||||
"rotated": "Phím tự động đã được xoay. Hãy lưu lại bí mật mới ngay bây giờ vì nó sẽ không được hiển thị lại.",
|
||||
"revoked": "Khóa tự động hóa đã bị thu hồi."
|
||||
},
|
||||
"program": {
|
||||
"error": {
|
||||
"reaction_error": "Đã xảy ra lỗi khi phản hồi bài viết",
|
||||
"news_feed_fail": "Không thể tìm nạp nguồn cấp tin tức của chương trình",
|
||||
"post_create_failed": "Không tạo được bài đăng",
|
||||
"post_update_failed": "Không thể cập nhật bài đăng",
|
||||
"code": {
|
||||
"program_forbidden": "Bạn phải là thành viên của chương trình này để thực hiện hành động này.",
|
||||
"program_member_check_failed": "Chúng tôi không thể xác minh tư cách thành viên chương trình của bạn. Hãy thử lại.",
|
||||
"program_not_found": "Chương trình này không thể được tìm thấy.",
|
||||
"program_create_failed": "Không thể tạo chương trình.",
|
||||
"program_update_failed": "Không thể cập nhật chương trình.",
|
||||
"program_delete_failed": "Không thể xóa chương trình.",
|
||||
"program_member_not_found": "Không thể tìm thấy thành viên chương trình đó.",
|
||||
"program_newsfeed_not_found": "Không thể tìm thấy bài đăng đó.",
|
||||
"program_newsfeed_comment_not_found": "Bình luận đó không thể được tìm thấy."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Đã xóa bình luận",
|
||||
"comment_added": "Đã thêm nhận xét",
|
||||
"feed_delete_success": "Đã xóa bài đăng thành công",
|
||||
"post_created": "Bài viết được tạo thành công",
|
||||
"post_updated": "Đã cập nhật bài đăng thành công"
|
||||
}
|
||||
},
|
||||
"ai_credits": {
|
||||
"purchase_success": "Gói AI credits đã được thêm vào số dư của bạn.",
|
||||
"checkout_failed": "Không thể bắt đầu thanh toán. Vui lòng thử lại."
|
||||
@@ -445,7 +419,33 @@
|
||||
"generate_1080_completed": "Đã thêm phiên bản 1080p",
|
||||
"generate_1080_failed": "Không thể tạo phiên bản 1080p"
|
||||
},
|
||||
"social_auth_not_enabled": "Nhà cung cấp xác thực này chưa được kích hoạt."
|
||||
"social_auth_not_enabled": "Nhà cung cấp xác thực này chưa được kích hoạt.",
|
||||
"cohort": {
|
||||
"error": {
|
||||
"reaction_error": "Đã xảy ra lỗi khi phản hồi bài viết",
|
||||
"news_feed_fail": "Không thể tìm nạp nguồn cấp tin tức của chương trình",
|
||||
"post_create_failed": "Không tạo được bài đăng",
|
||||
"post_update_failed": "Không thể cập nhật bài đăng",
|
||||
"code": {
|
||||
"cohort_forbidden": "Bạn phải là thành viên của chương trình này để thực hiện hành động này.",
|
||||
"cohort_member_check_failed": "Chúng tôi không thể xác minh tư cách thành viên chương trình của bạn. Hãy thử lại.",
|
||||
"cohort_not_found": "Chương trình này không thể được tìm thấy.",
|
||||
"cohort_create_failed": "Không thể tạo chương trình.",
|
||||
"cohort_update_failed": "Không thể cập nhật chương trình.",
|
||||
"cohort_delete_failed": "Không thể xóa chương trình.",
|
||||
"cohort_member_not_found": "Không thể tìm thấy thành viên chương trình đó.",
|
||||
"cohort_newsfeed_not_found": "Không thể tìm thấy bài đăng đó.",
|
||||
"cohort_newsfeed_comment_not_found": "Bình luận đó không thể được tìm thấy."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"comment_deleted": "Đã xóa bình luận",
|
||||
"comment_added": "Đã thêm nhận xét",
|
||||
"feed_delete_success": "Đã xóa bài đăng thành công",
|
||||
"post_created": "Bài viết được tạo thành công",
|
||||
"post_updated": "Đã cập nhật bài đăng thành công"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"heading": "Khám phá các khóa học của chúng tôi",
|
||||
@@ -492,7 +492,7 @@
|
||||
"explore": "Khám phá",
|
||||
"help": "Trợ giúp",
|
||||
"settings": "Cài đặt",
|
||||
"programs": "Chương trình"
|
||||
"cohorts": "Chương trình"
|
||||
},
|
||||
"upgrade": {
|
||||
"certificate": "Nâng cấp gói của bạn để tạo chứng chỉ",
|
||||
@@ -683,18 +683,18 @@
|
||||
"all_courses": "Tất cả các khóa học",
|
||||
"select_courses": "Chọn các khóa học cụ thể",
|
||||
"select_courses_placeholder": "Chọn khóa học...",
|
||||
"program_access": "Quyền truy cập chương trình",
|
||||
"no_programs": "Không gán chương trình",
|
||||
"all_programs": "Tất cả chương trình",
|
||||
"select_programs": "Chọn các chương trình cụ thể",
|
||||
"select_programs_placeholder": "Chọn chương trình...",
|
||||
"send_email": "Gửi email lời mời tới người dùng đã nhập",
|
||||
"submit": "Nhập người dùng",
|
||||
"back": "Quay lại Đối tượng",
|
||||
"snackbar_success": "{imported} lời mời mới, {assigned} học viên hiện có được quyền truy cập khóa học, {pendingInvitesRenewed} lời mời đang chờ được gia hạn ({emailsSent} email đã gửi)",
|
||||
"snackbar_no_emails": "Vui lòng nhập ít nhất một địa chỉ email",
|
||||
"snackbar_failed": "Không thể nhập người dùng",
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like."
|
||||
"page_subtitle": "Add many people at once by email, then assign courses or programs if you like.",
|
||||
"cohort_access": "Quyền truy cập chương trình",
|
||||
"no_cohorts": "Không gán chương trình",
|
||||
"all_cohorts": "Tất cả chương trình",
|
||||
"select_cohorts": "Chọn các chương trình cụ thể",
|
||||
"select_cohorts_placeholder": "Chọn chương trình..."
|
||||
},
|
||||
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||
"user_analytics": {
|
||||
@@ -2801,11 +2801,11 @@
|
||||
"upgrade": "Nâng cấp ngay",
|
||||
"tags": "Thẻ",
|
||||
"automation": "Tự động hóa",
|
||||
"programs": "Chương trình",
|
||||
"widgets": "Widget",
|
||||
"stats": "Thống kê",
|
||||
"analytics": "Phân tích",
|
||||
"compliance": "Tuân thủ"
|
||||
"compliance": "Tuân thủ",
|
||||
"cohorts": "Chương trình"
|
||||
},
|
||||
"media_manager": {
|
||||
"page_title": "Quản lý truyền thông - ClassroomIO",
|
||||
@@ -3940,212 +3940,6 @@
|
||||
"page_subtitle": "Automate ClassroomIO with thousands of apps through Zapier. Coming soon."
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Chương trình",
|
||||
"page_title": "Chương trình",
|
||||
"create": "Chương trình mới",
|
||||
"create_success": "Đã tạo chương trình",
|
||||
"update_success": "Đã cập nhật chương trình",
|
||||
"delete_success": "Chương trình đã bị xóa",
|
||||
"members_added": "Đã thêm thành viên",
|
||||
"member_removed": "Thành viên đã bị xóa",
|
||||
"course_added": "Đã thêm khóa học",
|
||||
"course_removed": "Đã xóa khóa học",
|
||||
"empty_title": "Chưa có chương trình nào",
|
||||
"empty_description": "Tạo chương trình chứng nhận hoặc tuân thủ đầu tiên của bạn.",
|
||||
"settings": {
|
||||
"general": "chung",
|
||||
"name_label": "Tên",
|
||||
"description_label": "Mô tả",
|
||||
"status_label": "Trạng thái",
|
||||
"save": "Lưu thay đổi",
|
||||
"danger_zone": "Vùng nguy hiểm",
|
||||
"delete": "Xóa chương trình",
|
||||
"delete_description": "Sau khi xóa, chương trình này và tất cả dữ liệu của nó sẽ không thể phục hồi được.",
|
||||
"confirm_delete": "Bạn có chắc chắn muốn xóa chương trình này? Điều này không thể hoàn tác được."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Chưa có chương trình nào",
|
||||
"empty_description": "Bạn chưa được ghi danh vào bất kỳ chương trình nào."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Mời",
|
||||
"invite_modal_title": "Mời thành viên",
|
||||
"invite_modal_description": "Thêm người vào chương trình này qua email.",
|
||||
"email_label": "Email",
|
||||
"role_label": "Vai trò",
|
||||
"add_another": "Thêm cái khác",
|
||||
"invite_submit": "Gửi lời mời",
|
||||
"empty_title": "Chưa có thành viên nào",
|
||||
"empty_description": "Mời mọi người tham gia chương trình này."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Thêm khóa học",
|
||||
"add_modal_title": "Thêm khóa học vào chương trình",
|
||||
"select_course": "Chọn một khóa học",
|
||||
"select_placeholder": "Chọn một khóa học…",
|
||||
"empty_title": "Chưa có khóa học nào",
|
||||
"empty_description": "Thêm các khóa học vào chương trình này.",
|
||||
"select_courses": "Chọn khóa học",
|
||||
"search_placeholder": "Tìm kiếm khóa học",
|
||||
"no_available_courses": "Không có khóa học có sẵn để thêm.",
|
||||
"no_matching_courses": "Không có khóa học phù hợp với tìm kiếm của bạn.",
|
||||
"no_matching_program_courses": "Không có chương trình khóa học phù hợp với tìm kiếm của bạn.",
|
||||
"pagination_status": "Trang {page} trong tổng số {totalPages}",
|
||||
"published_access_callout_title": "Quyền truy cập của sinh viên",
|
||||
"published_access_callout_description": "Sinh viên chỉ có quyền truy cập vào các khóa học được xuất bản. Các khóa học có thể có trong chương trình này cho nhóm của bạn trong khi vẫn chưa được xuất bản và các khóa học đó sẽ bị ẩn với sinh viên cho đến khi bạn xuất bản chúng."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Bài đăng mới",
|
||||
"post_placeholder": "Bạn đang nghĩ gì thế?",
|
||||
"post_submit": "bài đăng",
|
||||
"empty_title": "Chưa có bài viết nào",
|
||||
"empty_description": "Hãy là người đầu tiên đăng một cái gì đó.",
|
||||
"heading": "Nguồn cấp tin tức",
|
||||
"make_a_post": "Tạo một bài đăng",
|
||||
"edit_post": "Chỉnh sửa bài đăng",
|
||||
"editor_placeholder": "Chia sẻ bản cập nhật với chương trình của bạn",
|
||||
"cancel": "Hủy bỏ",
|
||||
"pinned": "Đã ghim",
|
||||
"delete_feed_title": "Xóa bài đăng",
|
||||
"delete_feed_description": "Bạn có chắc chắn muốn xóa bài đăng này?",
|
||||
"confirm_delete": "Xóa",
|
||||
"user_avatar_alt": "Hình đại diện của người dùng",
|
||||
"comments": {
|
||||
"comment": "bình luận",
|
||||
"showing_of": "Đang hiển thị {shown} trong tổng số {total}",
|
||||
"hide": "Ẩn bình luận",
|
||||
"placeholder": "Thêm bình luận chương trình",
|
||||
"submit_aria": "Gửi bình luận",
|
||||
"delete": "Xóa"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Ghim",
|
||||
"unpin": "Bỏ ghim",
|
||||
"edit": "Chỉnh sửa",
|
||||
"delete": "Xóa"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Tạo chương trình",
|
||||
"description": "Thêm một chương trình chứng nhận hoặc tuân thủ mới.",
|
||||
"name_label": "Tên",
|
||||
"name_placeholder": "ví dụ: Chứng nhận an toàn OSHA",
|
||||
"description_label": "Mô tả",
|
||||
"description_placeholder": "Chương trình này nói về cái gì?",
|
||||
"submit": "Tạo"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Nguồn cấp tin tức",
|
||||
"courses": "Khóa học",
|
||||
"people": "mọi người",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"status": {
|
||||
"active": "Đang hoạt động",
|
||||
"inactive": "Không hoạt động",
|
||||
"archived": "Đã lưu trữ"
|
||||
},
|
||||
"description": "Cấp cho một nhóm người quyền truy cập vào nhiều khóa học, chẳng hạn như một lớp học",
|
||||
"search_placeholder": "Tìm kiếm chương trình",
|
||||
"multiple_added": "Đã thêm khóa học",
|
||||
"no_matching_programs": "Không có chương trình nào phù hợp với bộ lọc của bạn.",
|
||||
"card": {
|
||||
"courses": "Khóa học",
|
||||
"students": "Sinh viên"
|
||||
},
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"courses": "Khóa học",
|
||||
"students": "Sinh viên",
|
||||
"status": "Trạng thái",
|
||||
"description": "Mô tả"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Lọc",
|
||||
"clear_all": "Xóa tất cả",
|
||||
"popover_title": "Bộ lọc",
|
||||
"sort": "Sắp xếp",
|
||||
"order": "Đặt hàng",
|
||||
"status": "Trạng thái",
|
||||
"ascending": "tăng dần",
|
||||
"descending": "mô tả",
|
||||
"sort_name": "Tên",
|
||||
"sort_courses": "Khóa học",
|
||||
"sort_students": "Sinh viên",
|
||||
"sort_status": "Trạng thái"
|
||||
},
|
||||
"invite_sent": "Đã gửi lời mời",
|
||||
"goals": {
|
||||
"heading": "Mục tiêu học tập",
|
||||
"description": "Đặt các mục tiêu có thể đo lường được trải rộng trên nhiều khóa học cho mọi người trong chương trình này.",
|
||||
"add": "Mục tiêu mới",
|
||||
"empty_title": "Chưa có mục tiêu nào",
|
||||
"empty_description": "Tạo mục tiêu để theo dõi tiến độ của nhóm qua nhiều khóa học.",
|
||||
"created": "Đã tạo mục tiêu",
|
||||
"updated": "Đã cập nhật mục tiêu",
|
||||
"deleted": "Đã xóa mục tiêu",
|
||||
"delete_confirm": "Xóa mục tiêu này? Lịch sử trạng thái của mỗi học sinh sẽ bị xóa.",
|
||||
"lms": {
|
||||
"heading": "Mục tiêu của bạn",
|
||||
"empty_title": "Không có mục tiêu nào được giao",
|
||||
"empty_description": "Khi người hướng dẫn của bạn đặt mục tiêu, họ sẽ hiển thị ở đây.",
|
||||
"due_in_days": "Đến hạn sau {days} ngày nữa",
|
||||
"due_tomorrow": "Hạn chót vào ngày mai",
|
||||
"due_today": "Hạn chót hôm nay",
|
||||
"overdue": "Quá hạn",
|
||||
"no_deadline": "Không có thời hạn"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "đi đúng hướng",
|
||||
"completed_short": "hoàn thành",
|
||||
"overdue_short": "quá hạn",
|
||||
"at_risk_short": "có nguy cơ"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Đã hoàn thành",
|
||||
"in_progress": "Đang tiến hành",
|
||||
"at_risk": "Có nguy cơ",
|
||||
"overdue": "Quá hạn",
|
||||
"not_started": "Chưa bắt đầu",
|
||||
"waived": "Miễn trừ"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Ngày tuyệt đối",
|
||||
"relative_to_join": "Liên quan đến việc sinh viên tham gia",
|
||||
"recurring": "Định kỳ",
|
||||
"none": "Không có thời hạn"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Hoàn thành tất cả các khóa học đã chọn",
|
||||
"n_of_m": "Hoàn thành N trong số M khóa học đã chọn",
|
||||
"score": "Đạt điểm trên ngưỡng trong mỗi khóa học",
|
||||
"pass_rate": "Ngưỡng tỷ lệ đậu của đội",
|
||||
"readiness": "Sẵn sàng (không có thời hạn)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Mục tiêu mới",
|
||||
"edit_title": "Chỉnh sửa mục tiêu",
|
||||
"title_label": "Tiêu đề",
|
||||
"title_placeholder": "ví dụ: Đoạn đường nối",
|
||||
"description_label": "Mô tả",
|
||||
"description_placeholder": "Bối cảnh tùy chọn cho nhóm",
|
||||
"type_label": "loại",
|
||||
"courses_label": "Khóa học",
|
||||
"required_count_label": "Số lượng khóa học yêu cầu",
|
||||
"score_threshold_label": "Điểm đậu của mỗi học sinh (%)",
|
||||
"team_pass_rate_label": "Tỷ lệ vượt qua của đội (%)",
|
||||
"deadline_label": "thời hạn",
|
||||
"deadline_date_label": "Ngày hết hạn",
|
||||
"relative_days_label": "Ngày kể từ khi học viên tham gia",
|
||||
"recurring_months_label": "Lặp lại mỗi (tháng)",
|
||||
"reminders_label": "Lời nhắc (ngày trước hạn)",
|
||||
"reminders_helper": "Được phân tách bằng dấu phẩy. Ví dụ: 7, 3, 1",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy bỏ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai_assistant": {
|
||||
"empty_state": "Hãy yêu cầu tôi trợ giúp về nội dung khóa học của bạn — soạn thảo bài học, tạo câu hỏi hoặc lên kế hoạch cho khóa học từ một tài liệu.",
|
||||
"input_placeholder": "Hãy hỏi trợ lý AI...",
|
||||
@@ -4794,5 +4588,211 @@
|
||||
"done": "Sẵn sàng",
|
||||
"failed": "Tải lên không thành công"
|
||||
}
|
||||
},
|
||||
"cohorts": {
|
||||
"heading": "Chương trình",
|
||||
"page_title": "Chương trình",
|
||||
"create": "Chương trình mới",
|
||||
"create_success": "Đã tạo chương trình",
|
||||
"update_success": "Đã cập nhật chương trình",
|
||||
"delete_success": "Chương trình đã bị xóa",
|
||||
"members_added": "Đã thêm thành viên",
|
||||
"member_removed": "Thành viên đã bị xóa",
|
||||
"course_added": "Đã thêm khóa học",
|
||||
"course_removed": "Đã xóa khóa học",
|
||||
"empty_title": "Chưa có chương trình nào",
|
||||
"empty_description": "Tạo chương trình chứng nhận hoặc tuân thủ đầu tiên của bạn.",
|
||||
"settings": {
|
||||
"general": "chung",
|
||||
"name_label": "Tên",
|
||||
"description_label": "Mô tả",
|
||||
"status_label": "Trạng thái",
|
||||
"save": "Lưu thay đổi",
|
||||
"danger_zone": "Vùng nguy hiểm",
|
||||
"delete": "Xóa chương trình",
|
||||
"delete_description": "Sau khi xóa, chương trình này và tất cả dữ liệu của nó sẽ không thể phục hồi được.",
|
||||
"confirm_delete": "Bạn có chắc chắn muốn xóa chương trình này? Điều này không thể hoàn tác được."
|
||||
},
|
||||
"lms": {
|
||||
"empty_title": "Chưa có chương trình nào",
|
||||
"empty_description": "Bạn chưa được ghi danh vào bất kỳ chương trình nào."
|
||||
},
|
||||
"people": {
|
||||
"invite": "Mời",
|
||||
"invite_modal_title": "Mời thành viên",
|
||||
"invite_modal_description": "Thêm người vào chương trình này qua email.",
|
||||
"email_label": "Email",
|
||||
"role_label": "Vai trò",
|
||||
"add_another": "Thêm cái khác",
|
||||
"invite_submit": "Gửi lời mời",
|
||||
"empty_title": "Chưa có thành viên nào",
|
||||
"empty_description": "Mời mọi người tham gia chương trình này."
|
||||
},
|
||||
"courses": {
|
||||
"add": "Thêm khóa học",
|
||||
"add_modal_title": "Thêm khóa học vào chương trình",
|
||||
"select_course": "Chọn một khóa học",
|
||||
"select_placeholder": "Chọn một khóa học…",
|
||||
"empty_title": "Chưa có khóa học nào",
|
||||
"empty_description": "Thêm các khóa học vào chương trình này.",
|
||||
"select_courses": "Chọn khóa học",
|
||||
"search_placeholder": "Tìm kiếm khóa học",
|
||||
"no_available_courses": "Không có khóa học có sẵn để thêm.",
|
||||
"no_matching_courses": "Không có khóa học phù hợp với tìm kiếm của bạn.",
|
||||
"no_matching_cohort_courses": "Không có chương trình khóa học phù hợp với tìm kiếm của bạn.",
|
||||
"pagination_status": "Trang {page} trong tổng số {totalPages}",
|
||||
"published_access_callout_title": "Quyền truy cập của sinh viên",
|
||||
"published_access_callout_description": "Sinh viên chỉ có quyền truy cập vào các khóa học được xuất bản. Các khóa học có thể có trong chương trình này cho nhóm của bạn trong khi vẫn chưa được xuất bản và các khóa học đó sẽ bị ẩn với sinh viên cho đến khi bạn xuất bản chúng."
|
||||
},
|
||||
"newsfeed": {
|
||||
"post": "Bài đăng mới",
|
||||
"post_placeholder": "Bạn đang nghĩ gì thế?",
|
||||
"post_submit": "bài đăng",
|
||||
"empty_title": "Chưa có bài viết nào",
|
||||
"empty_description": "Hãy là người đầu tiên đăng một cái gì đó.",
|
||||
"heading": "Nguồn cấp tin tức",
|
||||
"make_a_post": "Tạo một bài đăng",
|
||||
"edit_post": "Chỉnh sửa bài đăng",
|
||||
"editor_placeholder": "Chia sẻ bản cập nhật với chương trình của bạn",
|
||||
"cancel": "Hủy bỏ",
|
||||
"pinned": "Đã ghim",
|
||||
"delete_feed_title": "Xóa bài đăng",
|
||||
"delete_feed_description": "Bạn có chắc chắn muốn xóa bài đăng này?",
|
||||
"confirm_delete": "Xóa",
|
||||
"user_avatar_alt": "Hình đại diện của người dùng",
|
||||
"comments": {
|
||||
"comment": "bình luận",
|
||||
"showing_of": "Đang hiển thị {shown} trong tổng số {total}",
|
||||
"hide": "Ẩn bình luận",
|
||||
"placeholder": "Thêm bình luận chương trình",
|
||||
"submit_aria": "Gửi bình luận",
|
||||
"delete": "Xóa"
|
||||
},
|
||||
"card": {
|
||||
"pin": "Ghim",
|
||||
"unpin": "Bỏ ghim",
|
||||
"edit": "Chỉnh sửa",
|
||||
"delete": "Xóa"
|
||||
}
|
||||
},
|
||||
"create_modal": {
|
||||
"title": "Tạo chương trình",
|
||||
"description": "Thêm một chương trình chứng nhận hoặc tuân thủ mới.",
|
||||
"name_label": "Tên",
|
||||
"name_placeholder": "ví dụ: Chứng nhận an toàn OSHA",
|
||||
"description_label": "Mô tả",
|
||||
"description_placeholder": "Chương trình này nói về cái gì?",
|
||||
"submit": "Tạo"
|
||||
},
|
||||
"sidebar": {
|
||||
"newsfeed": "Nguồn cấp tin tức",
|
||||
"courses": "Khóa học",
|
||||
"people": "mọi người",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"status": {
|
||||
"active": "Đang hoạt động",
|
||||
"inactive": "Không hoạt động",
|
||||
"archived": "Đã lưu trữ"
|
||||
},
|
||||
"description": "Cấp cho một nhóm người quyền truy cập vào nhiều khóa học, chẳng hạn như một lớp học",
|
||||
"search_placeholder": "Tìm kiếm chương trình",
|
||||
"multiple_added": "Đã thêm khóa học",
|
||||
"no_matching_cohorts": "Không có chương trình nào phù hợp với bộ lọc của bạn.",
|
||||
"card": {
|
||||
"courses": "Khóa học",
|
||||
"students": "Sinh viên"
|
||||
},
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"courses": "Khóa học",
|
||||
"students": "Sinh viên",
|
||||
"status": "Trạng thái",
|
||||
"description": "Mô tả"
|
||||
},
|
||||
"filters": {
|
||||
"filter": "Lọc",
|
||||
"clear_all": "Xóa tất cả",
|
||||
"popover_title": "Bộ lọc",
|
||||
"sort": "Sắp xếp",
|
||||
"order": "Đặt hàng",
|
||||
"status": "Trạng thái",
|
||||
"ascending": "tăng dần",
|
||||
"descending": "mô tả",
|
||||
"sort_name": "Tên",
|
||||
"sort_courses": "Khóa học",
|
||||
"sort_students": "Sinh viên",
|
||||
"sort_status": "Trạng thái"
|
||||
},
|
||||
"invite_sent": "Đã gửi lời mời",
|
||||
"goals": {
|
||||
"heading": "Mục tiêu học tập",
|
||||
"description": "Đặt các mục tiêu có thể đo lường được trải rộng trên nhiều khóa học cho mọi người trong chương trình này.",
|
||||
"add": "Mục tiêu mới",
|
||||
"empty_title": "Chưa có mục tiêu nào",
|
||||
"empty_description": "Tạo mục tiêu để theo dõi tiến độ của nhóm qua nhiều khóa học.",
|
||||
"created": "Đã tạo mục tiêu",
|
||||
"updated": "Đã cập nhật mục tiêu",
|
||||
"deleted": "Đã xóa mục tiêu",
|
||||
"delete_confirm": "Xóa mục tiêu này? Lịch sử trạng thái của mỗi học sinh sẽ bị xóa.",
|
||||
"lms": {
|
||||
"heading": "Mục tiêu của bạn",
|
||||
"empty_title": "Không có mục tiêu nào được giao",
|
||||
"empty_description": "Khi người hướng dẫn của bạn đặt mục tiêu, họ sẽ hiển thị ở đây.",
|
||||
"due_in_days": "Đến hạn sau {days} ngày nữa",
|
||||
"due_tomorrow": "Hạn chót vào ngày mai",
|
||||
"due_today": "Hạn chót hôm nay",
|
||||
"overdue": "Quá hạn",
|
||||
"no_deadline": "Không có thời hạn"
|
||||
},
|
||||
"tile": {
|
||||
"on_track": "đi đúng hướng",
|
||||
"completed_short": "hoàn thành",
|
||||
"overdue_short": "quá hạn",
|
||||
"at_risk_short": "có nguy cơ"
|
||||
},
|
||||
"status": {
|
||||
"completed": "Đã hoàn thành",
|
||||
"in_progress": "Đang tiến hành",
|
||||
"at_risk": "Có nguy cơ",
|
||||
"overdue": "Quá hạn",
|
||||
"not_started": "Chưa bắt đầu",
|
||||
"waived": "Miễn trừ"
|
||||
},
|
||||
"deadline_kind": {
|
||||
"absolute": "Ngày tuyệt đối",
|
||||
"relative_to_join": "Liên quan đến việc sinh viên tham gia",
|
||||
"recurring": "Định kỳ",
|
||||
"none": "Không có thời hạn"
|
||||
},
|
||||
"type": {
|
||||
"complete_all": "Hoàn thành tất cả các khóa học đã chọn",
|
||||
"n_of_m": "Hoàn thành N trong số M khóa học đã chọn",
|
||||
"score": "Đạt điểm trên ngưỡng trong mỗi khóa học",
|
||||
"pass_rate": "Ngưỡng tỷ lệ đậu của đội",
|
||||
"readiness": "Sẵn sàng (không có thời hạn)"
|
||||
},
|
||||
"modal": {
|
||||
"create_title": "Mục tiêu mới",
|
||||
"edit_title": "Chỉnh sửa mục tiêu",
|
||||
"title_label": "Tiêu đề",
|
||||
"title_placeholder": "ví dụ: Đoạn đường nối",
|
||||
"description_label": "Mô tả",
|
||||
"description_placeholder": "Bối cảnh tùy chọn cho nhóm",
|
||||
"type_label": "loại",
|
||||
"courses_label": "Khóa học",
|
||||
"required_count_label": "Số lượng khóa học yêu cầu",
|
||||
"score_threshold_label": "Điểm đậu của mỗi học sinh (%)",
|
||||
"team_pass_rate_label": "Tỷ lệ vượt qua của đội (%)",
|
||||
"deadline_label": "thời hạn",
|
||||
"deadline_date_label": "Ngày hết hạn",
|
||||
"relative_days_label": "Ngày kể từ khi học viên tham gia",
|
||||
"recurring_months_label": "Lặp lại mỗi (tháng)",
|
||||
"reminders_label": "Lời nhắc (ngày trước hạn)",
|
||||
"reminders_helper": "Được phân tách bằng dấu phẩy. Ví dụ: 7, 3, 1",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy bỏ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export const load = async ({ params }) => {
|
||||
return {
|
||||
programId: params.id
|
||||
cohortId: params.id
|
||||
};
|
||||
};
|
||||
+16
-16
@@ -3,51 +3,51 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { ProgramSidebar } from '$features/program/components/sidebar';
|
||||
import ProgramHeader from '$features/program/components/program-header.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { CohortSidebar } from '$features/cohort/components/sidebar';
|
||||
import CohortHeader from '$features/cohort/components/cohort-header.svelte';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data.programId || !$profile.id) return;
|
||||
programApi.ensureProgramShell(data.programId);
|
||||
if (!data.cohortId || !$profile.id) return;
|
||||
cohortApi.ensureCohortShell(data.cohortId);
|
||||
});
|
||||
|
||||
const isProgramReady = $derived.by(() => {
|
||||
const isCohortReady = $derived.by(() => {
|
||||
return (
|
||||
programApi.program?.id === data.programId &&
|
||||
programApi.loadedMembersProgramId === data.programId &&
|
||||
programApi.loadedCoursesProgramId === data.programId
|
||||
cohortApi.cohort?.id === data.cohortId &&
|
||||
cohortApi.loadedMembersCohortId === data.cohortId &&
|
||||
cohortApi.loadedCoursesCohortId === data.cohortId
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{programApi.program?.name || 'Program'} - ClassroomIO</title>
|
||||
<title>{cohortApi.cohort?.name || 'Cohort'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Sidebar.Provider data-sveltekit-preload-data="off">
|
||||
<ProgramSidebar path={page.url.pathname} id={data.programId} {isProgramReady} />
|
||||
<CohortSidebar path={page.url.pathname} id={data.cohortId} {isCohortReady} />
|
||||
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<ProgramHeader />
|
||||
<CohortHeader />
|
||||
|
||||
{#if !isProgramReady}
|
||||
{#if !isCohortReady}
|
||||
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
|
||||
<Empty
|
||||
title="Loading program..."
|
||||
description="Please wait while we load your program data."
|
||||
title="Loading cohort..."
|
||||
description="Please wait while we load your cohort data."
|
||||
icon={Spinner}
|
||||
iconClass="h-8 w-8"
|
||||
variant="page"
|
||||
+14
-14
@@ -18,9 +18,9 @@
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import type { OrgCourses } from '$features/course/types';
|
||||
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
|
||||
import AddCourseToCohortModal from '$features/cohort/components/add-course-to-cohort-modal.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
let courseToDeleteId = $state<string | null>(null);
|
||||
let searchValue = $state('');
|
||||
|
||||
const courses = $derived(programApi.courses);
|
||||
const courses = $derived(cohortApi.courses);
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
@@ -72,7 +72,7 @@
|
||||
async function handleRemoveCourse() {
|
||||
if (!courseToDeleteId) return;
|
||||
|
||||
await programApi.removeCourse(data.programId, courseToDeleteId);
|
||||
await cohortApi.removeCourse(data.cohortId, courseToDeleteId);
|
||||
showDeleteModal = false;
|
||||
courseToDeleteId = null;
|
||||
}
|
||||
@@ -93,18 +93,18 @@
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-5xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent class="min-w-0 flex-1">
|
||||
<Page.Title>{$t('programs.sidebar.courses') || 'Courses'}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.courses') || 'Courses'}</Page.Title>
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('programs.courses.published_access_callout_title')}
|
||||
description={$t('programs.courses.published_access_callout_description')}
|
||||
title={$t('cohorts.courses.published_access_callout_title')}
|
||||
description={$t('cohorts.courses.published_access_callout_description')}
|
||||
class="mt-3 w-full"
|
||||
/>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showAddCourseModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
@@ -126,17 +126,17 @@
|
||||
|
||||
{#if filteredCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.courses.empty_title') || 'No courses yet'}
|
||||
title={$t('cohorts.courses.empty_title') || 'No courses yet'}
|
||||
description={searchValue.trim()
|
||||
? $t('programs.courses.no_matching_program_courses') || 'No program courses match your search.'
|
||||
: $t('programs.courses.empty_description') || 'Add courses to this program.'}
|
||||
? $t('cohorts.courses.no_matching_program_courses') || 'No cohort courses match your search.'
|
||||
: $t('cohorts.courses.empty_description') || 'Add courses to this cohort.'}
|
||||
icon={BookOpenIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if !searchValue.trim()}
|
||||
<Button onclick={() => (showAddCourseModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
@@ -206,5 +206,5 @@
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<AddCourseToProgramModal bind:open={showAddCourseModal} programId={data.programId} />
|
||||
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={programApi.isLoading} />
|
||||
<AddCourseToCohortModal bind:open={showAddCourseModal} cohortId={data.cohortId} />
|
||||
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={cohortApi.isLoading} />
|
||||
+32
-32
@@ -5,14 +5,14 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { NewsFeedLoader } from '$features/course/components/newsfeed';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/program/components/newsfeed';
|
||||
import MyGoalsWidget from '$features/program/components/goals/my-goals-widget.svelte';
|
||||
import { programApi, programNewsfeedApi } from '$features/program/api';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/cohort/components/newsfeed';
|
||||
import MyGoalsWidget from '$features/cohort/components/goals/my-goals-widget.svelte';
|
||||
import { cohortApi, cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
@@ -20,29 +20,29 @@
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const programId = data.programId;
|
||||
const cohortId = data.cohortId;
|
||||
|
||||
let edit = $state(false);
|
||||
let editFeed: ProgramNewsfeedItem | null = $state(null);
|
||||
let editFeed: CohortNewsfeedItem | null = $state(null);
|
||||
let isListing = $state(true);
|
||||
let isReactingByFeedId = $state<Record<string, boolean>>({});
|
||||
|
||||
const query = new URLSearchParams(page.url.search);
|
||||
const activeFeedId = query.get('feedId') || '';
|
||||
|
||||
const currentProgramMember = $derived.by(
|
||||
() => programApi.members.find((member) => member.profile?.id === $profile.id) ?? null
|
||||
const currentCohortMember = $derived.by(
|
||||
() => cohortApi.members.find((member) => member.profile?.id === $profile.id) ?? null
|
||||
);
|
||||
|
||||
const reactionAuthorId = $derived(currentProgramMember?.id || '');
|
||||
const reactionAuthorId = $derived(currentCohortMember?.id || '');
|
||||
|
||||
const currentProgramRoleId = $derived(currentProgramMember?.roleId || null);
|
||||
const currentCohortRoleId = $derived(currentCohortMember?.roleId || null);
|
||||
|
||||
const author = $derived({
|
||||
id: $profile.id || '',
|
||||
@@ -52,13 +52,13 @@
|
||||
});
|
||||
|
||||
const canCreateNewsfeed = $derived(
|
||||
Boolean($isOrgAdmin) || currentProgramRoleId === ROLE.ADMIN || currentProgramRoleId === ROLE.TUTOR
|
||||
Boolean($isOrgAdmin) || currentCohortRoleId === ROLE.ADMIN || currentCohortRoleId === ROLE.TUTOR
|
||||
);
|
||||
const canManageNewsfeed = $derived(Boolean($isOrgAdmin) || canCreateNewsfeed);
|
||||
const canComment = $derived(Boolean(reactionAuthorId));
|
||||
|
||||
const sortedFeeds = $derived.by(() => {
|
||||
return [...programNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
|
||||
return [...cohortNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
|
||||
return Number(Boolean(secondFeed.isPinned)) - Number(Boolean(firstFeed.isPinned));
|
||||
});
|
||||
});
|
||||
@@ -67,20 +67,20 @@
|
||||
isListing = true;
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.list(programId);
|
||||
await cohortNewsfeedApi.list(cohortId);
|
||||
} finally {
|
||||
isListing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteComment = async (feedId: string, commentId: string) => {
|
||||
await programNewsfeedApi.deleteComment(programId, feedId, commentId);
|
||||
await cohortNewsfeedApi.deleteComment(cohortId, feedId, commentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = programNewsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
const reactedFeed = cohortNewsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
|
||||
@@ -88,22 +88,22 @@
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.react(programId, feedId, updatedReaction);
|
||||
await cohortNewsfeedApi.react(cohortId, feedId, updatedReaction);
|
||||
} finally {
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: false };
|
||||
}
|
||||
};
|
||||
|
||||
const addNewComment = async (comment: string, feedId: string) => {
|
||||
await programNewsfeedApi.createComment(programId, feedId, comment, author);
|
||||
await cohortNewsfeedApi.createComment(cohortId, feedId, comment, author);
|
||||
};
|
||||
|
||||
const onPin = async (feedId: string, isPinned: boolean | null) => {
|
||||
await programNewsfeedApi.update(programId, feedId, { isPinned: !isPinned });
|
||||
await cohortNewsfeedApi.update(cohortId, feedId, { isPinned: !isPinned });
|
||||
};
|
||||
|
||||
const deleteFeed = async (feedId: string) => {
|
||||
await programNewsfeedApi.delete(programId, feedId);
|
||||
await cohortNewsfeedApi.delete(cohortId, feedId);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
@@ -115,21 +115,21 @@
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.newsfeed')}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.newsfeed')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if canCreateNewsfeed}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('programs.newsfeed.post')}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => cohortNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('cohorts.newsfeed.post')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NewFeedModal {programId} bind:edit bind:editFeed />
|
||||
<NewFeedModal {cohortId} bind:edit bind:editFeed />
|
||||
|
||||
<MyGoalsWidget {programId} />
|
||||
<MyGoalsWidget {cohortId} />
|
||||
|
||||
{#if isListing}
|
||||
<div class="flex w-full flex-col items-center">
|
||||
@@ -139,14 +139,14 @@
|
||||
</div>
|
||||
{:else if !sortedFeeds.length}
|
||||
<Empty
|
||||
title={$t('programs.newsfeed.empty_title')}
|
||||
description={$t('programs.newsfeed.empty_description')}
|
||||
title={$t('cohorts.newsfeed.empty_title')}
|
||||
description={$t('cohorts.newsfeed.empty_description')}
|
||||
icon={BookIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if canCreateNewsfeed}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('programs.newsfeed.post')}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => cohortNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('cohorts.newsfeed.post')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
@@ -155,14 +155,14 @@
|
||||
{#if feed.isPinned}
|
||||
<div class="flex items-center gap-2">
|
||||
<PinIcon size={16} class="filled" />
|
||||
<p class="text-sm">{$t('programs.newsfeed.pinned')}</p>
|
||||
<p class="text-sm">{$t('cohorts.newsfeed.pinned')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NewsFeedCard
|
||||
{feed}
|
||||
comments={programNewsfeedApi.commentsByFeedId[feed.id]}
|
||||
{programId}
|
||||
comments={cohortNewsfeedApi.commentsByFeedId[feed.id]}
|
||||
{cohortId}
|
||||
{author}
|
||||
{reactionAuthorId}
|
||||
{canComment}
|
||||
+17
-17
@@ -23,21 +23,21 @@
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import InviteMembersModal from '$features/program/components/invite-members-modal.svelte';
|
||||
import GoalsOverviewTiles from '$features/program/components/goals/goals-overview-tiles.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
|
||||
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let memberToDelete = $state<(typeof programApi.members)[number] | null>(null);
|
||||
let memberToDelete = $state<(typeof cohortApi.members)[number] | null>(null);
|
||||
let isDeleteModalOpen = $state(false);
|
||||
let filterBy = $state('all');
|
||||
let searchValue = $state('');
|
||||
let copiedEmail = $state<string | null>(null);
|
||||
|
||||
const members = $derived(sortAndFilterPeople(programApi.members, filterBy));
|
||||
const members = $derived(sortAndFilterPeople(cohortApi.members, filterBy));
|
||||
const currentUserRole = $derived.by(() => {
|
||||
const currentMember = programApi.members.find((member) => member.profileId === $profile.id);
|
||||
const currentMember = cohortApi.members.find((member) => member.profileId === $profile.id);
|
||||
return currentMember ? Number(currentMember.roleId) : null;
|
||||
});
|
||||
const canManageMembers = $derived.by(
|
||||
@@ -49,7 +49,7 @@
|
||||
{ label: $t(ROLE_LABEL[ROLE.STUDENT]), value: `${ROLE.STUDENT}` }
|
||||
]);
|
||||
|
||||
function sortAndFilterPeople(people: typeof programApi.members, activeFilter: string) {
|
||||
function sortAndFilterPeople(people: typeof cohortApi.members, activeFilter: string) {
|
||||
return [...(people || [])]
|
||||
.filter((person) => {
|
||||
if (activeFilter === 'all') {
|
||||
@@ -62,7 +62,7 @@
|
||||
.sort((a, b) => Number(a.roleId) - Number(b.roleId));
|
||||
}
|
||||
|
||||
function filterPeople(query: string, people: typeof programApi.members) {
|
||||
function filterPeople(query: string, people: typeof cohortApi.members) {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return people.filter((person) => {
|
||||
@@ -72,7 +72,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getEmail(person: (typeof programApi.members)[number]) {
|
||||
function getEmail(person: (typeof cohortApi.members)[number]) {
|
||||
return person.profile?.email ?? person.email ?? '';
|
||||
}
|
||||
|
||||
@@ -117,10 +117,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
programApi.success = false;
|
||||
cohortApi.success = false;
|
||||
|
||||
await programApi.removeMember(data.programId, memberToDelete.id);
|
||||
if (programApi.success) {
|
||||
await cohortApi.removeMember(data.cohortId, memberToDelete.id);
|
||||
if (cohortApi.success) {
|
||||
isDeleteModalOpen = false;
|
||||
memberToDelete = null;
|
||||
}
|
||||
@@ -134,13 +134,13 @@
|
||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.people')}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.people')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if canManageMembers}
|
||||
<Button onclick={openInviteModal}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('programs.people.invite')}
|
||||
{$t('cohorts.people.invite')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
@@ -148,7 +148,7 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<GoalsOverviewTiles programId={data.programId} />
|
||||
<GoalsOverviewTiles cohortId={data.cohortId} />
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex flex-col items-center justify-end gap-2 md:flex-row">
|
||||
@@ -283,7 +283,7 @@
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<InviteMembersModal programId={data.programId} />
|
||||
<InviteMembersModal cohortId={data.cohortId} />
|
||||
|
||||
<Dialog.Root bind:open={isDeleteModalOpen}>
|
||||
<Dialog.Content class="w-96 pt-3">
|
||||
@@ -300,7 +300,7 @@
|
||||
<Button variant="outline" onclick={() => (isDeleteModalOpen = false)}>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={deletePerson} loading={programApi.isLoading}>
|
||||
<Button variant="outline" onclick={deletePerson} loading={cohortApi.isLoading}>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
+29
-29
@@ -7,16 +7,16 @@
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import GoalsSection from '$features/program/components/goals/goals-section.svelte';
|
||||
import GoalsSection from '$features/cohort/components/goals/goals-section.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const programId = data.programId as string;
|
||||
const cohortId = data.cohortId as string;
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
@@ -24,27 +24,27 @@
|
||||
let isSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const p = programApi.program;
|
||||
if (p) {
|
||||
name = p.name;
|
||||
description = p.description ?? '';
|
||||
status = (p.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
|
||||
const item = cohortApi.cohort;
|
||||
if (item) {
|
||||
name = item.name;
|
||||
description = item.description ?? '';
|
||||
status = (item.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
|
||||
}
|
||||
});
|
||||
|
||||
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const currentMemberRole = $derived(cohortApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const canManageGoals = $derived(
|
||||
Boolean($isOrgAdmin) || currentMemberRole === ROLE.ADMIN || currentMemberRole === ROLE.TUTOR
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('programs.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED', label: $t('programs.status.archived') || 'Archived' }
|
||||
{ value: 'ACTIVE', label: $t('cohorts.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('cohorts.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED', label: $t('cohorts.status.archived') || 'Archived' }
|
||||
];
|
||||
|
||||
const hasUnsavedChanges = $derived.by(() => {
|
||||
const currentProgram = programApi.program;
|
||||
const currentProgram = cohortApi.program;
|
||||
if (!currentProgram) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
await programApi.updateProgram(programId, {
|
||||
await cohortApi.updateCohort(cohortId, {
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || null,
|
||||
status
|
||||
@@ -73,24 +73,24 @@
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
$t('programs.settings.confirm_delete') || 'Are you sure you want to delete this program? This cannot be undone.'
|
||||
$t('cohorts.settings.confirm_delete') || 'Are you sure you want to delete this cohort? This cannot be undone.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await programApi.deleteProgram(programId);
|
||||
goto(resolve(`${$currentOrgPath}/programs`, {}));
|
||||
await cohortApi.deleteCohort(cohortId);
|
||||
goto(resolve(`${$currentOrgPath}/cohorts`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header class="sticky top-13 z-10 bg-white">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.settings') || 'Settings'}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.settings') || 'Settings'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('programs.settings.save') || 'Save Changes'}
|
||||
{$t('cohorts.settings.save') || 'Save Changes'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
@@ -99,20 +99,20 @@
|
||||
<div class="w-full px-4 py-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<section>
|
||||
<h2 class="mb-4 text-sm font-semibold">{$t('programs.settings.general') || 'General'}</h2>
|
||||
<h2 class="mb-4 text-sm font-semibold">{$t('cohorts.settings.general') || 'General'}</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="prog-name">{$t('programs.settings.name_label') || 'Name'} *</Label>
|
||||
<Label for="prog-name">{$t('cohorts.settings.name_label') || 'Name'} *</Label>
|
||||
<Input id="prog-name" bind:value={name} maxlength={255} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="prog-desc">{$t('programs.settings.description_label') || 'Description'}</Label>
|
||||
<Label for="prog-desc">{$t('cohorts.settings.description_label') || 'Description'}</Label>
|
||||
<Textarea id="prog-desc" bind:value={description} rows={4} maxlength={2000} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.settings.status_label') || 'Status'}</Label>
|
||||
<Label>{$t('cohorts.settings.status_label') || 'Status'}</Label>
|
||||
<Select.Root type="single" bind:value={status}>
|
||||
<Select.Trigger class="w-48">
|
||||
{statusOptions.find((o) => o.value === status)?.label}
|
||||
@@ -129,20 +129,20 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<GoalsSection {programId} canManage={canManageGoals} />
|
||||
<GoalsSection {cohortId} canManage={canManageGoals} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<section>
|
||||
<h2 class="mb-1 text-sm font-semibold text-red-600">
|
||||
{$t('programs.settings.danger_zone') || 'Danger Zone'}
|
||||
{$t('cohorts.settings.danger_zone') || 'Danger Zone'}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-4 text-sm">
|
||||
{$t('programs.settings.delete_description') ||
|
||||
'Once deleted, this program and all its data cannot be recovered.'}
|
||||
{$t('cohorts.settings.delete_description') ||
|
||||
'Once deleted, this cohort and all its data cannot be recovered.'}
|
||||
</p>
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={programApi.isLoading}>
|
||||
{$t('programs.settings.delete') || 'Delete Program'}
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={cohortApi.isLoading}>
|
||||
{$t('cohorts.settings.delete') || 'Delete Program'}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { CohortsPage } from '$features/cohort/pages';
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id) {
|
||||
cohortApi.listEnrolledCohorts();
|
||||
}
|
||||
});
|
||||
|
||||
const cohorts = $derived(cohortApi.cohorts);
|
||||
const filteredCohorts = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
|
||||
return cohorts.filter((cohort) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [cohort.name, cohort.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('lms_navigation.cohorts') || 'Cohorts'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('lms_navigation.cohorts') || 'Cohorts'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CohortsPage
|
||||
cohorts={filteredCohorts}
|
||||
isLMS={true}
|
||||
isLoading={cohortApi.isLoading && cohorts.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('cohorts.lms.empty_title') || 'No cohorts yet'}
|
||||
emptyDescription={$t('cohorts.lms.empty_description')}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ProgramsPage } from '$features/program/pages';
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id) {
|
||||
programApi.listEnrolledPrograms();
|
||||
}
|
||||
});
|
||||
|
||||
const programs = $derived(programApi.programs);
|
||||
const filteredPrograms = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
|
||||
return programs.filter((program) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('lms_navigation.programs') || 'Programs'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('lms_navigation.programs') || 'Programs'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ProgramsPage
|
||||
programs={filteredPrograms}
|
||||
isLMS={true}
|
||||
isLoading={programApi.isLoading && programs.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('programs.lms.empty_title') || 'No programs yet'}
|
||||
emptyDescription={$t('programs.lms.empty_description')}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -4,8 +4,8 @@ import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
type GetOrganizationCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
type GetOrganizationCoursesSuccess = Extract<InferResponseType<GetOrganizationCoursesRequest>, { success: true }>;
|
||||
|
||||
type GetProgramsRequest = typeof classroomio.program.$get;
|
||||
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
|
||||
type GetCohortsRequest = typeof classroomio.cohort.$get;
|
||||
type GetCohortsSuccess = Extract<InferResponseType<GetCohortsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
@@ -13,21 +13,21 @@ export const load = async ({ parent, cookies }) => {
|
||||
if (!orgId) {
|
||||
return {
|
||||
courses: [],
|
||||
programs: []
|
||||
cohorts: []
|
||||
};
|
||||
}
|
||||
|
||||
const [coursesResult, programsResult] = await Promise.all([
|
||||
const [coursesResult, cohortsResult] = await Promise.all([
|
||||
safeServerApi<GetOrganizationCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.$get({ query: {} }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<GetProgramsSuccess>(() =>
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
safeServerApi<GetCohortsSuccess>(() =>
|
||||
classroomio.cohort.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
courses: coursesResult.ok ? coursesResult.body.data : [],
|
||||
programs: programsResult.ok ? programsResult.body.data : []
|
||||
cohorts: cohortsResult.ok ? cohortsResult.body.data : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ImportAudiencePage courses={data.courses} programs={data.programs} />
|
||||
<ImportAudiencePage courses={data.courses} cohorts={data.cohorts} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetCohortsRequest = typeof classroomio.cohort.$get;
|
||||
type GetCohortsSuccess = Extract<InferResponseType<GetCohortsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, locals, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
return { cohorts: [] };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetCohortsSuccess>(() =>
|
||||
classroomio.cohort.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
cohorts: result.ok ? result.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { CohortsPage } from '$features/cohort/pages';
|
||||
import type { Cohort } from '$features/cohort/utils/types';
|
||||
import CreateCohortModal from '$features/cohort/components/create-cohort-modal.svelte';
|
||||
import {
|
||||
DEFAULT_COHORT_SORT,
|
||||
DEFAULT_COHORT_SORT_ORDER,
|
||||
type CohortSortBy,
|
||||
type CohortSortOrder
|
||||
} from '$features/cohort/utils/constants';
|
||||
import CohortFilterPopover from '$features/cohort/components/cohort-filter-popover.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let searchValue = $state('');
|
||||
let sortKey = $state<CohortSortBy>(DEFAULT_COHORT_SORT);
|
||||
let selectedOrder = $state<CohortSortOrder>(DEFAULT_COHORT_SORT_ORDER);
|
||||
let selectedStatuses = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (data.cohorts) {
|
||||
cohortApi.cohorts = data.cohorts;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = $derived([
|
||||
{ value: 'ACTIVE' as const, label: $t('cohorts.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE' as const, label: $t('cohorts.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED' as const, label: $t('cohorts.status.archived') || 'Archived' }
|
||||
]);
|
||||
|
||||
const sortOptions = $derived([
|
||||
{ value: 'name' as const, label: $t('cohorts.filters.sort_name') || 'Name' },
|
||||
{ value: 'courses' as const, label: $t('cohorts.filters.sort_courses') || 'Courses' },
|
||||
{ value: 'students' as const, label: $t('cohorts.filters.sort_students') || 'Students' },
|
||||
{ value: 'status' as const, label: $t('cohorts.filters.sort_status') || 'Status' }
|
||||
]);
|
||||
|
||||
const filteredCohorts = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
const sourceCohorts = cohortApi.cohorts.filter((item: Cohort) => {
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
[item.name, item.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(item.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const sortedCohorts = [...sourceCohorts];
|
||||
|
||||
return sortedCohorts.sort((firstCohort, secondCohort) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortKey === 'name') {
|
||||
comparison = firstCohort.name.localeCompare(secondCohort.name);
|
||||
} else if (sortKey === 'courses') {
|
||||
comparison = firstCohort.courseCount - secondCohort.courseCount;
|
||||
} else if (sortKey === 'students') {
|
||||
comparison = firstCohort.studentCount - secondCohort.studentCount;
|
||||
} else if (sortKey === 'status') {
|
||||
comparison = firstCohort.status.localeCompare(secondCohort.status);
|
||||
}
|
||||
|
||||
return selectedOrder === 'asc' ? comparison : comparison * -1;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleStatus(status: string, checked: boolean) {
|
||||
const nextStatuses = new SvelteSet(selectedStatuses);
|
||||
if (checked) {
|
||||
nextStatuses.add(status);
|
||||
} else {
|
||||
nextStatuses.delete(status);
|
||||
}
|
||||
|
||||
selectedStatuses = Array.from(nextStatuses);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
sortKey = DEFAULT_COHORT_SORT;
|
||||
selectedOrder = DEFAULT_COHORT_SORT_ORDER;
|
||||
selectedStatuses = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('cohorts.page_title') || 'Cohorts'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('cohorts.heading') || 'Cohorts'}</Page.Title>
|
||||
<Page.Subtitle>
|
||||
{$t('cohorts.description') || 'Give a group of people access to multiple courses, like a class'}
|
||||
</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('cohorts.create') || 'New Program'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !cohortApi.isLoading && filteredCohorts.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
|
||||
<Empty
|
||||
title={$t('cohorts.empty_title') || 'No cohorts yet'}
|
||||
description={$t('cohorts.empty_description') || 'Create your first cohort to get started.'}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('cohorts.create') || 'New Program'}
|
||||
</Button>
|
||||
</Empty>
|
||||
{:else}
|
||||
<CohortsPage
|
||||
cohorts={filteredCohorts}
|
||||
isLoading={cohortApi.isLoading && filteredCohorts.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('cohorts.empty_title') || 'No cohorts yet'}
|
||||
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
|
||||
? $t('cohorts.no_matching_cohorts') || 'No cohorts match your filters.'
|
||||
: $t('cohorts.empty_description') || 'Create your first cohort to get started.'}
|
||||
>
|
||||
{#snippet filterControls()}
|
||||
<CohortFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedStatuses}
|
||||
{statusOptions}
|
||||
{sortOptions}
|
||||
onToggleStatus={toggleStatus}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
</CohortsPage>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<CreateCohortModal bind:open={showCreateModal} />
|
||||
@@ -1,21 +0,0 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetProgramsRequest = typeof classroomio.program.$get;
|
||||
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, locals, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
return { programs: [] };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetProgramsSuccess>(() =>
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
programs: result.ok ? result.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { ProgramsPage } from '$features/program/pages';
|
||||
import type { Program } from '$features/program/utils/types';
|
||||
import CreateProgramModal from '$features/program/components/create-program-modal.svelte';
|
||||
import {
|
||||
DEFAULT_PROGRAM_SORT,
|
||||
DEFAULT_PROGRAM_SORT_ORDER,
|
||||
type ProgramSortBy,
|
||||
type ProgramSortOrder
|
||||
} from '$features/program/utils/constants';
|
||||
import ProgramFilterPopover from '$features/program/components/program-filter-popover.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let searchValue = $state('');
|
||||
let sortKey = $state<ProgramSortBy>(DEFAULT_PROGRAM_SORT);
|
||||
let selectedOrder = $state<ProgramSortOrder>(DEFAULT_PROGRAM_SORT_ORDER);
|
||||
let selectedStatuses = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (data.programs) {
|
||||
programApi.programs = data.programs;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = $derived([
|
||||
{ value: 'ACTIVE' as const, label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE' as const, label: $t('programs.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED' as const, label: $t('programs.status.archived') || 'Archived' }
|
||||
]);
|
||||
|
||||
const sortOptions = $derived([
|
||||
{ value: 'name' as const, label: $t('programs.filters.sort_name') || 'Name' },
|
||||
{ value: 'courses' as const, label: $t('programs.filters.sort_courses') || 'Courses' },
|
||||
{ value: 'students' as const, label: $t('programs.filters.sort_students') || 'Students' },
|
||||
{ value: 'status' as const, label: $t('programs.filters.sort_status') || 'Status' }
|
||||
]);
|
||||
|
||||
const filteredPrograms = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
const sourcePrograms = programApi.programs.filter((program: Program) => {
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
[program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(program.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const sortedPrograms = [...sourcePrograms];
|
||||
|
||||
return sortedPrograms.sort((firstProgram, secondProgram) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortKey === 'name') {
|
||||
comparison = firstProgram.name.localeCompare(secondProgram.name);
|
||||
} else if (sortKey === 'courses') {
|
||||
comparison = firstProgram.courseCount - secondProgram.courseCount;
|
||||
} else if (sortKey === 'students') {
|
||||
comparison = firstProgram.studentCount - secondProgram.studentCount;
|
||||
} else if (sortKey === 'status') {
|
||||
comparison = firstProgram.status.localeCompare(secondProgram.status);
|
||||
}
|
||||
|
||||
return selectedOrder === 'asc' ? comparison : comparison * -1;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleStatus(status: string, checked: boolean) {
|
||||
const nextStatuses = new SvelteSet(selectedStatuses);
|
||||
if (checked) {
|
||||
nextStatuses.add(status);
|
||||
} else {
|
||||
nextStatuses.delete(status);
|
||||
}
|
||||
|
||||
selectedStatuses = Array.from(nextStatuses);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
sortKey = DEFAULT_PROGRAM_SORT;
|
||||
selectedOrder = DEFAULT_PROGRAM_SORT_ORDER;
|
||||
selectedStatuses = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.page_title') || 'Programs'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.heading') || 'Programs'}</Page.Title>
|
||||
<Page.Subtitle>
|
||||
{$t('programs.description') || 'Give a group of people access to multiple courses, like a class'}
|
||||
</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.create') || 'New Program'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programApi.isLoading && filteredPrograms.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.empty_title') || 'No programs yet'}
|
||||
description={$t('programs.empty_description') || 'Create your first program to get started.'}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.create') || 'New Program'}
|
||||
</Button>
|
||||
</Empty>
|
||||
{:else}
|
||||
<ProgramsPage
|
||||
programs={filteredPrograms}
|
||||
isLoading={programApi.isLoading && filteredPrograms.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('programs.empty_title') || 'No programs yet'}
|
||||
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
|
||||
? $t('programs.no_matching_programs') || 'No programs match your filters.'
|
||||
: $t('programs.empty_description') || 'Create your first program to get started.'}
|
||||
>
|
||||
{#snippet filterControls()}
|
||||
<ProgramFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedStatuses}
|
||||
{statusOptions}
|
||||
{sortOptions}
|
||||
onToggleStatus={toggleStatus}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
</ProgramsPage>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<CreateProgramModal bind:open={showCreateModal} />
|
||||
@@ -7,29 +7,29 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { GoalsOverviewRow } from '$features/program/utils/types';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { GoalsOverviewRow } from '$features/cohort/utils/types';
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listOverview();
|
||||
cohortGoalApi.listOverview();
|
||||
});
|
||||
|
||||
const overviewRows = $derived(programGoalApi.overviewRows);
|
||||
const overviewRows = $derived(cohortGoalApi.overviewRows);
|
||||
|
||||
const programs = $derived.by(() => {
|
||||
const map = new Map<string, { programId: string; programName: string; goals: GoalsOverviewRow[] }>();
|
||||
const cohorts = $derived.by(() => {
|
||||
const map = new Map<string, { cohortId: string; cohortName: string; goals: GoalsOverviewRow[] }>();
|
||||
|
||||
for (const row of overviewRows) {
|
||||
const bucket = map.get(row.programId) ?? {
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
const bucket = map.get(row.cohortId) ?? {
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName,
|
||||
goals: []
|
||||
};
|
||||
bucket.goals.push(row);
|
||||
map.set(row.programId, bucket);
|
||||
map.set(row.cohortId, bucket);
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => a.programName.localeCompare(b.programName));
|
||||
return [...map.values()].sort((a, b) => a.cohortName.localeCompare(b.cohortName));
|
||||
});
|
||||
|
||||
function aggregateOnTrackPct(rows: GoalsOverviewRow[]): number {
|
||||
@@ -46,45 +46,45 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.goals.heading')} · ClassroomIO</title>
|
||||
<title>{$t('cohorts.goals.heading')} · ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.goals.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('programs.goals.description')}</Page.Subtitle>
|
||||
<Page.Title>{$t('cohorts.goals.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('cohorts.goals.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programGoalApi.isLoading && overviewRows.length === 0}
|
||||
{#if !cohortGoalApi.isLoading && overviewRows.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.goals.empty_title')}
|
||||
description={$t('programs.goals.empty_description')}
|
||||
title={$t('cohorts.goals.empty_title')}
|
||||
description={$t('cohorts.goals.empty_description')}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-6 p-4">
|
||||
{#each programs as program (program.programId)}
|
||||
{@const onTrackPct = aggregateOnTrackPct(program.goals)}
|
||||
{@const totalLearners = aggregateCount(program.goals, 'totalLearners')}
|
||||
{@const overdueLearners = aggregateCount(program.goals, 'overdueCount')}
|
||||
{@const atRiskLearners = aggregateCount(program.goals, 'atRiskCount')}
|
||||
{#each cohorts as cohort (cohort.cohortId)}
|
||||
{@const onTrackPct = aggregateOnTrackPct(cohort.goals)}
|
||||
{@const totalLearners = aggregateCount(cohort.goals, 'totalLearners')}
|
||||
{@const overdueLearners = aggregateCount(cohort.goals, 'overdueCount')}
|
||||
{@const atRiskLearners = aggregateCount(cohort.goals, 'atRiskCount')}
|
||||
<section class="flex flex-col gap-3 rounded-md border p-4">
|
||||
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<a
|
||||
href={resolve(`/programs/${program.programId}/settings`, {})}
|
||||
href={resolve(`/cohorts/${cohort.cohortId}/settings`, {})}
|
||||
class="text-base font-semibold hover:underline"
|
||||
>
|
||||
{program.programName}
|
||||
{cohort.cohortName}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="text-base font-semibold text-emerald-600">{onTrackPct}%</span>
|
||||
{$t('programs.goals.tile.on_track')}
|
||||
{$t('cohorts.goals.tile.on_track')}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="font-medium">{totalLearners}</span> learners
|
||||
@@ -92,13 +92,13 @@
|
||||
{#if atRiskLearners > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRiskLearners}</span>
|
||||
{$t('programs.goals.tile.at_risk_short')}
|
||||
{$t('cohorts.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdueLearners > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdueLearners}</span>
|
||||
{$t('programs.goals.tile.overdue_short')}
|
||||
{$t('cohorts.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -110,19 +110,19 @@
|
||||
<Table.Row>
|
||||
<Table.Head>Goal</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.completed')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.in_progress')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.at_risk')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.overdue')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.tile.on_track')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.completed')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.in_progress')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.at_risk')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.overdue')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.tile.on_track')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each program.goals as goal (goal.goalId)}
|
||||
{#each cohort.goals as goal (goal.goalId)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{goal.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{goal.completedCount}/{goal.totalLearners}</Table.Cell>
|
||||
<Table.Cell>{goal.inProgressCount}</Table.Cell>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Cohorts
|
||||
description: Learn what Cohorts are in ClassroomIO, how goals work, and what students see in the Cohorts view.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
**Cohorts** group multiple courses into a structured learning path. Use them when students need to complete a set of courses toward a shared outcome (onboarding, certification prep, role-based training). Individual courses still live in the LMS; cohorts add goals and a dedicated **Cohorts** view for students.
|
||||
|
||||
## Cohorts vs courses
|
||||
|
||||
| Concept | Scope | Student view |
|
||||
| --- | --- | --- |
|
||||
| **Course** | Single content container with lessons | **Home**, **My Learning**, **Explore** |
|
||||
| **Cohort** | Multiple courses with shared goals | **Cohorts** in the LMS |
|
||||
|
||||
A student can be enrolled in courses without belonging to a cohort, and in a cohort that references several courses. See [Student Dashboard](/docs/student-dashboard) for how those views fit together.
|
||||
|
||||
## Admin workflow
|
||||
|
||||
From the admin dashboard:
|
||||
|
||||
1. Create a cohort and add courses to it.
|
||||
2. Configure cohort **Settings** (metadata, visibility, assignments).
|
||||
3. Add **Goals** that define what success looks like.
|
||||
4. Assign students to the cohort (existing students or by email invite).
|
||||
|
||||
Students assigned to a cohort see it under **Cohorts** in the LMS.
|
||||
|
||||
## Cohort goal types
|
||||
|
||||
Goals measure progress across the cohort's courses. Supported types:
|
||||
|
||||
| Type | What it measures |
|
||||
| --- | --- |
|
||||
| **Complete all** | Every selected course is completed |
|
||||
| **N of M** | A minimum number of selected courses completed |
|
||||
| **Score** | Per-learner score threshold on every selected course |
|
||||
| **Pass rate** | Team-wide pass-rate threshold |
|
||||
| **Readiness** | No deadline; tracks completion without a due date |
|
||||
|
||||
Deadlines can be absolute, relative to when the student joins, recurring, or none.
|
||||
|
||||
## Student experience
|
||||
|
||||
In the LMS, students open **Cohorts** to see cohorts they belong to. Each cohort hub includes:
|
||||
|
||||
- **Newsfeed** — updates from instructors
|
||||
- **Courses** — linked courses with progress
|
||||
- **Goals** — personal goal status when goals are configured
|
||||
|
||||
<Callout type="info">
|
||||
Legacy deployments may still have data in the original `program` tables. Run the migration script `pnpm --filter @cio/db migrate:programs-to-cohorts` after applying the cohort schema migration to copy existing data into the new tables without dropping the legacy tables.
|
||||
</Callout>
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
"---Core Features---",
|
||||
"course",
|
||||
"programs",
|
||||
"cohorts",
|
||||
"api",
|
||||
"mcp",
|
||||
"tags",
|
||||
|
||||
@@ -25,10 +25,10 @@ On creation, each org gets `https://<siteName>.myclassroomio.com` as its academy
|
||||
Share your academy URL with students. The generic `app.classroomio.com` host is for platform access when no org subdomain applies. Course and enroll links should use your academy when possible.
|
||||
</Callout>
|
||||
|
||||
## Courses and programs inside an org
|
||||
## Courses and cohorts inside an org
|
||||
|
||||
- **Courses** are individual learning containers. See [Course](/docs/course).
|
||||
- **Programs** group courses with shared goals. See [Programs](/docs/programs).
|
||||
- **Cohorts** group courses with shared goals. See [Cohorts](/docs/cohorts).
|
||||
- **Students** use the [Student Dashboard](/docs/student-dashboard) after they sign in.
|
||||
|
||||
## Related guides
|
||||
@@ -39,4 +39,4 @@ On creation, each org gets `https://<siteName>.myclassroomio.com` as its academy
|
||||
- [Roles and Permissions](/docs/roles-and-permissions)
|
||||
- [How Course Enrollment Works](/docs/course-enrollment)
|
||||
- [Course](/docs/course)
|
||||
- [Programs](/docs/programs)
|
||||
- [Cohorts](/docs/cohorts)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
title: Programs
|
||||
description: Learn what Programs are in ClassroomIO, how goals work, and what students see in the Programs view.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
**Programs** group multiple courses into a structured learning path. Use them when students need to complete a set of courses toward a shared outcome (onboarding, certification prep, role-based training). Individual courses still live in the LMS; programs add goals and a dedicated **Programs** view for students.
|
||||
|
||||
## Programs vs courses
|
||||
|
||||
| Concept | Scope | Student view |
|
||||
| --- | --- | --- |
|
||||
| **Course** | Single content container with lessons | **Home**, **My Learning**, **Explore** |
|
||||
| **Program** | Multiple courses with shared goals | **Programs** in the LMS |
|
||||
|
||||
A student can be enrolled in courses without belonging to a program, and in a program that references several courses. See [Student Dashboard](/docs/student-dashboard) for how those views fit together.
|
||||
|
||||
## Admin workflow
|
||||
|
||||
From the admin dashboard:
|
||||
|
||||
1. Create a program and add courses to it.
|
||||
2. Configure program **Settings** (metadata, visibility, assignments).
|
||||
3. Add **Goals** that define what success looks like.
|
||||
4. Assign students to the program (existing students or by email invite).
|
||||
|
||||
Students assigned to a program see it under **Programs** in the LMS.
|
||||
|
||||
## Program goal types
|
||||
|
||||
When you add a goal in **Programs → Settings → Goals**, you pick a **type**. Each type measures progress differently.
|
||||
|
||||
| Type | Label in the app | What it measures |
|
||||
| --- | --- | --- |
|
||||
| `complete_all` | Complete all selected courses | Every selected course must be finished |
|
||||
| `n_of_m` | Complete N of M selected courses | Student must finish a required count from the courses you select |
|
||||
| `score` | Score above threshold on every course | Minimum score on each selected course |
|
||||
| `pass_rate` | Team pass-rate threshold | Aggregate pass rate across the group |
|
||||
| `readiness` | Readiness (no deadline) | Readiness tracking without a fixed deadline |
|
||||
|
||||
### Complete N of M selected courses (`n_of_m`)
|
||||
|
||||
This type asks: "finish at least **N** courses from the list I picked." You select the courses, then set **required count** (N). N cannot be larger than the number of courses selected.
|
||||
|
||||
If you see wording like "N of M" in the dropdown, **M** is the number of courses you selected, not a separate field called "cells."
|
||||
|
||||
<Callout type="info" title="Deadlines on goals">
|
||||
Goals can also have deadline settings (absolute date, relative to join, recurring, or none). Configure these in the same goal form when creating or editing a goal.
|
||||
</Callout>
|
||||
|
||||
## What students see
|
||||
|
||||
Students who belong to a program open **Programs** in the LMS to see their tracks and goal progress. Individual course lessons still open from enrolled courses in **Home** or **My Learning**.
|
||||
|
||||
| Need | Where to look |
|
||||
| --- | --- |
|
||||
| Progress on a program track | **Programs** |
|
||||
| Lessons inside one course | **Home** or **My Learning** |
|
||||
| Optional courses not yet joined | **Explore** |
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Student Dashboard](/docs/student-dashboard) — Programs vs My Learning vs Explore
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — how students join courses inside a program
|
||||
- [Course](/docs/course) — single-course overview
|
||||
- [Roles and Permissions](/docs/roles-and-permissions) — who can manage programs and courses
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Student Dashboard
|
||||
description: Learn how students navigate the LMS, including Home, My Learning, Programs, and Explore.
|
||||
description: Learn how students navigate the LMS, including Home, My Learning, Cohorts, and Explore.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
@@ -15,7 +15,7 @@ Students reach the LMS after they sign in on your academy. The left sidebar list
|
||||
| --- | --- | --- |
|
||||
| **Home** | `/lms` | Enrolled courses with progress |
|
||||
| **My Learning** | `/lms/mylearning` | Enrolled courses only, grouped by status |
|
||||
| **Programs** | `/lms/programs` | Programs the student belongs to |
|
||||
| **Cohorts** | `/lms/cohorts` | Cohorts the student belongs to |
|
||||
| **Explore** | `/lms/explore` | Published courses they are not enrolled in yet |
|
||||
| **Exercise** | `/lms/exercises` | Exercises (if enabled for your org) |
|
||||
| **Community** | `/lms/community` | Community (if enabled for your org) |
|
||||
@@ -48,17 +48,17 @@ Both show enrolled courses. My Learning adds clearer filtering for students mana
|
||||
|
||||
Explore is useful when you want students to discover optional or elective courses without assigning them manually.
|
||||
|
||||
## Programs
|
||||
## Cohorts
|
||||
|
||||
**Programs** are separate from individual courses. A program groups courses under shared goals (for example, complete all courses in an onboarding track). Students who belong to a program see it under **Programs** in the LMS.
|
||||
**Cohorts** are separate from individual courses. A cohort groups courses under shared goals (for example, complete all courses in an onboarding track). Students who belong to a cohort see it under **Cohorts** in the LMS.
|
||||
|
||||
| Concept | Where students see it |
|
||||
| --- | --- |
|
||||
| Individual enrolled courses | Home, My Learning |
|
||||
| Courses not yet joined | Explore |
|
||||
| Multi-course tracks with goals | Programs |
|
||||
| Multi-course tracks with goals | Cohorts |
|
||||
|
||||
For how admins create programs and set goals, see [Programs](/docs/programs).
|
||||
For how admins create cohorts and set goals, see [Cohorts](/docs/cohorts).
|
||||
|
||||
## What students see right after enrolling
|
||||
|
||||
@@ -71,18 +71,18 @@ When a student enrolls in a course (from a landing page, invite link, or admin a
|
||||
|
||||
If you added them from **People** but they have not signed in yet, they may show as **Pending** in the admin People list until they accept the invite or create an account.
|
||||
|
||||
## Courses vs programs: quick comparison
|
||||
## Courses vs cohorts: quick comparison
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Where do I see courses I am taking? | **Home** or **My Learning** |
|
||||
| Where do I find courses I have not joined? | **Explore** |
|
||||
| Where do I see structured learning paths? | **Programs** |
|
||||
| Where do I see structured learning paths? | **Cohorts** |
|
||||
| Can one student use all three views? | Yes. They work together. |
|
||||
|
||||
## Related guides
|
||||
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — how students join courses and what settings control access
|
||||
- [How to Invite Students](/docs/invite-students) — send enrollment links by email
|
||||
- [Programs](/docs/programs) — multi-course tracks and goals for admins
|
||||
- [Cohorts](/docs/cohorts) — multi-course tracks and goals for admins
|
||||
- [Create a Course Landing Page](/docs/course-landingpage) — what prospects see before enrolling
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"db:setup:seed": "tsx src/scripts/db-setup.ts --seed",
|
||||
"db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts",
|
||||
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
||||
"migrate:programs-to-cohorts": "tsx src/scripts/migrate-programs-to-cohorts.ts",
|
||||
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts",
|
||||
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
|
||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
CREATE TYPE "public"."COHORT_GOAL_ASSIGNMENT_STATUS" AS ENUM('not_started', 'in_progress', 'completed', 'at_risk', 'overdue', 'waived');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COHORT_GOAL_DEADLINE_KIND" AS ENUM('absolute', 'relative_to_join', 'recurring', 'none');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COHORT_GOAL_STATUS" AS ENUM('active', 'archived');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COHORT_GOAL_TYPE" AS ENUM('complete_all', 'n_of_m', 'score', 'pass_rate', 'readiness');--> statement-breakpoint
|
||||
CREATE TYPE "public"."COHORT_STATUS" AS ENUM('ACTIVE', 'INACTIVE', 'ARCHIVED');--> statement-breakpoint
|
||||
CREATE TABLE "cohort" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"description" text,
|
||||
"cover_image" text,
|
||||
"status" "COHORT_STATUS" DEFAULT 'ACTIVE' NOT NULL,
|
||||
"created_by_profile_id" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cohort_course" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cohort_id" uuid NOT NULL,
|
||||
"course_id" uuid NOT NULL,
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "cohort_course_cohort_id_course_id_unique" UNIQUE("cohort_id","course_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cohort_goal" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cohort_id" uuid NOT NULL,
|
||||
"title" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"type" "COHORT_GOAL_TYPE" NOT NULL,
|
||||
"course_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"required_count" integer,
|
||||
"score_threshold" integer,
|
||||
"team_pass_rate_threshold" integer,
|
||||
"deadline_kind" "COHORT_GOAL_DEADLINE_KIND" DEFAULT 'none' NOT NULL,
|
||||
"deadline_date" timestamp with time zone,
|
||||
"relative_days" integer,
|
||||
"recurring_months" integer,
|
||||
"reminder_days_before" jsonb DEFAULT '[7,1]'::jsonb NOT NULL,
|
||||
"status" "COHORT_GOAL_STATUS" DEFAULT 'active' NOT NULL,
|
||||
"created_by_profile_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cohort_goal_assignment" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"goal_id" uuid NOT NULL,
|
||||
"cohort_member_id" uuid NOT NULL,
|
||||
"due_date" timestamp with time zone,
|
||||
"status" "COHORT_GOAL_ASSIGNMENT_STATUS" DEFAULT 'not_started' NOT NULL,
|
||||
"completed_count" integer DEFAULT 0 NOT NULL,
|
||||
"required_count" integer DEFAULT 0 NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
"last_evaluated_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "cohort_goal_assignment_goal_id_cohort_member_id_unique" UNIQUE("goal_id","cohort_member_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cohort_member" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cohort_id" uuid NOT NULL,
|
||||
"profile_id" uuid,
|
||||
"role_id" integer NOT NULL,
|
||||
"email" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "cohort_member_cohort_id_profile_id_unique" UNIQUE("cohort_id","profile_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cohort_newsfeed" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"author_id" uuid,
|
||||
"cohort_id" uuid,
|
||||
"content" text,
|
||||
"reaction" jsonb DEFAULT '{"clap":[],"smile":[],"thumbsup":[],"thumbsdown":[]}'::jsonb,
|
||||
"is_pinned" boolean DEFAULT false NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cohort_newsfeed_comment" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "cohort_newsfeed_comment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"author_id" uuid,
|
||||
"content" text,
|
||||
"cohort_newsfeed_id" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cohort" ADD CONSTRAINT "cohort_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort" ADD CONSTRAINT "cohort_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_course" ADD CONSTRAINT "cohort_course_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_course" ADD CONSTRAINT "cohort_course_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_goal" ADD CONSTRAINT "cohort_goal_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_goal" ADD CONSTRAINT "cohort_goal_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_goal_assignment" ADD CONSTRAINT "cohort_goal_assignment_goal_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "public"."cohort_goal"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_goal_assignment" ADD CONSTRAINT "cohort_goal_assignment_cohort_member_id_fkey" FOREIGN KEY ("cohort_member_id") REFERENCES "public"."cohort_member"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_member" ADD CONSTRAINT "cohort_member_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_member" ADD CONSTRAINT "cohort_member_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_member" ADD CONSTRAINT "cohort_member_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_newsfeed" ADD CONSTRAINT "cohort_newsfeed_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."cohort_member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_newsfeed" ADD CONSTRAINT "cohort_newsfeed_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_newsfeed_comment" ADD CONSTRAINT "cohort_newsfeed_comment_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."cohort_member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "cohort_newsfeed_comment" ADD CONSTRAINT "cohort_newsfeed_comment_cohort_newsfeed_id_fkey" FOREIGN KEY ("cohort_newsfeed_id") REFERENCES "public"."cohort_newsfeed"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_organization_id" ON "cohort" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_course_cohort_id" ON "cohort_course" USING btree ("cohort_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_goal_cohort_id" ON "cohort_goal" USING btree ("cohort_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_goal_status" ON "cohort_goal" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_goal_assignment_goal_id" ON "cohort_goal_assignment" USING btree ("goal_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_goal_assignment_cohort_member_id" ON "cohort_goal_assignment" USING btree ("cohort_member_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_goal_assignment_due_date" ON "cohort_goal_assignment" USING btree ("due_date");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_member_cohort_id" ON "cohort_member" USING btree ("cohort_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_member_profile_id" ON "cohort_member" USING btree ("profile_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cohort_newsfeed_cohort_id" ON "cohort_newsfeed" USING btree ("cohort_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1782684436805,
|
||||
"tag": "0001_slow_lila_cheney",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1782660458258,
|
||||
"tag": "0002_majestic_masked_marvel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TCohort = typeof schema.cohort.$inferSelect;
|
||||
export type TNewCohort = typeof schema.cohort.$inferInsert;
|
||||
export type TCohortMember = typeof schema.cohortMember.$inferSelect;
|
||||
export type TNewCohortMember = typeof schema.cohortMember.$inferInsert;
|
||||
export type TCohortCourse = typeof schema.cohortCourse.$inferSelect;
|
||||
export type TCohortNewsfeed = typeof schema.cohortNewsfeed.$inferSelect;
|
||||
export type TNewCohortNewsfeed = typeof schema.cohortNewsfeed.$inferInsert;
|
||||
export type TCohortNewsfeedComment = typeof schema.cohortNewsfeedComment.$inferSelect;
|
||||
export type TNewCohortNewsfeedComment = typeof schema.cohortNewsfeedComment.$inferInsert;
|
||||
|
||||
// ─── Program CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createCohort(data: TNewCohort): Promise<TCohort> {
|
||||
try {
|
||||
const [cohort] = await db.insert(schema.cohort).values(data).returning();
|
||||
if (!cohort) throw new Error('Failed to create cohort');
|
||||
return cohort;
|
||||
} catch (error) {
|
||||
console.error('createCohort error:', error);
|
||||
throw new Error(`Failed to create cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Inserts the cohort and adds `creatorProfileId` as a cohort team member (tutor) in one transaction. */
|
||||
export async function createCohortWithCreatorMembership(
|
||||
cohortData: TNewCohort,
|
||||
creatorProfileId: string
|
||||
): Promise<TCohort> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [cohort] = await tx.insert(schema.cohort).values(cohortData).returning();
|
||||
if (!cohort) {
|
||||
throw new Error('Failed to create cohort');
|
||||
}
|
||||
|
||||
await tx.insert(schema.cohortMember).values({
|
||||
cohortId: cohort.id,
|
||||
profileId: creatorProfileId,
|
||||
roleId: ROLE.TUTOR
|
||||
});
|
||||
|
||||
return cohort;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createCohortWithCreatorMembership error:', error);
|
||||
throw new Error(`Failed to create cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCohortById(cohortId: string): Promise<TCohort | null> {
|
||||
try {
|
||||
const [cohort] = await db.select().from(schema.cohort).where(eq(schema.cohort.id, cohortId)).limit(1);
|
||||
return cohort || null;
|
||||
} catch (error) {
|
||||
console.error('getCohortById error:', error);
|
||||
throw new Error(`Failed to get cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCohortsByOrg(
|
||||
organizationId: string,
|
||||
cohortIds?: string[]
|
||||
): Promise<Array<TCohort & { courseCount: number; studentCount: number }>> {
|
||||
try {
|
||||
const whereCondition =
|
||||
cohortIds && cohortIds.length > 0
|
||||
? and(eq(schema.cohort.organizationId, organizationId), inArray(schema.cohort.id, cohortIds))
|
||||
: eq(schema.cohort.organizationId, organizationId);
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
cohort: schema.cohort,
|
||||
courseCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.cohortCourse}
|
||||
WHERE ${eq(schema.cohortCourse.cohortId, schema.cohort.id)}),
|
||||
0
|
||||
)
|
||||
`.as('courseCount'),
|
||||
studentCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.cohortMember}
|
||||
WHERE ${and(
|
||||
eq(schema.cohortMember.cohortId, schema.cohort.id),
|
||||
eq(schema.cohortMember.roleId, ROLE.STUDENT)
|
||||
)}),
|
||||
0
|
||||
)
|
||||
`.as('studentCount')
|
||||
})
|
||||
.from(schema.cohort)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(schema.cohort.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.cohort,
|
||||
courseCount: Number(row.courseCount || 0),
|
||||
studentCount: Number(row.studentCount || 0)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getCohortsByOrg error:', error);
|
||||
throw new Error(
|
||||
`Failed to get cohorts for org "${organizationId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TSearchOrgCohort {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export async function searchOrgCohorts(orgId: string, search: string, limit: number): Promise<TSearchOrgCohort[]> {
|
||||
try {
|
||||
const searchValue = `%${search.trim()}%`;
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: schema.cohort.id,
|
||||
name: schema.cohort.name,
|
||||
description: schema.cohort.description,
|
||||
status: schema.cohort.status,
|
||||
updatedAt: schema.cohort.updatedAt
|
||||
})
|
||||
.from(schema.cohort)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cohort.organizationId, orgId),
|
||||
eq(schema.cohort.status, 'ACTIVE'),
|
||||
or(ilike(schema.cohort.name, searchValue), ilike(schema.cohort.description, searchValue))
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.cohort.updatedAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('searchOrgCohorts error:', error);
|
||||
throw new Error(`Failed to search org cohorts: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchLmsCohorts(
|
||||
orgId: string,
|
||||
profileId: string,
|
||||
search: string,
|
||||
limit: number
|
||||
): Promise<TSearchOrgCohort[]> {
|
||||
try {
|
||||
const searchValue = `%${search.trim()}%`;
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: schema.cohort.id,
|
||||
name: schema.cohort.name,
|
||||
description: schema.cohort.description,
|
||||
status: schema.cohort.status,
|
||||
updatedAt: schema.cohort.updatedAt
|
||||
})
|
||||
.from(schema.cohortMember)
|
||||
.innerJoin(schema.cohort, eq(schema.cohortMember.cohortId, schema.cohort.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cohortMember.profileId, profileId),
|
||||
eq(schema.cohort.organizationId, orgId),
|
||||
eq(schema.cohort.status, 'ACTIVE'),
|
||||
or(ilike(schema.cohort.name, searchValue), ilike(schema.cohort.description, searchValue))
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.cohort.updatedAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('searchLmsCohorts error:', error);
|
||||
throw new Error(`Failed to search LMS cohorts: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExistingCohortMembers(
|
||||
pairs: Array<{ cohortId: string; profileId: string }>
|
||||
): Promise<Set<string>> {
|
||||
if (pairs.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const cohortIds = [...new Set(pairs.map((pair) => pair.cohortId))];
|
||||
const profileIds = [...new Set(pairs.map((pair) => pair.profileId))];
|
||||
|
||||
const rows = await db
|
||||
.select({ cohortId: schema.cohortMember.cohortId, profileId: schema.cohortMember.profileId })
|
||||
.from(schema.cohortMember)
|
||||
.where(and(inArray(schema.cohortMember.cohortId, cohortIds), inArray(schema.cohortMember.profileId, profileIds)));
|
||||
|
||||
return new Set(
|
||||
rows
|
||||
.filter((row): row is { cohortId: string; profileId: string } => Boolean(row.cohortId && row.profileId))
|
||||
.map((row) => `${row.cohortId}:${row.profileId}`)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('getExistingCohortMembers error:', error);
|
||||
throw new Error(
|
||||
`Failed to check existing cohort members: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCohort(cohortId: string, data: Partial<TNewCohort>): Promise<TCohort | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.cohort)
|
||||
.set({ ...data, updatedAt: sql`now()` })
|
||||
.where(eq(schema.cohort.id, cohortId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateCohort error:', error);
|
||||
throw new Error(
|
||||
`Failed to update cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCohort(cohortId: string): Promise<TCohort | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.cohort).where(eq(schema.cohort.id, cohortId)).returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('deleteCohort error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Membership ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getCohortMemberByProfileId(cohortId: string, profileId: string): Promise<TCohortMember | null> {
|
||||
try {
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(schema.cohortMember)
|
||||
.where(and(eq(schema.cohortMember.cohortId, cohortId), eq(schema.cohortMember.profileId, profileId)))
|
||||
.limit(1);
|
||||
return member || null;
|
||||
} catch (error) {
|
||||
console.error('getCohortMemberByProfileId error:', error);
|
||||
throw new Error(`Failed to get cohort member: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCohortMember(cohortId: string, profileId: string): Promise<boolean> {
|
||||
try {
|
||||
const member = await getCohortMemberByProfileId(cohortId, profileId);
|
||||
return member !== null;
|
||||
} catch (error) {
|
||||
console.error('isCohortMember error:', error);
|
||||
throw new Error(`Failed to check cohort membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCohortMemberRole(cohortId: string, profileId: string): Promise<number | null> {
|
||||
try {
|
||||
const member = await getCohortMemberByProfileId(cohortId, profileId);
|
||||
return member?.roleId || null;
|
||||
} catch (error) {
|
||||
console.error('getCohortMemberRole error:', error);
|
||||
throw new Error(`Failed to get cohort member role: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isOrgAdminByCohortId(cohortId: string, profileId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({ orgMemberId: schema.organizationmember.id })
|
||||
.from(schema.cohort)
|
||||
.innerJoin(
|
||||
schema.organizationmember,
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, schema.cohort.organizationId),
|
||||
eq(schema.organizationmember.profileId, profileId),
|
||||
eq(schema.organizationmember.roleId, ROLE.ADMIN)
|
||||
)
|
||||
)
|
||||
.where(eq(schema.cohort.id, cohortId))
|
||||
.limit(1);
|
||||
return result.length > 0;
|
||||
} catch (error) {
|
||||
console.error('isOrgAdminByCohortId error:', error);
|
||||
throw new Error(`Failed to check org admin status: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCohortMember(data: TNewCohortMember, dbClient: DbOrTxClient = db): Promise<TCohortMember> {
|
||||
try {
|
||||
const [member] = await dbClient.insert(schema.cohortMember).values(data).returning();
|
||||
if (!member) throw new Error('Failed to add cohort member');
|
||||
return member;
|
||||
} catch (error) {
|
||||
console.error('addCohortMember error:', error);
|
||||
throw new Error(`Failed to add cohort member: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCohortMember(memberId: string): Promise<TCohortMember | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.cohortMember).where(eq(schema.cohortMember.id, memberId)).returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('removeCohortMember error:', error);
|
||||
throw new Error(
|
||||
`Failed to remove cohort member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCohortMember(
|
||||
memberId: string,
|
||||
data: Partial<TNewCohortMember>
|
||||
): Promise<TCohortMember | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.cohortMember)
|
||||
.set(data)
|
||||
.where(eq(schema.cohortMember.id, memberId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateCohortMember error:', error);
|
||||
throw new Error(
|
||||
`Failed to update cohort member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCohortMembers(cohortId: string): Promise<
|
||||
Array<
|
||||
TCohortMember & {
|
||||
profile: {
|
||||
id: string;
|
||||
fullname: string | null;
|
||||
username: string | null;
|
||||
avatarUrl: string | null;
|
||||
email: string | null;
|
||||
} | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
member: schema.cohortMember,
|
||||
profile: {
|
||||
id: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
username: schema.profile.username,
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
email: schema.profile.email
|
||||
}
|
||||
})
|
||||
.from(schema.cohortMember)
|
||||
.leftJoin(schema.profile, eq(schema.cohortMember.profileId, schema.profile.id))
|
||||
.where(eq(schema.cohortMember.cohortId, cohortId))
|
||||
.orderBy(asc(schema.cohortMember.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.member,
|
||||
profile: row.profile?.id ? row.profile : null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getCohortMembers error:', error);
|
||||
throw new Error(
|
||||
`Failed to get cohort members for "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEnrolledCohortsByProfile(
|
||||
profileId: string
|
||||
): Promise<Array<TCohort & { roleId: number; courseCount: number; studentCount: number }>> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
cohort: schema.cohort,
|
||||
roleId: schema.cohortMember.roleId,
|
||||
courseCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.cohortCourse}
|
||||
INNER JOIN ${schema.course}
|
||||
ON ${schema.cohortCourse.courseId} = ${schema.course.id}
|
||||
WHERE ${schema.cohortCourse.cohortId} = ${schema.cohort.id}
|
||||
AND (
|
||||
${schema.cohortMember.roleId} <> ${ROLE.STUDENT}
|
||||
OR ${schema.course.isPublished} = true
|
||||
)),
|
||||
0
|
||||
)
|
||||
`.as('courseCount'),
|
||||
studentCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.cohortMember} AS student_member
|
||||
WHERE student_member.cohort_id = ${schema.cohort.id}
|
||||
AND student_member.role_id = ${ROLE.STUDENT}),
|
||||
0
|
||||
)
|
||||
`.as('studentCount')
|
||||
})
|
||||
.from(schema.cohortMember)
|
||||
.innerJoin(schema.cohort, eq(schema.cohortMember.cohortId, schema.cohort.id))
|
||||
.where(eq(schema.cohortMember.profileId, profileId))
|
||||
.orderBy(desc(schema.cohort.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.cohort,
|
||||
roleId: row.roleId,
|
||||
courseCount: Number(row.courseCount || 0),
|
||||
studentCount: Number(row.studentCount || 0)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getEnrolledCohortsByProfile error:', error);
|
||||
throw new Error(
|
||||
`Failed to get enrolled cohorts for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Courses ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function addCourseToCohort(cohortId: string, courseId: string): Promise<TCohortCourse> {
|
||||
try {
|
||||
const [row] = await db.insert(schema.cohortCourse).values({ cohortId, courseId }).returning();
|
||||
if (!row) throw new Error('Failed to add course to cohort');
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('addCourseToCohort error:', error);
|
||||
throw new Error(`Failed to add course to cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCourseFromCohort(cohortId: string, courseId: string): Promise<TCohortCourse | null> {
|
||||
try {
|
||||
const [deleted] = await db
|
||||
.delete(schema.cohortCourse)
|
||||
.where(and(eq(schema.cohortCourse.cohortId, cohortId), eq(schema.cohortCourse.courseId, courseId)))
|
||||
.returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('removeCourseFromCohort error:', error);
|
||||
throw new Error(`Failed to remove course from cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoursesByCohort(
|
||||
cohortId: string,
|
||||
onlyPublished = false
|
||||
): Promise<
|
||||
Array<
|
||||
TCohortCourse & {
|
||||
course: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
coverImage: string | null;
|
||||
slug: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const whereCondition = onlyPublished
|
||||
? and(eq(schema.cohortCourse.cohortId, cohortId), eq(schema.course.isPublished, true))
|
||||
: eq(schema.cohortCourse.cohortId, cohortId);
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
cohortCourse: schema.cohortCourse,
|
||||
course: {
|
||||
id: schema.course.id,
|
||||
title: schema.course.title,
|
||||
description: schema.course.description,
|
||||
coverImage: schema.course.logo,
|
||||
slug: schema.course.slug,
|
||||
status: schema.course.status,
|
||||
isPublished: schema.course.isPublished
|
||||
}
|
||||
})
|
||||
.from(schema.cohortCourse)
|
||||
.innerJoin(schema.course, eq(schema.cohortCourse.courseId, schema.course.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(asc(schema.cohortCourse.addedAt));
|
||||
|
||||
return result.map((row) => ({ ...row.cohortCourse, course: row.course }));
|
||||
} catch (error) {
|
||||
console.error('getCoursesByCohort error:', error);
|
||||
throw new Error(
|
||||
`Failed to get courses for cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCohortCourse(cohortId: string, courseId: string): Promise<boolean> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ id: schema.cohortCourse.id })
|
||||
.from(schema.cohortCourse)
|
||||
.where(and(eq(schema.cohortCourse.cohortId, cohortId), eq(schema.cohortCourse.courseId, courseId)))
|
||||
.limit(1);
|
||||
return !!row;
|
||||
} catch (error) {
|
||||
console.error('isCohortCourse error:', error);
|
||||
throw new Error(`Failed to check cohort course: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Newsfeed ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCohortNewsfeed(
|
||||
cohortId: string,
|
||||
options: { cursor?: string; limit: number }
|
||||
): Promise<{
|
||||
items: Array<
|
||||
TCohortNewsfeed & {
|
||||
authorProfileId: string | null;
|
||||
authorFullname: string | null;
|
||||
authorUsername: string | null;
|
||||
authorAvatarUrl: string | null;
|
||||
commentCount: number;
|
||||
}
|
||||
>;
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { cursor, limit } = options;
|
||||
|
||||
const whereConditions = [eq(schema.cohortNewsfeed.cohortId, cohortId)];
|
||||
if (cursor) {
|
||||
whereConditions.push(sql`${schema.cohortNewsfeed.createdAt} < ${cursor}`);
|
||||
}
|
||||
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`count(*)`.as('count') })
|
||||
.from(schema.cohortNewsfeed)
|
||||
.where(eq(schema.cohortNewsfeed.cohortId, cohortId));
|
||||
const totalCount = Number(totalCountResult[0]?.count || 0);
|
||||
|
||||
const feeds = await db
|
||||
.select({
|
||||
feed: schema.cohortNewsfeed,
|
||||
profile: schema.profile,
|
||||
commentCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.cohortNewsfeedComment}
|
||||
WHERE ${eq(schema.cohortNewsfeedComment.cohortNewsfeedId, schema.cohortNewsfeed.id)}),
|
||||
0
|
||||
)
|
||||
`.as('commentCount')
|
||||
})
|
||||
.from(schema.cohortNewsfeed)
|
||||
.leftJoin(schema.cohortMember, eq(schema.cohortNewsfeed.authorId, schema.cohortMember.id))
|
||||
.leftJoin(schema.profile, eq(schema.cohortMember.profileId, schema.profile.id))
|
||||
.where(and(...whereConditions))
|
||||
.orderBy(desc(schema.cohortNewsfeed.createdAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const hasMore = feeds.length > limit;
|
||||
const items = feeds.slice(0, limit);
|
||||
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].feed.createdAt : null;
|
||||
|
||||
return {
|
||||
items: items.map((row) => ({
|
||||
...row.feed,
|
||||
authorProfileId: row.profile?.id || null,
|
||||
authorFullname: row.profile?.fullname || null,
|
||||
authorUsername: row.profile?.username || null,
|
||||
authorAvatarUrl: row.profile?.avatarUrl || null,
|
||||
commentCount: Number(row.commentCount || 0)
|
||||
})),
|
||||
totalCount,
|
||||
hasMore,
|
||||
nextCursor
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getCohortNewsfeed error:', error);
|
||||
throw new Error(
|
||||
`Failed to get cohort newsfeed "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCohortNewsfeedById(feedId: string): Promise<TCohortNewsfeed | null> {
|
||||
try {
|
||||
const [feed] = await db.select().from(schema.cohortNewsfeed).where(eq(schema.cohortNewsfeed.id, feedId)).limit(1);
|
||||
return feed || null;
|
||||
} catch (error) {
|
||||
console.error('getCohortNewsfeedById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get cohort newsfeed item "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCohortNewsfeed(data: TNewCohortNewsfeed): Promise<TCohortNewsfeed> {
|
||||
try {
|
||||
const [feed] = await db.insert(schema.cohortNewsfeed).values(data).returning();
|
||||
if (!feed) throw new Error('Failed to create cohort newsfeed');
|
||||
return feed;
|
||||
} catch (error) {
|
||||
console.error('createCohortNewsfeed error:', error);
|
||||
throw new Error(`Failed to create cohort newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCohortNewsfeed(
|
||||
feedId: string,
|
||||
data: Partial<TNewCohortNewsfeed>
|
||||
): Promise<TCohortNewsfeed | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.cohortNewsfeed)
|
||||
.set(data)
|
||||
.where(eq(schema.cohortNewsfeed.id, feedId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateCohortNewsfeed error:', error);
|
||||
throw new Error(
|
||||
`Failed to update cohort newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCohortNewsfeedReaction(
|
||||
feedId: string,
|
||||
reaction: { clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }
|
||||
): Promise<TCohortNewsfeed | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.cohortNewsfeed)
|
||||
.set({ reaction })
|
||||
.where(eq(schema.cohortNewsfeed.id, feedId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateCohortNewsfeedReaction error:', error);
|
||||
throw new Error(
|
||||
`Failed to update cohort newsfeed reaction "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCohortNewsfeed(feedId: string): Promise<TCohortNewsfeed | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.cohortNewsfeed).where(eq(schema.cohortNewsfeed.id, feedId)).returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('deleteCohortNewsfeed error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete cohort newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
|
||||
|
||||
export async function getCohortNewsfeedComments(feedId: string): Promise<
|
||||
Array<
|
||||
TCohortNewsfeedComment & {
|
||||
authorProfileId: string | null;
|
||||
authorFullname: string | null;
|
||||
authorUsername: string | null;
|
||||
authorAvatarUrl: string | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
comment: schema.cohortNewsfeedComment,
|
||||
profile: schema.profile
|
||||
})
|
||||
.from(schema.cohortNewsfeedComment)
|
||||
.leftJoin(schema.cohortMember, eq(schema.cohortNewsfeedComment.authorId, schema.cohortMember.id))
|
||||
.leftJoin(schema.profile, eq(schema.cohortMember.profileId, schema.profile.id))
|
||||
.where(eq(schema.cohortNewsfeedComment.cohortNewsfeedId, feedId))
|
||||
.orderBy(asc(schema.cohortNewsfeedComment.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.comment,
|
||||
authorProfileId: row.profile?.id || null,
|
||||
authorFullname: row.profile?.fullname || null,
|
||||
authorUsername: row.profile?.username || null,
|
||||
authorAvatarUrl: row.profile?.avatarUrl || null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getCohortNewsfeedComments error:', error);
|
||||
throw new Error(
|
||||
`Failed to get cohort newsfeed comments for "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCohortNewsfeedComment(data: TNewCohortNewsfeedComment): Promise<TCohortNewsfeedComment> {
|
||||
try {
|
||||
const [comment] = await db.insert(schema.cohortNewsfeedComment).values(data).returning();
|
||||
if (!comment) throw new Error('Failed to create cohort newsfeed comment');
|
||||
return comment;
|
||||
} catch (error) {
|
||||
console.error('createCohortNewsfeedComment error:', error);
|
||||
throw new Error(
|
||||
`Failed to create cohort newsfeed comment: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCohortNewsfeedComment(commentId: number): Promise<TCohortNewsfeedComment | null> {
|
||||
try {
|
||||
const [deleted] = await db
|
||||
.delete(schema.cohortNewsfeedComment)
|
||||
.where(eq(schema.cohortNewsfeedComment.id, commentId))
|
||||
.returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('deleteCohortNewsfeedComment error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete cohort newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCohortNewsfeedCommentById(commentId: number): Promise<TCohortNewsfeedComment | null> {
|
||||
try {
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(schema.cohortNewsfeedComment)
|
||||
.where(eq(schema.cohortNewsfeedComment.id, commentId))
|
||||
.limit(1);
|
||||
return comment || null;
|
||||
} catch (error) {
|
||||
console.error('getCohortNewsfeedCommentById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get cohort newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,85 +6,85 @@ import { db } from '@db/drizzle';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TProgramGoal = typeof schema.programGoal.$inferSelect;
|
||||
export type TNewProgramGoal = typeof schema.programGoal.$inferInsert;
|
||||
export type TProgramGoalAssignment = typeof schema.programGoalAssignment.$inferSelect;
|
||||
export type TNewProgramGoalAssignment = typeof schema.programGoalAssignment.$inferInsert;
|
||||
export type TCohortGoal = typeof schema.cohortGoal.$inferSelect;
|
||||
export type TNewCohortGoal = typeof schema.cohortGoal.$inferInsert;
|
||||
export type TCohortGoalAssignment = typeof schema.cohortGoalAssignment.$inferSelect;
|
||||
export type TNewCohortGoalAssignment = typeof schema.cohortGoalAssignment.$inferInsert;
|
||||
|
||||
// ─── Goal CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProgramGoal(data: TNewProgramGoal): Promise<TProgramGoal> {
|
||||
export async function createCohortGoal(data: TNewCohortGoal): Promise<TCohortGoal> {
|
||||
try {
|
||||
const [goal] = await db.insert(schema.programGoal).values(data).returning();
|
||||
if (!goal) throw new Error('Failed to create program goal');
|
||||
const [goal] = await db.insert(schema.cohortGoal).values(data).returning();
|
||||
if (!goal) throw new Error('Failed to create cohort goal');
|
||||
|
||||
return goal;
|
||||
} catch (error) {
|
||||
console.error('createProgramGoal error:', error);
|
||||
throw new Error(`Failed to create program goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
console.error('createCohortGoal error:', error);
|
||||
throw new Error(`Failed to create cohort goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalById(goalId: string): Promise<TProgramGoal | null> {
|
||||
export async function getCohortGoalById(goalId: string): Promise<TCohortGoal | null> {
|
||||
try {
|
||||
const [goal] = await db.select().from(schema.programGoal).where(eq(schema.programGoal.id, goalId)).limit(1);
|
||||
const [goal] = await db.select().from(schema.cohortGoal).where(eq(schema.cohortGoal.id, goalId)).limit(1);
|
||||
|
||||
return goal ?? null;
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalById error:', error);
|
||||
console.error('getCohortGoalById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
`Failed to get cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoals(
|
||||
programId: string,
|
||||
export async function getCohortGoals(
|
||||
cohortId: string,
|
||||
options: { includeArchived?: boolean } = {}
|
||||
): Promise<TProgramGoal[]> {
|
||||
): Promise<TCohortGoal[]> {
|
||||
try {
|
||||
const conditions = [eq(schema.programGoal.programId, programId)];
|
||||
const conditions = [eq(schema.cohortGoal.cohortId, cohortId)];
|
||||
if (!options.includeArchived) {
|
||||
conditions.push(eq(schema.programGoal.status, 'active'));
|
||||
conditions.push(eq(schema.cohortGoal.status, 'active'));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.programGoal)
|
||||
.from(schema.cohortGoal)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(schema.programGoal.createdAt));
|
||||
.orderBy(asc(schema.cohortGoal.createdAt));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoals error:', error);
|
||||
throw new Error(`Failed to list program goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
console.error('getCohortGoals error:', error);
|
||||
throw new Error(`Failed to list cohort goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalsByOrg(
|
||||
export async function getCohortGoalsByOrg(
|
||||
organizationId: string
|
||||
): Promise<Array<TProgramGoal & { programId: string; programName: string }>> {
|
||||
): Promise<Array<TCohortGoal & { cohortId: string; cohortName: string }>> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
goal: schema.programGoal,
|
||||
programName: schema.program.name
|
||||
goal: schema.cohortGoal,
|
||||
cohortName: schema.cohort.name
|
||||
})
|
||||
.from(schema.programGoal)
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.where(and(eq(schema.program.organizationId, organizationId), eq(schema.programGoal.status, 'active')));
|
||||
.from(schema.cohortGoal)
|
||||
.innerJoin(schema.cohort, eq(schema.cohortGoal.cohortId, schema.cohort.id))
|
||||
.where(and(eq(schema.cohort.organizationId, organizationId), eq(schema.cohortGoal.status, 'active')));
|
||||
|
||||
return result.map((row) => ({ ...row.goal, programName: row.programName }));
|
||||
return result.map((row) => ({ ...row.goal, cohortName: row.cohortName }));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalsByOrg error:', error);
|
||||
console.error('getCohortGoalsByOrg error:', error);
|
||||
throw new Error(`Failed to list goals for org: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every active goal across every org. Used by the daily cron sweep. */
|
||||
export async function listAllActiveProgramGoals(): Promise<TProgramGoal[]> {
|
||||
export async function listAllActiveCohortGoals(): Promise<TCohortGoal[]> {
|
||||
try {
|
||||
return await db.select().from(schema.programGoal).where(eq(schema.programGoal.status, 'active'));
|
||||
return await db.select().from(schema.cohortGoal).where(eq(schema.cohortGoal.status, 'active'));
|
||||
} catch (error) {
|
||||
console.error('listAllActiveProgramGoals error:', error);
|
||||
console.error('listAllActiveCohortGoals error:', error);
|
||||
throw new Error(`Failed to list active goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
@@ -96,12 +96,12 @@ export async function listAllActiveProgramGoals(): Promise<TProgramGoal[]> {
|
||||
*/
|
||||
export async function listAssignmentsForReminderScan(): Promise<
|
||||
Array<{
|
||||
assignment: TProgramGoalAssignment;
|
||||
goal: TProgramGoal;
|
||||
assignment: TCohortGoalAssignment;
|
||||
goal: TCohortGoal;
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
programId: string;
|
||||
programName: string;
|
||||
cohortId: string;
|
||||
cohortName: string;
|
||||
organizationName: string;
|
||||
organizationSiteName: string | null;
|
||||
organizationCustomDomain: string | null;
|
||||
@@ -113,12 +113,12 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
assignment: schema.programGoalAssignment,
|
||||
goal: schema.programGoal,
|
||||
profileId: schema.programMember.profileId,
|
||||
email: schema.programMember.email,
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name,
|
||||
assignment: schema.cohortGoalAssignment,
|
||||
goal: schema.cohortGoal,
|
||||
profileId: schema.cohortMember.profileId,
|
||||
email: schema.cohortMember.email,
|
||||
cohortId: schema.cohort.id,
|
||||
cohortName: schema.cohort.name,
|
||||
organizationName: schema.organization.name,
|
||||
organizationSiteName: schema.organization.siteName,
|
||||
organizationCustomDomain: schema.organization.customDomain,
|
||||
@@ -126,15 +126,15 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
organizationAvatarUrl: schema.organization.avatarUrl,
|
||||
organizationTheme: schema.organization.theme
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.innerJoin(schema.organization, eq(schema.program.organizationId, schema.organization.id))
|
||||
.from(schema.cohortGoalAssignment)
|
||||
.innerJoin(schema.cohortMember, eq(schema.cohortGoalAssignment.cohortMemberId, schema.cohortMember.id))
|
||||
.innerJoin(schema.cohortGoal, eq(schema.cohortGoalAssignment.goalId, schema.cohortGoal.id))
|
||||
.innerJoin(schema.cohort, eq(schema.cohortGoal.cohortId, schema.cohort.id))
|
||||
.innerJoin(schema.organization, eq(schema.cohort.organizationId, schema.organization.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.programGoal.status, 'active'),
|
||||
inArray(schema.programGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
|
||||
eq(schema.cohortGoal.status, 'active'),
|
||||
inArray(schema.cohortGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -145,8 +145,8 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
goal: row.goal,
|
||||
profileId: row.profileId!,
|
||||
email: row.email,
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName,
|
||||
organizationName: row.organizationName,
|
||||
organizationSiteName: row.organizationSiteName,
|
||||
organizationCustomDomain: row.organizationCustomDomain,
|
||||
@@ -162,49 +162,47 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramGoal(goalId: string, data: Partial<TNewProgramGoal>): Promise<TProgramGoal | null> {
|
||||
export async function updateCohortGoal(goalId: string, data: Partial<TNewCohortGoal>): Promise<TCohortGoal | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.programGoal)
|
||||
.update(schema.cohortGoal)
|
||||
.set({ ...data, updatedAt: sql`now()` })
|
||||
.where(eq(schema.programGoal.id, goalId))
|
||||
.where(eq(schema.cohortGoal.id, goalId))
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateProgramGoal error:', error);
|
||||
console.error('updateCohortGoal error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
`Failed to update cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramGoal(goalId: string): Promise<TProgramGoal | null> {
|
||||
export async function deleteCohortGoal(goalId: string): Promise<TCohortGoal | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.programGoal).where(eq(schema.programGoal.id, goalId)).returning();
|
||||
const [deleted] = await db.delete(schema.cohortGoal).where(eq(schema.cohortGoal.id, goalId)).returning();
|
||||
|
||||
return deleted ?? null;
|
||||
} catch (error) {
|
||||
console.error('deleteProgramGoal error:', error);
|
||||
console.error('deleteCohortGoal error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
`Failed to delete cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Goal Assignments ────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertProgramGoalAssignments(
|
||||
rows: TNewProgramGoalAssignment[]
|
||||
): Promise<TProgramGoalAssignment[]> {
|
||||
export async function upsertCohortGoalAssignments(rows: TNewCohortGoalAssignment[]): Promise<TCohortGoalAssignment[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.insert(schema.programGoalAssignment)
|
||||
.insert(schema.cohortGoalAssignment)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.programGoalAssignment.goalId, schema.programGoalAssignment.programMemberId],
|
||||
target: [schema.cohortGoalAssignment.goalId, schema.cohortGoalAssignment.cohortMemberId],
|
||||
set: {
|
||||
dueDate: sql`excluded.due_date`,
|
||||
status: sql`excluded.status`,
|
||||
@@ -217,62 +215,62 @@ export async function upsertProgramGoalAssignments(
|
||||
})
|
||||
.returning();
|
||||
} catch (error) {
|
||||
console.error('upsertProgramGoalAssignments error:', error);
|
||||
console.error('upsertCohortGoalAssignments error:', error);
|
||||
throw new Error(`Failed to upsert goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalAssignments(goalId: string): Promise<TProgramGoalAssignment[]> {
|
||||
export async function getCohortGoalAssignments(goalId: string): Promise<TCohortGoalAssignment[]> {
|
||||
try {
|
||||
return await db.select().from(schema.programGoalAssignment).where(eq(schema.programGoalAssignment.goalId, goalId));
|
||||
return await db.select().from(schema.cohortGoalAssignment).where(eq(schema.cohortGoalAssignment.goalId, goalId));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalAssignments error:', error);
|
||||
console.error('getCohortGoalAssignments error:', error);
|
||||
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignmentsForProgramMembers(programMemberIds: string[]): Promise<TProgramGoalAssignment[]> {
|
||||
if (programMemberIds.length === 0) return [];
|
||||
export async function getAssignmentsForCohortMembers(cohortMemberIds: string[]): Promise<TCohortGoalAssignment[]> {
|
||||
if (cohortMemberIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.programGoalAssignment)
|
||||
.where(inArray(schema.programGoalAssignment.programMemberId, programMemberIds));
|
||||
.from(schema.cohortGoalAssignment)
|
||||
.where(inArray(schema.cohortGoalAssignment.cohortMemberId, cohortMemberIds));
|
||||
} catch (error) {
|
||||
console.error('getAssignmentsForProgramMembers error:', error);
|
||||
console.error('getAssignmentsForCohortMembers error:', error);
|
||||
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignmentsForProfile(profileId: string): Promise<
|
||||
Array<
|
||||
TProgramGoalAssignment & {
|
||||
goal: TProgramGoal;
|
||||
programId: string;
|
||||
programName: string;
|
||||
TCohortGoalAssignment & {
|
||||
goal: TCohortGoal;
|
||||
cohortId: string;
|
||||
cohortName: string;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
assignment: schema.programGoalAssignment,
|
||||
goal: schema.programGoal,
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name
|
||||
assignment: schema.cohortGoalAssignment,
|
||||
goal: schema.cohortGoal,
|
||||
cohortId: schema.cohort.id,
|
||||
cohortName: schema.cohort.name
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.where(and(eq(schema.programMember.profileId, profileId), eq(schema.programGoal.status, 'active')));
|
||||
.from(schema.cohortGoalAssignment)
|
||||
.innerJoin(schema.cohortMember, eq(schema.cohortGoalAssignment.cohortMemberId, schema.cohortMember.id))
|
||||
.innerJoin(schema.cohortGoal, eq(schema.cohortGoalAssignment.goalId, schema.cohortGoal.id))
|
||||
.innerJoin(schema.cohort, eq(schema.cohortGoal.cohortId, schema.cohort.id))
|
||||
.where(and(eq(schema.cohortMember.profileId, profileId), eq(schema.cohortGoal.status, 'active')));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.assignment,
|
||||
goal: row.goal,
|
||||
programId: row.programId,
|
||||
programName: row.programName
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getAssignmentsForProfile error:', error);
|
||||
@@ -283,7 +281,7 @@ export async function getAssignmentsForProfile(profileId: string): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-goal status counts for a program (used by dashboard tiles).
|
||||
* Per-goal status counts for a cohort (used by dashboard tiles).
|
||||
*/
|
||||
export async function countAssignmentsByStatus(goalIds: string[]): Promise<Map<string, Record<string, number>>> {
|
||||
if (goalIds.length === 0) return new Map();
|
||||
@@ -291,13 +289,13 @@ export async function countAssignmentsByStatus(goalIds: string[]): Promise<Map<s
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
goalId: schema.programGoalAssignment.goalId,
|
||||
status: schema.programGoalAssignment.status,
|
||||
goalId: schema.cohortGoalAssignment.goalId,
|
||||
status: schema.cohortGoalAssignment.status,
|
||||
count: sql<number>`count(*)::int`.as('count')
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.where(inArray(schema.programGoalAssignment.goalId, goalIds))
|
||||
.groupBy(schema.programGoalAssignment.goalId, schema.programGoalAssignment.status);
|
||||
.from(schema.cohortGoalAssignment)
|
||||
.where(inArray(schema.cohortGoalAssignment.goalId, goalIds))
|
||||
.groupBy(schema.cohortGoalAssignment.goalId, schema.cohortGoalAssignment.status);
|
||||
|
||||
const map = new Map<string, Record<string, number>>();
|
||||
for (const row of rows) {
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './cohort';
|
||||
export * from './goal';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user