feat(admin): sessions management screen (list + filter + actions)
This commit is contained in:
@@ -4,6 +4,7 @@ import Login from './screens/Login';
|
|||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Live from './screens/Live';
|
import Live from './screens/Live';
|
||||||
import Activities from './screens/Activities';
|
import Activities from './screens/Activities';
|
||||||
|
import Sessions from './screens/Sessions';
|
||||||
|
|
||||||
function AuthedShell() {
|
function AuthedShell() {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +15,7 @@ function AuthedShell() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Live />} />
|
<Route path="/" element={<Live />} />
|
||||||
<Route path="/handelingen" element={<Activities />} />
|
<Route path="/handelingen" element={<Activities />} />
|
||||||
|
<Route path="/sessies" element={<Sessions />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { WorkSession } from '@solelog/shared';
|
import type {
|
||||||
|
AdminUpdateSessionInput,
|
||||||
|
CreateManualSessionInput,
|
||||||
|
WorkSession,
|
||||||
|
} from '@solelog/shared';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
// Active work sessions across all workers (admin cross-user view).
|
// Active work sessions across all workers (admin cross-user view).
|
||||||
@@ -11,3 +15,69 @@ export function useActiveSessions() {
|
|||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every session (newest first) for the Sessies management screen.
|
||||||
|
export function useAllSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'sessions', 'all'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roster for the create-form worker picker.
|
||||||
|
export function useAdminUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'users'],
|
||||||
|
queryFn: () => apiFetch<{ id: string; name: string; email: string }[]>('/api/admin/users'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateManualSession() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateManualSessionInput) =>
|
||||||
|
apiFetch<WorkSession>('/api/admin/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSession() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: AdminUpdateSessionInput }) =>
|
||||||
|
apiFetch<WorkSession>(`/api/admin/sessions/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminStopSession() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<WorkSession>(`/api/admin/sessions/${id}/stop`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminDiscardSession() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiFetch<WorkSession>(`/api/admin/sessions/${id}/discard`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { useMe } from '../api/me';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Live' },
|
{ to: '/', label: 'Live' },
|
||||||
{ to: '/handelingen', label: 'Handelingen' },
|
{ to: '/handelingen', label: 'Handelingen' },
|
||||||
|
{ to: '/sessies', label: 'Sessies' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming.
|
// Sections planned for Phase 3b — shown muted/disabled as a hint of what's coming.
|
||||||
const soonItems = ['Rapporten', 'Gebruikers', 'Handmatig'] as const;
|
const soonItems = ['Rapporten', 'Gebruikers'] as const;
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { signOut } = useAuth();
|
const { signOut } = useAuth();
|
||||||
|
|||||||
195
apps/admin/src/screens/Sessions.test.tsx
Normal file
195
apps/admin/src/screens/Sessions.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { WorkSession } from '@solelog/shared';
|
||||||
|
import Sessions from './Sessions';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApiFetch = vi.mocked(apiFetch);
|
||||||
|
|
||||||
|
function makeSession(over: Partial<WorkSession>): WorkSession {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
user_id: 'u1',
|
||||||
|
activity_id: 10,
|
||||||
|
activity_name: 'Frezen',
|
||||||
|
user_name: 'Jan',
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
pair_count: 4,
|
||||||
|
start_time: new Date('2026-06-17T08:00:00Z').toISOString(),
|
||||||
|
end_time: new Date('2026-06-17T09:00:00Z').toISOString(),
|
||||||
|
duration_seconds: 3000,
|
||||||
|
paused_seconds: 600,
|
||||||
|
paused_at: null,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
notes: null,
|
||||||
|
created_at: new Date('2026-06-17T09:00:00Z').toISOString(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSIONS: WorkSession[] = [
|
||||||
|
makeSession({
|
||||||
|
id: 1,
|
||||||
|
user_name: 'Jan',
|
||||||
|
activity_name: 'Frezen',
|
||||||
|
status: 'completed',
|
||||||
|
duration_seconds: 3000,
|
||||||
|
paused_seconds: 600,
|
||||||
|
end_time: new Date('2026-06-17T09:00:00Z').toISOString(),
|
||||||
|
}),
|
||||||
|
makeSession({
|
||||||
|
id: 2,
|
||||||
|
user_name: 'Piet',
|
||||||
|
activity_name: 'Lijmen',
|
||||||
|
status: 'active',
|
||||||
|
duration_seconds: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
end_time: null,
|
||||||
|
start_time: new Date(Date.now() - 120_000).toISOString(),
|
||||||
|
}),
|
||||||
|
makeSession({
|
||||||
|
id: 3,
|
||||||
|
user_name: 'Klaas',
|
||||||
|
activity_name: 'Snijden',
|
||||||
|
status: 'discarded',
|
||||||
|
duration_seconds: null,
|
||||||
|
paused_seconds: 0,
|
||||||
|
end_time: new Date('2026-06-17T07:00:00Z').toISOString(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Route any non-sessions call (users roster, activities) to an empty list.
|
||||||
|
function mockEndpoints(sessions: WorkSession[]) {
|
||||||
|
mockApiFetch.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/admin/sessions') return Promise.resolve(sessions as never);
|
||||||
|
return Promise.resolve([] as never);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessions() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Sessions />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sessions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a row per session with worker, activity and worked time', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Sessies')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Frezen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Piet')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Klaas')).toBeInTheDocument();
|
||||||
|
// worked for the completed manual session = 3000s = 00:50:00
|
||||||
|
expect(screen.getByText('00:50:00')).toBeInTheDocument();
|
||||||
|
// pause shown when paused_seconds > 0 (600s = 00:10:00)
|
||||||
|
expect(screen.getByText(/Pauze 00:10:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows the list when the status filter changes', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText('Status'), 'active');
|
||||||
|
|
||||||
|
expect(screen.getByText('Piet')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Jan')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Klaas')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Stop/Annuleer only on active rows and ✎ on all rows', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// ✎ edit on every row.
|
||||||
|
expect(screen.getAllByRole('button', { name: /Bewerk/ })).toHaveLength(3);
|
||||||
|
// Stop/Annuleer only on the single active row.
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Stop' })).toHaveLength(1);
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Annuleer' })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Stop on an active row calls the stop endpoint', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Piet')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/sessions/2/stop',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Annuleer on an active row calls the discard endpoint', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Piet')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Annuleer' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/sessions/2/discard',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('+ Nieuwe registratie opens the create form (worker picker visible)', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe registratie' }));
|
||||||
|
|
||||||
|
// Task 4 fills the form; for now we assert the create panel/region opens.
|
||||||
|
expect(await screen.findByTestId('session-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when there are no sessions', async () => {
|
||||||
|
mockEndpoints([]);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Nog geen sessies.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the discarded row hidden when filtering to voltooid', async () => {
|
||||||
|
mockEndpoints(SESSIONS);
|
||||||
|
renderSessions();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText('Status'), 'completed');
|
||||||
|
|
||||||
|
const list = screen.getByRole('list', { name: 'Sessies' });
|
||||||
|
expect(within(list).getByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(within(list).queryByText('Piet')).not.toBeInTheDocument();
|
||||||
|
expect(within(list).queryByText('Klaas')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
177
apps/admin/src/screens/Sessions.tsx
Normal file
177
apps/admin/src/screens/Sessions.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { SessionStatus, WorkSession } from '@solelog/shared';
|
||||||
|
import { useAdminDiscardSession, useAdminStopSession, useAllSessions } from '../api/admin-sessions';
|
||||||
|
import { formatTime } from '../lib/elapsed';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | SessionStatus;
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
{ value: 'active', label: 'Actief' },
|
||||||
|
{ value: 'completed', label: 'Voltooid' },
|
||||||
|
{ value: 'discarded', label: 'Geannuleerd' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<SessionStatus, string> = {
|
||||||
|
active: 'Actief',
|
||||||
|
completed: 'Voltooid',
|
||||||
|
discarded: 'Geannuleerd',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Worked seconds for a row: completed/discarded use the server-derived duration;
|
||||||
|
// an active session counts from start − paused (frozen at the pause moment when paused).
|
||||||
|
function workedSeconds(session: WorkSession): number {
|
||||||
|
if (session.status === 'active') {
|
||||||
|
const base = session.paused_at ? Date.parse(session.paused_at) : Date.now();
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return session.duration_seconds ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleDateString('nl-NL', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sessions() {
|
||||||
|
const { data, isLoading, isError } = useAllSessions();
|
||||||
|
const stopSession = useAdminStopSession();
|
||||||
|
const discardSession = useAdminDiscardSession();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>('all');
|
||||||
|
// Task 4 replaces this stub with the real create/edit form.
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<h1 className="screen-title">Sessies</h1>
|
||||||
|
<p className="muted">Kon sessies niet laden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = Array.isArray(data) ? data : [];
|
||||||
|
const visible = filter === 'all' ? sessions : sessions.filter((s) => s.status === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<div className="sessions-head">
|
||||||
|
<h1 className="screen-title">Sessies</h1>
|
||||||
|
<button type="button" className="btn-primary" onClick={() => setFormOpen(true)}>
|
||||||
|
+ Nieuwe registratie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sessions-toolbar">
|
||||||
|
<label className="sessions-filter">
|
||||||
|
Status
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value as StatusFilter)}
|
||||||
|
aria-label="Status"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formOpen && (
|
||||||
|
<section className="card" data-testid="session-form">
|
||||||
|
<h2 className="section-label">Nieuwe registratie</h2>
|
||||||
|
{/* Task 4 fills in the full create/edit form. */}
|
||||||
|
<label className="sessions-filter">
|
||||||
|
Medewerker
|
||||||
|
<select aria-label="Medewerker" disabled>
|
||||||
|
<option>—</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn-cancel" onClick={() => setFormOpen(false)}>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="muted">Laden…</p>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<p className="muted">
|
||||||
|
{sessions.length === 0 ? 'Nog geen sessies.' : 'Geen sessies in dit filter.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="sessions-list" aria-label="Sessies">
|
||||||
|
{visible.map((session) => (
|
||||||
|
<SessionRow
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onStop={() => stopSession.mutate(session.id)}
|
||||||
|
onDiscard={() => discardSession.mutate(session.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({
|
||||||
|
session,
|
||||||
|
onStop,
|
||||||
|
onDiscard,
|
||||||
|
}: {
|
||||||
|
session: WorkSession;
|
||||||
|
onStop: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}) {
|
||||||
|
const worked = workedSeconds(session);
|
||||||
|
const isActive = session.status === 'active';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="sessions-row">
|
||||||
|
<div className="sessions-cell sessions-worker">{session.user_name ?? 'Onbekend'}</div>
|
||||||
|
<div className="sessions-cell sessions-activity">
|
||||||
|
{session.activity_name ?? 'Onbekende handeling'}
|
||||||
|
{session.insole_type && <span className="sessions-type">{session.insole_type}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="sessions-cell sessions-worked">
|
||||||
|
<span className="sessions-time">{formatTime(worked)}</span>
|
||||||
|
{session.paused_seconds > 0 && (
|
||||||
|
<span className="sessions-pause">Pauze {formatTime(session.paused_seconds)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sessions-cell sessions-date">{formatDate(session.start_time)}</div>
|
||||||
|
<div className="sessions-cell sessions-status">
|
||||||
|
<span className={`sessions-badge sessions-badge-${session.status}`}>
|
||||||
|
{STATUS_LABEL[session.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sessions-cell sessions-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn icon-edit"
|
||||||
|
aria-label={`Bewerk sessie van ${session.user_name ?? 'onbekend'}`}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn-row-stop" onClick={onStop}>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-row-cancel" onClick={onDiscard}>
|
||||||
|
Annuleer
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -420,3 +420,160 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Sessions management (Sessies) ---- */
|
||||||
|
.sessions-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-head .screen-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-head .btn-primary {
|
||||||
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-filter select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.6fr 1fr 0.8fr 0.8fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-worker {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-activity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-type {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-worked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-pause {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-date {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-badge-active {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-badge-completed {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-badge-discarded {
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-stop {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-cancel {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fee2e2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user