950 lines
36 KiB
Vue
950 lines
36 KiB
Vue
<template>
|
|
<div class="flex h-full">
|
|
<!-- Sidebar -->
|
|
<aside class="w-44 bg-bg-surface border-r border-border-subtle shrink-0 p-3">
|
|
<h1 class="text-[0.8125rem] font-medium text-text-tertiary uppercase tracking-[0.08em] px-2 mb-3">Settings</h1>
|
|
<nav class="flex flex-col gap-0.5">
|
|
<button
|
|
v-for="tab in tabs"
|
|
:key="tab.id"
|
|
@click="activeTab = tab.id"
|
|
class="relative flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[0.8125rem] transition-colors duration-150"
|
|
:class="activeTab === tab.id
|
|
? 'bg-bg-elevated text-text-primary'
|
|
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
|
|
>
|
|
<!-- Active indicator -->
|
|
<div
|
|
v-if="activeTab === tab.id"
|
|
class="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-accent"
|
|
/>
|
|
<component :is="tab.icon" class="w-4 h-4 shrink-0" :stroke-width="1.5" />
|
|
{{ tab.label }}
|
|
</button>
|
|
</nav>
|
|
</aside>
|
|
|
|
<!-- Content pane -->
|
|
<div class="flex-1 p-6 overflow-y-auto">
|
|
<Transition name="fade" mode="out-in" :duration="{ enter: 200, leave: 150 }">
|
|
<div :key="activeTab">
|
|
<!-- General -->
|
|
<div v-if="activeTab === 'general'">
|
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">General</h2>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Adjust the interface zoom level</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
@click="decreaseZoom"
|
|
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
:disabled="zoomLevel <= 80"
|
|
>
|
|
<Minus class="w-3.5 h-3.5" />
|
|
</button>
|
|
<span class="w-12 text-center text-sm font-mono text-text-primary">{{ zoomLevel }}%</span>
|
|
<button
|
|
@click="increaseZoom"
|
|
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
:disabled="zoomLevel >= 150"
|
|
>
|
|
<Plus class="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between mt-5">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Locale</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
|
|
</div>
|
|
<div class="w-56">
|
|
<AppSelect
|
|
v-model="locale"
|
|
:options="LOCALES"
|
|
label-key="name"
|
|
value-key="code"
|
|
placeholder="System Default"
|
|
:placeholder-value="'system'"
|
|
:searchable="true"
|
|
@update:model-value="saveLocaleSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between mt-5">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Currency</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
|
|
</div>
|
|
<div class="w-56">
|
|
<AppSelect
|
|
v-model="currency"
|
|
:options="currencyOptions"
|
|
label-key="name"
|
|
value-key="code"
|
|
placeholder="US Dollar"
|
|
:placeholder-value="'USD'"
|
|
:searchable="true"
|
|
@update:model-value="saveLocaleSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-border-subtle mt-5 pt-5">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Theme</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Light or dark appearance</p>
|
|
</div>
|
|
<div class="w-40">
|
|
<AppSelect
|
|
v-model="themeMode"
|
|
:options="themeModes"
|
|
label-key="label"
|
|
value-key="value"
|
|
placeholder="Dark"
|
|
:placeholder-value="'dark'"
|
|
@update:model-value="saveThemeSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between mt-5">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Accent Color</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Primary interface color</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
v-for="ac in accentColors"
|
|
:key="ac.value"
|
|
@click="accentColor = ac.value; saveThemeSettings()"
|
|
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110 cursor-pointer"
|
|
:class="accentColor === ac.value ? 'border-text-primary scale-110' : 'border-transparent'"
|
|
:style="{ backgroundColor: ac.color }"
|
|
:title="ac.label"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timer -->
|
|
<div v-if="activeTab === 'timer'">
|
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Timer</h2>
|
|
|
|
<div class="space-y-5">
|
|
<!-- Idle Detection toggle -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Idle Detection</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Detect when you're idle and prompt to continue or stop</p>
|
|
</div>
|
|
<button
|
|
@click="toggleIdleDetection"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
|
idleDetection ? 'bg-status-running' : 'bg-bg-elevated'
|
|
]"
|
|
>
|
|
<span
|
|
:class="[
|
|
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
|
|
idleDetection ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Idle sub-settings — progressive disclosure -->
|
|
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Idle Timeout</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes before idle pause triggers</p>
|
|
</div>
|
|
<AppNumberInput
|
|
v-model="idleTimeout"
|
|
:min="1"
|
|
:max="60"
|
|
:step="1"
|
|
:precision="0"
|
|
suffix="min"
|
|
@update:model-value="saveSettings"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Reminder Interval</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p>
|
|
</div>
|
|
<AppNumberInput
|
|
v-model="reminderInterval"
|
|
:min="0"
|
|
:max="120"
|
|
:step="5"
|
|
:precision="0"
|
|
suffix="min"
|
|
@update:model-value="saveSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="border-t border-border-subtle" />
|
|
|
|
<!-- App Tracking Mode -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">What happens when a tracked app leaves focus</p>
|
|
</div>
|
|
<div class="w-48">
|
|
<AppSelect
|
|
v-model="appTrackingMode"
|
|
:options="appTrackingModes"
|
|
label-key="label"
|
|
value-key="value"
|
|
placeholder="Auto-pause"
|
|
:placeholder-value="'auto'"
|
|
@update:model-value="saveSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Check Interval -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Check Interval</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How often to check idle & app visibility</p>
|
|
</div>
|
|
<AppNumberInput
|
|
v-model="appCheckInterval"
|
|
:min="1"
|
|
:max="60"
|
|
:step="1"
|
|
:precision="0"
|
|
suffix="sec"
|
|
@update:model-value="saveSettings"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="border-t border-border-subtle" />
|
|
|
|
<!-- Keyboard Shortcuts -->
|
|
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Keyboard Shortcuts</h3>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Toggle Timer</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Start/stop the active timer</p>
|
|
</div>
|
|
<input
|
|
v-model="shortcutToggleTimer"
|
|
type="text"
|
|
class="w-48 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono text-center focus:outline-none focus:border-border-visible"
|
|
placeholder="CmdOrCtrl+Shift+T"
|
|
@change="saveShortcutSettings"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Show App</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Bring the app to front</p>
|
|
</div>
|
|
<input
|
|
v-model="shortcutShowApp"
|
|
type="text"
|
|
class="w-48 px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono text-center focus:outline-none focus:border-border-visible"
|
|
placeholder="CmdOrCtrl+Shift+Z"
|
|
@change="saveShortcutSettings"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="border-t border-border-subtle" />
|
|
|
|
<!-- Goals -->
|
|
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Goals</h3>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Daily Goal</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Target hours per day</p>
|
|
</div>
|
|
<AppNumberInput
|
|
v-model="dailyGoalHours"
|
|
:min="0"
|
|
:max="24"
|
|
:step="0.5"
|
|
:precision="1"
|
|
suffix="hrs"
|
|
@update:model-value="saveGoalSettings"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Weekly Goal</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Target hours per week</p>
|
|
</div>
|
|
<AppNumberInput
|
|
v-model="weeklyGoalHours"
|
|
:min="0"
|
|
:max="168"
|
|
:step="1"
|
|
:precision="0"
|
|
suffix="hrs"
|
|
@update:model-value="saveGoalSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Billing -->
|
|
<div v-if="activeTab === 'billing'">
|
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Billing</h2>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Default Hourly Rate</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Applied to new projects</p>
|
|
</div>
|
|
<AppNumberInput
|
|
v-model="hourlyRate"
|
|
:min="0"
|
|
:step="1"
|
|
:precision="2"
|
|
:prefix="getCurrencySymbol()"
|
|
@update:model-value="saveSettings"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="border-t border-border-subtle mt-5 pt-5" />
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Time Rounding</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round time entries for billing</p>
|
|
</div>
|
|
<button
|
|
@click="toggleRounding"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
|
roundingEnabled ? 'bg-status-running' : 'bg-bg-elevated'
|
|
]"
|
|
>
|
|
<span
|
|
:class="[
|
|
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
|
|
roundingEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="roundingEnabled" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1 mt-5">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Increment</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round to nearest increment</p>
|
|
</div>
|
|
<div class="w-36">
|
|
<AppSelect
|
|
v-model="roundingIncrement"
|
|
:options="roundingIncrements"
|
|
label-key="label"
|
|
value-key="value"
|
|
@update:model-value="saveRoundingSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Method</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Rounding direction</p>
|
|
</div>
|
|
<div class="w-36">
|
|
<AppSelect
|
|
v-model="roundingMethod"
|
|
:options="roundingMethods"
|
|
label-key="label"
|
|
value-key="value"
|
|
@update:model-value="saveRoundingSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="border-t border-border-subtle mt-5 pt-5" />
|
|
|
|
<!-- Business Identity -->
|
|
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-4">Business Identity</h3>
|
|
<p class="text-[0.6875rem] text-text-tertiary mb-4">This information appears on your invoices.</p>
|
|
|
|
<div class="space-y-4 max-w-md">
|
|
<div>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Business Name</label>
|
|
<input
|
|
v-model="businessName"
|
|
type="text"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
placeholder="Your Company Name"
|
|
@change="saveBusinessSettings"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Address</label>
|
|
<textarea
|
|
v-model="businessAddress"
|
|
rows="3"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible resize-none"
|
|
placeholder="123 Business St City, State ZIP"
|
|
@change="saveBusinessSettings"
|
|
></textarea>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Email</label>
|
|
<input
|
|
v-model="businessEmail"
|
|
type="email"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
placeholder="billing@company.com"
|
|
@change="saveBusinessSettings"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Phone</label>
|
|
<input
|
|
v-model="businessPhone"
|
|
type="tel"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
placeholder="(555) 123-4567"
|
|
@change="saveBusinessSettings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Logo</label>
|
|
<div class="flex items-center gap-4">
|
|
<div
|
|
v-if="businessLogo"
|
|
class="w-20 h-12 border border-border-subtle rounded flex items-center justify-center overflow-hidden bg-white"
|
|
>
|
|
<img :src="businessLogo" class="max-w-full max-h-full object-contain" />
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="uploadLogo"
|
|
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
|
|
>
|
|
{{ businessLogo ? 'Change' : 'Upload' }}
|
|
</button>
|
|
<button
|
|
v-if="businessLogo"
|
|
@click="removeLogo"
|
|
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-status-error rounded-lg hover:bg-status-error/10 transition-colors"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-[0.625rem] text-text-tertiary mt-1">PNG or JPG, max 200x80px. Appears on invoice header.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data -->
|
|
<div v-if="activeTab === 'data'">
|
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Data</h2>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Export -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Export All Data</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Download as JSON backup</p>
|
|
</div>
|
|
<button
|
|
@click="exportData"
|
|
class="px-4 py-1.5 border border-border-visible text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
|
>
|
|
Export
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Import Data -->
|
|
<div class="mb-8">
|
|
<h3 class="text-[0.8125rem] font-medium text-text-primary mb-4">Import Data</h3>
|
|
|
|
<div class="flex items-end gap-3 mb-4">
|
|
<div class="w-48">
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Format</label>
|
|
<AppSelect
|
|
v-model="importFormat"
|
|
:options="importFormats"
|
|
label-key="label"
|
|
value-key="value"
|
|
/>
|
|
</div>
|
|
<button
|
|
@click="handleImportFile"
|
|
class="flex items-center gap-2 px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
|
>
|
|
<Upload class="w-3.5 h-3.5" />
|
|
Choose File
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="importFileName" class="mb-4">
|
|
<p class="text-[0.75rem] text-text-secondary mb-2">File: {{ importFileName }}</p>
|
|
|
|
<!-- CSV Preview -->
|
|
<div v-if="importPreview.length > 0" class="overflow-x-auto mb-3">
|
|
<table class="text-[0.6875rem]">
|
|
<tr v-for="(row, i) in importPreview" :key="i" :class="i === 0 ? 'text-text-tertiary font-medium' : 'text-text-secondary'">
|
|
<td v-for="(cell, j) in row" :key="j" class="pr-4 py-0.5 whitespace-nowrap">{{ cell }}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
|
|
<button
|
|
@click="executeImport"
|
|
:disabled="isImporting"
|
|
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40"
|
|
>
|
|
{{ isImporting ? 'Importing...' : 'Import' }}
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error' : 'text-status-running'">
|
|
{{ importStatus }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Danger Zone -->
|
|
<div class="mt-8 rounded-xl border border-status-error/20 p-5">
|
|
<h3 class="text-xs text-status-error uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Permanently delete all entries, projects, clients, and invoices</p>
|
|
</div>
|
|
<button
|
|
@click="showClearDataDialog = true"
|
|
class="px-4 py-1.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
|
|
>
|
|
Clear Data
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
|
|
<!-- Clear Data Confirmation Dialog -->
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="showClearDataDialog"
|
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
|
@click.self="showClearDataDialog = false"
|
|
>
|
|
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
|
|
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Clear All Data</h2>
|
|
<p class="text-[0.75rem] text-text-secondary mb-4">
|
|
Are you sure? This action cannot be undone.
|
|
</p>
|
|
<p class="text-[0.6875rem] text-status-error mb-6">
|
|
All time entries, projects, clients, and invoices will be permanently deleted.
|
|
</p>
|
|
<div class="flex justify-end gap-3">
|
|
<button
|
|
@click="showClearDataDialog = false"
|
|
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="clearAllData"
|
|
class="px-4 py-2 bg-status-error text-white font-medium rounded-lg hover:bg-status-error/80 transition-colors duration-150"
|
|
>
|
|
Delete Everything
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, markRaw } from 'vue'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { Settings as SettingsIcon, Clock, Receipt, Database, Plus, Minus, Upload } from 'lucide-vue-next'
|
|
import { useSettingsStore } from '../stores/settings'
|
|
import { useToastStore } from '../stores/toast'
|
|
import AppNumberInput from '../components/AppNumberInput.vue'
|
|
import AppSelect from '../components/AppSelect.vue'
|
|
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
|
|
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
|
|
|
|
const settingsStore = useSettingsStore()
|
|
const toastStore = useToastStore()
|
|
|
|
// Tabs
|
|
const tabs = [
|
|
{ id: 'general', label: 'General', icon: markRaw(SettingsIcon) },
|
|
{ id: 'timer', label: 'Timer', icon: markRaw(Clock) },
|
|
{ id: 'billing', label: 'Billing', icon: markRaw(Receipt) },
|
|
{ id: 'data', label: 'Data', icon: markRaw(Database) },
|
|
]
|
|
|
|
const activeTab = ref('general')
|
|
|
|
// Settings state
|
|
const hourlyRate = ref(0)
|
|
const idleDetection = ref(true)
|
|
const idleTimeout = ref(5)
|
|
const reminderInterval = ref(15)
|
|
const appTrackingMode = ref('auto')
|
|
const appCheckInterval = ref(5)
|
|
const zoomLevel = ref(100)
|
|
const locale = ref('system')
|
|
const currency = ref('USD')
|
|
const currencyOptions = getCurrencies()
|
|
const themeMode = ref('dark')
|
|
const accentColor = ref('amber')
|
|
const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
|
|
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
|
|
|
|
// Goal settings
|
|
const dailyGoalHours = ref(8)
|
|
const weeklyGoalHours = ref(40)
|
|
|
|
// Rounding settings
|
|
const roundingEnabled = ref(false)
|
|
const roundingIncrement = ref(15)
|
|
const roundingMethod = ref('nearest')
|
|
|
|
// Business identity settings
|
|
const businessName = ref('')
|
|
const businessAddress = ref('')
|
|
const businessEmail = ref('')
|
|
const businessPhone = ref('')
|
|
const businessLogo = ref('')
|
|
|
|
const roundingIncrements = [
|
|
{ value: 1, label: '1 minute' },
|
|
{ value: 5, label: '5 minutes' },
|
|
{ value: 6, label: '6 minutes' },
|
|
{ value: 10, label: '10 minutes' },
|
|
{ value: 15, label: '15 minutes' },
|
|
{ value: 30, label: '30 minutes' },
|
|
{ value: 60, label: '1 hour' },
|
|
]
|
|
|
|
const roundingMethods = [
|
|
{ value: 'nearest', label: 'Nearest' },
|
|
{ value: 'up', label: 'Round up' },
|
|
{ value: 'down', label: 'Round down' },
|
|
]
|
|
|
|
const themeModes = [
|
|
{ value: 'dark', label: 'Dark' },
|
|
{ value: 'light', label: 'Light' },
|
|
{ value: 'system', label: 'System' },
|
|
]
|
|
|
|
const accentColors = [
|
|
{ value: 'amber', label: 'Amber', color: '#D97706' },
|
|
{ value: 'blue', label: 'Blue', color: '#3B82F6' },
|
|
{ value: 'purple', label: 'Purple', color: '#8B5CF6' },
|
|
{ value: 'green', label: 'Green', color: '#10B981' },
|
|
{ value: 'red', label: 'Red', color: '#EF4444' },
|
|
{ value: 'pink', label: 'Pink', color: '#EC4899' },
|
|
{ value: 'cyan', label: 'Cyan', color: '#06B6D4' },
|
|
]
|
|
|
|
const appTrackingModes = [
|
|
{ value: 'auto', label: 'Auto-pause/resume' },
|
|
{ value: 'notify', label: 'Notify + auto-resume' },
|
|
{ value: 'prompt', label: 'Prompt user' },
|
|
]
|
|
|
|
// Dialog state
|
|
const showClearDataDialog = ref(false)
|
|
|
|
// Import state
|
|
const importFormat = ref('toggl')
|
|
const importFileContent = ref('')
|
|
const importFileName = ref('')
|
|
const importPreview = ref<string[][]>([])
|
|
const importStatus = ref('')
|
|
const isImporting = ref(false)
|
|
|
|
const importFormats = [
|
|
{ label: 'Toggl CSV', value: 'toggl' },
|
|
{ label: 'Clockify CSV', value: 'clockify' },
|
|
{ label: 'Generic CSV', value: 'generic' },
|
|
{ label: 'ZeroClock JSON', value: 'json' },
|
|
]
|
|
|
|
// Zoom steps
|
|
const zoomSteps = [80, 90, 100, 110, 120, 130, 150]
|
|
|
|
function increaseZoom() {
|
|
const currentIndex = zoomSteps.indexOf(zoomLevel.value)
|
|
if (currentIndex < zoomSteps.length - 1) {
|
|
zoomLevel.value = zoomSteps[currentIndex + 1]
|
|
} else if (currentIndex === -1) {
|
|
const next = zoomSteps.find(s => s > zoomLevel.value)
|
|
if (next) zoomLevel.value = next
|
|
}
|
|
applyZoom()
|
|
}
|
|
|
|
function decreaseZoom() {
|
|
const currentIndex = zoomSteps.indexOf(zoomLevel.value)
|
|
if (currentIndex > 0) {
|
|
zoomLevel.value = zoomSteps[currentIndex - 1]
|
|
} else if (currentIndex === -1) {
|
|
const prev = [...zoomSteps].reverse().find(s => s < zoomLevel.value)
|
|
if (prev) zoomLevel.value = prev
|
|
}
|
|
applyZoom()
|
|
}
|
|
|
|
function applyZoom() {
|
|
const app = document.getElementById('app')
|
|
if (app) {
|
|
(app.style as any).zoom = `${zoomLevel.value}%`
|
|
}
|
|
settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())
|
|
}
|
|
|
|
// Toggle idle detection with auto-save
|
|
function toggleIdleDetection() {
|
|
idleDetection.value = !idleDetection.value
|
|
saveSettings()
|
|
}
|
|
|
|
// Save settings (called on any change)
|
|
async function saveSettings() {
|
|
try {
|
|
await settingsStore.updateSetting('hourly_rate', hourlyRate.value.toString())
|
|
await settingsStore.updateSetting('idle_detection', idleDetection.value.toString())
|
|
await settingsStore.updateSetting('idle_timeout', idleTimeout.value.toString())
|
|
await settingsStore.updateSetting('reminder_interval', reminderInterval.value.toString())
|
|
await settingsStore.updateSetting('app_tracking_mode', appTrackingMode.value)
|
|
await settingsStore.updateSetting('app_check_interval', appCheckInterval.value.toString())
|
|
} catch (error) {
|
|
console.error('Failed to save settings:', error)
|
|
toastStore.error('Failed to save settings')
|
|
}
|
|
}
|
|
|
|
// Save locale/currency settings
|
|
async function saveLocaleSettings() {
|
|
await settingsStore.updateSetting('locale', locale.value)
|
|
await settingsStore.updateSetting('currency', currency.value)
|
|
}
|
|
|
|
// Save theme settings
|
|
async function saveThemeSettings() {
|
|
await settingsStore.updateSetting('theme_mode', themeMode.value)
|
|
await settingsStore.updateSetting('accent_color', accentColor.value)
|
|
}
|
|
|
|
// Save shortcut settings
|
|
async function saveShortcutSettings() {
|
|
await settingsStore.updateSetting('shortcut_toggle_timer', shortcutToggleTimer.value)
|
|
await settingsStore.updateSetting('shortcut_show_app', shortcutShowApp.value)
|
|
}
|
|
|
|
// Save goal settings
|
|
async function saveGoalSettings() {
|
|
await settingsStore.updateSetting('daily_goal_hours', dailyGoalHours.value.toString())
|
|
await settingsStore.updateSetting('weekly_goal_hours', weeklyGoalHours.value.toString())
|
|
}
|
|
|
|
// Toggle rounding
|
|
function toggleRounding() {
|
|
roundingEnabled.value = !roundingEnabled.value
|
|
saveRoundingSettings()
|
|
}
|
|
|
|
// Save rounding settings
|
|
async function saveRoundingSettings() {
|
|
await settingsStore.updateSetting('rounding_enabled', roundingEnabled.value.toString())
|
|
await settingsStore.updateSetting('rounding_increment', roundingIncrement.value.toString())
|
|
await settingsStore.updateSetting('rounding_method', roundingMethod.value)
|
|
}
|
|
|
|
// Save business identity settings
|
|
async function saveBusinessSettings() {
|
|
await settingsStore.updateSetting('business_name', businessName.value)
|
|
await settingsStore.updateSetting('business_address', businessAddress.value)
|
|
await settingsStore.updateSetting('business_email', businessEmail.value)
|
|
await settingsStore.updateSetting('business_phone', businessPhone.value)
|
|
}
|
|
|
|
async function uploadLogo() {
|
|
try {
|
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
|
const selected = await open({
|
|
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }]
|
|
})
|
|
if (!selected) return
|
|
|
|
const { readFile } = await import('@tauri-apps/plugin-fs')
|
|
const bytes = await readFile(selected as string)
|
|
const ext = (selected as string).toLowerCase().endsWith('.png') ? 'png' : 'jpeg'
|
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
const dataUrl = `data:image/${ext};base64,${base64}`
|
|
businessLogo.value = dataUrl
|
|
await settingsStore.updateSetting('business_logo', dataUrl)
|
|
} catch (e) {
|
|
console.error('Failed to upload logo:', e)
|
|
toastStore.error('Failed to upload logo')
|
|
}
|
|
}
|
|
|
|
async function removeLogo() {
|
|
businessLogo.value = ''
|
|
await settingsStore.updateSetting('business_logo', '')
|
|
}
|
|
|
|
// Import file handling
|
|
async function handleImportFile() {
|
|
try {
|
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
|
const selected = await open({
|
|
filters: [
|
|
{ name: 'Data Files', extensions: ['csv', 'json'] }
|
|
]
|
|
})
|
|
if (!selected) return
|
|
|
|
const filePath = selected as string
|
|
// @ts-ignore - plugin-fs types are available at runtime
|
|
const { readTextFile } = await import('@tauri-apps/plugin-fs')
|
|
const content: string = await readTextFile(filePath)
|
|
importFileContent.value = content
|
|
importFileName.value = filePath.split(/[/\\]/).pop() || 'file'
|
|
|
|
if (importFormat.value === 'json') {
|
|
importPreview.value = []
|
|
} else {
|
|
importPreview.value = parseCSV(content).slice(0, 6)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to read file:', e)
|
|
}
|
|
}
|
|
|
|
async function executeImport() {
|
|
if (!importFileContent.value) return
|
|
isImporting.value = true
|
|
importStatus.value = 'Importing...'
|
|
|
|
try {
|
|
if (importFormat.value === 'json') {
|
|
const data = JSON.parse(importFileContent.value)
|
|
await invoke('import_json_data', { data })
|
|
importStatus.value = 'Import complete!'
|
|
} else {
|
|
const rows = parseCSV(importFileContent.value)
|
|
let entries: ImportEntry[]
|
|
|
|
if (importFormat.value === 'toggl' || importFormat.value === 'clockify') {
|
|
entries = mapTogglCSV(rows)
|
|
} else {
|
|
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
|
|
}
|
|
|
|
await invoke('import_entries', { entries })
|
|
importStatus.value = `Imported ${entries.length} entries!`
|
|
}
|
|
|
|
importFileContent.value = ''
|
|
importFileName.value = ''
|
|
importPreview.value = []
|
|
} catch (e) {
|
|
importStatus.value = `Error: ${e}`
|
|
} finally {
|
|
isImporting.value = false
|
|
}
|
|
}
|
|
|
|
// Export all data
|
|
async function exportData() {
|
|
try {
|
|
const data = await invoke('export_data')
|
|
|
|
const json = JSON.stringify(data, null, 2)
|
|
const blob = new Blob([json], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `zeroclock-export-${new Date().toISOString().split('T')[0]}.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch (error) {
|
|
console.error('Failed to export data:', error)
|
|
toastStore.error('Failed to export data')
|
|
}
|
|
}
|
|
|
|
// Clear all data
|
|
async function clearAllData() {
|
|
try {
|
|
await invoke('clear_all_data')
|
|
showClearDataDialog.value = false
|
|
toastStore.success('All data has been cleared')
|
|
} catch (error) {
|
|
console.error('Failed to clear data:', error)
|
|
toastStore.error('Failed to clear data')
|
|
}
|
|
}
|
|
|
|
// Load settings on mount
|
|
onMounted(async () => {
|
|
await settingsStore.fetchSettings()
|
|
|
|
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
|
|
idleDetection.value = settingsStore.settings.idle_detection !== 'false'
|
|
idleTimeout.value = parseInt(settingsStore.settings.idle_timeout) || 5
|
|
reminderInterval.value = parseInt(settingsStore.settings.reminder_interval) || 15
|
|
appTrackingMode.value = settingsStore.settings.app_tracking_mode || 'auto'
|
|
appCheckInterval.value = parseInt(settingsStore.settings.app_check_interval) || 5
|
|
zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100
|
|
locale.value = settingsStore.settings.locale || 'system'
|
|
currency.value = settingsStore.settings.currency || 'USD'
|
|
themeMode.value = settingsStore.settings.theme_mode || 'dark'
|
|
accentColor.value = settingsStore.settings.accent_color || 'amber'
|
|
shortcutToggleTimer.value = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
|
|
shortcutShowApp.value = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
|
|
dailyGoalHours.value = parseFloat(settingsStore.settings.daily_goal_hours) || 8
|
|
weeklyGoalHours.value = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
|
|
roundingEnabled.value = settingsStore.settings.rounding_enabled === 'true'
|
|
roundingIncrement.value = parseInt(settingsStore.settings.rounding_increment) || 15
|
|
roundingMethod.value = settingsStore.settings.rounding_method || 'nearest'
|
|
businessName.value = settingsStore.settings.business_name || ''
|
|
businessAddress.value = settingsStore.settings.business_address || ''
|
|
businessEmail.value = settingsStore.settings.business_email || ''
|
|
businessPhone.value = settingsStore.settings.business_phone || ''
|
|
businessLogo.value = settingsStore.settings.business_logo || ''
|
|
})
|
|
</script>
|