mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 12:39:39 +02:00
feat(taktik): add timer indicator, settings, templates, CSV export, keyboard shortcuts
- TimerIndicator: compact navbar bar with pulsing dot, elapsed time, stop button - Settings page: working hours, rounding, billing rate, currency, timer config - Templates page: save/use entry templates, sorted by usage count - CSV export: semicolon-delimited with UTF-8 BOM for Excel compatibility - Keyboard shortcuts: s=start/stop timer, n=new entry, Escape=blur - Timer initialization moved to layout (available on all pages) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f0e330884
commit
4a91fd7e97
8 changed files with 693 additions and 25 deletions
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
|
||||
let {
|
||||
onNewEntry,
|
||||
}: {
|
||||
onNewEntry?: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Don't trigger when typing in inputs
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT'
|
||||
) {
|
||||
// Escape still works in inputs
|
||||
if (e.key === 'Escape') {
|
||||
(target as HTMLInputElement).blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
if (timerStore.isRunning) {
|
||||
timerStore.stop();
|
||||
} else {
|
||||
timerStore.start();
|
||||
}
|
||||
break;
|
||||
case 'n':
|
||||
e.preventDefault();
|
||||
onNewEntry?.();
|
||||
break;
|
||||
case 'g':
|
||||
// g then t = go to timer, g then e = entries, etc.
|
||||
break;
|
||||
case '?':
|
||||
// Show keyboard shortcuts help (future)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import { formatDuration } from '$lib/data/queries';
|
||||
import type { Project } from '@taktik/shared';
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
|
||||
let project = $derived(
|
||||
timerStore.runningEntry?.projectId
|
||||
? allProjects.value.find((p) => p.id === timerStore.runningEntry!.projectId)
|
||||
: undefined
|
||||
);
|
||||
|
||||
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
|
||||
|
||||
async function handleStop() {
|
||||
await timerStore.stop();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if timerStore.isRunning}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary)/0.1)] px-3 py-1.5 border border-[hsl(var(--primary)/0.2)]"
|
||||
>
|
||||
<!-- Pulsing dot -->
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[hsl(var(--primary))] opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-[hsl(var(--primary))]"></span>
|
||||
</div>
|
||||
|
||||
<!-- Project dot + Description -->
|
||||
{#if project}
|
||||
<div
|
||||
class="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style="background-color: {project.color}"
|
||||
></div>
|
||||
{/if}
|
||||
<span class="hidden sm:inline max-w-[120px] truncate text-xs text-[hsl(var(--foreground))]">
|
||||
{timerStore.runningEntry?.description || $_('timer.running')}
|
||||
</span>
|
||||
|
||||
<!-- Elapsed time -->
|
||||
<span class="duration-display text-xs font-medium text-[hsl(var(--primary))]">
|
||||
{formattedTime}
|
||||
</span>
|
||||
|
||||
<!-- Stop button -->
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600"
|
||||
title={$_('timer.stop')}
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
61
apps/taktik/apps/web/src/lib/utils/export.ts
Normal file
61
apps/taktik/apps/web/src/lib/utils/export.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* CSV Export utility for time entries
|
||||
*/
|
||||
|
||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
||||
|
||||
export function exportEntriesToCSV(
|
||||
entries: TimeEntry[],
|
||||
projects: Project[],
|
||||
clients: Client[]
|
||||
): void {
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]));
|
||||
const clientMap = new Map(clients.map((c) => [c.id, c]));
|
||||
|
||||
const headers = [
|
||||
'Datum',
|
||||
'Beschreibung',
|
||||
'Projekt',
|
||||
'Kunde',
|
||||
'Dauer (h)',
|
||||
'Dauer (min)',
|
||||
'Abrechenbar',
|
||||
'Tags',
|
||||
'Startzeit',
|
||||
'Endzeit',
|
||||
];
|
||||
|
||||
const rows = entries.map((e) => {
|
||||
const project = e.projectId ? projectMap.get(e.projectId) : undefined;
|
||||
const client = e.clientId ? clientMap.get(e.clientId) : undefined;
|
||||
const hours = Math.floor(e.duration / 3600);
|
||||
const minutes = Math.floor((e.duration % 3600) / 60);
|
||||
|
||||
return [
|
||||
e.date,
|
||||
`"${(e.description || '').replace(/"/g, '""')}"`,
|
||||
`"${(project?.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(client?.name || '').replace(/"/g, '""')}"`,
|
||||
hours.toString(),
|
||||
(hours * 60 + minutes).toString(),
|
||||
e.isBillable ? 'Ja' : 'Nein',
|
||||
`"${e.tags.join(', ')}"`,
|
||||
e.startTime
|
||||
? new Date(e.startTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: '',
|
||||
e.endTime
|
||||
? new Date(e.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: '',
|
||||
];
|
||||
});
|
||||
|
||||
const csv = [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n');
|
||||
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
|
||||
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `taktik-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { setContext } from 'svelte';
|
||||
import { setContext, onDestroy } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import TimerIndicator from '$lib/components/TimerIndicator.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { SyncIndicator } from '@manacore/shared-ui';
|
||||
|
|
@ -51,6 +53,7 @@
|
|||
}
|
||||
|
||||
viewStore.initialize();
|
||||
await timerStore.initialize();
|
||||
initialized = true;
|
||||
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('taktik')) {
|
||||
|
|
@ -64,11 +67,16 @@
|
|||
{ href: '/projects', label: $_('nav.projects'), icon: 'folder' },
|
||||
{ href: '/clients', label: $_('nav.clients'), icon: 'buildings' },
|
||||
{ href: '/reports', label: $_('nav.reports'), icon: 'chart-bar' },
|
||||
{ href: '/templates', label: $_('nav.templates'), icon: 'bookmark' },
|
||||
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
|
||||
{ href: '/mana', label: 'Mana', icon: 'star' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
onDestroy(() => {
|
||||
timerStore.destroy();
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
|
|
@ -116,6 +124,9 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Timer Indicator (visible when timer running) -->
|
||||
<TimerIndicator />
|
||||
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, getContext } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
||||
import {
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import EntryForm from '$lib/components/EntryForm.svelte';
|
||||
import QuickStart from '$lib/components/QuickStart.svelte';
|
||||
import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.svelte';
|
||||
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
|
|
@ -25,14 +26,6 @@
|
|||
let todayBillable = $derived(getBillableDuration(todayEntries));
|
||||
|
||||
let showEntryForm = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await timerStore.initialize();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
timerStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -82,3 +75,4 @@
|
|||
|
||||
<!-- Manual Entry Form -->
|
||||
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />
|
||||
<KeyboardShortcuts onNewEntry={() => (showEntryForm = true)} />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
||||
import { exportEntriesToCSV } from '$lib/utils/export';
|
||||
import {
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
|
|
@ -87,17 +88,25 @@
|
|||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.reports')}</h1>
|
||||
<div class="flex gap-1">
|
||||
{#each ['week', 'month'] as p}
|
||||
<button
|
||||
onclick={() => (period = p as any)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm transition-colors {period === p
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
|
||||
>
|
||||
{p === 'week' ? $_('entry.thisWeek') : $_('entry.thisMonth')}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
{#each ['week', 'month'] as p}
|
||||
<button
|
||||
onclick={() => (period = p as any)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm transition-colors {period === p
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
|
||||
>
|
||||
{p === 'week' ? $_('entry.thisWeek') : $_('entry.thisMonth')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => exportEntriesToCSV(entries(), allProjects.value, allClients.value)}
|
||||
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))]"
|
||||
>
|
||||
CSV Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,290 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settingsCollection } from '$lib/data/local-store';
|
||||
import type { TaktikSettings } from '@taktik/shared';
|
||||
import { CURRENCIES, ROUNDING_INCREMENTS } from '@taktik/shared/constants';
|
||||
|
||||
const settings = getContext<{ value: TaktikSettings | null }>('settings');
|
||||
|
||||
// Local edit state, synced from settings
|
||||
let workingHoursPerDay = $state(8);
|
||||
let workingDaysPerWeek = $state(5);
|
||||
let roundingIncrement = $state(0);
|
||||
let roundingMethod = $state<'none' | 'up' | 'down' | 'nearest'>('none');
|
||||
let defaultRate = $state(0);
|
||||
let defaultCurrency = $state('EUR');
|
||||
let weekStartsOn = $state<0 | 1>(1);
|
||||
let timerReminderMinutes = $state(0);
|
||||
let autoStopTimerHours = $state(0);
|
||||
let defaultVisibility = $state<'private' | 'guild'>('private');
|
||||
let initialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const s = settings.value;
|
||||
if (s && !initialized) {
|
||||
workingHoursPerDay = s.workingHoursPerDay;
|
||||
workingDaysPerWeek = s.workingDaysPerWeek;
|
||||
roundingIncrement = s.roundingIncrement;
|
||||
roundingMethod = s.roundingMethod;
|
||||
defaultRate = s.defaultBillingRate?.amount ?? 0;
|
||||
defaultCurrency = s.defaultBillingRate?.currency ?? 'EUR';
|
||||
weekStartsOn = s.weekStartsOn;
|
||||
timerReminderMinutes = s.timerReminderMinutes;
|
||||
autoStopTimerHours = s.autoStopTimerHours;
|
||||
defaultVisibility = s.defaultVisibility;
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function save(updates: Record<string, unknown>) {
|
||||
if (!settings.value) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
settingsCollection.update(settings.value!.id, updates);
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.settings')} | Taktik</title>
|
||||
<title>{$_('settings.title')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.settings')}</h1>
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Einstellungen kommen bald.</p>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('settings.title')}</h1>
|
||||
|
||||
<!-- Working Time -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">Arbeitszeit</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.workingHours')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={workingHoursPerDay}
|
||||
min="1"
|
||||
max="24"
|
||||
step="0.5"
|
||||
oninput={(e) => {
|
||||
workingHoursPerDay = parseFloat((e.target as HTMLInputElement).value) || 8;
|
||||
save({ workingHoursPerDay });
|
||||
}}
|
||||
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>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.workingDays')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={workingDaysPerWeek}
|
||||
min="1"
|
||||
max="7"
|
||||
oninput={(e) => {
|
||||
workingDaysPerWeek = parseInt((e.target as HTMLInputElement).value) || 5;
|
||||
save({ workingDaysPerWeek });
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.weekStart')}</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
weekStartsOn = 1;
|
||||
save({ weekStartsOn: 1 });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {weekStartsOn === 1
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.monday')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
weekStartsOn = 0;
|
||||
save({ weekStartsOn: 0 });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {weekStartsOn === 0
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.sunday')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rounding -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">{$_('settings.rounding')}</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]">Intervall</label>
|
||||
<select
|
||||
value={roundingIncrement}
|
||||
onchange={(e) => {
|
||||
roundingIncrement = parseInt((e.target as HTMLSelectElement).value);
|
||||
save({ roundingIncrement });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{#each ROUNDING_INCREMENTS as inc}
|
||||
<option value={inc}>{inc === 0 ? $_('settings.none') : `${inc} min`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.roundingMethod')}</label
|
||||
>
|
||||
<select
|
||||
value={roundingMethod}
|
||||
onchange={(e) => {
|
||||
roundingMethod = (e.target as HTMLSelectElement).value as any;
|
||||
save({ roundingMethod });
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<option value="none">{$_('settings.none')}</option>
|
||||
<option value="up">{$_('settings.up')}</option>
|
||||
<option value="down">{$_('settings.down')}</option>
|
||||
<option value="nearest">{$_('settings.nearest')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">
|
||||
{$_('settings.billingRate')}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.billingRate')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={defaultRate}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
defaultRate = parseFloat((e.target as HTMLInputElement).value) || 0;
|
||||
save({
|
||||
defaultBillingRate:
|
||||
defaultRate > 0
|
||||
? { amount: defaultRate, currency: defaultCurrency, per: 'hour' }
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
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>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.currency')}</label
|
||||
>
|
||||
<select
|
||||
value={defaultCurrency}
|
||||
onchange={(e) => {
|
||||
defaultCurrency = (e.target as HTMLSelectElement).value;
|
||||
if (defaultRate > 0)
|
||||
save({
|
||||
defaultBillingRate: { amount: defaultRate, currency: defaultCurrency, per: 'hour' },
|
||||
});
|
||||
}}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{#each CURRENCIES as curr}
|
||||
<option value={curr.code}>{curr.symbol} {curr.code}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<span class="pb-2 text-sm text-[hsl(var(--muted-foreground))]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">Timer</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.timerReminder')}</label
|
||||
>
|
||||
<p class="mb-1.5 text-xs text-[hsl(var(--muted-foreground))]">0 = aus</p>
|
||||
<input
|
||||
type="number"
|
||||
value={timerReminderMinutes}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
timerReminderMinutes = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
save({ timerReminderMinutes });
|
||||
}}
|
||||
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>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[hsl(var(--foreground))]"
|
||||
>{$_('settings.autoStop')}</label
|
||||
>
|
||||
<p class="mb-1.5 text-xs text-[hsl(var(--muted-foreground))]">0 = aus</p>
|
||||
<input
|
||||
type="number"
|
||||
value={autoStopTimerHours}
|
||||
min="0"
|
||||
step="1"
|
||||
oninput={(e) => {
|
||||
autoStopTimerHours = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
save({ autoStopTimerHours });
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-[hsl(var(--foreground))]">{$_('settings.visibility')}</h2>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('settings.visibilityDesc')}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
defaultVisibility = 'private';
|
||||
save({ defaultVisibility: 'private' });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {defaultVisibility === 'private'
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.private')}</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
defaultVisibility = 'guild';
|
||||
save({ defaultVisibility: 'guild' });
|
||||
}}
|
||||
class="rounded-lg px-4 py-2 text-sm transition-colors {defaultVisibility === 'guild'
|
||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
|
||||
: 'border border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
|
||||
>{$_('settings.guild')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
197
apps/taktik/apps/web/src/routes/(app)/templates/+page.svelte
Normal file
197
apps/taktik/apps/web/src/routes/(app)/templates/+page.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { templateCollection, timeEntryCollection } from '$lib/data/local-store';
|
||||
import { timerStore } from '$lib/stores/timer.svelte';
|
||||
import type { EntryTemplate, Project, Client } from '@taktik/shared';
|
||||
|
||||
const allTemplates = getContext<{ value: EntryTemplate[] }>('templates');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
let newProjectId = $state('');
|
||||
let newIsBillable = $state(false);
|
||||
|
||||
let sortedTemplates = $derived(
|
||||
[...allTemplates.value].sort((a, b) => b.usageCount - a.usageCount)
|
||||
);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
await templateCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
name: newName.trim(),
|
||||
projectId: newProjectId || null,
|
||||
clientId: newProjectId
|
||||
? (allProjects.value.find((p) => p.id === newProjectId)?.clientId ?? null)
|
||||
: null,
|
||||
description: newDescription,
|
||||
isBillable: newIsBillable,
|
||||
tags: [],
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
});
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
newProjectId = '';
|
||||
newIsBillable = false;
|
||||
showCreateForm = false;
|
||||
}
|
||||
|
||||
async function useTemplate(template: EntryTemplate) {
|
||||
// Update usage stats
|
||||
await templateCollection.update(template.id, {
|
||||
usageCount: template.usageCount + 1,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Start timer with template data
|
||||
await timerStore.start({
|
||||
projectId: template.projectId ?? undefined,
|
||||
clientId: template.clientId ?? undefined,
|
||||
description: template.description,
|
||||
isBillable: template.isBillable,
|
||||
tags: template.tags,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteTemplate(id: string) {
|
||||
await templateCollection.delete(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.templates')} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.templates')}</h1>
|
||||
<button
|
||||
onclick={() => (showCreateForm = !showCreateForm)}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>
|
||||
+ {$_('template.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Vorlagenname"
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newDescription}
|
||||
placeholder={$_('entry.description')}
|
||||
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
bind:value={newProjectId}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<option value="">{$_('project.internal')}</option>
|
||||
{#each allProjects.value.filter((p) => !p.isArchived) as proj}
|
||||
<option value={proj.id}>{proj.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"
|
||||
bind:checked={newIsBillable}
|
||||
class="accent-[hsl(var(--primary))]"
|
||||
/>
|
||||
{$_('entry.billable')}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateForm = false)}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('common.cancel')}</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
|
||||
>{$_('common.create')}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if sortedTemplates.length === 0 && !showCreateForm}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
<p>{$_('template.noTemplates')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each sortedTemplates as template (template.id)}
|
||||
{@const project = template.projectId
|
||||
? allProjects.value.find((p) => p.id === template.projectId)
|
||||
: undefined}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
|
||||
>
|
||||
{#if project}
|
||||
<div
|
||||
class="h-3 w-3 shrink-0 rounded-full"
|
||||
style="background-color: {project.color}"
|
||||
></div>
|
||||
{:else}
|
||||
<div class="h-3 w-3 shrink-0 rounded-full bg-gray-400"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{template.name}</p>
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{template.description || $_('timer.noDescription')}
|
||||
{#if project}
|
||||
· {project.name}{/if}
|
||||
{#if template.isBillable}
|
||||
· ${/if}
|
||||
{#if template.usageCount > 0}
|
||||
· {template.usageCount}x{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => useTemplate(template)}
|
||||
disabled={timerStore.isRunning}
|
||||
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-1.5 text-xs font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
{$_('timer.start')}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteTemplate(template.id)}
|
||||
class="rounded-lg p-1.5 text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue