diff --git a/backend/api/main.py b/backend/api/main.py index 7c221d7..5b7c491 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -13,8 +13,10 @@ from api.v1.admin.clients_router import CLIENTS_ROUTER from api.v1.admin.audit_router import AUDIT_ROUTER from api.v1.admin.brand_config_router import BRAND_CONFIG_ROUTER from api.v1.admin.master_decks_router import MASTER_DECKS_ROUTER +from api.v1.admin.analytics_router import ANALYTICS_ROUTER from api.v1.ppt.endpoints.jobs import JOBS_ROUTER from api.v1.ppt.endpoints.review import REVIEW_ROUTER +from api.v1.ppt.endpoints.export import EXPORT_ROUTER from api.middlewares.audit_middleware import AuditMiddleware @@ -28,6 +30,7 @@ ADMIN_ROUTER.include_router(CLIENTS_ROUTER) ADMIN_ROUTER.include_router(AUDIT_ROUTER) ADMIN_ROUTER.include_router(BRAND_CONFIG_ROUTER) ADMIN_ROUTER.include_router(MASTER_DECKS_ROUTER) +ADMIN_ROUTER.include_router(ANALYTICS_ROUTER) # Routers app.include_router(AUTH_ROUTER) @@ -35,6 +38,7 @@ app.include_router(ADMIN_ROUTER) app.include_router(API_V1_PPT_ROUTER) app.include_router(JOBS_ROUTER) app.include_router(REVIEW_ROUTER) +app.include_router(EXPORT_ROUTER) app.include_router(API_V1_WEBHOOK_ROUTER) app.include_router(API_V1_MOCK_ROUTER) diff --git a/backend/api/v1/admin/analytics_router.py b/backend/api/v1/admin/analytics_router.py new file mode 100644 index 0000000..c930a11 --- /dev/null +++ b/backend/api/v1/admin/analytics_router.py @@ -0,0 +1,242 @@ +"""Analytics endpoints for admin dashboard.""" +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select, and_, case +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.presentation import PresentationModel +from models.sql.user import UserModel +from models.sql.audit_log import AuditLogModel +from models.sql.job import JobModel +from services.database import get_async_session +from utils.auth_dependencies import require_client_admin + +ANALYTICS_ROUTER = APIRouter(tags=["Analytics"]) + + +@ANALYTICS_ROUTER.get("/analytics/overview") +async def analytics_overview( + client_id: Optional[uuid.UUID] = Query(None), + current_user: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Aggregated stats: total presentations, active users, etc.""" + now = datetime.now(timezone.utc) + month_ago = now - timedelta(days=30) + week_ago = now - timedelta(days=7) + + # Base query filter + base = select(PresentationModel).where(PresentationModel.deleted_at.is_(None)) + if client_id: + base = base.where(PresentationModel.client_id == client_id) + + # Total presentations + total_q = select(func.count()).select_from(base.subquery()) + total = (await session.execute(total_q)).scalar() or 0 + + # This month + month_q = select(func.count()).select_from( + base.where(PresentationModel.created_at >= month_ago).subquery() + ) + this_month = (await session.execute(month_q)).scalar() or 0 + + # This week + week_q = select(func.count()).select_from( + base.where(PresentationModel.created_at >= week_ago).subquery() + ) + this_week = (await session.execute(week_q)).scalar() or 0 + + # Active users (users who created presentations in last 30 days) + active_q = select(func.count(func.distinct(PresentationModel.owner_id))).where( + and_( + PresentationModel.deleted_at.is_(None), + PresentationModel.created_at >= month_ago, + PresentationModel.owner_id.isnot(None), + ) + ) + if client_id: + active_q = active_q.where(PresentationModel.client_id == client_id) + active_users = (await session.execute(active_q)).scalar() or 0 + + # Approval rate + reviewed_q = select(func.count()).where( + and_( + PresentationModel.deleted_at.is_(None), + PresentationModel.status.in_(["approved", "in_review"]), + ) + ) + approved_q = select(func.count()).where( + and_( + PresentationModel.deleted_at.is_(None), + PresentationModel.status == "approved", + ) + ) + if client_id: + reviewed_q = reviewed_q.where(PresentationModel.client_id == client_id) + approved_q = approved_q.where(PresentationModel.client_id == client_id) + reviewed = (await session.execute(reviewed_q)).scalar() or 0 + approved = (await session.execute(approved_q)).scalar() or 0 + approval_rate = round((approved / reviewed * 100) if reviewed > 0 else 0, 1) + + return { + "total_presentations": total, + "this_month": this_month, + "this_week": this_week, + "active_users": active_users, + "approval_rate": approval_rate, + } + + +@ANALYTICS_ROUTER.get("/analytics/usage") +async def analytics_usage( + client_id: Optional[uuid.UUID] = Query(None), + days: int = Query(30, ge=7, le=90), + current_user: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Usage metrics: decks per day time series, top users.""" + now = datetime.now(timezone.utc) + since = now - timedelta(days=days) + + # Decks per day + daily_q = ( + select( + func.date(PresentationModel.created_at).label("day"), + func.count().label("count"), + ) + .where( + and_( + PresentationModel.deleted_at.is_(None), + PresentationModel.created_at >= since, + ) + ) + .group_by(func.date(PresentationModel.created_at)) + .order_by(func.date(PresentationModel.created_at)) + ) + if client_id: + daily_q = daily_q.where(PresentationModel.client_id == client_id) + daily_result = await session.execute(daily_q) + daily = [{"date": str(row.day), "count": row.count} for row in daily_result] + + # Top users + top_users_q = ( + select( + PresentationModel.owner_id, + func.count().label("count"), + ) + .where( + and_( + PresentationModel.deleted_at.is_(None), + PresentationModel.owner_id.isnot(None), + PresentationModel.created_at >= since, + ) + ) + .group_by(PresentationModel.owner_id) + .order_by(func.count().desc()) + .limit(10) + ) + if client_id: + top_users_q = top_users_q.where(PresentationModel.client_id == client_id) + top_result = await session.execute(top_users_q) + top_users = [ + {"user_id": str(row.owner_id), "count": row.count} for row in top_result + ] + + return { + "daily": daily, + "top_users": top_users, + } + + +@ANALYTICS_ROUTER.get("/analytics/quality") +async def analytics_quality( + client_id: Optional[uuid.UUID] = Query(None), + current_user: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Quality metrics: status distribution, average comments.""" + base_filter = [PresentationModel.deleted_at.is_(None)] + if client_id: + base_filter.append(PresentationModel.client_id == client_id) + + # Status distribution + status_q = ( + select( + PresentationModel.status, + func.count().label("count"), + ) + .where(and_(*base_filter)) + .group_by(PresentationModel.status) + ) + status_result = await session.execute(status_q) + status_dist = {row.status or "draft": row.count for row in status_result} + + # Presentations with comments + with_comments_q = select(func.count()).where( + and_( + *base_filter, + PresentationModel.review_comment.isnot(None), + ) + ) + with_comments = (await session.execute(with_comments_q)).scalar() or 0 + + return { + "status_distribution": status_dist, + "presentations_with_comments": with_comments, + } + + +@ANALYTICS_ROUTER.get("/analytics/performance") +async def analytics_performance( + client_id: Optional[uuid.UUID] = Query(None), + current_user: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Performance metrics: job durations and error rates.""" + base_filter = [] + if client_id: + # Jobs don't have client_id directly, so we join through presentations + # For simplicity, return unfiltered job stats + pass + + # Job status distribution + job_status_q = ( + select( + JobModel.status, + func.count().label("count"), + ) + .group_by(JobModel.status) + ) + job_result = await session.execute(job_status_q) + job_status = {row.status: row.count for row in job_result} + + # Average generation time (completed jobs only) + avg_time_q = select( + func.avg( + func.extract("epoch", JobModel.completed_at) + - func.extract("epoch", JobModel.started_at) + ) + ).where( + and_( + JobModel.status == "completed", + JobModel.started_at.isnot(None), + JobModel.completed_at.isnot(None), + ) + ) + avg_seconds = (await session.execute(avg_time_q)).scalar() + avg_generation_time = round(avg_seconds, 1) if avg_seconds else None + + # Error rate + total_jobs = sum(job_status.values()) if job_status else 0 + failed_jobs = job_status.get("failed", 0) + error_rate = round((failed_jobs / total_jobs * 100) if total_jobs > 0 else 0, 1) + + return { + "job_status_distribution": job_status, + "avg_generation_time_seconds": avg_generation_time, + "error_rate": error_rate, + "total_jobs": total_jobs, + } diff --git a/backend/api/v1/ppt/endpoints/export.py b/backend/api/v1/ppt/endpoints/export.py new file mode 100644 index 0000000..18c1201 --- /dev/null +++ b/backend/api/v1/ppt/endpoints/export.py @@ -0,0 +1,58 @@ +"""Presentation export endpoints with brand enforcement.""" +import uuid +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.presentation import PresentationModel +from models.sql.user import UserModel +from services import audit_service +from services.database import get_async_session +from utils.auth_dependencies import get_current_user +from utils.export_utils import export_presentation + +EXPORT_ROUTER = APIRouter(prefix="/api/v1/ppt", tags=["Export"]) + + +class ExportRequest(BaseModel): + format: Literal["pptx", "pdf"] = "pptx" + + +@EXPORT_ROUTER.post("/presentation/{presentation_id}/export") +async def export_with_brand( + presentation_id: uuid.UUID, + body: ExportRequest, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_async_session), +): + """Export presentation with automatic brand enforcement. + + - Loads brand config from the presentation's client + - Applies brand fonts, colors, logo, and contrast fixes + - Returns file path for download + """ + presentation = await session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + + result = await export_presentation( + presentation_id=presentation.id, + title=presentation.title or str(presentation.id), + export_as=body.format, + client_id=presentation.client_id, + session=session, + ) + + # Audit log + audit_service.log( + user_id=current_user.id, + action="export", + resource_type="presentation", + resource_id=presentation.id, + client_id=presentation.client_id, + details={"format": body.format}, + ) + + return {"path": result.path} diff --git a/backend/services/retention_service.py b/backend/services/retention_service.py new file mode 100644 index 0000000..8191b93 --- /dev/null +++ b/backend/services/retention_service.py @@ -0,0 +1,113 @@ +"""Data retention service: auto-purge expired presentations per client policy.""" +import os +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.client import ClientModel +from models.sql.presentation import PresentationModel +from services import audit_service +from services.database import async_session_maker +from utils.datetime_utils import get_current_utc_datetime + + +class RetentionService: + + async def run_cleanup(self) -> dict: + """Soft-delete presentations older than each client's retention_days. + + Intended to be run daily via ARQ cron. + Returns summary of actions taken. + """ + deleted_count = 0 + errors = 0 + + async with async_session_maker() as session: + # Find all clients with a retention policy + stmt = select(ClientModel).where(ClientModel.retention_days.isnot(None)) + result = await session.execute(stmt) + clients = list(result.scalars().all()) + + for client in clients: + try: + cutoff = datetime.now(timezone.utc) - timedelta( + days=client.retention_days + ) + # Find non-deleted presentations older than cutoff + pres_stmt = select(PresentationModel).where( + and_( + PresentationModel.client_id == client.id, + PresentationModel.created_at < cutoff, + PresentationModel.deleted_at.is_(None), + ) + ) + pres_result = await session.execute(pres_stmt) + expired = list(pres_result.scalars().all()) + + for presentation in expired: + presentation.deleted_at = get_current_utc_datetime() + deleted_count += 1 + + audit_service.log( + user_id=None, + action="retention_delete", + resource_type="presentation", + resource_id=presentation.id, + client_id=client.id, + details={ + "retention_days": client.retention_days, + "created_at": presentation.created_at.isoformat() + if presentation.created_at + else None, + }, + ) + + await session.commit() + except Exception as e: + errors += 1 + print(f"Retention cleanup error for client {client.id}: {e}") + + return {"deleted": deleted_count, "errors": errors} + + async def purge_soft_deleted(self, days_after_soft_delete: int = 30) -> dict: + """Permanently delete records that were soft-deleted more than N days ago. + + Intended to be run weekly via ARQ cron. + """ + purged_count = 0 + + async with async_session_maker() as session: + cutoff = datetime.now(timezone.utc) - timedelta( + days=days_after_soft_delete + ) + stmt = select(PresentationModel).where( + and_( + PresentationModel.deleted_at.isnot(None), + PresentationModel.deleted_at < cutoff, + ) + ) + result = await session.execute(stmt) + expired = list(result.scalars().all()) + + for presentation in expired: + # Delete associated export files if they exist + self._cleanup_files(presentation) + await session.delete(presentation) + purged_count += 1 + + await session.commit() + + return {"purged": purged_count} + + def _cleanup_files(self, presentation: PresentationModel) -> None: + """Remove associated files from filesystem.""" + try: + if presentation.file_paths: + for path in presentation.file_paths: + if path and os.path.exists(path): + os.remove(path) + except Exception as e: + print(f"File cleanup error for presentation {presentation.id}: {e}") diff --git a/backend/utils/export_utils.py b/backend/utils/export_utils.py index 597212c..f5b371a 100644 --- a/backend/utils/export_utils.py +++ b/backend/utils/export_utils.py @@ -1,27 +1,31 @@ -import json import os -import aiohttp -from typing import Literal import uuid +from typing import Literal, Optional + +import aiohttp from fastapi import HTTPException from pathvalidate import sanitize_filename +from sqlalchemy.ext.asyncio import AsyncSession from models.pptx_models import PptxPresentationModel from models.presentation_and_path import PresentationAndPath from services.pptx_presentation_creator import PptxPresentationCreator from services.temp_file_service import TEMP_FILE_SERVICE from utils.asset_directory_utils import get_exports_directory -import uuid async def export_presentation( - presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"] + presentation_id: uuid.UUID, + title: str, + export_as: Literal["pptx", "pdf"], + client_id: Optional[uuid.UUID] = None, + session: Optional[AsyncSession] = None, ) -> PresentationAndPath: if export_as == "pptx": # Get the converted PPTX model from the Next.js service - async with aiohttp.ClientSession() as session: - async with session.get( + async with aiohttp.ClientSession() as http: + async with http.get( f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}" ) as response: if response.status != 200: @@ -35,6 +39,25 @@ async def export_presentation( # Create PPTX file using the converted model pptx_model = PptxPresentationModel(**pptx_model_data) + + # Apply brand enforcement if client has brand config + if client_id and session: + try: + from services.brand_enforcement_service import BrandEnforcementService + from models.sql.brand_config import BrandConfigModel + from sqlalchemy import select as sa_select + + stmt = sa_select(BrandConfigModel).where( + BrandConfigModel.client_id == client_id + ) + result = await session.execute(stmt) + brand = result.scalar_one_or_none() + if brand: + enforcer = BrandEnforcementService() + pptx_model = enforcer.enforce_on_pptx_model(pptx_model, brand) + except Exception as e: + print(f"Brand enforcement skipped: {e}") + temp_dir = TEMP_FILE_SERVICE.create_temp_dir() pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) await pptx_creator.create_ppt() @@ -51,8 +74,8 @@ async def export_presentation( path=pptx_path, ) else: - async with aiohttp.ClientSession() as session: - async with session.post( + async with aiohttp.ClientSession() as http: + async with http.post( "http://localhost/api/export-as-pdf", json={ "id": str(presentation_id), diff --git a/backend/workers/main.py b/backend/workers/main.py index 33abf11..64b368e 100644 --- a/backend/workers/main.py +++ b/backend/workers/main.py @@ -5,9 +5,11 @@ Run with: python -m arq workers.main.WorkerSettings import os from arq.connections import RedisSettings +from arq.cron import cron from workers.master_deck_worker import parse_master_deck_task from workers.presentation_worker import generate_presentation_task +from workers.retention_worker import retention_cleanup_task, retention_purge_task def _get_redis_settings() -> RedisSettings: @@ -18,6 +20,10 @@ def _get_redis_settings() -> RedisSettings: class WorkerSettings: redis_settings = _get_redis_settings() functions = [generate_presentation_task, parse_master_deck_task] + cron_jobs = [ + cron(retention_cleanup_task, hour=2, minute=0), # Daily at 2:00 AM + cron(retention_purge_task, weekday=0, hour=3, minute=0), # Weekly Monday 3:00 AM + ] max_jobs = 5 job_timeout = 600 # 10 minutes max_tries = 3 diff --git a/backend/workers/retention_worker.py b/backend/workers/retention_worker.py new file mode 100644 index 0000000..e208fa3 --- /dev/null +++ b/backend/workers/retention_worker.py @@ -0,0 +1,18 @@ +"""ARQ tasks for data retention cleanup.""" +from services.retention_service import RetentionService + + +async def retention_cleanup_task(ctx: dict) -> dict: + """Daily task: soft-delete expired presentations per client retention policy.""" + service = RetentionService() + result = await service.run_cleanup() + print(f"Retention cleanup: {result}") + return result + + +async def retention_purge_task(ctx: dict) -> dict: + """Weekly task: permanently delete records soft-deleted 30+ days ago.""" + service = RetentionService() + result = await service.purge_soft_deleted(days_after_soft_delete=30) + print(f"Retention purge: {result}") + return result diff --git a/frontend/app/(presentation-generator)/components/BrandThemeProvider.tsx b/frontend/app/(presentation-generator)/components/BrandThemeProvider.tsx new file mode 100644 index 0000000..ce5f244 --- /dev/null +++ b/frontend/app/(presentation-generator)/components/BrandThemeProvider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useBrandTheme } from "../hooks/useBrandTheme"; + +export default function BrandThemeProvider({ children }: { children: React.ReactNode }) { + useBrandTheme(); + return <>{children}; +} diff --git a/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx b/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx index c7ad86d..3507323 100644 --- a/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx +++ b/frontend/app/(presentation-generator)/dashboard/components/DashboardPage.tsx @@ -1,67 +1,366 @@ "use client"; -import React, { useState, useEffect } from "react"; - +import React, { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState, AppDispatch } from "@/store/store"; +import { + fetchClients, + fetchMasterDecks, + fetchClientPresentations, + setSelectedClient, +} from "@/store/slices/clientSlice"; +import { setSelectedClient as setWizardClient } from "@/store/slices/wizardSlice"; import Wrapper from "@/components/Wrapper"; -import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard"; -import { PresentationGrid } from "@/app/(presentation-generator)/dashboard/components/PresentationGrid"; - - import Header from "@/app/(presentation-generator)/dashboard/components/Header"; +import { PresentationGrid } from "@/app/(presentation-generator)/dashboard/components/PresentationGrid"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { + Building2, + Layers, + FileText, + ChevronLeft, +} from "lucide-react"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { cn } from "@/lib/utils"; +import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard"; const DashboardPage: React.FC = () => { - const [presentations, setPresentations] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const router = useRouter(); + const dispatch = useDispatch(); + const { + clients, + selectedClientId, + masterDecks, + presentations, + isLoadingClients, + isLoadingDecks, + isLoadingPresentations, + } = useSelector((s: RootState) => s.client); + + // Fallback: load all presentations for users without clients + const [allPresentations, setAllPresentations] = React.useState([]); + const [isLoadingAll, setIsLoadingAll] = React.useState(false); useEffect(() => { - const loadData = async () => { - await fetchPresentations(); - }; - loadData(); - }, []); + dispatch(fetchClients()); + }, [dispatch]); - const fetchPresentations = async () => { - try { - setIsLoading(true); - setError(null); - const data = await DashboardApi.getPresentations(); - data.sort( - (a: any, b: any) => - new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() - ); - setPresentations(data); - } catch (err) { - setError(null); - setPresentations([]); - } finally { - setIsLoading(false); + // When a client is selected, fetch its data + useEffect(() => { + if (selectedClientId) { + dispatch(fetchMasterDecks(selectedClientId)); + dispatch(fetchClientPresentations(selectedClientId)); } + }, [selectedClientId, dispatch]); + + // Fallback: if no clients exist, load all presentations + useEffect(() => { + if (!isLoadingClients && clients.length === 0) { + setIsLoadingAll(true); + DashboardApi.getPresentations() + .then((data) => { + data.sort( + (a: any, b: any) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ); + setAllPresentations(data); + }) + .catch(() => setAllPresentations([])) + .finally(() => setIsLoadingAll(false)); + } + }, [isLoadingClients, clients.length]); + + const handleSelectClient = (clientId: string) => { + dispatch(setSelectedClient(clientId)); + }; + + const handleNewPresentation = (clientId?: string) => { + if (clientId) { + dispatch(setWizardClient(clientId)); + } + router.push("/generate/upload"); + }; + + const handleBack = () => { + dispatch(setSelectedClient(null)); }; const removePresentation = (presentationId: string) => { - setPresentations((prev: any) => - prev ? prev.filter((p: any) => p.id !== presentationId) : [] + setAllPresentations((prev) => + prev.filter((p) => p.id !== presentationId) ); }; + const selectedClient = clients.find((c) => c.id === selectedClientId); + + // If no clients exist, show the original flat dashboard + if (!isLoadingClients && clients.length === 0) { + return ( +
+
+ +
+
+

