feat(taktik): add detail pages, duration rounding, confirmation dialogs

- Project detail page (/projects/[id]): stats, budget progress, inline
  edit, full entry list with billing value calculation
- Client detail page (/clients/[id]): stats, project cards, entry list,
  billing value summary
- Duration rounding: configurable increment (1-15 min) and method
  (up/down/nearest), applied automatically when timer stops
- ConfirmDialog component: reusable modal for destructive actions
- Confirmation required before deleting entries, projects, and clients
- 18 new rounding tests (67 total, all passing)
- i18n: added deleteConfirm keys for DE and EN

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 08:44:59 +02:00
parent 7552c351c0
commit 49df3ead09
28 changed files with 1874 additions and 18 deletions

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
let {
visible = false,
title = '',
message = '',
confirmLabel,
cancelLabel,
destructive = true,
onConfirm,
onCancel,
}: {
visible: boolean;
title: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
} = $props();
</script>
{#if visible}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div
class="mx-4 w-full max-w-sm rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl"
>
<h3 class="text-lg font-semibold text-[hsl(var(--foreground))]">{title}</h3>
{#if message}
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">{message}</p>
{/if}
<div class="mt-5 flex gap-2">
<button
onclick={onCancel}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:text-[hsl(var(--foreground))]"
>
{cancelLabel || $_('common.cancel')}
</button>
<button
onclick={onConfirm}
class="flex-1 rounded-lg py-2.5 text-sm font-medium transition-colors {destructive
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
>
{confirmLabel || $_('common.delete')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -4,6 +4,7 @@
import { timeEntryCollection } from '$lib/data/local-store';
import { formatDurationCompact } from '$lib/data/queries';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import ConfirmDialog from './ConfirmDialog.svelte';
let {
entry,
@ -40,6 +41,8 @@
entry.clientId ? allClients.value.find((c) => c.id === entry.clientId) : undefined
);
let showDeleteConfirm = $state(false);
let saveDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSave(updates: Record<string, unknown>) {
@ -74,7 +77,12 @@
}
async function handleDelete() {
showDeleteConfirm = true;
}
async function confirmDelete() {
await timeEntryCollection.delete(entry.id);
showDeleteConfirm = false;
onCollapse?.();
}
@ -197,3 +205,11 @@
</div>
{/if}
</div>
<ConfirmDialog
visible={showDeleteConfirm}
title={$_('common.delete')}
message={$_('entry.deleteConfirm')}
onConfirm={confirmDelete}
onCancel={() => (showDeleteConfirm = false)}
/>

View file

@ -36,7 +36,8 @@
"today": "Heute",
"thisWeek": "Diese Woche",
"thisMonth": "Dieser Monat",
"manual": "Manuell erfassen"
"manual": "Manuell erfassen",
"deleteConfirm": "Möchtest du diesen Zeiteintrag wirklich löschen? Dies kann nicht rückgängig gemacht werden."
},
"project": {
"create": "Projekt erstellen",
@ -49,7 +50,8 @@
"budget": "Budget",
"noProjects": "Keine Projekte",
"archived": "Archiviert",
"internal": "Intern"
"internal": "Intern",
"deleteConfirm": "Möchtest du dieses Projekt wirklich löschen? Dies kann nicht rückgängig gemacht werden."
},
"client": {
"create": "Kunde erstellen",
@ -59,7 +61,8 @@
"shortCode": "Kürzel",
"email": "E-Mail",
"billingRate": "Stundensatz",
"noClients": "Keine Kunden"
"noClients": "Keine Kunden",
"deleteConfirm": "Möchtest du diesen Kunden wirklich löschen? Dies kann nicht rückgängig gemacht werden."
},
"report": {
"title": "Reports",

View file

@ -36,7 +36,8 @@
"today": "Today",
"thisWeek": "This Week",
"thisMonth": "This Month",
"manual": "Manual Entry"
"manual": "Manual Entry",
"deleteConfirm": "Are you sure you want to delete this time entry? This cannot be undone."
},
"project": {
"create": "Create Project",
@ -49,7 +50,8 @@
"budget": "Budget",
"noProjects": "No projects",
"archived": "Archived",
"internal": "Internal"
"internal": "Internal",
"deleteConfirm": "Are you sure you want to delete this project? This cannot be undone."
},
"client": {
"create": "Create Client",
@ -59,7 +61,8 @@
"shortCode": "Short Code",
"email": "Email",
"billingRate": "Billing Rate",
"noClients": "No clients"
"noClients": "No clients",
"deleteConfirm": "Are you sure you want to delete this client? This cannot be undone."
},
"report": {
"title": "Reports",

View file

@ -7,7 +7,12 @@
*/
import { browser } from '$app/environment';
import { timeEntryCollection, type LocalTimeEntry } from '$lib/data/local-store';
import {
timeEntryCollection,
settingsCollection,
type LocalTimeEntry,
} from '$lib/data/local-store';
import { roundDuration } from '$lib/utils/rounding';
let runningEntry = $state<LocalTimeEntry | null>(null);
let elapsedSeconds = $state(0);
@ -119,17 +124,24 @@ export const timerStore = {
? Math.floor((now.getTime() - new Date(runningEntry.startTime).getTime()) / 1000)
: elapsedSeconds;
// Apply rounding from settings
const settings = await settingsCollection.getAll();
const s = settings[0];
const roundedDuration = s
? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod)
: finalDuration;
await timeEntryCollection.update(runningEntry.id, {
isRunning: false,
endTime: now.toISOString(),
duration: finalDuration,
duration: roundedDuration,
});
const stoppedEntry = {
...runningEntry,
isRunning: false,
endTime: now.toISOString(),
duration: finalDuration,
duration: roundedDuration,
};
stopTicking();
runningEntry = null;

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { roundDuration } from './rounding';
describe('roundDuration', () => {
describe('no rounding', () => {
it('returns original when increment is 0', () => {
expect(roundDuration(3661, 0, 'up')).toBe(3661);
});
it('returns original when method is none', () => {
expect(roundDuration(3661, 5, 'none')).toBe(3661);
});
});
describe('round up', () => {
it('rounds 7 min up to 15 min', () => {
expect(roundDuration(7 * 60, 15, 'up')).toBe(15 * 60);
});
it('rounds 16 min up to 30 min', () => {
expect(roundDuration(16 * 60, 15, 'up')).toBe(30 * 60);
});
it('does not round exact values', () => {
expect(roundDuration(15 * 60, 15, 'up')).toBe(15 * 60);
});
it('rounds 1 min up to 5 min', () => {
expect(roundDuration(60, 5, 'up')).toBe(5 * 60);
});
it('rounds 61 min up to 66 min (6 min increment)', () => {
expect(roundDuration(61 * 60, 6, 'up')).toBe(66 * 60);
});
});
describe('round down', () => {
it('rounds 7 min down to 0 min', () => {
expect(roundDuration(7 * 60, 15, 'down')).toBe(0);
});
it('rounds 22 min down to 15 min', () => {
expect(roundDuration(22 * 60, 15, 'down')).toBe(15 * 60);
});
it('does not round exact values', () => {
expect(roundDuration(30 * 60, 15, 'down')).toBe(30 * 60);
});
});
describe('round nearest', () => {
it('rounds 7 min nearest to 10 (with 10 min increment)', () => {
expect(roundDuration(7 * 60, 10, 'nearest')).toBe(10 * 60);
});
it('rounds 3 min nearest to 0 (with 10 min increment)', () => {
expect(roundDuration(3 * 60, 10, 'nearest')).toBe(0);
});
it('rounds 5 min nearest to 10 (midpoint rounds up)', () => {
expect(roundDuration(5 * 60, 10, 'nearest')).toBe(10 * 60);
});
it('rounds 8 min nearest to 10 (with 5 min increment)', () => {
expect(roundDuration(8 * 60, 5, 'nearest')).toBe(10 * 60);
});
it('rounds 2 min nearest to 0 (with 5 min increment)', () => {
expect(roundDuration(2 * 60, 5, 'nearest')).toBe(0);
});
});
describe('edge cases', () => {
it('handles 0 seconds', () => {
expect(roundDuration(0, 15, 'up')).toBe(0);
});
it('handles 1 minute increment', () => {
expect(roundDuration(90, 1, 'up')).toBe(120); // 1.5 min -> 2 min
});
it('handles negative increment as no rounding', () => {
expect(roundDuration(3661, -5, 'up')).toBe(3661);
});
});
});

View file

@ -0,0 +1,36 @@
/**
* Duration rounding utility
*
* Applies rounding based on user settings (increment + method).
*/
import type { RoundingMethod } from '@taktik/shared';
/**
* Round a duration in seconds based on settings.
* @param seconds - Duration in seconds
* @param increment - Rounding increment in minutes (0 = no rounding)
* @param method - Rounding method: 'none' | 'up' | 'down' | 'nearest'
* @returns Rounded duration in seconds
*/
export function roundDuration(seconds: number, increment: number, method: RoundingMethod): number {
if (increment <= 0 || method === 'none') return seconds;
const incrementSeconds = increment * 60;
const remainder = seconds % incrementSeconds;
if (remainder === 0) return seconds;
switch (method) {
case 'up':
return seconds - remainder + incrementSeconds;
case 'down':
return seconds - remainder;
case 'nearest':
return remainder >= incrementSeconds / 2
? seconds - remainder + incrementSeconds
: seconds - remainder;
default:
return seconds;
}
}

View file

@ -5,6 +5,7 @@
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { Client, Project, TimeEntry } from '@taktik/shared';
import { PROJECT_COLORS } from '@taktik/shared/constants';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
@ -13,6 +14,7 @@
let showCreateForm = $state(false);
let editingClientId = $state<string | null>(null);
let showArchived = $state(false);
let deleteConfirmId = $state<string | null>(null);
let newName = $state('');
let newShortCode = $state('');
@ -60,9 +62,15 @@
await clientCollection.update(id, { isArchived: archive });
}
async function handleDelete(id: string) {
await clientCollection.delete(id);
function handleDelete(id: string) {
deleteConfirmId = id;
}
async function confirmDelete() {
if (!deleteConfirmId) return;
await clientCollection.delete(deleteConfirmId);
editingClientId = null;
deleteConfirmId = null;
}
// Edit state
@ -345,3 +353,11 @@
</div>
{/if}
</div>
<ConfirmDialog
visible={deleteConfirmId !== null}
title={$_('common.delete')}
message={$_('client.deleteConfirm')}
onConfirm={confirmDelete}
onCancel={() => (deleteConfirmId = null)}
/>

View file

@ -0,0 +1,160 @@
<script lang="ts">
import { page } from '$app/stores';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { clientCollection } from '$lib/data/local-store';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
} from '$lib/data/queries';
import EntryList from '$lib/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '@taktik/shared';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let clientId = $derived($page.params.id);
let client = $derived(allClients.value.find((c) => c.id === clientId));
let clientProjects = $derived(
allProjects.value.filter((p) => p.clientId === clientId).sort((a, b) => a.order - b.order)
);
let clientEntries = $derived(
allTimeEntries.value
.filter((e) => e.clientId === clientId && !e.isRunning)
.sort((a, b) => b.date.localeCompare(a.date))
);
let totalDuration = $derived(getTotalDuration(clientEntries));
let billableDuration = $derived(getBillableDuration(clientEntries));
function getProjectHours(projectId: string): number {
return getTotalDuration(clientEntries.filter((e) => e.projectId === projectId));
}
let billingValue = $derived(() => {
if (!client?.billingRate) return null;
return (billableDuration / 3600) * client.billingRate.amount;
});
</script>
<svelte:head>
<title>{client?.name || 'Kunde'} | Taktik</title>
</svelte:head>
{#if !client}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-[hsl(var(--muted-foreground))]">Kunde nicht gefunden.</p>
<a href="/clients" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Header -->
<div>
<a
href="/clients"
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{$_('nav.clients')}
</a>
<div class="flex items-center gap-4">
<div
class="flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0).toUpperCase()}
</div>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{client.name}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{#if client.shortCode}{client.shortCode} ·
{/if}
{#if client.email}{client.email} ·
{/if}
{#if client.billingRate}
{client.billingRate.amount} {client.billingRate.currency}/h
{/if}
</p>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.projects')}</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{clientProjects.length}</p>
</div>
{#if billingValue() !== null}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">Wert</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{billingValue()!.toFixed(0)}
{client.billingRate!.currency}
</p>
</div>
{/if}
</div>
<!-- Projects -->
{#if clientProjects.length > 0}
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.projects')}
</h2>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each clientProjects as proj}
{@const hours = getProjectHours(proj.id)}
<a
href="/projects/{proj.id}"
class="entry-item flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4"
>
<div class="h-3 w-3 rounded-full" style="background-color: {proj.color}"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{proj.name}</p>
{#if proj.isBillable}
<span class="text-xs text-[hsl(var(--primary))]">{$_('project.billable')}</span>
{/if}
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</a>
{/each}
</div>
</div>
{/if}
<!-- Entries -->
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
</h2>
<EntryList entries={clientEntries} />
</div>
</div>
{/if}

View file

@ -5,6 +5,7 @@
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { Project, Client, TimeEntry } from '@taktik/shared';
import { PROJECT_COLORS } from '@taktik/shared/constants';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
@ -13,6 +14,7 @@
let showCreateForm = $state(false);
let editingProjectId = $state<string | null>(null);
let showArchived = $state(false);
let deleteConfirmId = $state<string | null>(null);
// New project form
let newName = $state('');
@ -68,9 +70,15 @@
await projectCollection.update(id, { isArchived: archive });
}
async function handleDelete(id: string) {
await projectCollection.delete(id);
function handleDelete(id: string) {
deleteConfirmId = id;
}
async function confirmDelete() {
if (!deleteConfirmId) return;
await projectCollection.delete(deleteConfirmId);
editingProjectId = null;
deleteConfirmId = null;
}
// Inline edit state
@ -354,3 +362,11 @@
</div>
{/if}
</div>
<ConfirmDialog
visible={deleteConfirmId !== null}
title={$_('common.delete')}
message={$_('project.deleteConfirm')}
onConfirm={confirmDelete}
onCancel={() => (deleteConfirmId = null)}
/>

View file

@ -0,0 +1,330 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { projectCollection } from '$lib/data/local-store';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
} from '$lib/data/queries';
import EntryList from '$lib/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '@taktik/shared';
import { PROJECT_COLORS } from '@taktik/shared/constants';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let projectId = $derived($page.params.id);
let project = $derived(allProjects.value.find((p) => p.id === projectId));
let client = $derived(
project?.clientId ? allClients.value.find((c) => c.id === project!.clientId) : undefined
);
let projectEntries = $derived(
allTimeEntries.value
.filter((e) => e.projectId === projectId && !e.isRunning)
.sort((a, b) => b.date.localeCompare(a.date))
);
let totalDuration = $derived(getTotalDuration(projectEntries));
let billableDuration = $derived(getBillableDuration(projectEntries));
let budgetPercent = $derived(() => {
if (!project?.budget || project.budget.type !== 'hours') return null;
const hoursLogged = totalDuration / 3600;
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
});
let budgetHoursUsed = $derived(totalDuration / 3600);
let budgetHoursTotal = $derived(project?.budget?.type === 'hours' ? project.budget.amount : null);
// Edit state
let isEditing = $state(false);
let editName = $state('');
let editDescription = $state('');
let editClientId = $state('');
let editColor = $state('');
let editIsBillable = $state(false);
let editBudgetHours = $state(0);
let editRateAmount = $state(0);
function startEditing() {
if (!project) return;
isEditing = true;
editName = project.name;
editDescription = project.description ?? '';
editClientId = project.clientId ?? '';
editColor = project.color;
editIsBillable = project.isBillable;
editBudgetHours = project.budget?.type === 'hours' ? project.budget.amount : 0;
editRateAmount = project.billingRate?.amount ?? 0;
}
let debounce: ReturnType<typeof setTimeout> | null = null;
function save(updates: Record<string, unknown>) {
const id = projectId;
if (!id) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
projectCollection.update(id, updates);
}, 500);
}
async function handleArchive() {
const id = projectId;
if (!project || !id) return;
await projectCollection.update(id, { isArchived: !project.isArchived });
}
</script>
<svelte:head>
<title>{project?.name || 'Projekt'} | Taktik</title>
</svelte:head>
{#if !project}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-[hsl(var(--muted-foreground))]">Projekt nicht gefunden.</p>
<a href="/projects" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Header -->
<div>
<a
href="/projects"
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{$_('nav.projects')}
</a>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="h-4 w-4 rounded-full" style="background-color: {project.color}"></div>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{project.name}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{client?.name || $_('project.internal')}
{#if project.isBillable}
<span
class="ml-2 rounded bg-[hsl(var(--primary)/0.1)] px-1.5 py-0.5 text-xs text-[hsl(var(--primary))]"
>
{$_('project.billable')}
</span>
{/if}
{#if project.isArchived}
<span
class="ml-2 rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
>
{$_('project.archived')}
</span>
{/if}
</p>
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => (isEditing ? (isEditing = false) : startEditing())}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
{isEditing ? $_('common.close') : $_('common.edit')}
</button>
<button
onclick={handleArchive}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))]"
>
{project.isArchived ? $_('common.unarchive') : $_('common.archive')}
</button>
</div>
</div>
</div>
<!-- Edit Form -->
{#if isEditing}
<div
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
save({ name: editName });
}}
placeholder={$_('project.name')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none"
/>
<input
type="text"
value={editDescription}
oninput={(e) => {
editDescription = (e.target as HTMLInputElement).value;
save({ description: editDescription || null });
}}
placeholder={$_('project.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
<div class="flex gap-2">
<select
value={editClientId}
onchange={(e) => {
editClientId = (e.target as HTMLSelectElement).value;
save({ clientId: editClientId || null });
}}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<label
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
>
<input
type="checkbox"
checked={editIsBillable}
onchange={() => {
editIsBillable = !editIsBillable;
save({ isBillable: editIsBillable });
}}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-1">
<label class="text-xs text-[hsl(var(--muted-foreground))]"
>{$_('project.budget')} (h):</label
>
<input
type="number"
value={editBudgetHours}
min="0"
oninput={(e) => {
editBudgetHours = parseInt((e.target as HTMLInputElement).value) || 0;
save({
budget: editBudgetHours > 0 ? { type: 'hours', amount: editBudgetHours } : null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
/>
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-[hsl(var(--muted-foreground))]">Rate:</label>
<input
type="number"
value={editRateAmount}
min="0"
step="5"
oninput={(e) => {
editRateAmount = parseInt((e.target as HTMLInputElement).value) || 0;
save({
billingRate:
editRateAmount > 0
? { amount: editRateAmount, currency: 'EUR', per: 'hour' }
: null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
save({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
</div>
{/if}
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
{#if budgetHoursTotal}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h
</p>
</div>
{/if}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{projectEntries.length}</p>
</div>
</div>
<!-- Budget Progress -->
{#if budgetPercent() !== null}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center justify-between text-sm">
<span class="text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</span>
<span class="font-medium text-[hsl(var(--foreground))]">{budgetPercent()}%</span>
</div>
<div class="mt-2 h-2.5 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {budgetPercent()! > 90
? 'bg-red-500'
: budgetPercent()! > 75
? 'bg-amber-500'
: 'bg-[hsl(var(--primary))]'}"
style="width: {budgetPercent()}%"
></div>
</div>
{#if project.billingRate}
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
{project.billingRate.amount}
{project.billingRate.currency}/h · Wert: {(
(billableDuration / 3600) *
project.billingRate.amount
).toFixed(0)}
{project.billingRate.currency}
</p>
{/if}
</div>
{/if}
<!-- Entries -->
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
</h2>
<EntryList entries={projectEntries} />
</div>
</div>
{/if}

View file

@ -1,7 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { defineConfig } from 'vitest/config';
import { defineConfig } from 'vite';
import { getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
@ -57,8 +57,4 @@ export default defineConfig({
port: 5197,
},
define: getBuildDefines(),
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
},
});