feat: Implement full-stack course announcements management with cohort segmentation.
This commit is contained in:
@@ -39,8 +39,9 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
- **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo).
|
- **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.
|
- **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).
|
- **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced).
|
||||||
- **Cohorts & Groups**: Segmentación de estudiantes en cohortes para gestión de grupos y análisis comparativo.
|
- **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes, exportación masiva a CSV con desgloses por categoría y pertenencia a cohortes.
|
||||||
- **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes y exportación a CSV.
|
- **Bulk Operations**: Herramientas administrativas para inscripción masiva de usuarios vía email y comunicación segmentada.
|
||||||
|
- **Segmented Announcements**: Sistema de anuncios con capacidad de dirigirse a cohortes específicas y notificaciones filtradas.
|
||||||
- **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos.
|
- **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos.
|
||||||
- **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio.
|
- **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio.
|
||||||
- **Learning Sequences**: Gestión de prerrequisitos entre lecciones con cumplimiento forzado en el LMS.
|
- **Learning Sequences**: Gestión de prerrequisitos entre lecciones con cumplimiento forzado en el LMS.
|
||||||
|
|||||||
+5
-5
@@ -186,7 +186,7 @@
|
|||||||
- [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
- [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||||
- [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
- [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
||||||
- [x] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
- [x] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||||
- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva.
|
- [x] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva. ✅
|
||||||
- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares.
|
- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares.
|
||||||
- [ ] **Course Preview**: Vista previa de lecciones sin inscripción.
|
- [ ] **Course Preview**: Vista previa de lecciones sin inscripción.
|
||||||
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
|
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
|
||||||
@@ -215,9 +215,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios**, **monetización integrada con Mercado Pago**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado** y **Secuencias de Aprendizaje (Prerrequisitos)**.
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado** y **Secuencias de Aprendizaje**.
|
||||||
|
|
||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos.
|
1. **Course Teams**: Gestión de múltiples instructores por curso con roles granulares.
|
||||||
2. **Course Teams**: Gestión de múltiples instructores por curso.
|
2. **Course Preview**: Vista previa de lecciones sin inscripción para mejorar la conversión.
|
||||||
3. **Course Preview**: Vista previa de lecciones sin inscripción.
|
3. **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration to support multiple instructors per course
|
||||||
|
CREATE TABLE course_instructors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'instructor', -- 'primary', 'instructor', 'assistant'
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(course_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed with existing data from courses table
|
||||||
|
INSERT INTO course_instructors (course_id, user_id, role)
|
||||||
|
SELECT id, instructor_id, 'primary' FROM courses;
|
||||||
|
|
||||||
|
-- We keep the instructor_id in courses for now to avoid breaking changes,
|
||||||
|
-- but it should be considered deprecated in favor of course_instructors.
|
||||||
|
COMMENT ON COLUMN courses.instructor_id IS 'Deprecated: use course_instructors table instead.';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Add table for announcement-cohort relationship
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement_cohorts (
|
||||||
|
announcement_id UUID NOT NULL REFERENCES course_announcements(id) ON DELETE CASCADE,
|
||||||
|
cohort_id UUID NOT NULL REFERENCES cohorts(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (announcement_id, cohort_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for performance
|
||||||
|
CREATE INDEX idx_announcement_cohorts_cohort ON announcement_cohorts(cohort_id);
|
||||||
@@ -5,7 +5,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use common::auth::Claims;
|
use common::auth::Claims;
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
use common::models::{CourseAnnouncement, AnnouncementWithAuthor};
|
use common::models::{AnnouncementWithAuthor, CourseAnnouncement};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -17,6 +17,7 @@ pub struct CreateAnnouncementPayload {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub is_pinned: Option<bool>,
|
pub is_pinned: Option<bool>,
|
||||||
|
pub cohort_ids: Option<Vec<Uuid>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -33,7 +34,7 @@ pub async fn list_announcements(
|
|||||||
Path(course_id): Path<Uuid>,
|
Path(course_id): Path<Uuid>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<AnnouncementWithAuthor>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<AnnouncementWithAuthor>>, (StatusCode, String)> {
|
||||||
let announcements = sqlx::query_as::<_, AnnouncementWithAuthor>(
|
let mut announcements = sqlx::query_as::<_, AnnouncementWithAuthor>(
|
||||||
"SELECT
|
"SELECT
|
||||||
a.*,
|
a.*,
|
||||||
u.full_name as author_name,
|
u.full_name as author_name,
|
||||||
@@ -41,7 +42,7 @@ pub async fn list_announcements(
|
|||||||
FROM course_announcements a
|
FROM course_announcements a
|
||||||
LEFT JOIN users u ON a.author_id = u.id
|
LEFT JOIN users u ON a.author_id = u.id
|
||||||
WHERE a.course_id = $1 AND a.organization_id = $2
|
WHERE a.course_id = $1 AND a.organization_id = $2
|
||||||
ORDER BY a.is_pinned DESC, a.created_at DESC"
|
ORDER BY a.is_pinned DESC, a.created_at DESC",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -49,6 +50,21 @@ pub async fn list_announcements(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Attach cohort_ids to each announcement
|
||||||
|
for a in &mut announcements {
|
||||||
|
let cohorts = sqlx::query!(
|
||||||
|
"SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||||
|
a.id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if !cohorts.is_empty() {
|
||||||
|
a.cohort_ids = Some(cohorts.into_iter().map(|c| c.cohort_id).collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(announcements))
|
Ok(Json(announcements))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +83,19 @@ pub async fn create_announcement(
|
|||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only instructors can create announcements".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only instructors can create announcements".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create announcement
|
let mut tx = pool
|
||||||
let announcement = sqlx::query_as::<_, CourseAnnouncement>(
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// 1. Create announcement
|
||||||
|
let mut announcement = sqlx::query_as::<_, CourseAnnouncement>(
|
||||||
"INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned)
|
"INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *"
|
RETURNING *"
|
||||||
@@ -82,18 +106,63 @@ pub async fn create_announcement(
|
|||||||
.bind(&payload.title)
|
.bind(&payload.title)
|
||||||
.bind(&payload.content)
|
.bind(&payload.content)
|
||||||
.bind(payload.is_pinned.unwrap_or(false))
|
.bind(payload.is_pinned.unwrap_or(false))
|
||||||
.fetch_one(&pool)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Get all enrolled students for notifications
|
// 2. Link cohorts if provided
|
||||||
let enrolled_students = sqlx::query_as::<_, (Uuid,)>(
|
if let Some(ref cohort_ids) = payload.cohort_ids {
|
||||||
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2"
|
for cohort_id in cohort_ids {
|
||||||
)
|
sqlx::query(
|
||||||
.bind(course_id)
|
"INSERT INTO announcement_cohorts (announcement_id, cohort_id) VALUES ($1, $2)",
|
||||||
.bind(claims.sub) // Exclude the announcement author
|
)
|
||||||
.fetch_all(&pool)
|
.bind(announcement.id)
|
||||||
.await
|
.bind(cohort_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
}
|
||||||
|
announcement.cohort_ids = Some(cohort_ids.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// 3. Get target students for notifications
|
||||||
|
let enrolled_students = if let Some(ref cohort_ids) = payload.cohort_ids {
|
||||||
|
if !cohort_ids.is_empty() {
|
||||||
|
sqlx::query_as::<_, (Uuid,)>(
|
||||||
|
"SELECT DISTINCT uc.user_id
|
||||||
|
FROM user_cohorts uc
|
||||||
|
JOIN enrollments e ON uc.user_id = e.user_id AND e.course_id = $1
|
||||||
|
WHERE uc.cohort_id = ANY($2) AND uc.user_id != $3",
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(cohort_ids)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
// Fallback to everyone if empty list provided (though UI should prevent)
|
||||||
|
sqlx::query_as::<_, (Uuid,)>(
|
||||||
|
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2",
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No segment provided -> everyone in the course
|
||||||
|
sqlx::query_as::<_, (Uuid,)>(
|
||||||
|
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2",
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Create notification for each enrolled student
|
// Create notification for each enrolled student
|
||||||
@@ -137,12 +206,15 @@ pub async fn update_announcement(
|
|||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only instructors can update announcements".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only instructors can update announcements".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current announcement to verify ownership
|
// Get current announcement to verify ownership
|
||||||
let current = sqlx::query_as::<_, CourseAnnouncement>(
|
let current = sqlx::query_as::<_, CourseAnnouncement>(
|
||||||
"SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2"
|
"SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2",
|
||||||
)
|
)
|
||||||
.bind(announcement_id)
|
.bind(announcement_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
@@ -158,7 +230,7 @@ pub async fn update_announcement(
|
|||||||
"UPDATE course_announcements
|
"UPDATE course_announcements
|
||||||
SET title = $1, content = $2, is_pinned = $3
|
SET title = $1, content = $2, is_pinned = $3
|
||||||
WHERE id = $4 AND organization_id = $5
|
WHERE id = $4 AND organization_id = $5
|
||||||
RETURNING *"
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(content)
|
.bind(content)
|
||||||
@@ -186,12 +258,15 @@ pub async fn delete_announcement(
|
|||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only instructors can delete announcements".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only instructors can delete announcements".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM course_announcements
|
"DELETE FROM course_announcements
|
||||||
WHERE id = $1 AND organization_id = $2"
|
WHERE id = $1 AND organization_id = $2",
|
||||||
)
|
)
|
||||||
.bind(announcement_id)
|
.bind(announcement_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
|
|||||||
@@ -418,6 +418,8 @@ pub struct CourseAnnouncement {
|
|||||||
pub is_pinned: bool,
|
pub is_pinned: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
#[sqlx(skip)]
|
||||||
|
pub cohort_ids: Option<Vec<Uuid>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
@@ -435,6 +437,8 @@ pub struct AnnouncementWithAuthor {
|
|||||||
// Author info
|
// Author info
|
||||||
pub author_name: String,
|
pub author_name: String,
|
||||||
pub author_avatar: Option<String>,
|
pub author_avatar: Option<String>,
|
||||||
|
#[sqlx(skip)]
|
||||||
|
pub cohort_ids: Option<Vec<Uuid>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Student Notes
|
// Student Notes
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { lmsApi, AnnouncementWithAuthor, Cohort } from "@/lib/api";
|
||||||
|
import { Megaphone, Plus, Search, Loader2, ArrowLeft, Pin, Trash2, Users } from "lucide-react";
|
||||||
|
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { es } from "date-fns/locale";
|
||||||
|
|
||||||
|
export default function AnnouncementsPage() {
|
||||||
|
const { id } = useParams() as { id: string };
|
||||||
|
const router = useRouter();
|
||||||
|
const [announcements, setAnnouncements] = useState<AnnouncementWithAuthor[]>([]);
|
||||||
|
const [cohorts, setCohorts] = useState<Cohort[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [showNewModal, setShowNewModal] = useState(false);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [annData, cohortData] = await Promise.all([
|
||||||
|
lmsApi.listAnnouncements(id),
|
||||||
|
lmsApi.getCohorts()
|
||||||
|
]);
|
||||||
|
setAnnouncements(annData);
|
||||||
|
setCohorts(cohortData.filter(c => c.course_id === id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching announcements:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleDelete = async (announcementId: string) => {
|
||||||
|
if (!confirm("¿Estás seguro de que deseas eliminar este anuncio?")) return;
|
||||||
|
try {
|
||||||
|
await lmsApi.deleteAnnouncement(announcementId);
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting announcement:", error);
|
||||||
|
alert("Error al eliminar el anuncio");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAnnouncements = announcements.filter(a =>
|
||||||
|
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
a.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0f1115] text-white p-8">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-orange-400 to-red-400 bg-clip-text text-transparent">
|
||||||
|
Announcements
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Manage course communications and cohort segments</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewModal(true)}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-orange-600 hover:bg-orange-500 rounded-xl font-bold shadow-lg shadow-orange-500/20 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
New Announcement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CourseEditorLayout activeTab="announcements">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="glass p-4 rounded-2xl flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search announcements..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-orange-500/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Announcements List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<Loader2 className="w-10 h-10 text-orange-500 animate-spin" />
|
||||||
|
<p className="text-gray-400">Loading announcements...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredAnnouncements.length > 0 ? (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{filteredAnnouncements.map((a) => (
|
||||||
|
<div key={a.id} className={`relative p-6 rounded-2xl border transition-all duration-300 ${a.is_pinned ? 'bg-orange-500/10 border-orange-500/30' : 'bg-white/5 border-white/10 hover:border-white/20'}`}>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center text-white font-bold overflow-hidden">
|
||||||
|
{a.author_avatar ? (
|
||||||
|
<img src={a.author_avatar} alt={a.author_name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
a.author_name.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white">{a.author_name}</h4>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<span>{formatDistanceToNow(new Date(a.created_at), { addSuffix: true, locale: es })}</span>
|
||||||
|
{a.cohort_ids && a.cohort_ids.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center gap-1 text-blue-400 font-medium">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
<span>{a.cohort_ids.length} Cohorts</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{a.is_pinned && <Pin className="w-4 h-4 text-orange-400 fill-current" />}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(a.id)}
|
||||||
|
className="p-2 rounded-lg hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">{a.title}</h3>
|
||||||
|
<p className="text-gray-300 whitespace-pre-wrap">{a.content}</p>
|
||||||
|
|
||||||
|
{/* Display Target Cohort Names if segmented */}
|
||||||
|
{a.cohort_ids && a.cohort_ids.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{a.cohort_ids.map(cid => {
|
||||||
|
const cohort = cohorts.find(c => c.id === cid);
|
||||||
|
return (
|
||||||
|
<span key={cid} className="px-2 py-1 bg-blue-500/10 border border-blue-500/20 rounded-md text-[10px] text-blue-400 font-bold uppercase tracking-wider">
|
||||||
|
{cohort?.name || 'Unknown Cohort'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-20 text-center">
|
||||||
|
<Megaphone className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">No announcements found</h3>
|
||||||
|
<p className="text-gray-400">Start by creating a new announcement for your students.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CourseEditorLayout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewModal && (
|
||||||
|
<NewAnnouncementModal
|
||||||
|
courseId={id}
|
||||||
|
cohorts={cohorts}
|
||||||
|
onClose={() => setShowNewModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowNewModal(false);
|
||||||
|
fetchData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline NewAnnouncementModal for simplicity, or move to its own file if it grows
|
||||||
|
function NewAnnouncementModal({ courseId, cohorts, onClose, onSuccess }: { courseId: string, cohorts: Cohort[], onClose: () => void, onSuccess: () => void }) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [isPinned, setIsPinned] = useState(false);
|
||||||
|
const [selectedCohorts, setSelectedCohorts] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await lmsApi.createAnnouncement(courseId, {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
is_pinned: isPinned,
|
||||||
|
cohort_ids: selectedCohorts.length > 0 ? selectedCohorts : undefined
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("Failed to create announcement");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCohort = (id: string) => {
|
||||||
|
setSelectedCohorts(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-[#1a1c1e] border border-white/10 rounded-3xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Megaphone className="w-5 h-5 text-orange-500" />
|
||||||
|
Create New Announcement
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors">
|
||||||
|
<Plus className="w-6 h-6 rotate-45" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-gray-400">Title</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
|
||||||
|
placeholder="Announcement title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-gray-400">Content</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50 resize-none"
|
||||||
|
placeholder="Type your message here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-bold text-gray-400 flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
Target Segments (Optional)
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500">Select specific cohorts to receive this announcement. Leave empty to send to all students.</p>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-1">
|
||||||
|
{cohorts.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCohort(c.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${selectedCohorts.includes(c.id) ? 'bg-blue-600 border-blue-500 text-white' : 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10'}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-orange-500/5 border border-orange-500/10 rounded-2xl">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="pin"
|
||||||
|
checked={isPinned}
|
||||||
|
onChange={(e) => setIsPinned(e.target.checked)}
|
||||||
|
className="w-5 h-5 rounded border-white/10 bg-white/5 text-orange-600 focus:ring-orange-500/50"
|
||||||
|
/>
|
||||||
|
<label htmlFor="pin" className="text-sm font-medium text-gray-300 cursor-pointer">
|
||||||
|
Pin this announcement to the top
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button type="button" onClick={onClose} className="px-6 py-2.5 font-bold text-gray-500 hover:bg-white/5 rounded-xl transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-8 py-2.5 bg-orange-600 hover:bg-orange-500 disabled:opacity-50 text-white rounded-xl font-bold flex items-center gap-2 shadow-lg shadow-orange-500/20"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Megaphone className="w-4 h-4" />}
|
||||||
|
Publish Announcement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, GraduationCap } from "lucide-react";
|
import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, GraduationCap, Megaphone } from "lucide-react";
|
||||||
|
|
||||||
interface CourseEditorLayoutProps {
|
interface CourseEditorLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades";
|
activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades" | "announcements";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
|
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
|
||||||
@@ -18,6 +18,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
|
|||||||
{ key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` },
|
{ key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` },
|
||||||
{ key: "rubrics", label: "Rubrics", icon: Layout, href: `/courses/${id}/rubrics` },
|
{ key: "rubrics", label: "Rubrics", icon: Layout, href: `/courses/${id}/rubrics` },
|
||||||
{ key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` },
|
{ key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` },
|
||||||
|
{ key: "announcements", label: "Announcements", icon: Megaphone, href: `/courses/${id}/announcements` },
|
||||||
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
|
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
|
||||||
{ key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` },
|
{ key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` },
|
||||||
{ key: "files", label: "Files & Uploads", icon: Folder, href: `/courses/${id}/files` },
|
{ key: "files", label: "Files & Uploads", icon: Folder, href: `/courses/${id}/files` },
|
||||||
|
|||||||
@@ -454,6 +454,37 @@ export interface BulkEnrollResponse {
|
|||||||
already_enrolled_emails: string[];
|
already_enrolled_emails: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CourseAnnouncement {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
course_id: string;
|
||||||
|
author_id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
cohort_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementWithAuthor extends CourseAnnouncement {
|
||||||
|
author_name: string;
|
||||||
|
author_avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAnnouncementPayload {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
is_pinned?: boolean;
|
||||||
|
cohort_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAnnouncementPayload {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
is_pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CourseSubmission {
|
export interface CourseSubmission {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -763,6 +794,16 @@ export const lmsApi = {
|
|||||||
apiFetch(`/courses/${courseId}/lessons/${lessonId}/peer-review`, { method: 'POST', body: JSON.stringify({ submission_id: submissionId, score, feedback }) }, true),
|
apiFetch(`/courses/${courseId}/lessons/${lessonId}/peer-review`, { method: 'POST', body: JSON.stringify({ submission_id: submissionId, score, feedback }) }, true),
|
||||||
getMySubmissionFeedback: (courseId: string, lessonId: string): Promise<PeerReview[]> =>
|
getMySubmissionFeedback: (courseId: string, lessonId: string): Promise<PeerReview[]> =>
|
||||||
apiFetch(`/courses/${courseId}/lessons/${lessonId}/feedback`, {}, true),
|
apiFetch(`/courses/${courseId}/lessons/${lessonId}/feedback`, {}, true),
|
||||||
|
|
||||||
|
// Announcements
|
||||||
|
listAnnouncements: (courseId: string): Promise<AnnouncementWithAuthor[]> =>
|
||||||
|
apiFetch(`/courses/${courseId}/announcements`, {}, true),
|
||||||
|
createAnnouncement: (courseId: string, payload: CreateAnnouncementPayload): Promise<CourseAnnouncement> =>
|
||||||
|
apiFetch(`/courses/${courseId}/announcements`, { method: 'POST', body: JSON.stringify(payload) }, true),
|
||||||
|
updateAnnouncement: (announcementId: string, payload: UpdateAnnouncementPayload): Promise<CourseAnnouncement> =>
|
||||||
|
apiFetch(`/announcements/${announcementId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
||||||
|
deleteAnnouncement: (announcementId: string): Promise<void> =>
|
||||||
|
apiFetch(`/announcements/${announcementId}`, { method: 'DELETE' }, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BackgroundTask {
|
export interface BackgroundTask {
|
||||||
|
|||||||
Reference in New Issue
Block a user