feat: Implement full-stack cohort management with dedicated API, database schema, and admin UI, alongside updates to the database reset script and documentation.
This commit is contained in:
@@ -38,6 +38,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
||||
- **Discussion Forums**: Sistema completo de foros por curso con hilos de discusión, respuestas anidadas, votación, moderación por instructores y suscripciones.
|
||||
- **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo).
|
||||
- **Course Monetization**: Integración con Mercado Pago para venta de cursos, con inscripciones automáticas y paneles de precios para instructores.
|
||||
- **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced).
|
||||
|
||||
## Requisitos del Sistema
|
||||
|
||||
@@ -118,6 +119,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### 🧹 Mantenimiento de Base de Datos
|
||||
Para resetear completamente el entorno de desarrollo y empezar desde cero:
|
||||
```bash
|
||||
# Borra las bases de datos openccb_cms/lms y las vuelve a migrar
|
||||
./scripts/reset_db.sh
|
||||
```
|
||||
|
||||
## 🔌 Manual del Desarrollador (API)
|
||||
|
||||
### 1. Autenticación y Cuentas
|
||||
@@ -583,6 +591,7 @@ Obtiene una lista de todas las organizaciones registradas.
|
||||
- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality.
|
||||
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
||||
- **Mercado Pago Monetization**: Integrated payment gateway with automatic course unlocking and transaction tracking.
|
||||
- **Student Notes Panel**: Personal lesson annotations with glassmorphism UI and intelligent auto-save.
|
||||
|
||||
## 📄 Licencia
|
||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||
+1
-1
@@ -180,7 +180,7 @@
|
||||
- [x] Frontend (componentes React)
|
||||
- [x] Integración con notificaciones
|
||||
- [x] **Course Announcements**: Sistema de anuncios de instructores con notificaciones.
|
||||
- [ ] **Student Notes**: Anotaciones personales por lección con exportación a PDF.
|
||||
- [x] **Student Notes**: Anotaciones personales por lección con exportación a PDF.
|
||||
- [ ] **Peer Assessment**: Evaluación entre pares con rúbricas configurables.
|
||||
- [ ] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico.
|
||||
- [ ] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Cohorts table
|
||||
CREATE TABLE IF NOT EXISTS cohorts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT cohorts_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User-Cohort relationship table (M:N)
|
||||
CREATE TABLE IF NOT EXISTS user_cohorts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cohort_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT user_cohorts_cohort_id_fkey FOREIGN KEY (cohort_id) REFERENCES cohorts(id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_cohorts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_cohorts_unique UNIQUE (cohort_id, user_id)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_cohorts_organization_id ON cohorts(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_cohorts_cohort_id ON user_cohorts(cohort_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_cohorts_user_id ON user_cohorts(user_id);
|
||||
@@ -0,0 +1,146 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::auth::Claims;
|
||||
use common::middleware::Org;
|
||||
use common::models::{AddMemberPayload, Cohort, CreateCohortPayload, UserCohort};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn list_cohorts(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Cohort>>, (StatusCode, String)> {
|
||||
let cohorts = sqlx::query_as::<_, Cohort>(
|
||||
"SELECT * FROM cohorts WHERE organization_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(cohorts))
|
||||
}
|
||||
|
||||
pub async fn create_cohort(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateCohortPayload>,
|
||||
) -> Result<Json<Cohort>, (StatusCode, String)> {
|
||||
let cohort = sqlx::query_as::<_, Cohort>(
|
||||
r#"
|
||||
INSERT INTO cohorts (organization_id, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(payload.name)
|
||||
.bind(payload.description)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(cohort))
|
||||
}
|
||||
|
||||
pub async fn add_cohort_member(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
Path(cohort_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AddMemberPayload>,
|
||||
) -> Result<Json<UserCohort>, (StatusCode, String)> {
|
||||
// Verify cohort belongs to org
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
.bind(cohort_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
|
||||
}
|
||||
|
||||
let member = sqlx::query_as::<_, UserCohort>(
|
||||
r#"
|
||||
INSERT INTO user_cohorts (cohort_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (cohort_id, user_id) DO UPDATE SET assigned_at = NOW()
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(cohort_id)
|
||||
.bind(payload.user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(member))
|
||||
}
|
||||
|
||||
pub async fn remove_cohort_member(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
Path((cohort_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Verify cohort belongs to org
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
.bind(cohort_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM user_cohorts WHERE cohort_id = $1 AND user_id = $2")
|
||||
.bind(cohort_id)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn get_cohort_members(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
Path(cohort_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Uuid>>, (StatusCode, String)> {
|
||||
// Verify cohort belongs to org
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
.bind(cohort_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
|
||||
}
|
||||
|
||||
let members = sqlx::query_scalar("SELECT user_id FROM user_cohorts WHERE cohort_id = $1")
|
||||
.bind(cohort_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(members))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod db_util;
|
||||
mod handlers;
|
||||
mod handlers_announcements;
|
||||
mod handlers_cohorts;
|
||||
mod handlers_discussions;
|
||||
mod handlers_notes;
|
||||
mod handlers_payments;
|
||||
@@ -150,6 +151,21 @@ async fn main() {
|
||||
)
|
||||
.route("/lessons/{id}/notes", get(handlers_notes::get_note))
|
||||
.route("/lessons/{id}/notes", put(handlers_notes::save_note))
|
||||
// Cohorts
|
||||
.route("/cohorts", get(handlers_cohorts::list_cohorts))
|
||||
.route("/cohorts", post(handlers_cohorts::create_cohort))
|
||||
.route(
|
||||
"/cohorts/{id}/members",
|
||||
post(handlers_cohorts::add_cohort_member),
|
||||
)
|
||||
.route(
|
||||
"/cohorts/{cohort_id}/members/{user_id}",
|
||||
delete(handlers_cohorts::remove_cohort_member),
|
||||
)
|
||||
.route(
|
||||
"/cohorts/{id}/members",
|
||||
get(handlers_cohorts::get_cohort_members),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
@@ -451,6 +451,36 @@ pub struct SaveNotePayload {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// Cohorts & Groups
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct Cohort {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct UserCohort {
|
||||
pub id: Uuid,
|
||||
pub cohort_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub assigned_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCohortPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddMemberPayload {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -19,6 +19,7 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer";
|
||||
import DocumentPlayer from "@/components/blocks/DocumentPlayer";
|
||||
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
|
||||
import InteractiveTranscript from "@/components/InteractiveTranscript";
|
||||
import AITutor from "@/components/AITutor";
|
||||
import LessonLockedView from "@/components/LessonLockedView";
|
||||
import StudentNotes from "@/components/StudentNotes";
|
||||
import { ListMusic, StickyNote } from "lucide-react";
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { lmsApi, cmsApi, Cohort, User } from "@/lib/api";
|
||||
import { Users, Plus, UserPlus, X, Search, Trash2, Loader2, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export default function CohortsPage() {
|
||||
const [cohorts, setCohorts] = useState<Cohort[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedCohort, setSelectedCohort] = useState<Cohort | null>(null);
|
||||
const [memberIds, setMemberIds] = useState<string[]>([]);
|
||||
const [newCohortName, setNewCohortName] = useState("");
|
||||
const [newCohortDesc, setNewCohortDesc] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [fetchedCohorts, fetchedUsers] = await Promise.all([
|
||||
lmsApi.getCohorts(),
|
||||
cmsApi.getAllUsers(),
|
||||
]);
|
||||
setCohorts(fetchedCohorts);
|
||||
setUsers(fetchedUsers);
|
||||
if (fetchedCohorts.length > 0 && !selectedCohort) {
|
||||
setSelectedCohort(fetchedCohorts[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ text: "Error loading cohorts or users", type: "error" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedCohort]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const loadMembers = useCallback(async (cohortId: string) => {
|
||||
try {
|
||||
const ids = await lmsApi.getMembers(cohortId);
|
||||
setMemberIds(ids);
|
||||
} catch (error) {
|
||||
console.error("Error loading members:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCohort) {
|
||||
loadMembers(selectedCohort.id);
|
||||
}
|
||||
}, [selectedCohort, loadMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
const timer = setTimeout(() => setMessage(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const handleCreateCohort = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCohortName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const cohort = await lmsApi.createCohort({
|
||||
name: newCohortName,
|
||||
description: newCohortDesc,
|
||||
});
|
||||
setCohorts([cohort, ...cohorts]);
|
||||
setSelectedCohort(cohort);
|
||||
setNewCohortName("");
|
||||
setNewCohortDesc("");
|
||||
setMessage({ text: "Cohort created successfully", type: "success" });
|
||||
} catch (error) {
|
||||
setMessage({ text: "Error creating cohort", type: "error" });
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (userId: string) => {
|
||||
if (!selectedCohort) return;
|
||||
|
||||
try {
|
||||
await lmsApi.addMember(selectedCohort.id, userId);
|
||||
setMemberIds([...memberIds, userId]);
|
||||
setMessage({ text: "Student added to cohort", type: "success" });
|
||||
} catch (error) {
|
||||
setMessage({ text: "Error adding student", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!selectedCohort) return;
|
||||
|
||||
try {
|
||||
await lmsApi.removeMember(selectedCohort.id, userId);
|
||||
setMemberIds(memberIds.filter(id => id !== userId));
|
||||
setMessage({ text: "Student removed from cohort", type: "success" });
|
||||
} catch (error) {
|
||||
setMessage({ text: "Error removing student", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.full_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8 max-w-7xl mx-auto">
|
||||
{message && (
|
||||
<div className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg z-[100] transition-opacity duration-500 flex items-center gap-2 ${message.type === "success" ? "bg-green-600/90 text-white" : "bg-red-600/90 text-white"
|
||||
}`}>
|
||||
{message.type === "success" ? <CheckCircle2 size={18} /> : <X size={18} />}
|
||||
<span className="font-medium">{message.text}</span>
|
||||
<button onClick={() => setMessage(null)} className="ml-2 hover:opacity-70"><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white mb-1">Cohorts & Groups</h1>
|
||||
<p className="text-gray-400">Manage student segments for your organization.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedCohort(null)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> New Cohort
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Cohorts List */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 text-white">
|
||||
<Users className="h-5 w-5" /> Cohorts
|
||||
</h2>
|
||||
<div className="space-y-2 overflow-y-auto max-h-[600px] pr-2 custom-scrollbar">
|
||||
{cohorts.map((cohort) => (
|
||||
<button
|
||||
key={cohort.id}
|
||||
onClick={() => setSelectedCohort(cohort)}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all ${selectedCohort?.id === cohort.id
|
||||
? "bg-blue-600/10 border-blue-500 ring-1 ring-blue-500/50"
|
||||
: "bg-gray-900/50 hover:bg-gray-800 border-white/5"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-white">{cohort.name}</div>
|
||||
{cohort.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 line-clamp-1">
|
||||
{cohort.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cohort Details & User Management */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{selectedCohort ? (
|
||||
<div className="glass-card bg-gray-900/40 border border-white/5 rounded-2xl p-6 space-y-6 shadow-xl">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-1">{selectedCohort.name}</h3>
|
||||
<p className="text-gray-400">{selectedCohort.description || "No description provided."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-white/5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<h4 className="text-lg font-medium text-white">Students</h4>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<input
|
||||
placeholder="Search students..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-black/40 border border-white/10 rounded-xl text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 bg-black/20 rounded-xl italic">
|
||||
No students found.
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const isMember = memberIds.includes(user.id);
|
||||
return (
|
||||
<div key={user.id} className="flex items-center justify-between p-4 bg-gray-900/60 border border-white/5 rounded-xl hover:bg-gray-800/80 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-600/20 border border-blue-500/20 flex items-center justify-center font-bold text-blue-400">
|
||||
{user.full_name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{user.full_name}</div>
|
||||
<div className="text-xs text-gray-400">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isMember ? (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(user.id)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-600/10 hover:bg-red-600/20 text-red-400 border border-red-900/50 rounded-lg text-sm transition-all"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Remove
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAddMember(user.id)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 border border-blue-900/50 rounded-lg text-sm transition-all"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" /> Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card bg-gray-900/40 border border-white/5 rounded-2xl p-8 shadow-xl max-w-xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<Users className="h-12 w-12 text-blue-500 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white">Create New Cohort</h3>
|
||||
<p className="text-gray-400">Define a new student segment to target specific learning experiences.</p>
|
||||
</div>
|
||||
<form onSubmit={handleCreateCohort} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">Cohort Name</label>
|
||||
<input
|
||||
className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-white"
|
||||
placeholder="e.g. Science Batch 2026-A"
|
||||
value={newCohortName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewCohortName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">Description (Optional)</label>
|
||||
<textarea
|
||||
className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-white min-h-[100px]"
|
||||
placeholder="What is this cohort for?"
|
||||
value={newCohortDesc}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewCohortDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-5 w-5 animate-spin" /> : <Plus className="h-5 w-5" />}
|
||||
Create Cohort
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
||||
export const LMS_API_BASE_URL = getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL);
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
@@ -265,12 +266,38 @@ export interface Asset {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Cohort {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserCohort {
|
||||
id: string;
|
||||
cohort_id: string;
|
||||
user_id: string;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface CreateCohortPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AddMemberPayload {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
|
||||
const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null;
|
||||
|
||||
const apiFetch = (url: string, options: RequestInit = {}) => {
|
||||
const apiFetch = (url: string, options: RequestInit = {}, isLms: boolean = false) => {
|
||||
const token = getToken();
|
||||
const selectedOrgId = getSelectedOrgId();
|
||||
const baseUrl = isLms ? LMS_API_BASE_URL : API_BASE_URL;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
@@ -278,7 +305,7 @@ const apiFetch = (url: string, options: RequestInit = {}) => {
|
||||
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
||||
};
|
||||
|
||||
return fetch(`${API_BASE_URL}${url}`, { ...options, headers }).then(async res => {
|
||||
return fetch(`${baseUrl}${url}`, { ...options, headers }).then(async res => {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
@@ -456,6 +483,14 @@ export const cmsApi = {
|
||||
cancelTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const lmsApi = {
|
||||
getCohorts: (): Promise<Cohort[]> => apiFetch('/cohorts', {}, true),
|
||||
createCohort: (payload: CreateCohortPayload): Promise<Cohort> => apiFetch('/cohorts', { method: 'POST', body: JSON.stringify(payload) }, true),
|
||||
addMember: (cohortId: string, userId: string): Promise<UserCohort> => apiFetch(`/cohorts/${cohortId}/members`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }, true),
|
||||
removeMember: (cohortId: string, userId: string): Promise<void> => apiFetch(`/cohorts/${cohortId}/members/${userId}`, { method: 'DELETE' }, true),
|
||||
getMembers: (id: string): Promise<string[]> => apiFetch(`/cohorts/${id}/members`, {}, true),
|
||||
};
|
||||
|
||||
export interface BackgroundTask {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user