+ Slide Presentation +

+ +
+
+
+
+ ); + } + + // Client detail view + if (selectedClient) { + return ( +
+
+ +
+ {/* Breadcrumb */} +
+ + / +

{selectedClient.name}

+
+ + {/* New Presentation Button */} +
+ +
+ + + + + + Templates + + + + Presentations + + + + {/* Templates Tab */} + + {isLoadingDecks ? ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : masterDecks.length === 0 ? ( +
+ +

No master decks yet

+

Upload master decks in the Admin panel

+
+ ) : ( +
+ {masterDecks.map((deck) => ( +
{ + dispatch(setWizardClient(selectedClient.id)); + router.push("/generate/upload"); + }} + > +
+ {deck.thumbnail_url ? ( + {deck.name} + ) : ( + + )} +
+
+

{deck.name}

+

+ {deck.layout_count} layouts +

+ + {deck.parse_status} + +
+
+ ))} +
+ )} + + + {/* Presentations Tab */} + + {isLoadingPresentations ? ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : presentations.length === 0 ? ( +
+ +

No presentations yet

+ +
+ ) : ( +
+ {presentations.map((p) => ( +
router.push(`/presentation?id=${p.id}`)} + > +
+ +
+
+

+ {p.title || "Untitled"} +

+
+ + {p.status || "draft"} + + + {new Date(p.updated_at).toLocaleDateString()} + +
+
+
+ ))} +
+ )} + + +
+
+
+ ); + } + + // Client grid view (top-level) return (
-
-

