Strengthened CRF, added more vue, and removed viewtransitions
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m42s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m42s
This commit is contained in:
@@ -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()],
|
||||
|
||||
12
src/components/AutoSubmit.vue
Normal file
12
src/components/AutoSubmit.vue
Normal 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>
|
||||
16
src/components/ColorDot.vue
Normal file
16
src/components/ColorDot.vue
Normal 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>
|
||||
26
src/components/ConfirmForm.vue
Normal file
26
src/components/ConfirmForm.vue
Normal 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>
|
||||
34
src/components/ModalButton.vue
Normal file
34
src/components/ModalButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,26 +147,31 @@ 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()">
|
||||
<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()">
|
||||
<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()">
|
||||
<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>
|
||||
@@ -173,11 +180,13 @@ const getStatusColor = (status: string) => {
|
||||
<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()">
|
||||
<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>
|
||||
@@ -185,6 +194,7 @@ const getStatusColor = (status: string) => {
|
||||
<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>
|
||||
|
||||
@@ -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,7 +262,8 @@ 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()">
|
||||
<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>
|
||||
@@ -269,38 +272,42 @@ function getTimeRangeLabel(range: string) {
|
||||
<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>
|
||||
<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'))}
|
||||
onchange="this.form.submit()"
|
||||
/>
|
||||
</AutoSubmit>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">To Date</legend>
|
||||
<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'))}
|
||||
onchange="this.form.submit()"
|
||||
/>
|
||||
</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()">
|
||||
<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}>
|
||||
@@ -308,11 +315,13 @@ function getTimeRangeLabel(range: string) {
|
||||
</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()">
|
||||
<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}>
|
||||
@@ -320,11 +329,13 @@ function getTimeRangeLabel(range: string) {
|
||||
</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()">
|
||||
<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}>
|
||||
@@ -332,6 +343,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +208,8 @@ 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()">
|
||||
<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}>
|
||||
@@ -214,34 +217,41 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</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()">
|
||||
<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()">
|
||||
<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()">
|
||||
<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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user