Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,36 @@ export async function apiFetch<T>(path: string, init?: ApiFetchOptions): Promise
export async function apiPublicFetch<T>(path: string, init?: RequestInit): Promise<T> {
return apiFetch<T>(path, { ...init, includeAuth: false });
}

export async function apiDownload(path: string): Promise<void> {
const token = getBearerToken();
const headers = new Headers();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}

const response = await fetch(buildApiUrl(path), { headers });

if (!response.ok) {
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
const body = (await response.json()) as { error?: string };
throw new Error(body.error ?? `Export failed with status ${response.status}`);
}
throw new Error(`Export failed with status ${response.status}`);
}

const disposition = response.headers.get('content-disposition') ?? '';
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/);
const filename = filenameMatch?.[1] ?? 'export.csv';

const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
33 changes: 29 additions & 4 deletions packages/client/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Calendar,
CalendarClock,
Clock,
Download,
FolderOpen,
Link2Off,
Plus,
Expand All @@ -21,7 +22,7 @@ import type {
ProjectsResponse
} from '@opencalendar/shared';

import { apiFetch } from '../api/client.js';
import { apiFetch, apiDownload } from '../api/client.js';
import { CreateProjectModal } from '../components/CreateProjectModal.js';
import { OnboardingWizard } from '../components/OnboardingWizard.js';
import { useAuth } from '../context/AuthContext.js';
Expand All @@ -43,6 +44,20 @@ export function DashboardPage(): JSX.Element {
const isEngineer = user?.role === 'engineer';
const canManageProjects = user?.role === 'pm' || user?.role === 'admin';
const [dashStats, setDashStats] = useState<DashboardStats | null>(null);
const [exportPending, setExportPending] = useState(false);

async function handleExportAllSessions(): Promise<void> {
setExportPending(true);
try {
await apiDownload('/export/sessions');
showToast('CSV exported.', 'success');
} catch (exportError) {
const message = exportError instanceof Error ? exportError.message : 'Export failed';
showToast(message, 'error');
} finally {
setExportPending(false);
}
}

const loadProjects = useCallback(async () => {
setIsLoading(true);
Expand Down Expand Up @@ -159,9 +174,19 @@ export function DashboardPage(): JSX.Element {
<div className="header-row">
<h2>Dashboard</h2>
{canManageProjects ? (
<button type="button" onClick={() => setIsCreateModalOpen(true)} className="header-button">
<Plus size={16} /> Create Project
</button>
<div className="header-actions">
<button
type="button"
className="header-button export-button"
disabled={exportPending}
onClick={() => void handleExportAllSessions()}
>
<Download size={16} /> {exportPending ? 'Exporting...' : 'Export All Sessions'}
</button>
<button type="button" onClick={() => setIsCreateModalOpen(true)} className="header-button">
<Plus size={16} /> Create Project
</button>
</div>
) : null}
</div>

Expand Down
33 changes: 30 additions & 3 deletions packages/client/src/pages/ProjectDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { CalendarX, ClipboardCopy, Inbox, Pencil, Plus, Save, Trash2, CalendarSearch } from 'lucide-react';
import { CalendarX, ClipboardCopy, Download, Inbox, Pencil, Plus, Save, Trash2, CalendarSearch } from 'lucide-react';

import type {
Booking,
Expand All @@ -12,7 +12,7 @@ import type {
UpdateProjectRequest
} from '@opencalendar/shared';

import { apiFetch } from '../api/client.js';
import { apiFetch, apiDownload } from '../api/client.js';
import { AddTimeBlockModal } from '../components/AddTimeBlockModal.js';
import { AvailabilitySolverModal } from '../components/AvailabilitySolverModal.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
Expand Down Expand Up @@ -155,6 +155,7 @@ export function ProjectDetailPage(): JSX.Element {
const [editBlockState, setEditBlockState] = useState<EditTimeBlockState | null>(null);
const [editPending, setEditPending] = useState(false);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [exportPending, setExportPending] = useState(false);

const isPm = user?.role === 'pm' || user?.role === 'admin';

Expand Down Expand Up @@ -399,6 +400,20 @@ export function ProjectDetailPage(): JSX.Element {
}
}

async function handleExportCsv(): Promise<void> {
if (!project) return;
setExportPending(true);
try {
await apiDownload(`/projects/${project.id}/export`);
showToast('CSV exported.', 'success');
} catch (exportError) {
const message = exportError instanceof Error ? exportError.message : 'Export failed';
showToast(message, 'error');
} finally {
setExportPending(false);
}
}

async function handleSaveEditedTimeBlock(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();

Expand Down Expand Up @@ -714,7 +729,19 @@ export function ProjectDetailPage(): JSX.Element {
</div>

<div className="detail-card">
<h3>Signed Up Clients</h3>
<div className="header-row">
<h3>Signed Up Clients</h3>
{signupRows.length > 0 ? (
<button
type="button"
className="header-button export-button"
disabled={exportPending}
onClick={() => void handleExportCsv()}
>
<Download size={16} /> {exportPending ? 'Exporting...' : 'Export CSV'}
</button>
) : null}
</div>
{signupRows.length === 0 ? (
<div className="empty-state">
<Inbox size={24} />
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,22 @@ h3 > svg {
flex-wrap: wrap;
}

.export-button {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-muted);
}

.export-button:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}

.export-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

.solver-trigger-button {
background: var(--color-surface);
color: var(--color-primary);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import bookingRoutes from './routes/booking.js';
import bookingsRoutes from './routes/bookings.js';
import brandingRoutes from './routes/branding.js';
import dashboardRoutes from './routes/dashboard.js';
import { projectExportRouter, globalExportRouter } from './routes/export.js';
import projectRoutes from './routes/projects.js';
import setupRoutes from './routes/setup.js';
import timeBlockRoutes from './routes/timeBlocks.js';
Expand Down Expand Up @@ -73,6 +74,8 @@ function mountApiVersionRoutes(app: express.Express, basePath: '/api' | '/api/v1
app.use(`${basePath}/admin`, adminRoutes);
app.use(`${basePath}/projects`, projectRoutes);
app.use(`${basePath}/projects`, availabilitySolverRoutes);
app.use(`${basePath}/projects`, projectExportRouter);
app.use(`${basePath}/export`, globalExportRouter);
app.use(`${basePath}/time-blocks`, timeBlockRoutes);
app.use(`${basePath}/schedule`, bookingRoutes);
app.use(`${basePath}/bookings`, bookingsRoutes);
Expand Down
Loading
Loading