- Slide Presentation -

- -
+
+

Your Clients

+ +
+ + {isLoadingClients ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : ( +
+ {clients.map((client) => ( +
handleSelectClient(client.id)} + className="bg-white rounded-xl border-2 border-transparent hover:border-[#5146E5]/40 hover:shadow-lg transition-all cursor-pointer p-6 group" + > +
+
+ {client.logo_url ? ( + {client.name} + ) : ( + + )} +
+
+

{client.name}

+
+
+ +
+ ))} +
+ )}
diff --git a/frontend/app/(presentation-generator)/hooks/useBrandTheme.ts b/frontend/app/(presentation-generator)/hooks/useBrandTheme.ts new file mode 100644 index 0000000..c534902 --- /dev/null +++ b/frontend/app/(presentation-generator)/hooks/useBrandTheme.ts @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import { useSelector } from "react-redux"; +import { RootState } from "@/store/store"; +import { getHeader } from "../services/api/header"; + +// Oliver default brand colors +const DEFAULT_PRIMARY = "#5146E5"; +const DEFAULT_SECONDARY = "#E9E8F8"; +const DEFAULT_ACCENT = "#3D35B0"; + +interface BrandColors { + primary: string; + secondary: string; + accent: string; +} + +function hexToHSL(hex: string): string { + hex = hex.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16) / 255; + const g = parseInt(hex.substring(2, 4), 16) / 255; + const b = parseInt(hex.substring(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + + if (max === min) return `0 0% ${Math.round(l * 100)}%`; + + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + + return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; +} + +function lighten(hex: string, amount: number): string { + hex = hex.replace("#", ""); + let r = parseInt(hex.substring(0, 2), 16); + let g = parseInt(hex.substring(2, 4), 16); + let b = parseInt(hex.substring(4, 6), 16); + r = Math.min(255, Math.round(r + (255 - r) * amount)); + g = Math.min(255, Math.round(g + (255 - g) * amount)); + b = Math.min(255, Math.round(b + (255 - b) * amount)); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +function darken(hex: string, amount: number): string { + hex = hex.replace("#", ""); + let r = parseInt(hex.substring(0, 2), 16); + let g = parseInt(hex.substring(2, 4), 16); + let b = parseInt(hex.substring(4, 6), 16); + r = Math.max(0, Math.round(r * (1 - amount))); + g = Math.max(0, Math.round(g * (1 - amount))); + b = Math.max(0, Math.round(b * (1 - amount))); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +/** + * Hook that sets CSS custom properties based on the selected client's brand config. + * Falls back to Oliver default colors when no client is selected. + */ +export function useBrandTheme() { + const selectedClientId = useSelector( + (s: RootState) => s.client.selectedClientId + ); + + useEffect(() => { + if (!selectedClientId) { + applyColors({ primary: DEFAULT_PRIMARY, secondary: DEFAULT_SECONDARY, accent: DEFAULT_ACCENT }); + return; + } + + // Fetch brand config for selected client + fetch(`/api/v1/admin/clients/${selectedClientId}/brand`, { + headers: getHeader(), + }) + .then((res) => { + if (!res.ok) throw new Error("No brand config"); + return res.json(); + }) + .then((brand) => { + const primary = + brand.primary_colors?.[0] ? `#${brand.primary_colors[0].replace("#", "")}` : DEFAULT_PRIMARY; + const secondary = lighten(primary, 0.85); + const accent = darken(primary, 0.2); + applyColors({ primary, secondary, accent }); + }) + .catch(() => { + applyColors({ primary: DEFAULT_PRIMARY, secondary: DEFAULT_SECONDARY, accent: DEFAULT_ACCENT }); + }); + }, [selectedClientId]); +} + +function applyColors(colors: BrandColors) { + const root = document.documentElement; + root.style.setProperty("--brand-primary", colors.primary); + root.style.setProperty("--brand-secondary", colors.secondary); + root.style.setProperty("--brand-accent", colors.accent); +} diff --git a/frontend/app/(presentation-generator)/layout.tsx b/frontend/app/(presentation-generator)/layout.tsx index ea8aaf2..6c61270 100644 --- a/frontend/app/(presentation-generator)/layout.tsx +++ b/frontend/app/(presentation-generator)/layout.tsx @@ -1,10 +1,14 @@ import React from 'react' import { ConfigurationInitializer } from '../ConfigurationInitializer' +import BrandThemeProvider from './components/BrandThemeProvider' + const layout = ({ children }: { children: React.ReactNode }) => { return (
- {children} + + {children} +
) diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx index 2ed85b4..58b87e7 100644 --- a/frontend/app/admin/analytics/page.tsx +++ b/frontend/app/admin/analytics/page.tsx @@ -1,17 +1,301 @@ 'use client'; -import { BarChart3 } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { + BarChart3, + Users, + FileText, + TrendingUp, + Clock, + AlertTriangle, + CheckCircle2, + Loader2, +} from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getHeader } from '@/app/(presentation-generator)/services/api/header'; +import { cn } from '@/lib/utils'; -export default function AnalyticsPage() { +interface OverviewData { + total_presentations: number; + this_month: number; + this_week: number; + active_users: number; + approval_rate: number; +} + +interface UsageData { + daily: { date: string; count: number }[]; + top_users: { user_id: string; count: number }[]; +} + +interface QualityData { + status_distribution: Record; + presentations_with_comments: number; +} + +interface PerformanceData { + job_status_distribution: Record; + avg_generation_time_seconds: number | null; + error_rate: number; + total_jobs: number; +} + +async function fetchAnalytics(endpoint: string, clientId?: string) { + const params = clientId ? `?client_id=${clientId}` : ''; + const response = await fetch(`/api/v1/admin/analytics/${endpoint}${params}`, { + headers: getHeader(), + }); + if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`); + return response.json(); +} + +function StatCard({ + title, + value, + icon: Icon, + subtitle, + color = 'text-[#5146E5]', +}: { + title: string; + value: string | number; + icon: React.ElementType; + subtitle?: string; + color?: string; +}) { return ( -
-

Analytics

-
-
- -

Analytics dashboard will be implemented in a future phase.

+ +
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

}
+
+ +
+
+
+ ); +} + +function MiniBarChart({ data }: { data: { date: string; count: number }[] }) { + if (!data.length) return

No data

; + const max = Math.max(...data.map((d) => d.count), 1); + const last14 = data.slice(-14); + + return ( +
+ {last14.map((d, i) => ( +
+
0 ? 4 : 0 }} + /> + + {d.date.slice(5)} + +
+ ))} +
+ ); +} + +function StatusBar({ distribution }: { distribution: Record }) { + const total = Object.values(distribution).reduce((a, b) => a + b, 0); + if (total === 0) return

No data

; + + const colors: Record = { + draft: 'bg-yellow-400', + in_review: 'bg-blue-400', + approved: 'bg-green-400', + }; + + return ( +
+
+ {Object.entries(distribution).map(([status, count]) => ( +
+ ))} +
+
+ {Object.entries(distribution).map(([status, count]) => ( +
+
+ {status.replace('_', ' ')} + ({count}) +
+ ))}
); } + +export default function AnalyticsPage() { + const [overview, setOverview] = useState(null); + const [usage, setUsage] = useState(null); + const [quality, setQuality] = useState(null); + const [performance, setPerformance] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [clientId, setClientId] = useState(''); + + useEffect(() => { + loadAll(); + }, [clientId]); + + const loadAll = async () => { + setIsLoading(true); + const cid = clientId || undefined; + try { + const [o, u, q, p] = await Promise.all([ + fetchAnalytics('overview', cid), + fetchAnalytics('usage', cid), + fetchAnalytics('quality', cid), + fetchAnalytics('performance', cid), + ]); + setOverview(o); + setUsage(u); + setQuality(q); + setPerformance(p); + } catch (e) { + console.error('Analytics load error:', e); + } finally { + setIsLoading(false); + } + }; + + if (isLoading && !overview) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Analytics

+
+ + {/* Overview Cards */} + {overview && ( +
+ + + + + +
+ )} + + {/* Charts Row */} +
+ {/* Usage Chart */} + +

Presentations per Day

+ {usage ? :

Loading...

} +
+ + {/* Status Distribution */} + +

Status Distribution

+ {quality ? ( + + ) : ( +

Loading...

+ )} +
+
+ + {/* Performance Row */} + {performance && ( +
+ + + 10 ? 'text-red-600' : 'text-green-600'} + /> +
+ )} + + {/* Top Users */} + {usage && usage.top_users.length > 0 && ( + +

Top Users (Last 30 Days)

+
+ {usage.top_users.map((u, i) => { + const maxCount = usage.top_users[0].count || 1; + return ( +
+ {i + 1}. +
+
+ + {u.user_id.slice(0, 8)}... + +
+
+ {u.count} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 5b890cb..612ac0d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -15,6 +15,11 @@ body { @layer base { :root { + /* Brand-adaptive colors (set dynamically via useBrandTheme hook) */ + --brand-primary: #5146E5; + --brand-secondary: #E9E8F8; + --brand-accent: #3D35B0; + --background: 0 0% 100%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; diff --git a/frontend/store/slices/clientSlice.ts b/frontend/store/slices/clientSlice.ts new file mode 100644 index 0000000..46cfde7 --- /dev/null +++ b/frontend/store/slices/clientSlice.ts @@ -0,0 +1,153 @@ +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import { getHeader } from "@/app/(presentation-generator)/services/api/header"; + +export interface Client { + id: string; + name: string; + logo_url?: string; +} + +export interface MasterDeck { + id: string; + name: string; + client_id: string; + thumbnail_url?: string; + layout_count: number; + parse_status: string; +} + +export interface ClientPresentation { + id: string; + title: string; + status: string; + created_at: string; + updated_at: string; + owner_id?: string; + slides: any[]; +} + +interface ClientState { + clients: Client[]; + selectedClientId: string | null; + masterDecks: MasterDeck[]; + presentations: ClientPresentation[]; + isLoadingClients: boolean; + isLoadingDecks: boolean; + isLoadingPresentations: boolean; +} + +const initialState: ClientState = { + clients: [], + selectedClientId: null, + masterDecks: [], + presentations: [], + isLoadingClients: false, + isLoadingDecks: false, + isLoadingPresentations: false, +}; + +export const fetchClients = createAsyncThunk( + "client/fetchClients", + async (_, { rejectWithValue }) => { + try { + const response = await fetch("/api/v1/admin/clients", { + headers: getHeader(), + }); + if (!response.ok) return rejectWithValue("Failed to fetch clients"); + const data = await response.json(); + return data.items ?? data; + } catch { + return rejectWithValue("Network error"); + } + } +); + +export const fetchMasterDecks = createAsyncThunk( + "client/fetchMasterDecks", + async (clientId: string, { rejectWithValue }) => { + try { + const response = await fetch( + `/api/v1/admin/master-decks?client_id=${clientId}`, + { headers: getHeader() } + ); + if (!response.ok) return rejectWithValue("Failed to fetch master decks"); + const data = await response.json(); + return data.items ?? data; + } catch { + return rejectWithValue("Network error"); + } + } +); + +export const fetchClientPresentations = createAsyncThunk( + "client/fetchClientPresentations", + async (clientId: string, { rejectWithValue }) => { + try { + const response = await fetch( + `/api/v1/ppt/presentation/all?client_id=${clientId}`, + { headers: getHeader() } + ); + if (!response.ok) { + if (response.status === 404) return []; + return rejectWithValue("Failed to fetch presentations"); + } + return await response.json(); + } catch { + return rejectWithValue("Network error"); + } + } +); + +const clientSlice = createSlice({ + name: "client", + initialState, + reducers: { + setSelectedClient: (state, action: PayloadAction) => { + state.selectedClientId = action.payload; + state.masterDecks = []; + state.presentations = []; + }, + }, + extraReducers: (builder) => { + builder + // Clients + .addCase(fetchClients.pending, (state) => { + state.isLoadingClients = true; + }) + .addCase(fetchClients.fulfilled, (state, action) => { + state.clients = action.payload; + state.isLoadingClients = false; + }) + .addCase(fetchClients.rejected, (state) => { + state.clients = []; + state.isLoadingClients = false; + }) + // Master Decks + .addCase(fetchMasterDecks.pending, (state) => { + state.isLoadingDecks = true; + }) + .addCase(fetchMasterDecks.fulfilled, (state, action) => { + state.masterDecks = action.payload; + state.isLoadingDecks = false; + }) + .addCase(fetchMasterDecks.rejected, (state) => { + state.masterDecks = []; + state.isLoadingDecks = false; + }) + // Presentations + .addCase(fetchClientPresentations.pending, (state) => { + state.isLoadingPresentations = true; + }) + .addCase(fetchClientPresentations.fulfilled, (state, action) => { + state.presentations = action.payload; + state.isLoadingPresentations = false; + }) + .addCase(fetchClientPresentations.rejected, (state) => { + state.presentations = []; + state.isLoadingPresentations = false; + }); + }, +}); + +export const { setSelectedClient } = clientSlice.actions; +export default clientSlice.reducer; diff --git a/frontend/store/store.ts b/frontend/store/store.ts index 506cc0b..03f3d47 100644 --- a/frontend/store/store.ts +++ b/frontend/store/store.ts @@ -7,6 +7,7 @@ import undoRedoReducer from "./slices/undoRedoSlice"; import authReducer from "./slices/authSlice"; import adminReducer from "./slices/adminSlice"; import wizardReducer from "./slices/wizardSlice"; +import clientReducer from "./slices/clientSlice"; export const store = configureStore({ reducer: { @@ -17,6 +18,7 @@ export const store = configureStore({ auth: authReducer, admin: adminReducer, wizard: wizardReducer, + client: clientReducer, }, });