Phase 6: Export & Polish — brand export, client dashboard, retention, analytics

- Brand-enforced export pipeline (PPTX/PDF with auto brand fonts/colors/logo)
- Client library dashboard with two-level navigation (client grid → detail tabs)
- Data retention service with ARQ cron jobs (daily cleanup + weekly purge)
- Brand-adaptive UI theme via CSS custom properties (dynamic per client)
- Analytics dashboard with overview, usage, quality, and performance metrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-26 16:41:58 +00:00
parent ad65f6fe2d
commit c97841f6d1
15 changed files with 1382 additions and 61 deletions

View file

@ -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)

View file

@ -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,
}

View file

@ -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}

View file

@ -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}")

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,8 @@
"use client";
import { useBrandTheme } from "../hooks/useBrandTheme";
export default function BrandThemeProvider({ children }: { children: React.ReactNode }) {
useBrandTheme();
return <>{children}</>;
}

View file

@ -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<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const dispatch = useDispatch<AppDispatch>();
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<any[]>([]);
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 (
<div className="min-h-screen bg-[#E9E8F8]">
<Header />
<Wrapper>
<main className="container mx-auto px-4 py-8">
<section>
<h2 className="text-2xl font-roboto font-medium mb-6">
Slide Presentation
</h2>
<PresentationGrid
presentations={allPresentations}
type="slide"
isLoading={isLoadingAll}
onPresentationDeleted={removePresentation}
/>
</section>
</main>
</Wrapper>
</div>
);
}
// Client detail view
if (selectedClient) {
return (
<div className="min-h-screen bg-[#E9E8F8]">
<Header />
<Wrapper>
<main className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-1">
<ChevronLeft className="w-4 h-4" />
All Clients
</Button>
<span className="text-gray-300">/</span>
<h2 className="text-xl font-semibold">{selectedClient.name}</h2>
</div>
{/* New Presentation Button */}
<div className="mb-6">
<Button
onClick={() => handleNewPresentation(selectedClient.id)}
className="bg-[#5146E5] hover:bg-[#5146E5]/90 text-white rounded-full px-6"
>
<PlusIcon className="w-4 h-4 mr-2" />
New Presentation
</Button>
</div>
<Tabs defaultValue="templates">
<TabsList className="mb-6">
<TabsTrigger value="templates" className="gap-1.5">
<Layers className="w-4 h-4" />
Templates
</TabsTrigger>
<TabsTrigger value="presentations" className="gap-1.5">
<FileText className="w-4 h-4" />
Presentations
</TabsTrigger>
</TabsList>
{/* Templates Tab */}
<TabsContent value="templates">
{isLoadingDecks ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white rounded-xl p-4 animate-pulse">
<div className="aspect-video bg-gray-200 rounded-lg mb-3" />
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
) : masterDecks.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border-2 border-dashed border-gray-200">
<Layers className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 mb-1">No master decks yet</p>
<p className="text-sm text-gray-400">Upload master decks in the Admin panel</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{masterDecks.map((deck) => (
<div
key={deck.id}
className="bg-white rounded-xl border hover:border-[#5146E5]/40 hover:shadow-md transition-all cursor-pointer overflow-hidden"
onClick={() => {
dispatch(setWizardClient(selectedClient.id));
router.push("/generate/upload");
}}
>
<div className="aspect-video bg-gray-100 flex items-center justify-center">
{deck.thumbnail_url ? (
<img
src={deck.thumbnail_url}
alt={deck.name}
className="w-full h-full object-cover"
/>
) : (
<Layers className="w-10 h-10 text-gray-300" />
)}
</div>
<div className="p-4">
<h3 className="font-medium text-sm truncate">{deck.name}</h3>
<p className="text-xs text-gray-400 mt-1">
{deck.layout_count} layouts
</p>
<span
className={cn(
"inline-block mt-2 text-[10px] font-semibold uppercase px-2 py-0.5 rounded",
deck.parse_status === "completed"
? "bg-green-50 text-green-600"
: deck.parse_status === "failed"
? "bg-red-50 text-red-600"
: "bg-yellow-50 text-yellow-600"
)}
>
{deck.parse_status}
</span>
</div>
</div>
))}
</div>
)}
</TabsContent>
{/* Presentations Tab */}
<TabsContent value="presentations">
{isLoadingPresentations ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white rounded-xl p-4 animate-pulse">
<div className="aspect-video bg-gray-200 rounded-lg mb-3" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
</div>
))}
</div>
) : presentations.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border-2 border-dashed border-gray-200">
<FileText className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 mb-3">No presentations yet</p>
<Button
onClick={() => handleNewPresentation(selectedClient.id)}
className="bg-[#5146E5] hover:bg-[#5146E5]/90 text-white rounded-full"
>
Create First Presentation
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{presentations.map((p) => (
<div
key={p.id}
className="bg-white rounded-xl border hover:border-[#5146E5]/40 hover:shadow-md transition-all cursor-pointer overflow-hidden"
onClick={() => router.push(`/presentation?id=${p.id}`)}
>
<div className="aspect-video bg-gray-100 flex items-center justify-center">
<FileText className="w-10 h-10 text-gray-300" />
</div>
<div className="p-4">
<h3 className="font-medium text-sm truncate">
{p.title || "Untitled"}
</h3>
<div className="flex items-center gap-2 mt-2">
<span
className={cn(
"text-[10px] font-semibold uppercase px-2 py-0.5 rounded",
p.status === "approved"
? "bg-green-50 text-green-600"
: p.status === "in_review"
? "bg-blue-50 text-blue-600"
: "bg-yellow-50 text-yellow-600"
)}
>
{p.status || "draft"}
</span>
<span className="text-xs text-gray-400">
{new Date(p.updated_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</main>
</Wrapper>
</div>
);
}
// Client grid view (top-level)
return (
<div className="min-h-screen bg-[#E9E8F8]">
<Header />
<Wrapper>
<main className="container mx-auto px-4 py-8">
<section>
<h2 className="text-2xl font-roboto font-medium mb-6">
Slide Presentation
</h2>
<PresentationGrid
presentations={presentations}
type="slide"
isLoading={isLoading}
error={error}
onPresentationDeleted={removePresentation}
/>
</section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-roboto font-medium">Your Clients</h2>
<Button
onClick={() => handleNewPresentation()}
className="bg-[#5146E5] hover:bg-[#5146E5]/90 text-white rounded-full px-6"
>
<PlusIcon className="w-4 h-4 mr-2" />
New Presentation
</Button>
</div>
{isLoadingClients ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white rounded-xl p-6 animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-gray-200 rounded-lg" />
<div className="space-y-2 flex-1">
<div className="h-5 bg-gray-200 rounded w-2/3" />
<div className="h-3 bg-gray-200 rounded w-1/3" />
</div>
</div>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{clients.map((client) => (
<div
key={client.id}
onClick={() => 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"
>
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-lg bg-[#5146E5]/10 flex items-center justify-center flex-shrink-0 group-hover:bg-[#5146E5]/20 transition-colors">
{client.logo_url ? (
<img
src={client.logo_url}
alt={client.name}
className="w-8 h-8 object-contain"
/>
) : (
<Building2 className="w-6 h-6 text-[#5146E5]" />
)}
</div>
<div>
<h3 className="font-semibold text-lg">{client.name}</h3>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full rounded-full mt-2 group-hover:bg-[#5146E5] group-hover:text-white group-hover:border-[#5146E5] transition-colors"
onClick={(e) => {
e.stopPropagation();
handleNewPresentation(client.id);
}}
>
<PlusIcon className="w-4 h-4 mr-1" />
New Presentation
</Button>
</div>
))}
</div>
)}
</main>
</Wrapper>
</div>

View file

@ -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);
}

View file

@ -1,10 +1,14 @@
import React from 'react'
import { ConfigurationInitializer } from '../ConfigurationInitializer'
import BrandThemeProvider from './components/BrandThemeProvider'
const layout = ({ children }: { children: React.ReactNode }) => {
return (
<div>
<ConfigurationInitializer>
{children}
<BrandThemeProvider>
{children}
</BrandThemeProvider>
</ConfigurationInitializer>
</div>
)

View file

@ -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<string, number>;
presentations_with_comments: number;
}
interface PerformanceData {
job_status_distribution: Record<string, number>;
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 (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Analytics</h1>
<div className="flex items-center justify-center h-64 border-2 border-dashed border-gray-300 rounded-lg">
<div className="text-center text-gray-400">
<BarChart3 className="w-10 h-10 mx-auto mb-2" />
<p>Analytics dashboard will be implemented in a future phase.</p>
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
</div>
<div className={cn('p-2 rounded-lg bg-gray-50', color)}>
<Icon className="w-5 h-5" />
</div>
</div>
</Card>
);
}
function MiniBarChart({ data }: { data: { date: string; count: number }[] }) {
if (!data.length) return <p className="text-gray-400 text-sm text-center py-8">No data</p>;
const max = Math.max(...data.map((d) => d.count), 1);
const last14 = data.slice(-14);
return (
<div className="flex items-end gap-1 h-32">
{last14.map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-[#5146E5] rounded-t"
style={{ height: `${(d.count / max) * 100}%`, minHeight: d.count > 0 ? 4 : 0 }}
/>
<span className="text-[9px] text-gray-400 truncate w-full text-center">
{d.date.slice(5)}
</span>
</div>
))}
</div>
);
}
function StatusBar({ distribution }: { distribution: Record<string, number> }) {
const total = Object.values(distribution).reduce((a, b) => a + b, 0);
if (total === 0) return <p className="text-gray-400 text-sm text-center py-4">No data</p>;
const colors: Record<string, string> = {
draft: 'bg-yellow-400',
in_review: 'bg-blue-400',
approved: 'bg-green-400',
};
return (
<div>
<div className="flex rounded-full overflow-hidden h-4 mb-3">
{Object.entries(distribution).map(([status, count]) => (
<div
key={status}
className={cn('transition-all', colors[status] || 'bg-gray-300')}
style={{ width: `${(count / total) * 100}%` }}
/>
))}
</div>
<div className="flex gap-4 text-xs">
{Object.entries(distribution).map(([status, count]) => (
<div key={status} className="flex items-center gap-1.5">
<div className={cn('w-2.5 h-2.5 rounded-full', colors[status] || 'bg-gray-300')} />
<span className="capitalize">{status.replace('_', ' ')}</span>
<span className="text-gray-400">({count})</span>
</div>
))}
</div>
</div>
);
}
export default function AnalyticsPage() {
const [overview, setOverview] = useState<OverviewData | null>(null);
const [usage, setUsage] = useState<UsageData | null>(null);
const [quality, setQuality] = useState<QualityData | null>(null);
const [performance, setPerformance] = useState<PerformanceData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [clientId, setClientId] = useState<string>('');
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 (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Analytics</h1>
</div>
{/* Overview Cards */}
{overview && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
title="Total Presentations"
value={overview.total_presentations}
icon={FileText}
/>
<StatCard
title="This Month"
value={overview.this_month}
icon={TrendingUp}
color="text-green-600"
/>
<StatCard
title="This Week"
value={overview.this_week}
icon={BarChart3}
color="text-blue-600"
/>
<StatCard
title="Active Users"
value={overview.active_users}
icon={Users}
subtitle="Last 30 days"
/>
<StatCard
title="Approval Rate"
value={`${overview.approval_rate}%`}
icon={CheckCircle2}
color="text-green-600"
/>
</div>
)}
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Usage Chart */}
<Card className="p-5">
<h3 className="text-sm font-semibold mb-4">Presentations per Day</h3>
{usage ? <MiniBarChart data={usage.daily} /> : <p className="text-gray-400">Loading...</p>}
</Card>
{/* Status Distribution */}
<Card className="p-5">
<h3 className="text-sm font-semibold mb-4">Status Distribution</h3>
{quality ? (
<StatusBar distribution={quality.status_distribution} />
) : (
<p className="text-gray-400">Loading...</p>
)}
</Card>
</div>
{/* Performance Row */}
{performance && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Avg Generation Time"
value={
performance.avg_generation_time_seconds
? `${Math.round(performance.avg_generation_time_seconds)}s`
: 'N/A'
}
icon={Clock}
color="text-orange-600"
/>
<StatCard
title="Total Jobs"
value={performance.total_jobs}
icon={BarChart3}
/>
<StatCard
title="Error Rate"
value={`${performance.error_rate}%`}
icon={AlertTriangle}
color={performance.error_rate > 10 ? 'text-red-600' : 'text-green-600'}
/>
</div>
)}
{/* Top Users */}
{usage && usage.top_users.length > 0 && (
<Card className="p-5">
<h3 className="text-sm font-semibold mb-4">Top Users (Last 30 Days)</h3>
<div className="space-y-2">
{usage.top_users.map((u, i) => {
const maxCount = usage.top_users[0].count || 1;
return (
<div key={u.user_id} className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-5">{i + 1}.</span>
<div className="flex-1">
<div
className="h-6 bg-[#5146E5]/10 rounded flex items-center px-2"
style={{ width: `${(u.count / maxCount) * 100}%`, minWidth: 60 }}
>
<span className="text-xs font-medium truncate">
{u.user_id.slice(0, 8)}...
</span>
</div>
</div>
<span className="text-sm font-semibold text-gray-700">{u.count}</span>
</div>
);
})}
</div>
</Card>
)}
</div>
);
}

View file

@ -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%;

View file

@ -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<string | null>) => {
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;

View file

@ -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,
},
});