Strengthened CRF, added more vue, and removed viewtransitions
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m42s

This commit is contained in:
2026-02-12 16:19:59 -07:00
parent e5c5d68739
commit 3e17e58c9a
15 changed files with 282 additions and 145 deletions

View File

@@ -9,7 +9,13 @@ export default defineConfig({
output: "server",
integrations: [vue()],
security: {
csp: process.env.NODE_ENV === "production",
csp: process.env.NODE_ENV === "production"
? {
scriptDirective: {
strictDynamic: true,
},
}
: false,
},
vite: {
plugins: [tailwindcss()],

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
function onChange(e: Event) {
const el = e.target as HTMLElement;
el.closest('form')?.submit();
}
</script>
<template>
<span @change="onChange">
<slot />
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
color: string;
as?: string;
class?: string;
borderColor?: boolean;
}>();
</script>
<template>
<component
:is="as || 'span'"
:class="$props.class"
:style="borderColor ? { borderColor: color } : { backgroundColor: color }"
><slot /></component>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
message: string;
action: string;
method?: string;
class?: string;
}>();
function onSubmit(e: Event) {
if (!confirm((e.currentTarget as HTMLFormElement).dataset.message!)) {
e.preventDefault();
}
}
</script>
<template>
<form
:method="method || 'POST'"
:action="action"
:class="$props.class"
:data-message="message"
@submit="onSubmit"
>
<slot />
</form>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
modalId: string;
action?: 'open' | 'close';
class?: string;
title?: string;
type?: string;
}>();
function onClick(e: MouseEvent) {
const btn = e.currentTarget as HTMLElement;
const id = btn.dataset.modalId!;
const act = btn.dataset.action || 'open';
const modal = document.getElementById(id) as HTMLDialogElement | null;
if (act === 'close') {
modal?.close();
} else {
modal?.showModal();
}
}
</script>
<template>
<button
:type="(type as any) || 'button'"
:class="$props.class"
:title="$props.title"
:data-modal-id="modalId"
:data-action="action || 'open'"
@click="onClick"
>
<slot />
</button>
</template>

View File

@@ -6,7 +6,6 @@ import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm';
import Avatar from '../components/Avatar.astro';
import ThemeToggle from '../components/ThemeToggle.vue';
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@@ -55,8 +54,7 @@ function isActive(item: { href: string; exact?: boolean }) {
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
<script is:inline>
<script>
const theme = localStorage.getItem('theme') || 'macchiato';
document.documentElement.setAttribute('data-theme', theme);
</script>
@@ -105,7 +103,6 @@ function isActive(item: { href: string; exact?: boolean }) {
class="select select-sm w-full bg-base-300/40 border-base-300/60 focus:border-primary/50 focus:outline-none text-sm font-medium"
id="team-switcher"
aria-label="Switch team"
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
>
{userMemberships.map(({ membership, organization }) => (
<option
@@ -187,7 +184,7 @@ function isActive(item: { href: string; exact?: boolean }) {
</div>
<div class="px-3 pb-3">
<form action="/api/auth/logout" method="POST" data-astro-reload>
<form action="/api/auth/logout" method="POST">
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start gap-2 text-base-content/60 hover:text-error hover:bg-error/10 font-medium">
<Icon name="arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
Logout
@@ -198,5 +195,13 @@ function isActive(item: { href: string; exact?: boolean }) {
</aside>
</div>
</div>
<script>
// Team switcher - sets cookie and reloads
const teamSwitcher = document.getElementById('team-switcher') as HTMLSelectElement | null;
teamSwitcher?.addEventListener('change', () => {
document.cookie = 'currentTeamId=' + teamSwitcher.value + '; path=/';
window.location.reload();
});
</script>
</body>
</html>

View File

@@ -1,6 +1,5 @@
---
import '../styles/global.css';
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@@ -18,8 +17,7 @@ const { title } = Astro.props;
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
<script is:inline>
<script>
const theme = localStorage.getItem('theme') || 'macchiato';
document.documentElement.setAttribute('data-theme', theme);
</script>

View File

@@ -1,6 +1,7 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import Icon from '../../../../components/Icon.astro';
import ModalButton from '../../../../components/ModalButton.vue';
import { db } from '../../../../db';
import { clients } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
@@ -141,13 +142,13 @@ if (!client) return Astro.redirect('/dashboard/clients');
</div>
<div class="flex justify-between items-center mt-4">
<button
type="button"
<ModalButton
client:load
modalId="delete_modal"
class="btn btn-error btn-outline btn-sm"
onclick={`document.getElementById('delete_modal').showModal()`}
>
Delete Client
</button>
</ModalButton>
<div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>

View File

@@ -1,6 +1,8 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import Icon from '../../../../components/Icon.astro';
import ConfirmForm from '../../../../components/ConfirmForm.vue';
import ColorDot from '../../../../components/ColorDot.vue';
import { db } from '../../../../db';
import { clients, timeEntries, tags, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
@@ -110,12 +112,12 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<Icon name="pencil" class="w-3 h-3" />
Edit
</a>
<form method="POST" action={`/api/clients/${client.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this client? This will also delete all associated time entries.');">
<ConfirmForm client:load message="Are you sure you want to delete this client? This will also delete all associated time entries." action={`/api/clients/${client.id}/delete`}>
<button type="submit" class="btn btn-error btn-outline btn-xs">
<Icon name="trash" class="w-3 h-3" />
Delete
</button>
</form>
</ConfirmForm>
</div>
</div>
@@ -182,7 +184,7 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
{tag ? (
<div class="badge badge-xs badge-outline flex items-center gap-1">
{tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
<ColorDot client:load color={tag.color} class="w-2 h-2 rounded-full" />
)}
<span>{tag.name}</span>
</div>

View File

@@ -2,6 +2,7 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Icon from '../../components/Icon.astro';
import StatCard from '../../components/StatCard.astro';
import ColorDot from '../../components/ColorDot.vue';
import { db } from '../../db';
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
@@ -200,7 +201,7 @@ const hasMembership = userOrgs.length > 0;
{stats.recentEntries.length > 0 ? (
<ul class="space-y-2 mt-3">
{stats.recentEntries.map(({ entry, client, tag }) => (
<li class="p-2.5 rounded-lg bg-base-200/50 border-l-3 hover:bg-base-200 transition-colors" style={`border-color: ${tag?.color || 'oklch(var(--p))'}`}>
<ColorDot client:load as="li" color={tag?.color || 'oklch(var(--p))'} borderColor class="p-2.5 rounded-lg bg-base-200/50 border-l-3 hover:bg-base-200 transition-colors">
<div class="font-medium text-sm">{client.name}</div>
<div class="text-xs text-base-content/50 mt-0.5 flex flex-wrap gap-2 items-center">
<span class="flex gap-1 flex-wrap">
@@ -210,7 +211,7 @@ const hasMembership = userOrgs.length > 0;
</span>
<span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
</div>
</li>
</ColorDot>
))}
</ul>
) : (

View File

@@ -1,6 +1,8 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import Icon from '../../../components/Icon.astro';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import ModalButton from '../../../components/ModalButton.vue';
import { db } from '../../../db';
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
@@ -139,13 +141,13 @@ const isDraft = invoice.status === 'draft';
</li>
)}
<li>
<form method="POST" action="/api/invoices/delete" onsubmit="return confirm('Are you sure?');">
<ConfirmForm client:load message="Are you sure?" action="/api/invoices/delete">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="text-error">
<Icon name="trash" class="w-4 h-4" />
Delete
</button>
</form>
</ConfirmForm>
</li>
</ul>
</div>
@@ -258,10 +260,10 @@ const isDraft = invoice.status === 'draft';
<!-- Add Item Form (Only if Draft) -->
{isDraft && (
<div class="flex justify-end mb-4">
<button onclick="document.getElementById('import_time_modal').showModal()" class="btn btn-sm btn-outline gap-2">
<ModalButton client:load modalId="import_time_modal" class="btn btn-sm btn-outline gap-2">
<Icon name="clock" class="w-4 h-4" />
Import Time
</button>
</ModalButton>
</div>
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-200">
@@ -309,9 +311,9 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60 flex items-center gap-2">
Tax ({invoice.taxRate ?? 0}%)
{isDraft && (
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<ModalButton client:load modalId="tax_modal" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<Icon name="pencil" class="w-3 h-3" />
</button>
</ModalButton>
)}
</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
@@ -364,7 +366,7 @@ const isDraft = invoice.status === 'draft';
/>
</fieldset>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('tax_modal').close()">Cancel</button>
<ModalButton client:load modalId="tax_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Update</button>
</div>
</form>
@@ -397,7 +399,7 @@ const isDraft = invoice.status === 'draft';
</label>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('import_time_modal').close()">Cancel</button>
<ModalButton client:load modalId="import_time_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Import</button>
</div>
</form>

View File

@@ -2,6 +2,8 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import Icon from '../../../components/Icon.astro';
import StatCard from '../../../components/StatCard.astro';
import AutoSubmit from '../../../components/AutoSubmit.vue';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import { db } from '../../../db';
import { invoices, clients } from '../../../db/schema';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
@@ -145,46 +147,54 @@ const getStatusColor = (status: string) => {
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Year</legend>
<select name="year" class="select w-full" onchange="this.form.submit()">
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
{availableYears.map(year => (
<option value={year} selected={year === selectedYear}>{year}</option>
))}
</select>
<AutoSubmit client:load>
<select name="year" class="select w-full">
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
{availableYears.map(year => (
<option value={year} selected={year === selectedYear}>{year}</option>
))}
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Type</legend>
<select name="type" class="select w-full" onchange="this.form.submit()">
<option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option>
</select>
<AutoSubmit client:load>
<select name="type" class="select w-full">
<option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option>
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend>
<select name="status" class="select w-full" onchange="this.form.submit()">
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option>
</select>
<AutoSubmit client:load>
<select name="status" class="select w-full">
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option>
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend>
<select name="sort" class="select w-full" onchange="this.form.submit()">
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
</select>
<AutoSubmit client:load>
<select name="sort" class="select w-full">
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
</select>
</AutoSubmit>
</fieldset>
</form>
@@ -294,13 +304,13 @@ const getStatusColor = (status: string) => {
)}
<div class="divider my-1"></div>
<li>
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full">
<ConfirmForm client:load message="Are you sure? This action cannot be undone." action="/api/invoices/delete" class="w-full">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="w-full justify-start text-error hover:bg-error/10">
<Icon name="trash" class="w-4 h-4" />
Delete
</button>
</form>
</ConfirmForm>
</li>
</ul>
</div>

View File

@@ -5,6 +5,8 @@ import StatCard from '../../components/StatCard.astro';
import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import AutoSubmit from '../../components/AutoSubmit.vue';
import ColorDot from '../../components/ColorDot.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
@@ -260,78 +262,88 @@ function getTimeRangeLabel(range: string) {
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Time Range</legend>
<select id="reports-range" name="range" class="select w-full" onchange="this.form.submit()">
<option value="today" selected={timeRange === 'today'}>Today</option>
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select>
<AutoSubmit client:load>
<select id="reports-range" name="range" class="select w-full">
<option value="today" selected={timeRange === 'today'}>Today</option>
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select>
</AutoSubmit>
</fieldset>
{timeRange === 'custom' && (
<>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">From Date</legend>
<input
type="date"
id="reports-from"
name="from"
class="input w-full"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
<AutoSubmit client:load>
<input
type="date"
id="reports-from"
name="from"
class="input w-full"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
/>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">To Date</legend>
<input
type="date"
id="reports-to"
name="to"
class="input w-full"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
<AutoSubmit client:load>
<input
type="date"
id="reports-to"
name="to"
class="input w-full"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
/>
</AutoSubmit>
</fieldset>
</>
)}
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Team Member</legend>
<select id="reports-member" name="member" class="select w-full" onchange="this.form.submit()">
<option value="">All Members</option>
{teamMembers.map(member => (
<option value={member.id} selected={selectedMemberId === member.id}>
{member.name}
</option>
))}
</select>
<AutoSubmit client:load>
<select id="reports-member" name="member" class="select w-full">
<option value="">All Members</option>
{teamMembers.map(member => (
<option value={member.id} selected={selectedMemberId === member.id}>
{member.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Tag</legend>
<select id="reports-tag" name="tag" class="select w-full" onchange="this.form.submit()">
<option value="">All Tags</option>
{allTags.map(tag => (
<option value={tag.id} selected={selectedTagId === tag.id}>
{tag.name}
</option>
))}
</select>
<AutoSubmit client:load>
<select id="reports-tag" name="tag" class="select w-full">
<option value="">All Tags</option>
{allTags.map(tag => (
<option value={tag.id} selected={selectedTagId === tag.id}>
{tag.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend>
<select id="reports-client" name="client" class="select w-full" onchange="this.form.submit()">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={selectedClientId === client.id}>
{client.name}
</option>
))}
</select>
<AutoSubmit client:load>
<select id="reports-client" name="client" class="select w-full">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={selectedClientId === client.id}>
{client.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
</form>
</div>
@@ -620,7 +632,7 @@ function getTimeRangeLabel(range: string) {
<td>
<div class="flex items-center gap-2">
{stat.tag.color && (
<span class="w-3 h-3 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
<ColorDot client:load color={stat.tag.color} class="w-3 h-3 rounded-full" />
)}
<span>{stat.tag.name}</span>
</div>
@@ -736,7 +748,7 @@ function getTimeRangeLabel(range: string) {
{e.tag ? (
<div class="badge badge-xs badge-outline flex items-center gap-1">
{e.tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
<ColorDot client:load color={e.tag.color} class="w-2 h-2 rounded-full" />
)}
<span>{e.tag.name}</span>
</div>

View File

@@ -1,6 +1,9 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import Icon from '../../../components/Icon.astro';
import ModalButton from '../../../components/ModalButton.vue';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import ColorDot from '../../../components/ColorDot.vue';
import { db } from '../../../db';
import { organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm';
@@ -234,10 +237,10 @@ const successType = url.searchParams.get('success');
<Icon name="tag" class="w-4 h-4" />
Tags & Rates
</h2>
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-xs">
<ModalButton client:load modalId="new_tag_modal" class="btn btn-primary btn-xs">
<Icon name="plus" class="w-3 h-3" />
Add Tag
</button>
</ModalButton>
</div>
<p class="text-base-content/60 text-xs mb-4">
@@ -268,7 +271,7 @@ const successType = url.searchParams.get('success');
<td>
<div class="flex items-center gap-2">
{tag.color && (
<div class="w-3 h-3 rounded-full" style={`background-color: ${tag.color}`}></div>
<ColorDot client:load color={tag.color} class="w-3 h-3 rounded-full" />
)}
<span class="font-medium">{tag.name}</span>
</div>
@@ -282,17 +285,18 @@ const successType = url.searchParams.get('success');
</td>
<td>
<div class="flex gap-1">
<button
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`}
<ModalButton
client:load
modalId={`edit_tag_modal_${tag.id}`}
class="btn btn-ghost btn-xs btn-square"
>
<Icon name="pencil" class="w-3 h-3" />
</button>
<form method="POST" action={`/api/tags/${tag.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this tag?');">
</ModalButton>
<ConfirmForm client:load message="Are you sure you want to delete this tag?" action={`/api/tags/${tag.id}/delete`}>
<button class="btn btn-ghost btn-xs btn-square text-error">
<Icon name="trash" class="w-3 h-3" />
</button>
</form>
</ConfirmForm>
</div>
{/* Edit Modal */}
@@ -314,7 +318,7 @@ const successType = url.searchParams.get('success');
<p class="text-xs text-base-content/40 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
</fieldset>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button>
<ModalButton client:load modalId={`edit_tag_modal_${tag.id}`} action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
@@ -352,7 +356,7 @@ const successType = url.searchParams.get('success');
<p class="text-xs text-base-content/40 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
</fieldset>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('new_tag_modal').close()">Cancel</button>
<ModalButton client:load modalId="new_tag_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Create Tag</button>
</div>
</form>
@@ -363,5 +367,4 @@ const successType = url.searchParams.get('success');
</dialog>
</DashboardLayout>

View File

@@ -3,6 +3,8 @@ import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Icon from '../../components/Icon.astro';
import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue';
import AutoSubmit from '../../components/AutoSubmit.vue';
import ConfirmForm from '../../components/ConfirmForm.vue';
import { db } from '../../db';
import { timeEntries, clients, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
@@ -206,42 +208,50 @@ const paginationPages = getPaginationPages(page, totalPages);
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend>
<select id="tracker-client" name="client" class="select w-full" onchange="this.form.submit()">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}>
{client.name}
</option>
))}
</select>
<AutoSubmit client:load>
<select id="tracker-client" name="client" class="select w-full">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}>
{client.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend>
<select id="tracker-status" name="status" class="select w-full" onchange="this.form.submit()">
<option value="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
</select>
<AutoSubmit client:load>
<select id="tracker-status" name="status" class="select w-full">
<option value="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Entry Type</legend>
<select id="tracker-type" name="type" class="select w-full" onchange="this.form.submit()">
<option value="" selected={filterType === ''}>All Types</option>
<option value="timed" selected={filterType === 'timed'}>Timed</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
<AutoSubmit client:load>
<select id="tracker-type" name="type" class="select w-full">
<option value="" selected={filterType === ''}>All Types</option>
<option value="timed" selected={filterType === 'timed'}>Timed</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend>
<select id="tracker-sort" name="sort" class="select w-full" onchange="this.form.submit()">
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select>
<AutoSubmit client:load>
<select id="tracker-sort" name="sort" class="select w-full">
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select>
</AutoSubmit>
</fieldset>
<input type="hidden" name="page" value="1" />
@@ -322,15 +332,14 @@ const paginationPages = getPaginationPages(page, totalPages);
</td>
<td class="font-mono font-semibold text-primary text-sm">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<ConfirmForm client:load message="Are you sure you want to delete this entry?" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button
type="submit"
class="btn btn-ghost btn-xs text-error"
onclick="return confirm('Are you sure you want to delete this entry?')"
>
<Icon name="trash" class="w-3.5 h-3.5" />
</button>
</form>
</ConfirmForm>
</td>
</tr>
))}