Compare commits
3 Commits
2.3.0
...
caf763aa1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
caf763aa1e
|
|||
|
12d59bb42f
|
|||
|
c39865031a
|
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chronus",
|
||||
"type": "module",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.6",
|
||||
"@astrojs/node": "10.0.0-beta.0",
|
||||
"@astrojs/node": "10.0.0-beta.2",
|
||||
"@astrojs/vue": "6.0.0-beta.0",
|
||||
"@ceereals/vue-pdf": "^0.2.1",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
@@ -21,23 +21,23 @@
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "6.0.0-beta.1",
|
||||
"astro": "6.0.0-beta.9",
|
||||
"astro-icon": "^1.1.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"daisyui": "^5.5.14",
|
||||
"dotenv": "^17.2.3",
|
||||
"daisyui": "^5.5.18",
|
||||
"dotenv": "^17.2.4",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.27",
|
||||
"vue": "^3.5.28",
|
||||
"vue-chartjs": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/daisyui": "^2.1.1",
|
||||
"@iconify-json/heroicons": "^1.2.3",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"drizzle-kit": "0.31.8"
|
||||
"drizzle-kit": "0.31.9"
|
||||
}
|
||||
}
|
||||
|
||||
1343
pnpm-lock.yaml
generated
1343
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ const initial = name ? name.charAt(0).toUpperCase() : '?';
|
||||
---
|
||||
|
||||
<div class:list={["avatar placeholder", className]}>
|
||||
<div class="bg-primary text-primary-content w-10 rounded-full flex items-center justify-center">
|
||||
<span class="text-lg font-semibold">{initial}</span>
|
||||
<div class="bg-primary/15 text-primary w-9 h-9 rounded-full flex items-center justify-center">
|
||||
<span class="text-sm font-semibold">{initial}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
29
src/components/StatCard.astro
Normal file
29
src/components/StatCard.astro
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
const { title, value, description, icon, color = 'text-primary', valueClass } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4 gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wider text-base-content/60">{title}</span>
|
||||
{icon && (
|
||||
<div class:list={[color, "opacity-40"]}>
|
||||
<Icon name={icon} class="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class:list={["text-2xl font-bold", color, valueClass]}>{value}</div>
|
||||
{description && <div class="text-xs text-base-content/50">{description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,6 +30,20 @@ const userMemberships = await db.select({
|
||||
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
||||
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: 'heroicons:home', exact: true },
|
||||
{ href: '/dashboard/tracker', label: 'Time Tracker', icon: 'heroicons:clock' },
|
||||
{ href: '/dashboard/invoices', label: 'Invoices & Quotes', icon: 'heroicons:document-currency-dollar' },
|
||||
{ href: '/dashboard/reports', label: 'Reports', icon: 'heroicons:chart-bar' },
|
||||
{ href: '/dashboard/clients', label: 'Clients', icon: 'heroicons:building-office' },
|
||||
{ href: '/dashboard/team', label: 'Team', icon: 'heroicons:user-group' },
|
||||
];
|
||||
|
||||
function isActive(item: { href: string; exact?: boolean }) {
|
||||
if (item.exact) return Astro.url.pathname === item.href;
|
||||
return Astro.url.pathname.startsWith(item.href);
|
||||
}
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -51,157 +65,137 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
<div class="drawer lg:drawer-open flex-1 overflow-auto">
|
||||
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col h-full overflow-auto">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-200/50 backdrop-blur-sm sticky top-0 z-50 lg:hidden border-b border-base-300/50">
|
||||
<div class="flex-none lg:hidden">
|
||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
||||
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
||||
<!-- Mobile Navbar -->
|
||||
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-200">
|
||||
<div class="flex-none">
|
||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm">
|
||||
<Icon name="heroicons:bars-3" class="w-5 h-5" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 px-2 flex items-center gap-2">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||
<img src="/logo.webp" alt="Chronus" class="h-7 w-7" />
|
||||
<span class="text-lg font-bold text-primary">Chronus</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ThemeToggle client:load />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page content here -->
|
||||
<main class="p-6 md:p-8">
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-50">
|
||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<ul class="menu bg-base-200/95 backdrop-blur-sm min-h-full w-80 p-4 border-r border-base-300/30">
|
||||
<!-- Sidebar content here -->
|
||||
<li class="mb-6">
|
||||
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||
Chronus
|
||||
<aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-300/40">
|
||||
<!-- Logo -->
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<a href="/dashboard" class="flex items-center gap-2.5 group">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
{/* Team Switcher */}
|
||||
<!-- Team Switcher -->
|
||||
{userMemberships.length > 0 && (
|
||||
<li class="mb-4">
|
||||
<div class="form-control">
|
||||
<select
|
||||
class="select select-bordered w-full font-semibold bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary focus:outline-none focus:outline-offset-0 transition-all duration-200 hover:border-primary/40 focus:ring-3 focus:ring-primary/15 [&>option]:bg-base-300 [&>option]:text-base-content [&>option]:p-2"
|
||||
id="team-switcher"
|
||||
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
||||
>
|
||||
{userMemberships.map(({ membership, organization }) => (
|
||||
<option
|
||||
value={organization.id}
|
||||
selected={organization.id === currentTeamId}
|
||||
>
|
||||
{organization.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
<div class="px-4 pb-2">
|
||||
<select
|
||||
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
|
||||
value={organization.id}
|
||||
selected={organization.id === currentTeamId}
|
||||
>
|
||||
{organization.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userMemberships.length === 0 && (
|
||||
<li class="mb-4">
|
||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||
<div class="px-4 pb-2">
|
||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm btn-block">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Create Team
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
<div class="divider my-1 mx-4"></div>
|
||||
|
||||
<li><a href="/dashboard" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname === "/dashboard" }
|
||||
]}>
|
||||
<Icon name="heroicons:home" class="w-5 h-5" />
|
||||
Dashboard
|
||||
</a></li>
|
||||
<li><a href="/dashboard/tracker" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/tracker") }
|
||||
]}>
|
||||
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||
Time Tracker
|
||||
</a></li>
|
||||
<li><a href="/dashboard/invoices" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/invoices") }
|
||||
]}>
|
||||
<Icon name="heroicons:document-currency-dollar" class="w-5 h-5" />
|
||||
Invoices & Quotes
|
||||
</a></li>
|
||||
<li><a href="/dashboard/reports" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/reports") }
|
||||
]}>
|
||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||
Reports
|
||||
</a></li>
|
||||
<li><a href="/dashboard/clients" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/clients") }
|
||||
]}>
|
||||
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
||||
Clients
|
||||
</a></li>
|
||||
<li><a href="/dashboard/team" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/team") }
|
||||
]}>
|
||||
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
||||
Team
|
||||
</a></li>
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-3">
|
||||
<ul class="menu menu-sm gap-0.5 p-0">
|
||||
{navItems.map(item => (
|
||||
<li>
|
||||
<a href={item.href} class:list={[
|
||||
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
|
||||
isActive(item)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-base-content/70 hover:text-base-content hover:bg-base-300/50"
|
||||
]}>
|
||||
<Icon name={item.icon} class="w-[18px] h-[18px]" />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{user.isSiteAdmin && (
|
||||
<>
|
||||
<div class="divider my-2"></div>
|
||||
<li><a href="/admin" class:list={[
|
||||
"font-semibold hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/admin") }
|
||||
]}>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||
Site Admin
|
||||
</a></li>
|
||||
</>
|
||||
)}
|
||||
{user.isSiteAdmin && (
|
||||
<>
|
||||
<div class="divider my-1"></div>
|
||||
<ul class="menu menu-sm p-0">
|
||||
<li>
|
||||
<a href="/admin" class:list={[
|
||||
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
|
||||
Astro.url.pathname.startsWith("/admin")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-base-content/70 hover:text-base-content hover:bg-base-300/50"
|
||||
]}>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-[18px] h-[18px]" />
|
||||
Site Admin
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
<!-- Bottom Section -->
|
||||
<div class="mt-auto border-t border-base-300/40">
|
||||
<div class="p-3">
|
||||
<a href="/dashboard/settings" class="flex items-center gap-3 rounded-lg p-2.5 hover:bg-base-300/40 group">
|
||||
<Avatar name={user.name} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{user.name}</div>
|
||||
<div class="text-xs text-base-content/50 truncate">{user.email}</div>
|
||||
</div>
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4 text-base-content/30 group-hover:text-base-content/50" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-300/30 hover:bg-base-300/60 rounded-lg p-3 transition-colors">
|
||||
<Avatar name={user.name} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm truncate">{user.name}</div>
|
||||
<div class="text-xs text-base-content/50 truncate">{user.email}</div>
|
||||
</div>
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-40" />
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div class="flex justify-between items-center p-2 hover:bg-transparent">
|
||||
<span class="font-semibold text-sm text-base-content/70 pl-2">Theme</span>
|
||||
<ThemeToggle client:load />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<form action="/api/auth/logout" method="POST" class="contents">
|
||||
<button type="submit" class="flex w-full items-center gap-2 py-2 px-4 text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
|
||||
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex items-center justify-between px-5 pb-2">
|
||||
<span class="text-xs text-base-content/40 font-medium">Theme</span>
|
||||
<ThemeToggle client:load />
|
||||
</div>
|
||||
|
||||
<div class="px-3 pb-3">
|
||||
<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="heroicons:arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -25,3 +25,16 @@ export function formatTimeRange(start: Date, end: Date | null): string {
|
||||
const ms = end.getTime() - start.getTime();
|
||||
return formatDuration(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a cent-based amount as a currency string.
|
||||
* @param amount - Amount in cents (e.g. 1500 = $15.00)
|
||||
* @param currency - ISO 4217 currency code (default: 'USD')
|
||||
* @returns Formatted currency string like "$15.00"
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
24
src/lib/getCurrentTeam.ts
Normal file
24
src/lib/getCurrentTeam.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { db } from '../db';
|
||||
import { members } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type User = { id: string; [key: string]: any };
|
||||
|
||||
/**
|
||||
* Get the current team membership for a user based on the currentTeamId cookie.
|
||||
* Returns the membership row, or null if the user has no memberships.
|
||||
*/
|
||||
export async function getCurrentTeam(user: User, currentTeamId?: string | null) {
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return null;
|
||||
|
||||
const membership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
|
||||
return membership;
|
||||
}
|
||||
@@ -2,6 +2,30 @@ import { db } from "../db";
|
||||
import { clients, tags as tagsTable } from "../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const MAX_LENGTHS = {
|
||||
name: 255,
|
||||
email: 320,
|
||||
password: 128,
|
||||
phone: 50,
|
||||
address: 255, // street, city, state, zip, country
|
||||
currency: 10,
|
||||
invoiceNumber: 50,
|
||||
invoiceNotes: 5000,
|
||||
itemDescription: 2000,
|
||||
description: 2000, // time entry description
|
||||
} as const;
|
||||
|
||||
export function exceedsLength(
|
||||
field: string,
|
||||
value: string | null | undefined,
|
||||
maxLength: number,
|
||||
): string | null {
|
||||
if (value && value.length > maxLength) {
|
||||
return `${field} must be ${maxLength} characters or fewer`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validateTimeEntryResources({
|
||||
organizationId,
|
||||
clientId,
|
||||
@@ -60,3 +84,9 @@ export function validateTimeRange(
|
||||
|
||||
return { valid: true, startDate, endDate };
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email) && email.length <= 320;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import StatCard from '../../components/StatCard.astro';
|
||||
import { db } from '../../db';
|
||||
import { siteSettings, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -21,52 +22,52 @@ const allUsers = await db.select().from(users).all();
|
||||
---
|
||||
|
||||
<DashboardLayout title="Site Admin - Chronus">
|
||||
<h1 class="text-3xl font-bold mb-6">Site Administration</h1>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Site Administration</h1>
|
||||
<p class="text-base-content/60 text-sm mt-1">Manage users and site settings</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Statistics -->
|
||||
<div class="stats shadow border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Users</div>
|
||||
<div class="stat-value">{allUsers.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={String(allUsers.length)}
|
||||
description="Registered accounts"
|
||||
icon="heroicons:users"
|
||||
color="text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Site Settings</h2>
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">Site Settings</h2>
|
||||
|
||||
<form method="POST" action="/api/admin/settings">
|
||||
<div class="form-control">
|
||||
<label for="registration_enabled" class="label pb-2 font-medium text-sm sm:text-base">
|
||||
Allow New Registrations
|
||||
</label>
|
||||
|
||||
<br>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Allow New Registrations</legend>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="registration_enabled"
|
||||
class="toggle toggle-primary shrink-0 mt-1"
|
||||
class="toggle toggle-primary shrink-0"
|
||||
checked={registrationEnabled}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users List -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">All Users</h2>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<h2 class="text-sm font-semibold">All Users</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -77,22 +78,22 @@ const allUsers = await db.select().from(users).all();
|
||||
</thead>
|
||||
<tbody>
|
||||
{allUsers.map(u => (
|
||||
<tr>
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar name={u.name} />
|
||||
<div class="font-bold">{u.name}</div>
|
||||
<div class="font-medium">{u.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{u.email}</td>
|
||||
<td class="text-base-content/60">{u.email}</td>
|
||||
<td>
|
||||
{u.isSiteAdmin ? (
|
||||
<span class="badge badge-primary">Yes</span>
|
||||
<span class="badge badge-xs badge-primary">Yes</span>
|
||||
) : (
|
||||
<span class="badge badge-ghost">No</span>
|
||||
<span class="badge badge-xs badge-ghost">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
<td class="text-base-content/40">{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -65,7 +65,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
console.error("Passkey authentication verification failed:", error);
|
||||
return new Response(JSON.stringify({ error: "Verification failed" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ import type { APIRoute } from "astro";
|
||||
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeyChallenges } from "../../../../../db/schema";
|
||||
import { lte } from "drizzle-orm";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
await db
|
||||
.delete(passkeyChallenges)
|
||||
.where(lte(passkeyChallenges.expiresAt, new Date()));
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: new URL(request.url).hostname,
|
||||
userVerification: "preferred",
|
||||
|
||||
@@ -48,7 +48,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
expectedRPID: new URL(request.url).hostname,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
console.error("Passkey registration verification failed:", error);
|
||||
return new Response(JSON.stringify({ error: "Verification failed" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
|
||||
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, lte } from "drizzle-orm";
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
@@ -13,6 +13,10 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(passkeyChallenges)
|
||||
.where(lte(passkeyChallenges.expiresAt, new Date()));
|
||||
|
||||
const userPasskeys = await db.query.passkeys.findMany({
|
||||
where: eq(passkeys.userId, user.id),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
siteSettings,
|
||||
} from "../../../db/schema";
|
||||
import { hashPassword, createSession } from "../../../lib/auth";
|
||||
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation";
|
||||
import { eq, count, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -37,6 +38,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
return redirect("/signup?error=missing_fields");
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return redirect("/signup?error=invalid_email");
|
||||
}
|
||||
|
||||
if (name.length > MAX_LENGTHS.name) {
|
||||
return redirect("/signup?error=name_too_long");
|
||||
}
|
||||
|
||||
if (password.length > MAX_LENGTHS.password) {
|
||||
return redirect("/signup?error=password_too_long");
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return redirect("/signup?error=password_too_short");
|
||||
}
|
||||
@@ -47,7 +60,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
if (existingUser) {
|
||||
return redirect("/signup?error=user_exists");
|
||||
return redirect("/login?registered=true");
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
@@ -52,6 +52,17 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||
return new Response("Not authorized", { status: 403 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
if (locals.scopes) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only owners and admins can delete clients" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("Only owners and admins can delete clients", { status: 403 });
|
||||
}
|
||||
|
||||
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||
|
||||
await db.delete(clients).where(eq(clients.id, id)).run();
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import { clients, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -49,6 +50,25 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
return new Response("Client name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const lengthError =
|
||||
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||
if (lengthError) {
|
||||
if (locals.scopes) {
|
||||
return new Response(JSON.stringify({ error: lengthError }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await db
|
||||
.select()
|
||||
@@ -87,6 +107,17 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
return new Response("Not authorized", { status: 403 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
if (locals.scopes) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only owners and admins can update clients" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("Only owners and admins can update clients", { status: 403 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from "../../../db";
|
||||
import { clients, members } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -45,6 +46,25 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
return new Response("Name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const lengthError =
|
||||
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||
if (lengthError) {
|
||||
if (locals.scopes) {
|
||||
return new Response(JSON.stringify({ error: lengthError }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
const userOrg = await db
|
||||
.select()
|
||||
.from(members)
|
||||
@@ -55,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
return new Response("No organization found", { status: 400 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
if (locals.scopes) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only owners and admins can create clients" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("Only owners and admins can create clients", { status: 403 });
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
|
||||
await db.insert(clients).values({
|
||||
|
||||
@@ -45,6 +45,11 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
return new Response("Only owners and admins can convert quotes", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const lastInvoice = await db
|
||||
.select()
|
||||
|
||||
@@ -107,7 +107,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`,
|
||||
"Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.pdf"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -222,51 +222,52 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
return redirect(`/dashboard/invoices/${id}?error=no-entries`);
|
||||
}
|
||||
|
||||
// Transaction-like operations
|
||||
try {
|
||||
await db.insert(invoiceItems).values(newItems);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(invoiceItems).values(newItems);
|
||||
|
||||
if (entryIdsToUpdate.length > 0) {
|
||||
await db
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: invoice.id })
|
||||
.where(inArray(timeEntries.id, entryIdsToUpdate));
|
||||
}
|
||||
|
||||
const allItems = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id));
|
||||
|
||||
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
let discountAmount = 0;
|
||||
if (invoice.discountType === "percentage") {
|
||||
discountAmount = Math.round(
|
||||
subtotal * ((invoice.discountValue || 0) / 100),
|
||||
);
|
||||
} else {
|
||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||
if (invoice.discountValue && invoice.discountValue > 0) {
|
||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||
if (entryIdsToUpdate.length > 0) {
|
||||
await tx
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: invoice.id })
|
||||
.where(inArray(timeEntries.id, entryIdsToUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
||||
const taxAmount = Math.round(
|
||||
taxableAmount * ((invoice.taxRate || 0) / 100),
|
||||
);
|
||||
const total = subtotal - discountAmount + taxAmount;
|
||||
const allItems = await tx
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id));
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
subtotal,
|
||||
discountAmount,
|
||||
taxAmount,
|
||||
total,
|
||||
})
|
||||
.where(eq(invoices.id, invoice.id));
|
||||
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
let discountAmount = 0;
|
||||
if (invoice.discountType === "percentage") {
|
||||
discountAmount = Math.round(
|
||||
subtotal * ((invoice.discountValue || 0) / 100),
|
||||
);
|
||||
} else {
|
||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||
if (invoice.discountValue && invoice.discountValue > 0) {
|
||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
||||
const taxAmount = Math.round(
|
||||
taxableAmount * ((invoice.taxRate || 0) / 100),
|
||||
);
|
||||
const total = subtotal - discountAmount + taxAmount;
|
||||
|
||||
await tx
|
||||
.update(invoices)
|
||||
.set({
|
||||
subtotal,
|
||||
discountAmount,
|
||||
taxAmount,
|
||||
total,
|
||||
})
|
||||
.where(eq(invoices.id, invoice.id));
|
||||
});
|
||||
|
||||
return redirect(`/dashboard/invoices/${id}?success=imported`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from "../../../../../db";
|
||||
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
@@ -61,6 +62,11 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
const lengthError = exceedsLength("Description", description, MAX_LENGTHS.itemDescription);
|
||||
if (lengthError) {
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
const quantity = parseFloat(quantityStr);
|
||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||
|
||||
|
||||
@@ -60,6 +60,13 @@ export const POST: APIRoute = async ({
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Destructive status changes require owner/admin
|
||||
const destructiveStatuses = ["void"];
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (destructiveStatuses.includes(status) && !isAdminOrOwner) {
|
||||
return new Response("Only owners and admins can void invoices", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(invoices)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from "../../../../db";
|
||||
import { invoices, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||
const user = locals.user;
|
||||
@@ -56,6 +57,14 @@ export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
const lengthError =
|
||||
exceedsLength("Invoice number", number, MAX_LENGTHS.invoiceNumber) ||
|
||||
exceedsLength("Currency", currency, MAX_LENGTHS.currency) ||
|
||||
exceedsLength("Notes", notes, MAX_LENGTHS.invoiceNotes);
|
||||
if (lengthError) {
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const issueDate = new Date(issueDateStr);
|
||||
const dueDate = new Date(dueDateStr);
|
||||
|
||||
@@ -43,6 +43,11 @@ export const POST: APIRoute = async ({ request, redirect, locals }) => {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
return new Response("Only owners and admins can delete invoices", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete invoice items first (manual cascade)
|
||||
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path";
|
||||
import { db } from "../../../db";
|
||||
import { organizations, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -29,6 +30,18 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const lengthError =
|
||||
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||
exceedsLength("Country", country, MAX_LENGTHS.address) ||
|
||||
exceedsLength("Currency", defaultCurrency, MAX_LENGTHS.currency);
|
||||
if (lengthError) {
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify user is admin/owner of this organization
|
||||
const membership = await db
|
||||
@@ -67,7 +80,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const ext = logo.name.split(".").pop() || "png";
|
||||
const rawExt = (logo.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z]/g, "");
|
||||
const allowedExtensions = ["png", "jpg", "jpeg"];
|
||||
const ext = allowedExtensions.includes(rawExt) ? rawExt : "png";
|
||||
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
|
||||
@@ -128,6 +128,13 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
||||
"Tag",
|
||||
"Description",
|
||||
];
|
||||
const sanitizeCell = (value: string): string => {
|
||||
if (/^[=+\-@\t\r]/.test(value)) {
|
||||
return `\t${value}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const rows = entries.map((e) => {
|
||||
const start = e.entry.startTime;
|
||||
const end = e.entry.endTime;
|
||||
@@ -144,10 +151,10 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
||||
start.toLocaleTimeString(),
|
||||
end ? end.toLocaleTimeString() : "",
|
||||
end ? duration.toFixed(2) : "Running",
|
||||
`"${(e.user.name || "").replace(/"/g, '""')}"`,
|
||||
`"${(e.client.name || "").replace(/"/g, '""')}"`,
|
||||
`"${tagsStr.replace(/"/g, '""')}"`,
|
||||
`"${(e.entry.description || "").replace(/"/g, '""')}"`,
|
||||
`"${sanitizeCell((e.user.name || "").replace(/"/g, '""'))}"`,
|
||||
`"${sanitizeCell((e.client.name || "").replace(/"/g, '""'))}"`,
|
||||
`"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
|
||||
`"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
|
||||
].join(",");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users, members } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { isValidEmail } from '../../../lib/validation';
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -26,6 +27,10 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
return new Response('Email is required', { status: 400 });
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return new Response('Invalid email format', { status: 400 });
|
||||
}
|
||||
|
||||
if (!['member', 'admin'].includes(role)) {
|
||||
return new Response('Invalid role', { status: 400 });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
|
||||
import {
|
||||
validateTimeEntryResources,
|
||||
validateTimeRange,
|
||||
MAX_LENGTHS,
|
||||
} from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
@@ -27,6 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (description && description.length > MAX_LENGTHS.description) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Description must be ${MAX_LENGTHS.description} characters or fewer` }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
return new Response(JSON.stringify({ error: "Start time is required" }), {
|
||||
status: 400,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from "../../../db";
|
||||
import { timeEntries, members } from "../../../db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { validateTimeEntryResources } from "../../../lib/validation";
|
||||
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||
@@ -17,6 +17,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return new Response("Client is required", { status: 400 });
|
||||
}
|
||||
|
||||
if (description && description.length > MAX_LENGTHS.description) {
|
||||
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
|
||||
}
|
||||
|
||||
const runningEntry = await db
|
||||
.select()
|
||||
.from(timeEntries)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { users, sessions } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { MAX_LENGTHS } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
|
||||
const user = locals.user;
|
||||
const contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
@@ -53,6 +54,13 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
|
||||
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user from database
|
||||
const dbUser = await db
|
||||
@@ -90,6 +98,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
// Invalidate all sessions, then re-create one for the current user
|
||||
const currentSessionId = cookies.get("session_id")?.value;
|
||||
if (currentSessionId) {
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
eq(sessions.userId, user.id),
|
||||
)
|
||||
.run();
|
||||
|
||||
const { createSession } = await import("../../../lib/auth");
|
||||
const { sessionId, expiresAt } = await createSession(user.id);
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
} else {
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.userId, user.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { db } from '../../db';
|
||||
import { clients, members } from '../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { clients } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const organizationId = userMembership.organizationId;
|
||||
|
||||
@@ -32,20 +21,20 @@ const allClients = await db.select()
|
||||
|
||||
<DashboardLayout title="Clients - Chronus">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Clients</h1>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Clients</h1>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Client</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{allClients.map(client => (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{client.name}</h2>
|
||||
{client.email && <p class="text-sm text-gray-500">{client.email}</p>}
|
||||
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-sm btn-ghost">View</a>
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4 gap-1">
|
||||
<h2 class="font-semibold">{client.name}</h2>
|
||||
{client.email && <p class="text-sm text-base-content/60">{client.email}</p>}
|
||||
<p class="text-xs text-base-content/40">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||
<div class="card-actions justify-end mt-3">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-xs btn-ghost">View</a>
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-xs btn-primary">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,9 +42,9 @@ const allClients = await db.select()
|
||||
</div>
|
||||
|
||||
{allClients.length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<p class="text-gray-500 mb-4">No clients yet</p>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Your First Client</a>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p class="text-base-content/50 text-sm mb-4">No clients yet</p>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Your First Client</a>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, members } from '../../../../db/schema';
|
||||
import { clients } from '../../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
|
||||
const { id } = Astro.params;
|
||||
if (!id) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const client = await db.select()
|
||||
.from(clients)
|
||||
@@ -40,145 +29,129 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
<DashboardLayout title={`Edit ${client.name} - Chronus`}>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Edit Client</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Edit Client</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action={`/api/clients/${client.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
Client Name
|
||||
</label>
|
||||
<form method="POST" action={`/api/clients/${client.id}/update`} class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Client Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={client.name}
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
Email (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email (optional)</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={client.email || ''}
|
||||
placeholder="jason.borne@cia.com"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="phone">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Phone (optional)</legend>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={client.phone || ''}
|
||||
placeholder="+1 (780) 420-1337"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider">Address Details</div>
|
||||
<div class="divider text-xs text-base-content/40">Address Details</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="street">
|
||||
Street Address (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Street Address (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
value={client.street || ''}
|
||||
placeholder="123 Business Rd"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="city">
|
||||
City (optional)
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">City (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={client.city || ''}
|
||||
placeholder="Edmonton"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="state">
|
||||
State / Province (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">State / Province (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
value={client.state || ''}
|
||||
placeholder="AB"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="zip">
|
||||
Zip / Postal Code (optional)
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
value={client.zip || ''}
|
||||
placeholder="10001"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="country">
|
||||
Country (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Country (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={client.country || ''}
|
||||
placeholder="Canada"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between mt-6">
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-outline"
|
||||
class="btn btn-error btn-outline btn-sm"
|
||||
onclick={`document.getElementById('delete_modal').showModal()`}
|
||||
>
|
||||
Delete Client
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,17 +161,17 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<dialog id="delete_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg text-error">Delete Client?</h3>
|
||||
<p class="py-4">
|
||||
<h3 class="font-semibold text-base text-error">Delete Client?</h3>
|
||||
<p class="py-4 text-sm">
|
||||
Are you sure you want to delete <strong>{client.name}</strong>?
|
||||
This action cannot be undone and will delete all associated time entries.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
<button class="btn btn-sm">Cancel</button>
|
||||
</form>
|
||||
<form method="POST" action={`/api/clients/${client.id}/delete`}>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
<button type="submit" class="btn btn-error btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
|
||||
import { clients, timeEntries, tags, users } from '../../../../db/schema';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||
import StatCard from '../../../../components/StatCard.astro';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
|
||||
const { id } = Astro.params;
|
||||
if (!id) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const client = await db.select()
|
||||
.from(clients)
|
||||
@@ -73,34 +63,34 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
|
||||
<DashboardLayout title={`${client.name} - Clients - Chronus`}>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href="/dashboard/clients" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">{client.name}</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">{client.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
|
||||
<!-- Client Details Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 lg:col-span-2">
|
||||
<div class="card-body">
|
||||
<div class="card card-border bg-base-100 lg:col-span-2">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
||||
<h2 class="text-sm font-semibold mb-3">{client.name}</h2>
|
||||
<div class="space-y-2 mb-4">
|
||||
{client.email && (
|
||||
<div class="flex items-center gap-2 text-base-content/70">
|
||||
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
||||
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div class="flex items-center gap-2 text-base-content/70">
|
||||
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||
<Icon name="heroicons:phone" class="w-4 h-4" />
|
||||
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
{(client.street || client.city || client.state || client.zip || client.country) && (
|
||||
<div class="flex items-start gap-2 text-base-content/70">
|
||||
<div class="flex items-start gap-2 text-base-content/60">
|
||||
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
|
||||
<div class="text-sm space-y-0.5">
|
||||
{client.street && <div>{client.street}</div>}
|
||||
@@ -116,68 +106,65 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-xs">
|
||||
<Icon name="heroicons: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.');">
|
||||
<button type="submit" class="btn btn-error btn-outline btn-sm">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
<button type="submit" class="btn btn-error btn-outline btn-xs">
|
||||
<Icon name="heroicons:trash" class="w-3 h-3" />
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<div class="stats shadow w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Time Tracked</div>
|
||||
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
|
||||
<div class="stat-desc">Across all projects</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Entries</div>
|
||||
<div class="stat-value text-secondary">{totalEntriesCount}</div>
|
||||
<div class="stat-desc">Recorded entries</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
title="Total Time Tracked"
|
||||
value={`${totalHours}h ${totalMinutes}m`}
|
||||
description="Across all projects"
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Entries"
|
||||
value={String(totalEntriesCount)}
|
||||
description="Recorded entries"
|
||||
icon="heroicons:list-bullet"
|
||||
color="text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Information</h3>
|
||||
<div class="card card-border bg-base-100 h-fit">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-sm font-semibold mb-3">Information</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-base-content/60">Created</div>
|
||||
<div>{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
||||
<div class="text-xs text-base-content/40">Created</div>
|
||||
<div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Recent Activity</h2>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<h2 class="text-sm font-semibold">Recent Activity</h2>
|
||||
</div>
|
||||
|
||||
{recentEntries.length > 0 ? (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
@@ -189,11 +176,11 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentEntries.map(({ entry, tag, user: entryUser }) => (
|
||||
<tr>
|
||||
<tr class="hover">
|
||||
<td>{entry.description || '-'}</td>
|
||||
<td>
|
||||
{tag ? (
|
||||
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||
<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>
|
||||
)}
|
||||
@@ -201,8 +188,8 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>{entryUser?.name || 'Unknown'}</td>
|
||||
<td>{entry.startTime.toLocaleDateString()}</td>
|
||||
<td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
|
||||
<td class="text-base-content/40">{entry.startTime.toLocaleDateString()}</td>
|
||||
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -210,14 +197,14 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<div class="text-center py-8 text-base-content/40 text-sm">
|
||||
No time entries recorded for this client yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentEntries.length > 0 && (
|
||||
<div class="card-actions justify-center mt-4">
|
||||
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-sm">
|
||||
<div class="flex justify-center py-3 border-t border-base-200">
|
||||
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs">
|
||||
View All Entries
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -7,124 +7,108 @@ if (!user) return Astro.redirect('/login');
|
||||
|
||||
<DashboardLayout title="New Client - Chronus">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Add New Client</h1>
|
||||
|
||||
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
Client Name
|
||||
</label>
|
||||
<form method="POST" action="/api/clients/create" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Client Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
Email (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email (optional)</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="jason.borne@cia.com"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="phone">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Phone (optional)</legend>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
placeholder="+1 (780) 420-1337"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider">Address Details</div>
|
||||
<div class="divider text-xs text-base-content/40">Address Details</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="street">
|
||||
Street Address (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Street Address (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
placeholder="123 Business Rd"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="city">
|
||||
City (optional)
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">City (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
placeholder="Edmonton"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="state">
|
||||
State / Province (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">State / Province (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
placeholder="AB"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="zip">
|
||||
Zip / Postal Code (optional)
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
placeholder="10001"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="country">
|
||||
Country (optional)
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Country (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Canada"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Client</button>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Create Client</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StatCard from '../../components/StatCard.astro';
|
||||
import { db } from '../../db';
|
||||
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||
@@ -103,25 +104,25 @@ const hasMembership = userOrgs.length > 0;
|
||||
---
|
||||
|
||||
<DashboardLayout title="Dashboard - Chronus">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-primary mb-2">
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p class="text-base-content/60">Welcome back, {user.name}!</p>
|
||||
<p class="text-base-content/60 text-sm mt-1">Welcome back, {user.name}!</p>
|
||||
</div>
|
||||
<a href="/dashboard/organizations/new" class="btn btn-outline">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
<a href="/dashboard/organizations/new" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
New Team
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{!hasMembership && (
|
||||
<div class="alert alert-info mb-8">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<div class="alert alert-info mb-6 text-sm">
|
||||
<Icon name="heroicons:information-circle" class="w-5 h-5" />
|
||||
<div>
|
||||
<h3 class="font-bold">Welcome to Chronus!</h3>
|
||||
<div class="text-sm">You're not part of any team yet. Create one or wait for an invitation.</div>
|
||||
<div class="text-xs">You're not part of any team yet. Create one or wait for an invitation.</div>
|
||||
</div>
|
||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
@@ -133,63 +134,56 @@ const hasMembership = userOrgs.length > 0;
|
||||
{hasMembership && (
|
||||
<>
|
||||
<!-- Stats Overview -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">This Week</div>
|
||||
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
|
||||
<div class="stat-desc">Total tracked time</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:calendar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">This Month</div>
|
||||
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
|
||||
<div class="stat-desc">Total tracked time</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-accent">
|
||||
<Icon name="heroicons:play-circle" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Active Timers</div>
|
||||
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
|
||||
<div class="stat-desc">Currently running</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-info">
|
||||
<Icon name="heroicons:building-office" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Clients</div>
|
||||
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
|
||||
<div class="stat-desc">Total active</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="This Week"
|
||||
value={formatDuration(stats.totalTimeThisWeek)}
|
||||
description="Total tracked time"
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="This Month"
|
||||
value={formatDuration(stats.totalTimeThisMonth)}
|
||||
description="Total tracked time"
|
||||
icon="heroicons:calendar"
|
||||
color="text-secondary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Timers"
|
||||
value={String(stats.activeTimers)}
|
||||
description="Currently running"
|
||||
icon="heroicons:play-circle"
|
||||
color="text-accent"
|
||||
/>
|
||||
<StatCard
|
||||
title="Clients"
|
||||
value={String(stats.totalClients)}
|
||||
description="Total active"
|
||||
icon="heroicons:building-office"
|
||||
color="text-info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Quick Actions -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:bolt" class="w-6 h-6 text-warning" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:bolt" class="w-4 h-4 text-warning" />
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3 mt-4">
|
||||
<a href="/dashboard/tracker" class="btn btn-primary">
|
||||
<Icon name="heroicons:play" class="w-5 h-5" />
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:play" class="w-4 h-4" />
|
||||
Start Timer
|
||||
</a>
|
||||
<a href="/dashboard/clients/new" class="btn btn-outline">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
<a href="/dashboard/clients/new" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Add Client
|
||||
</a>
|
||||
<a href="/dashboard/reports" class="btn btn-outline">
|
||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||
<a href="/dashboard/reports" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:chart-bar" class="w-4 h-4" />
|
||||
View Reports
|
||||
</a>
|
||||
</div>
|
||||
@@ -197,32 +191,32 @@ const hasMembership = userOrgs.length > 0;
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:clock" class="w-6 h-6 text-success" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:clock" class="w-4 h-4 text-success" />
|
||||
Recent Activity
|
||||
</h2>
|
||||
{stats.recentEntries.length > 0 ? (
|
||||
<ul class="space-y-3 mt-4">
|
||||
<ul class="space-y-2 mt-3">
|
||||
{stats.recentEntries.map(({ entry, client, tag }) => (
|
||||
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tag?.color || '#3b82f6'}`}>
|
||||
<div class="font-semibold text-sm">{client.name}</div>
|
||||
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
|
||||
<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))'}`}>
|
||||
<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">
|
||||
{tag ? (
|
||||
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
||||
) : <span class="italic opacity-50">No tag</span>}
|
||||
</span>
|
||||
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||
<span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center mt-4">
|
||||
<Icon name="heroicons:clock" class="w-12 h-12 text-base-content/20 mb-3" />
|
||||
<p class="text-base-content/60 text-sm">No recent time entries</p>
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center mt-3">
|
||||
<Icon name="heroicons:clock" class="w-10 h-10 text-base-content/15 mb-2" />
|
||||
<p class="text-base-content/40 text-sm">No recent time entries</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { formatCurrency } from '../../../lib/formatTime';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
@@ -49,13 +50,6 @@ const items = await db.select()
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||
.all();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: invoice.currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const isDraft = invoice.status === 'draft';
|
||||
---
|
||||
|
||||
@@ -68,7 +62,7 @@ const isDraft = invoice.status === 'draft';
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs btn-square">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<div class={`badge ${
|
||||
<div class={`badge badge-xs ${
|
||||
invoice.status === 'paid' || invoice.status === 'accepted' ? 'badge-success' :
|
||||
invoice.status === 'sent' ? 'badge-info' :
|
||||
invoice.status === 'void' || invoice.status === 'declined' ? 'badge-error' :
|
||||
@@ -77,15 +71,15 @@ const isDraft = invoice.status === 'draft';
|
||||
{invoice.status}
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold">{invoice.number}</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">{invoice.number}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{isDraft && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="sent" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:paper-airplane" class="w-5 h-5" />
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:paper-airplane" class="w-4 h-4" />
|
||||
Mark Sent
|
||||
</button>
|
||||
</form>
|
||||
@@ -93,8 +87,8 @@ const isDraft = invoice.status === 'draft';
|
||||
{(invoice.status !== 'paid' && invoice.status !== 'void' && invoice.type === 'invoice') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="paid" />
|
||||
<button type="submit" class="btn btn-success">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
<Icon name="heroicons:check" class="w-4 h-4" />
|
||||
Mark Paid
|
||||
</button>
|
||||
</form>
|
||||
@@ -102,25 +96,25 @@ const isDraft = invoice.status === 'draft';
|
||||
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && invoice.type === 'quote') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="accepted" />
|
||||
<button type="submit" class="btn btn-success">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
<Icon name="heroicons:check" class="w-4 h-4" />
|
||||
Mark Accepted
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.type === 'quote' && invoice.status === 'accepted') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/convert`}>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:document-duplicate" class="w-5 h-5" />
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
|
||||
Convert to Invoice
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div role="button" tabindex="0" class="btn btn-square btn-ghost border border-base-300">
|
||||
<Icon name="heroicons:ellipsis-horizontal" class="w-6 h-6" />
|
||||
<div role="button" tabindex="0" class="btn btn-square btn-ghost btn-sm border border-base-200">
|
||||
<Icon name="heroicons:ellipsis-horizontal" class="w-4 h-4" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
@@ -159,7 +153,7 @@ const isDraft = invoice.status === 'draft';
|
||||
</div>
|
||||
|
||||
<!-- Invoice Paper -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 print:shadow-none print:border-none">
|
||||
<div class="card card-border bg-base-100 print:shadow-none print:border-none">
|
||||
<div class="card-body p-8 sm:p-12">
|
||||
<!-- Header Section -->
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-8 mb-12">
|
||||
@@ -235,8 +229,8 @@ const isDraft = invoice.status === 'draft';
|
||||
<tr>
|
||||
<td class="py-4">{item.description}</td>
|
||||
<td class="py-4 text-right">{item.quantity}</td>
|
||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
|
||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
|
||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice, invoice.currency)}</td>
|
||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount, invoice.currency)}</td>
|
||||
{isDraft && (
|
||||
<td class="py-4 text-right">
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
|
||||
@@ -270,20 +264,20 @@ const isDraft = invoice.status === 'draft';
|
||||
</button>
|
||||
</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-300/50">
|
||||
<h4 class="text-sm font-bold mb-3">Add Item</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4 items-end">
|
||||
<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">
|
||||
<h4 class="text-xs font-semibold mb-3">Add Item</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-end">
|
||||
<div class="sm:col-span-6">
|
||||
<label class="label text-xs pt-0" for="item-description">Description</label>
|
||||
<input type="text" id="item-description" name="description" class="input input-sm input-bordered w-full" required placeholder="Service or product..." />
|
||||
<label class="text-xs text-base-content/60" for="item-description">Description</label>
|
||||
<input type="text" id="item-description" name="description" class="input input-sm w-full" required placeholder="Service or product..." />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label text-xs pt-0" for="item-quantity">Qty</label>
|
||||
<input type="number" id="item-quantity" name="quantity" step="0.01" class="input input-sm input-bordered w-full" required value="1" />
|
||||
<label class="text-xs text-base-content/60" for="item-quantity">Qty</label>
|
||||
<input type="number" id="item-quantity" name="quantity" step="0.01" class="input input-sm w-full" required value="1" />
|
||||
</div>
|
||||
<div class="sm:col-span-3">
|
||||
<label class="label text-xs pt-0" for="item-unit-price">Unit Price ({invoice.currency})</label>
|
||||
<input type="number" id="item-unit-price" name="unitPrice" step="0.01" class="input input-sm input-bordered w-full" required placeholder="0.00" />
|
||||
<label class="text-xs text-base-content/60" for="item-unit-price">Unit Price ({invoice.currency})</label>
|
||||
<input type="number" id="item-unit-price" name="unitPrice" step="0.01" class="input input-sm w-full" required placeholder="0.00" />
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-full">
|
||||
@@ -299,7 +293,7 @@ const isDraft = invoice.status === 'draft';
|
||||
<div class="w-64 space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||
</div>
|
||||
{(invoice.discountAmount && invoice.discountAmount > 0) && (
|
||||
<div class="flex justify-between text-sm">
|
||||
@@ -307,7 +301,7 @@ const isDraft = invoice.status === 'draft';
|
||||
Discount
|
||||
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
|
||||
</span>
|
||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
|
||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
||||
@@ -320,13 +314,13 @@ const isDraft = invoice.status === 'draft';
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span class="text-primary">{formatCurrency(invoice.total)}</span>
|
||||
<span class="text-primary">{formatCurrency(invoice.total, invoice.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,13 +346,11 @@ const isDraft = invoice.status === 'draft';
|
||||
<!-- Tax Modal -->
|
||||
<dialog id="tax_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Update Tax Rate</h3>
|
||||
<p class="py-4">Enter the tax percentage to apply to the subtotal.</p>
|
||||
<h3 class="font-semibold text-base">Update Tax Rate</h3>
|
||||
<p class="py-3 text-sm text-base-content/60">Enter the tax percentage to apply to the subtotal.</p>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/update-tax`}>
|
||||
<div class="form-control mb-6">
|
||||
<label class="label" for="tax-rate">
|
||||
Tax Rate (%)
|
||||
</label>
|
||||
<fieldset class="fieldset mb-4">
|
||||
<legend class="fieldset-legend text-xs">Tax Rate (%)</legend>
|
||||
<input
|
||||
type="number"
|
||||
id="tax-rate"
|
||||
@@ -366,14 +358,14 @@ const isDraft = invoice.status === 'draft';
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
value={invoice.taxRate ?? 0}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="document.getElementById('tax_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
<button type="button" class="btn btn-sm" onclick="document.getElementById('tax_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -385,30 +377,28 @@ const isDraft = invoice.status === 'draft';
|
||||
<!-- Import Time Modal -->
|
||||
<dialog id="import_time_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Import Time Entries</h3>
|
||||
<p class="py-4">Import billable time entries for this client.</p>
|
||||
<h3 class="font-semibold text-base">Import Time Entries</h3>
|
||||
<p class="py-3 text-sm text-base-content/60">Import billable time entries for this client.</p>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/import-time`}>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="start-date">Start Date</label>
|
||||
<input type="date" id="start-date" name="startDate" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="end-date">End Date</label>
|
||||
<input type="date" id="end-date" name="endDate" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Start Date</legend>
|
||||
<input type="date" id="start-date" name="startDate" class="input" required />
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">End Date</legend>
|
||||
<input type="date" id="end-date" name="endDate" class="input" required />
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-6">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input type="checkbox" name="groupByDay" class="checkbox" />
|
||||
<span class="label-text">Group entries by day</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="label cursor-pointer justify-start gap-3 mb-4">
|
||||
<input type="checkbox" name="groupByDay" class="checkbox checkbox-sm" />
|
||||
<span class="text-sm">Group entries by day</span>
|
||||
</label>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="document.getElementById('import_time_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Import</button>
|
||||
<button type="button" class="btn btn-sm" onclick="document.getElementById('import_time_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -47,83 +47,73 @@ const discountValueDisplay = invoice.discountType === 'fixed'
|
||||
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-xs gap-2 pl-0 hover:bg-transparent text-base-content/60">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
Back to Invoice
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold mt-2">Edit Details</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mt-2">Edit Details</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body gap-6">
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/update`} class="card card-border bg-base-100">
|
||||
<div class="card-body p-4 gap-3">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<!-- Number -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-number">
|
||||
Number
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Number</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="invoice-number"
|
||||
name="number"
|
||||
class="input input-bordered font-mono"
|
||||
class="input font-mono"
|
||||
value={invoice.number}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Currency -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-currency">
|
||||
Currency
|
||||
</label>
|
||||
<select id="invoice-currency" name="currency" class="select select-bordered w-full">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Currency</legend>
|
||||
<select id="invoice-currency" name="currency" class="select w-full">
|
||||
<option value="USD" selected={invoice.currency === 'USD'}>USD ($)</option>
|
||||
<option value="EUR" selected={invoice.currency === 'EUR'}>EUR (€)</option>
|
||||
<option value="GBP" selected={invoice.currency === 'GBP'}>GBP (£)</option>
|
||||
<option value="CAD" selected={invoice.currency === 'CAD'}>CAD ($)</option>
|
||||
<option value="AUD" selected={invoice.currency === 'AUD'}>AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Issue Date -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-issue-date">
|
||||
Issue Date
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Issue Date</legend>
|
||||
<input
|
||||
type="date"
|
||||
id="invoice-issue-date"
|
||||
name="issueDate"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
value={issueDateStr}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-due-date">
|
||||
{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}</legend>
|
||||
<input
|
||||
type="date"
|
||||
id="invoice-due-date"
|
||||
name="dueDate"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
value={dueDateStr}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Discount -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-discount-type">
|
||||
Discount
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Discount</legend>
|
||||
<div class="join w-full">
|
||||
<select id="invoice-discount-type" name="discountType" class="select select-bordered join-item">
|
||||
<select id="invoice-discount-type" name="discountType" class="select join-item">
|
||||
<option value="percentage" selected={!invoice.discountType || invoice.discountType === 'percentage'}>%</option>
|
||||
<option value="fixed" selected={invoice.discountType === 'fixed'}>Fixed</option>
|
||||
</select>
|
||||
@@ -133,48 +123,44 @@ const discountValueDisplay = invoice.discountType === 'fixed'
|
||||
name="discountValue"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered join-item w-full"
|
||||
class="input join-item w-full"
|
||||
value={discountValueDisplay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Tax Rate -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-tax-rate">
|
||||
Tax Rate (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="invoice-tax-rate"
|
||||
name="taxRate"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
class="input input-bordered"
|
||||
value={invoice.taxRate}
|
||||
/>
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Tax Rate (%)</legend>
|
||||
<input
|
||||
type="number"
|
||||
id="invoice-tax-rate"
|
||||
name="taxRate"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
class="input"
|
||||
value={invoice.taxRate}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control flex flex-col">
|
||||
<label class="label font-semibold" for="invoice-notes">
|
||||
Notes / Terms
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Notes / Terms</legend>
|
||||
<textarea
|
||||
id="invoice-notes"
|
||||
name="notes"
|
||||
class="textarea textarea-bordered h-32 font-mono text-sm"
|
||||
class="textarea h-32 font-mono text-sm"
|
||||
placeholder="Payment terms, bank details, or thank you notes..."
|
||||
>{invoice.notes}</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StatCard from '../../../components/StatCard.astro';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, clients, members } from '../../../db/schema';
|
||||
import { invoices, clients } from '../../../db/schema';
|
||||
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
import { formatCurrency } from '../../../lib/formatTime';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const currentTeamIdResolved = userMembership.organizationId;
|
||||
|
||||
// Get filter parameters
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearParam = Astro.url.searchParams.get('year');
|
||||
const selectedYear = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam);
|
||||
const selectedYear: string | number = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam);
|
||||
const yearNum = typeof selectedYear === 'number' ? selectedYear : currentYear;
|
||||
const selectedType = Astro.url.searchParams.get('type') || 'all';
|
||||
const selectedStatus = Astro.url.searchParams.get('status') || 'all';
|
||||
const sortBy = Astro.url.searchParams.get('sort') || 'date-desc';
|
||||
@@ -52,8 +44,8 @@ if (!availableYears.includes(currentYear)) {
|
||||
}
|
||||
|
||||
// Filter by year
|
||||
const yearStart = selectedYear === 'current' ? new Date(currentYear, 0, 1) : new Date(selectedYear, 0, 1);
|
||||
const yearEnd = selectedYear === 'current' ? new Date() : new Date(selectedYear, 11, 31, 23, 59, 59);
|
||||
const yearStart = new Date(yearNum, 0, 1);
|
||||
const yearEnd = selectedYear === 'current' ? new Date() : new Date(yearNum, 11, 31, 23, 59, 59);
|
||||
|
||||
let filteredInvoices = allInvoicesRaw.filter(i => {
|
||||
const issueDate = i.invoice.issueDate;
|
||||
@@ -96,13 +88,6 @@ const yearInvoices = allInvoicesRaw.filter(i => {
|
||||
return issueDate >= yearStart && issueDate <= yearEnd;
|
||||
});
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'badge-success';
|
||||
@@ -119,86 +104,67 @@ const getStatusColor = (status: string) => {
|
||||
<DashboardLayout title="Invoices & Quotes - Chronus">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Invoices & Quotes</h1>
|
||||
<p class="text-base-content/60 mt-1">Manage your billing and estimates</p>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Invoices & Quotes</h1>
|
||||
<p class="text-base-content/60 text-sm mt-1">Manage your billing and estimates</p>
|
||||
</div>
|
||||
<a href="/dashboard/invoices/new" class="btn btn-primary">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
<a href="/dashboard/invoices/new" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Create New
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:document-text" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Invoices</div>
|
||||
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
|
||||
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Open Quotes</div>
|
||||
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
|
||||
<div class="stat-desc">Waiting for approval</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Revenue</div>
|
||||
<div class="stat-value text-success">
|
||||
{formatCurrency(yearInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
||||
</div>
|
||||
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="Total Invoices"
|
||||
value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
|
||||
description={selectedYear === 'current' ? `${currentYear} (YTD)` : String(selectedYear)}
|
||||
icon="heroicons:document-text"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Open Quotes"
|
||||
value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
|
||||
description="Waiting for approval"
|
||||
icon="heroicons:clipboard-document-list"
|
||||
color="text-secondary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(yearInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
||||
description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
|
||||
icon="heroicons:currency-dollar"
|
||||
color="text-success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Year</span>
|
||||
</label>
|
||||
<select name="year" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Type</span>
|
||||
</label>
|
||||
<select name="type" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Status</span>
|
||||
</label>
|
||||
<select name="status" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<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>
|
||||
@@ -207,13 +173,11 @@ const getStatusColor = (status: string) => {
|
||||
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
|
||||
<option value="void" selected={selectedStatus === 'void'}>Void</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Sort By</span>
|
||||
</label>
|
||||
<select name="sort" class="select select-bordered w-full" onchange="this.form.submit()">
|
||||
<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>
|
||||
@@ -221,13 +185,13 @@ 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>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{(selectedYear !== 'current' || selectedType !== 'all' || selectedStatus !== 'all' || sortBy !== 'date-desc') && (
|
||||
<div class="mt-4">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
<div class="mt-3">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:x-mark" class="w-3 h-3" />
|
||||
Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
@@ -235,19 +199,19 @@ const getStatusColor = (status: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-6 py-4 border-b border-base-200 bg-base-200/30">
|
||||
<p class="text-sm text-base-content/70">
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<p class="text-xs text-base-content/50">
|
||||
Showing <span class="font-semibold text-base-content">{allInvoices.length}</span>
|
||||
{allInvoices.length === 1 ? 'result' : 'results'}
|
||||
{selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0">
|
||||
<table class="table table-zebra">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="bg-base-200/50">
|
||||
<tr>
|
||||
<th>Number</th>
|
||||
<th>Client</th>
|
||||
<th>Date</th>
|
||||
@@ -261,14 +225,14 @@ const getStatusColor = (status: string) => {
|
||||
<tbody>
|
||||
{allInvoices.length === 0 ? (
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-8 text-base-content/60">
|
||||
<td colspan="8" class="text-center py-8 text-base-content/50 text-sm">
|
||||
No invoices or quotes found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
allInvoices.map(({ invoice, client }) => (
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td class="font-mono font-medium">
|
||||
<tr class="hover">
|
||||
<td class="font-mono font-medium text-sm">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="link link-hover text-primary">
|
||||
{invoice.number}
|
||||
</a>
|
||||
@@ -286,7 +250,7 @@ const getStatusColor = (status: string) => {
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</td>
|
||||
<td>
|
||||
<div class={`badge ${getStatusColor(invoice.status)} badge-sm uppercase font-bold tracking-wider`}>
|
||||
<div class={`badge ${getStatusColor(invoice.status)} badge-xs uppercase font-bold tracking-wider`}>
|
||||
{invoice.status}
|
||||
</div>
|
||||
</td>
|
||||
@@ -295,10 +259,10 @@ const getStatusColor = (status: string) => {
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
|
||||
<div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-4 h-4" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-100">
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 bg-base-100 rounded-box w-52 border border-base-200 z-100">
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Icon name="heroicons:eye" class="w-4 h-4" />
|
||||
|
||||
@@ -2,26 +2,15 @@
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { clients, members, invoices, organizations } from '../../../db/schema';
|
||||
import { clients, invoices, organizations } from '../../../db/schema';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const currentTeamIdResolved = userMembership.organizationId;
|
||||
|
||||
@@ -91,124 +80,112 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
|
||||
<DashboardLayout title="New Document - Chronus">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs gap-2 pl-0 hover:bg-transparent text-base-content/60">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
Back to Invoices
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold mt-2">Create New Document</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mt-2">Create New Document</h1>
|
||||
</div>
|
||||
|
||||
{teamClients.length === 0 ? (
|
||||
<div role="alert" class="alert alert-warning shadow-lg">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<h3 class="font-bold">No Clients Found</h3>
|
||||
<h3 class="font-semibold text-sm">No Clients Found</h3>
|
||||
<div class="text-xs">You need to add a client before you can create an invoice or quote.</div>
|
||||
</div>
|
||||
<a href="/dashboard/clients" class="btn btn-sm">Manage Clients</a>
|
||||
</div>
|
||||
) : (
|
||||
<form method="POST" action="/api/invoices/create" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body gap-6">
|
||||
<form method="POST" action="/api/invoices/create" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4 gap-4">
|
||||
|
||||
<!-- Document Type -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="document-type-invoice">
|
||||
Document Type
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="label cursor-pointer justify-start gap-2 border border-base-300 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all font-medium" for="document-type-invoice">
|
||||
<input type="radio" id="document-type-invoice" name="type" value="invoice" class="radio radio-primary" checked />
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Document Type</legend>
|
||||
<div class="flex gap-3">
|
||||
<label class="label cursor-pointer justify-start gap-2 border border-base-200 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all font-medium text-sm" for="document-type-invoice">
|
||||
<input type="radio" id="document-type-invoice" name="type" value="invoice" class="radio radio-primary radio-sm" checked />
|
||||
Invoice
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2 border border-base-300 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all font-medium" for="document-type-quote">
|
||||
<input type="radio" id="document-type-quote" name="type" value="quote" class="radio radio-primary" />
|
||||
<label class="label cursor-pointer justify-start gap-2 border border-base-200 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all font-medium text-sm" for="document-type-quote">
|
||||
<input type="radio" id="document-type-quote" name="type" value="quote" class="radio radio-primary radio-sm" />
|
||||
Quote / Estimate
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<!-- Client -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-client">
|
||||
Client
|
||||
</label>
|
||||
<select id="invoice-client" name="clientId" class="select select-bordered w-full" required>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Client</legend>
|
||||
<select id="invoice-client" name="clientId" class="select w-full" required>
|
||||
<option value="" disabled selected>Select a client...</option>
|
||||
{teamClients.map(client => (
|
||||
<option value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Number -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="documentNumber">
|
||||
Number
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Number</legend>
|
||||
<input
|
||||
type="text"
|
||||
name="number"
|
||||
id="documentNumber"
|
||||
class="input input-bordered font-mono"
|
||||
class="input font-mono"
|
||||
value={nextInvoiceNumber}
|
||||
data-invoice-number={nextInvoiceNumber}
|
||||
data-quote-number={nextQuoteNumber}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Issue Date -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-issue-date">
|
||||
Issue Date
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Issue Date</legend>
|
||||
<input
|
||||
type="date"
|
||||
id="invoice-issue-date"
|
||||
name="issueDate"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
value={today}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-due-date" id="dueDateLabel">
|
||||
Due Date
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs" id="dueDateLabel">Due Date</legend>
|
||||
<input
|
||||
type="date"
|
||||
id="invoice-due-date"
|
||||
name="dueDate"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
value={defaultDueDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Currency -->
|
||||
<div class="form-control">
|
||||
<label class="label font-semibold" for="invoice-currency">
|
||||
Currency
|
||||
</label>
|
||||
<select id="invoice-currency" name="currency" class="select select-bordered w-full">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Currency</legend>
|
||||
<select id="invoice-currency" name="currency" class="select w-full">
|
||||
<option value="USD" selected={currentOrganization?.defaultCurrency === 'USD'}>USD ($)</option>
|
||||
<option value="EUR" selected={currentOrganization?.defaultCurrency === 'EUR'}>EUR (€)</option>
|
||||
<option value="GBP" selected={currentOrganization?.defaultCurrency === 'GBP'}>GBP (£)</option>
|
||||
<option value="CAD" selected={currentOrganization?.defaultCurrency === 'CAD'}>CAD ($)</option>
|
||||
<option value="AUD" selected={currentOrganization?.defaultCurrency === 'AUD'}>AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-0"></div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
Create Draft
|
||||
<Icon name="heroicons:arrow-right" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -12,36 +12,34 @@ if (!user) return Astro.redirect('/login');
|
||||
<DashboardLayout title="Create Team - Chronus">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/dashboard" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href="/dashboard" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Create New Team</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Create New Team</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/api/organizations/create" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-info mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<span>Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4" />
|
||||
<span class="text-sm">Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2 font-medium" for="name">
|
||||
Team Name
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Team Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Team</button>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<a href="/dashboard" class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Create Team</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
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 { db } from '../../db';
|
||||
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||
import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const teamMembers = await db.select({
|
||||
id: users.id,
|
||||
@@ -247,13 +237,6 @@ const revenueByClient = allClients.map(client => {
|
||||
};
|
||||
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
|
||||
|
||||
function formatCurrency(amount: number, currency: string = 'USD') {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
function getTimeRangeLabel(range: string) {
|
||||
switch (range) {
|
||||
case 'today': return 'Today';
|
||||
@@ -269,17 +252,15 @@ function getTimeRangeLabel(range: string) {
|
||||
---
|
||||
|
||||
<DashboardLayout title="Reports - Chronus">
|
||||
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Team Reports</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-range">
|
||||
Time Range
|
||||
</label>
|
||||
<select id="reports-range" name="range" class="select select-bordered" onchange="this.form.submit()">
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<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>
|
||||
@@ -288,44 +269,38 @@ 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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{timeRange === 'custom' && (
|
||||
<>
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-from">
|
||||
From Date
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">From Date</legend>
|
||||
<input
|
||||
type="date"
|
||||
id="reports-from"
|
||||
name="from"
|
||||
class="input input-bordered w-full"
|
||||
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()"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-to">
|
||||
To Date
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">To Date</legend>
|
||||
<input
|
||||
type="date"
|
||||
id="reports-to"
|
||||
name="to"
|
||||
class="input input-bordered w-full"
|
||||
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()"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-member">
|
||||
Team Member
|
||||
</label>
|
||||
<select id="reports-member" name="member" class="select select-bordered" onchange="this.form.submit()">
|
||||
<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}>
|
||||
@@ -333,13 +308,11 @@ function getTimeRangeLabel(range: string) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-tag">
|
||||
Tag
|
||||
</label>
|
||||
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()">
|
||||
<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}>
|
||||
@@ -347,13 +320,11 @@ function getTimeRangeLabel(range: string) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-client">
|
||||
Client
|
||||
</label>
|
||||
<select id="reports-client" name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<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}>
|
||||
@@ -361,78 +332,49 @@ function getTimeRangeLabel(range: string) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
form {
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
select, input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Time</div>
|
||||
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
|
||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Entries</div>
|
||||
<div class="stat-value text-secondary">{entries.length}</div>
|
||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Revenue</div>
|
||||
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
|
||||
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-accent">
|
||||
<Icon name="heroicons:user-group" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Active Members</div>
|
||||
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
|
||||
<div class="stat-desc">of {teamMembers.length} total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="Total Time"
|
||||
value={formatDuration(totalTime)}
|
||||
description={getTimeRangeLabel(timeRange)}
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Entries"
|
||||
value={String(entries.length)}
|
||||
description={getTimeRangeLabel(timeRange)}
|
||||
icon="heroicons:list-bullet"
|
||||
color="text-secondary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Revenue"
|
||||
value={formatCurrency(revenueStats.total)}
|
||||
description={`${invoiceStats.paid} paid invoices`}
|
||||
icon="heroicons:currency-dollar"
|
||||
color="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Members"
|
||||
value={String(statsByMember.filter(s => s.entryCount > 0).length)}
|
||||
description={`of ${teamMembers.length} total`}
|
||||
icon="heroicons:user-group"
|
||||
color="text-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Invoice & Quote Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:document-text" class="w-4 h-4" />
|
||||
Invoices Overview
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@@ -465,10 +407,10 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:clipboard-document-list" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:clipboard-document-list" class="w-4 h-4" />
|
||||
Quotes Overview
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@@ -506,14 +448,14 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
|
||||
{!selectedClientId && revenueByClient.length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:banknotes" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:banknotes" class="w-4 h-4" />
|
||||
Revenue by Client
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
@@ -526,11 +468,11 @@ function getTimeRangeLabel(range: string) {
|
||||
{revenueByClient.slice(0, 10).map(stat => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-bold">{stat.client.name}</div>
|
||||
<div class="font-medium">{stat.client.name}</div>
|
||||
</td>
|
||||
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td>
|
||||
<td class="font-mono font-semibold text-success text-sm">{formatCurrency(stat.revenue)}</td>
|
||||
<td>{stat.invoiceCount}</td>
|
||||
<td class="font-mono">
|
||||
<td class="font-mono text-sm">
|
||||
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -545,13 +487,13 @@ function getTimeRangeLabel(range: string) {
|
||||
{/* Charts Section - Only show if there's data */}
|
||||
{totalTime > 0 && (
|
||||
<>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
{/* Tag Distribution Chart - Only show when no tag filter */}
|
||||
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:chart-pie" class="w-4 h-4" />
|
||||
Tag Distribution
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
@@ -570,10 +512,10 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Client Distribution Chart - Only show when no client filter */}
|
||||
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:chart-bar" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:chart-bar" class="w-4 h-4" />
|
||||
Time by Client
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
@@ -592,10 +534,10 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Team Member Chart - Only show when no member filter */}
|
||||
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:users" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:users" class="w-4 h-4" />
|
||||
Time by Team Member
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
@@ -615,14 +557,14 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Stats by Member - Only show if there's data and no member filter */}
|
||||
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:users" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:users" class="w-4 h-4" />
|
||||
By Team Member
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
@@ -636,13 +578,13 @@ function getTimeRangeLabel(range: string) {
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<div class="font-bold">{stat.member.name}</div>
|
||||
<div class="text-sm opacity-50">{stat.member.email}</div>
|
||||
<div class="font-medium">{stat.member.name}</div>
|
||||
<div class="text-xs text-base-content/40">{stat.member.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
|
||||
<td>{stat.entryCount}</td>
|
||||
<td class="font-mono">
|
||||
<td class="font-mono text-sm">
|
||||
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -656,14 +598,14 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Stats by Tag - Only show if there's data and no tag filter */}
|
||||
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:tag" class="w-4 h-4" />
|
||||
By Tag
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
@@ -678,21 +620,21 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{stat.tag.color && (
|
||||
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
|
||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
|
||||
)}
|
||||
<span>{stat.tag.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-primary w-20"
|
||||
class="progress progress-primary w-16"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
<span class="text-xs">
|
||||
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -708,14 +650,14 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Stats by Client - Only show if there's data and no client filter */}
|
||||
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:building-office" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:building-office" class="w-4 h-4" />
|
||||
By Client
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
@@ -728,16 +670,16 @@ function getTimeRangeLabel(range: string) {
|
||||
{statsByClient.filter(s => s.totalTime > 0).map(stat => (
|
||||
<tr>
|
||||
<td>{stat.client.name}</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-secondary w-20"
|
||||
class="progress progress-secondary w-16"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
<span class="text-xs">
|
||||
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -752,23 +694,23 @@ function getTimeRangeLabel(range: string) {
|
||||
)}
|
||||
|
||||
{/* Detailed Entries */}
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:document-text" class="w-4 h-4" />
|
||||
Detailed Entries ({entries.length})
|
||||
</h2>
|
||||
{entries.length > 0 && (
|
||||
<a href={`/api/reports/export${url.search}`} class="btn btn-sm btn-outline" target="_blank">
|
||||
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
|
||||
<a href={`/api/reports/export${url.search}`} class="btn btn-xs btn-ghost" target="_blank">
|
||||
<Icon name="heroicons:arrow-down-tray" class="w-3.5 h-3.5" />
|
||||
Export CSV
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
@@ -784,7 +726,7 @@ function getTimeRangeLabel(range: string) {
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">
|
||||
{e.entry.startTime.toLocaleDateString()}<br/>
|
||||
<span class="text-xs opacity-50">
|
||||
<span class="text-xs text-base-content/40">
|
||||
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</td>
|
||||
@@ -792,18 +734,18 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{e.client.name}</td>
|
||||
<td>
|
||||
{e.tag ? (
|
||||
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||
<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>
|
||||
)}
|
||||
<span>{e.tag.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span class="opacity-50">-</span>
|
||||
<span class="text-base-content/30">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{e.entry.description || '-'}</td>
|
||||
<td class="font-mono">
|
||||
<td class="text-base-content/60">{e.entry.description || '-'}</td>
|
||||
<td class="font-mono text-sm">
|
||||
{e.entry.endTime
|
||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||
: 'Running...'
|
||||
@@ -815,12 +757,12 @@ function getTimeRangeLabel(range: string) {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Icon name="heroicons:inbox" class="w-16 h-16 text-base-content/20 mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No time entries found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your filters or select a different time range.</p>
|
||||
<a href="/dashboard/tracker" class="btn btn-primary">
|
||||
<Icon name="heroicons:play" class="w-5 h-5" />
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Icon name="heroicons:inbox" class="w-12 h-12 text-base-content/15 mb-3" />
|
||||
<h3 class="text-base font-semibold mb-1">No time entries found</h3>
|
||||
<p class="text-base-content/50 text-sm mb-4">Try adjusting your filters or select a different time range.</p>
|
||||
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:play" class="w-4 h-4" />
|
||||
Start Tracking Time
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ const userPasskeys = await db.select()
|
||||
|
||||
<DashboardLayout title="Account Settings - Chronus">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-primary">
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6 sm:mb-8">
|
||||
Account Settings
|
||||
</h1>
|
||||
|
||||
@@ -50,10 +50,10 @@ const userPasskeys = await db.select()
|
||||
)}
|
||||
|
||||
<!-- Profile Information -->
|
||||
<ProfileForm client:load user={user} />
|
||||
<ProfileForm client:idle user={user} />
|
||||
|
||||
<!-- Change Password -->
|
||||
<PasswordForm client:load />
|
||||
<PasswordForm client:idle />
|
||||
|
||||
<!-- Passkeys -->
|
||||
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
|
||||
@@ -69,25 +69,25 @@ const userPasskeys = await db.select()
|
||||
createdAt: t.createdAt ? t.createdAt.toISOString() : ''
|
||||
}))} />
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon name="heroicons:information-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4" />
|
||||
Account Information
|
||||
</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
|
||||
<span class="text-base-content/70 text-sm sm:text-base">Account ID</span>
|
||||
<span class="font-mono text-xs sm:text-sm break-all">{user.id}</span>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-200 gap-2 sm:gap-0">
|
||||
<span class="text-base-content/60 text-sm">Account ID</span>
|
||||
<span class="font-mono text-xs break-all">{user.id}</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
|
||||
<span class="text-base-content/70 text-sm sm:text-base">Email</span>
|
||||
<span class="text-sm sm:text-base break-all">{user.email}</span>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-200 gap-2 sm:gap-0">
|
||||
<span class="text-base-content/60 text-sm">Email</span>
|
||||
<span class="text-sm break-all">{user.email}</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 gap-2 sm:gap-0">
|
||||
<span class="text-base-content/70 text-sm sm:text-base">Site Administrator</span>
|
||||
<span class={user.isSiteAdmin ? "badge badge-primary" : "badge badge-ghost"}>
|
||||
<span class="text-base-content/60 text-sm">Site Administrator</span>
|
||||
<span class={user.isSiteAdmin ? "badge badge-xs badge-primary" : "badge badge-xs badge-ghost"}>
|
||||
{user.isSiteAdmin ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,24 +5,13 @@ import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { members, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const teamMembers = await db.select({
|
||||
member: members,
|
||||
@@ -39,24 +28,27 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
|
||||
<DashboardLayout title="Team - Chronus">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-3xl font-bold">Team Members</h1>
|
||||
<div>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Team Members</h1>
|
||||
<p class="text-base-content/60 text-sm mt-1">Manage your organization's team</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4" />
|
||||
Settings
|
||||
</a>
|
||||
<a href="/dashboard/team/invite" class="btn btn-primary">Invite Member</a>
|
||||
<a href="/dashboard/team/invite" class="btn btn-primary btn-sm">Invite Member</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -68,21 +60,21 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
</thead>
|
||||
<tbody>
|
||||
{teamMembers.map(({ member, user: teamUser }) => (
|
||||
<tr>
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar name={teamUser.name} />
|
||||
<div>
|
||||
<div class="font-bold">{teamUser.name}</div>
|
||||
<div class="font-medium">{teamUser.name}</div>
|
||||
{teamUser.id === user.id && (
|
||||
<span class="badge badge-sm">You</span>
|
||||
<span class="badge badge-xs">You</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{teamUser.email}</td>
|
||||
<td class="text-base-content/60">{teamUser.email}</td>
|
||||
<td>
|
||||
<span class={`badge ${
|
||||
<span class={`badge badge-xs ${
|
||||
member.role === 'owner' ? 'badge-primary' :
|
||||
member.role === 'admin' ? 'badge-secondary' :
|
||||
'badge-ghost'
|
||||
@@ -90,15 +82,15 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
<td class="text-base-content/40">{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
{isAdmin && (
|
||||
<td>
|
||||
{teamUser.id !== user.id && member.role !== 'owner' && (
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-4 h-4" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<li>
|
||||
<form method="POST" action={`/api/team/change-role`}>
|
||||
<input type="hidden" name="userId" value={teamUser.id} />
|
||||
|
||||
@@ -29,45 +29,39 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
|
||||
<DashboardLayout title="Invite Team Member - Chronus">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Invite Team Member</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Invite Team Member</h1>
|
||||
|
||||
<form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/api/team/invite" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-info mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6 shrink-0" />
|
||||
<span>The user must already have an account. They'll be added to your organization.</span>
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4 shrink-0" />
|
||||
<span class="text-sm">The user must already have an account. They'll be added to your organization.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
Email Address
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email Address</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="user@example.com"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="role">
|
||||
Role
|
||||
</label>
|
||||
<select id="role" name="role" class="select select-bordered" required>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Role</legend>
|
||||
<select id="role" name="role" class="select" required>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<label class="label h-auto block">
|
||||
<span class="label-text-alt">Members can track time. Admins can manage team and clients.</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/40 mt-1">Members can track time. Admins can manage team and clients.</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/team" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Invite Member</button>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<a href="/dashboard/team" class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Invite Member</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,26 +2,15 @@
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { members, organizations, tags } from '../../../db/schema';
|
||||
import { organizations, tags } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
@@ -48,42 +37,40 @@ const successType = url.searchParams.get('success');
|
||||
|
||||
<DashboardLayout title="Team Settings - Chronus">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/dashboard/team" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href="/dashboard/team" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Team Settings</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Team Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Team Settings -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:building-office-2" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
|
||||
<Icon name="heroicons:building-office-2" class="w-4 h-4" />
|
||||
Team Settings
|
||||
</h2>
|
||||
|
||||
{successType === 'org-name' && (
|
||||
<div class="alert alert-success mb-4">
|
||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||
<span>Team information updated successfully!</span>
|
||||
<Icon name="heroicons:check-circle" class="w-4 h-4" />
|
||||
<span class="text-sm">Team information updated successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
action="/api/organizations/update-name"
|
||||
method="POST"
|
||||
class="space-y-4"
|
||||
class="space-y-3"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<input type="hidden" name="organizationId" value={organization.id} />
|
||||
|
||||
<div class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Team Logo</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Team Logo</legend>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-base-200 text-neutral-content rounded-xl w-24 border border-base-300 flex items-center justify-center overflow-hidden">
|
||||
<div class="bg-base-200 text-neutral-content rounded-xl w-20 border border-base-200 flex items-center justify-center overflow-hidden">
|
||||
{organization.logoUrl ? (
|
||||
<img
|
||||
src={organization.logoUrl}
|
||||
@@ -93,7 +80,7 @@ const successType = url.searchParams.get('success');
|
||||
) : (
|
||||
<Icon
|
||||
name="heroicons:photo"
|
||||
class="w-8 h-8 opacity-40 text-base-content"
|
||||
class="w-6 h-6 opacity-40 text-base-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,118 +90,100 @@ const successType = url.searchParams.get('success');
|
||||
type="file"
|
||||
name="logo"
|
||||
accept="image/png, image/jpeg"
|
||||
class="file-input file-input-bordered w-full max-w-xs"
|
||||
class="file-input file-input-bordered file-input-sm w-full max-w-xs"
|
||||
/>
|
||||
<div class="text-xs text-base-content/60 mt-2">
|
||||
Upload a company logo (PNG, JPG).
|
||||
<br />
|
||||
Will be displayed on invoices and quotes.
|
||||
<div class="text-xs text-base-content/40 mt-1">
|
||||
Upload a company logo (PNG, JPG). Will be displayed on invoices and quotes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="team-name">
|
||||
Team Name
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Team Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="team-name"
|
||||
name="name"
|
||||
value={organization.name}
|
||||
placeholder="Organization name"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/40 mt-1">This name is visible to all team members</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider">Address Information</div>
|
||||
<div class="divider text-xs text-base-content/40 my-2">Address Information</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="team-street">
|
||||
Street Address
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Street Address</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="team-street"
|
||||
name="street"
|
||||
value={organization.street || ''}
|
||||
placeholder="123 Main Street"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="team-city">
|
||||
City
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">City</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="team-city"
|
||||
name="city"
|
||||
value={organization.city || ''}
|
||||
placeholder="City"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="team-state">
|
||||
State/Province
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">State/Province</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="team-state"
|
||||
name="state"
|
||||
value={organization.state || ''}
|
||||
placeholder="State/Province"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="team-zip">
|
||||
Postal Code
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Postal Code</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="team-zip"
|
||||
name="zip"
|
||||
value={organization.zip || ''}
|
||||
placeholder="12345"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="team-country">
|
||||
Country
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Country</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="team-country"
|
||||
name="country"
|
||||
value={organization.country || ''}
|
||||
placeholder="Country"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="divider">Defaults</div>
|
||||
<div class="divider text-xs text-base-content/40 my-2">Defaults</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="default-tax-rate">
|
||||
Default Tax Rate (%)
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Default Tax Rate (%)</legend>
|
||||
<input
|
||||
type="number"
|
||||
id="default-tax-rate"
|
||||
@@ -223,18 +192,16 @@ const successType = url.searchParams.get('success');
|
||||
min="0"
|
||||
max="100"
|
||||
value={organization.defaultTaxRate || 0}
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="default-currency">
|
||||
Default Currency
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Default Currency</legend>
|
||||
<select
|
||||
id="default-currency"
|
||||
name="defaultCurrency"
|
||||
class="select select-bordered w-full"
|
||||
class="select w-full"
|
||||
>
|
||||
<option value="USD" selected={!organization.defaultCurrency || organization.defaultCurrency === 'USD'}>USD ($)</option>
|
||||
<option value="EUR" selected={organization.defaultCurrency === 'EUR'}>EUR (€)</option>
|
||||
@@ -242,16 +209,16 @@ const successType = url.searchParams.get('success');
|
||||
<option value="CAD" selected={organization.defaultCurrency === 'CAD'}>CAD ($)</option>
|
||||
<option value="AUD" selected={organization.defaultCurrency === 'AUD'}>AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mt-6">
|
||||
<span class="text-xs text-base-content/60 text-center sm:text-left">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-3 mt-4">
|
||||
<span class="text-xs text-base-content/40 text-center sm:text-left">
|
||||
Address information appears on invoices and quotes
|
||||
</span>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
<button type="submit" class="btn btn-primary btn-sm w-full sm:w-auto">
|
||||
<Icon name="heroicons:check" class="w-4 h-4" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
@@ -260,35 +227,34 @@ const successType = url.searchParams.get('success');
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:tag" class="w-4 h-4" />
|
||||
Tags & Rates
|
||||
</h2>
|
||||
{/* We'll use a simple form submission for now or client-side JS for better UX later */}
|
||||
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-xs">
|
||||
<Icon name="heroicons:plus" class="w-3 h-3" />
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/70 mb-4">
|
||||
<p class="text-base-content/60 text-xs mb-4">
|
||||
Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes.
|
||||
</p>
|
||||
|
||||
{allTags.length === 0 ? (
|
||||
<div class="alert alert-info">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4" />
|
||||
<div>
|
||||
<div class="font-bold">No tags yet</div>
|
||||
<div class="text-sm">Create tags to add context and rates to your time entries.</div>
|
||||
<div class="font-semibold text-sm">No tags yet</div>
|
||||
<div class="text-xs">Create tags to add context and rates to your time entries.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -298,7 +264,7 @@ const successType = url.searchParams.get('success');
|
||||
</thead>
|
||||
<tbody>
|
||||
{allTags.map(tag => (
|
||||
<tr>
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{tag.color && (
|
||||
@@ -309,22 +275,22 @@ const successType = url.searchParams.get('success');
|
||||
</td>
|
||||
<td>
|
||||
{tag.rate ? (
|
||||
<span class="font-mono">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
|
||||
<span class="font-mono text-sm">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
|
||||
) : (
|
||||
<span class="text-base-content/40 text-xs italic">No rate</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`}
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
>
|
||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
||||
<Icon name="heroicons: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?');">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
<Icon name="heroicons:trash" class="w-3 h-3" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -332,32 +298,24 @@ const successType = url.searchParams.get('success');
|
||||
{/* Edit Modal */}
|
||||
<dialog id={`edit_tag_modal_${tag.id}`} class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Edit Tag</h3>
|
||||
<h3 class="font-semibold text-base">Edit Tag</h3>
|
||||
<form method="POST" action={`/api/tags/${tag.id}/update`}>
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" name="name" value={tag.name} class="input input-bordered w-full" required />
|
||||
</div>
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Color</span>
|
||||
</label>
|
||||
<input type="color" name="color" value={tag.color || '#3b82f6'} class="input input-bordered w-full h-12 p-1" />
|
||||
</div>
|
||||
<div class="form-control w-full mb-6">
|
||||
<label class="label">
|
||||
<span class="label-text">Hourly Rate (cents)</span>
|
||||
</label>
|
||||
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input input-bordered w-full" />
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset class="fieldset mb-3">
|
||||
<legend class="fieldset-legend text-xs">Name</legend>
|
||||
<input type="text" name="name" value={tag.name} class="input w-full" required />
|
||||
</fieldset>
|
||||
<fieldset class="fieldset mb-3">
|
||||
<legend class="fieldset-legend text-xs">Color</legend>
|
||||
<input type="color" name="color" value={tag.color || '#3b82f6'} class="input w-full h-12 p-1" />
|
||||
</fieldset>
|
||||
<fieldset class="fieldset mb-4">
|
||||
<legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
|
||||
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input w-full" />
|
||||
<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" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-sm" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -377,33 +335,25 @@ const successType = url.searchParams.get('success');
|
||||
|
||||
<dialog id="new_tag_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">New Tag</h3>
|
||||
<h3 class="font-semibold text-base">New Tag</h3>
|
||||
<form method="POST" action="/api/tags/create">
|
||||
<input type="hidden" name="organizationId" value={organization.id} />
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="input input-bordered w-full" required placeholder="e.g. Billable, Rush" />
|
||||
</div>
|
||||
<div class="form-control w-full mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Color</span>
|
||||
</label>
|
||||
<input type="color" name="color" value="#3b82f6" class="input input-bordered w-full h-12 p-1" />
|
||||
</div>
|
||||
<div class="form-control w-full mb-6">
|
||||
<label class="label">
|
||||
<span class="label-text">Hourly Rate (cents)</span>
|
||||
</label>
|
||||
<input type="number" name="rate" value="0" min="0" class="input input-bordered w-full" />
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset class="fieldset mb-3">
|
||||
<legend class="fieldset-legend text-xs">Name</legend>
|
||||
<input type="text" name="name" class="input w-full" required placeholder="e.g. Billable, Rush" />
|
||||
</fieldset>
|
||||
<fieldset class="fieldset mb-3">
|
||||
<legend class="fieldset-legend text-xs">Color</legend>
|
||||
<input type="color" name="color" value="#3b82f6" class="input w-full h-12 p-1" />
|
||||
</fieldset>
|
||||
<fieldset class="fieldset mb-4">
|
||||
<legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
|
||||
<input type="number" name="rate" value="0" min="0" class="input w-full" />
|
||||
<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" onclick="document.getElementById('new_tag_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Tag</button>
|
||||
<button type="button" class="btn btn-sm" onclick="document.getElementById('new_tag_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Create Tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,27 +4,16 @@ import { Icon } from 'astro-icon/components';
|
||||
import Timer from '../../components/Timer.vue';
|
||||
import ManualEntry from '../../components/ManualEntry.vue';
|
||||
import { db } from '../../db';
|
||||
import { timeEntries, clients, members, tags, users } from '../../db/schema';
|
||||
import { timeEntries, clients, tags, users } from '../../db/schema';
|
||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const organizationId = userMembership.organizationId;
|
||||
|
||||
@@ -150,15 +139,15 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
---
|
||||
|
||||
<DashboardLayout title="Time Tracker - Chronus">
|
||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Time Tracker</h1>
|
||||
|
||||
<!-- Tabs for Timer and Manual Entry -->
|
||||
<div role="tablist" class="tabs tabs-lifted mb-6">
|
||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
<div class="tabs tabs-lift mb-6">
|
||||
<input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
@@ -177,11 +166,11 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
<input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
@@ -200,28 +189,24 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
) : null}
|
||||
|
||||
<!-- Filters and Search -->
|
||||
<div class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="tracker-search">
|
||||
Search
|
||||
</label>
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Search</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="tracker-search"
|
||||
name="search"
|
||||
placeholder="Search descriptions..."
|
||||
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full"
|
||||
class="input w-full"
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="tracker-client">
|
||||
Client
|
||||
</label>
|
||||
<select id="tracker-client" name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<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}>
|
||||
@@ -229,48 +214,40 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="tracker-status">
|
||||
Status
|
||||
</label>
|
||||
<select id="tracker-status" name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="tracker-type">
|
||||
Entry Type
|
||||
</label>
|
||||
<select id="tracker-type" name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="tracker-sort">
|
||||
Sort By
|
||||
</label>
|
||||
<select id="tracker-sort" name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<div class="form-control md:col-span-2 lg:col-span-6">
|
||||
<button type="submit" class="btn btn-primary shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all">
|
||||
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
||||
<div class="flex items-end md:col-span-2 lg:col-span-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-full">
|
||||
<Icon name="heroicons:magnifying-glass" class="w-4 h-4" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
@@ -278,24 +255,24 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200/30 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:list-bullet" class="w-4 h-4" />
|
||||
Time Entries ({totalCount?.count || 0} total)
|
||||
</h2>
|
||||
{(filterClient || filterStatus || filterType || searchTerm) && (
|
||||
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost hover:bg-base-300/50 transition-colors">
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
<a href="/dashboard/tracker" class="btn btn-xs btn-ghost">
|
||||
<Icon name="heroicons:x-mark" class="w-3 h-3" />
|
||||
Clear Filters
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="bg-base-300/30">
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Client</th>
|
||||
<th>Description</th>
|
||||
@@ -308,26 +285,26 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(({ entry, client, user: entryUser }) => (
|
||||
<tr class="hover:bg-base-300/20 transition-colors">
|
||||
<tr class="hover">
|
||||
<td>
|
||||
{entry.isManual ? (
|
||||
<span class="badge badge-info badge-sm gap-1 shadow-sm" title="Manual Entry">
|
||||
<span class="badge badge-info badge-xs gap-1" title="Manual Entry">
|
||||
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||
Manual
|
||||
</span>
|
||||
) : (
|
||||
<span class="badge badge-success badge-sm gap-1 shadow-sm" title="Timed Entry">
|
||||
<span class="badge badge-success badge-xs gap-1" title="Timed Entry">
|
||||
<Icon name="heroicons:clock" class="w-3 h-3" />
|
||||
Timed
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="font-medium">{client?.name || 'Unknown'}</td>
|
||||
<td class="text-base-content/80">{entry.description || '-'}</td>
|
||||
<td class="text-base-content/60">{entry.description || '-'}</td>
|
||||
<td>{entryUser?.name || 'Unknown'}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
{entry.startTime.toLocaleDateString()}<br/>
|
||||
<span class="text-xs opacity-50">
|
||||
<span class="text-xs text-base-content/40">
|
||||
{entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</td>
|
||||
@@ -335,23 +312,23 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
{entry.endTime ? (
|
||||
<>
|
||||
{entry.endTime.toLocaleDateString()}<br/>
|
||||
<span class="text-xs opacity-50">
|
||||
<span class="text-xs text-base-content/40">
|
||||
{entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="badge badge-success shadow-sm">Running</span>
|
||||
<span class="badge badge-success badge-xs">Running</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="font-mono font-semibold text-primary">{formatTimeRange(entry.startTime, entry.endTime)}</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">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-ghost btn-sm text-error hover:bg-error/10 transition-colors"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick="return confirm('Are you sure you want to delete this entry?')"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
<Icon name="heroicons:trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -363,20 +340,20 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
|
||||
<!-- Pagination -->
|
||||
{totalPages > 1 && (
|
||||
<div class="flex justify-center items-center gap-2 mt-6">
|
||||
<div class="flex justify-center items-center gap-1 mt-4">
|
||||
<a
|
||||
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm transition-all ${page === 1 ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
|
||||
class={`btn btn-xs ${page === 1 ? 'btn-disabled' : ''}`}
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
|
||||
Previous
|
||||
<Icon name="heroicons:chevron-left" class="w-3 h-3" />
|
||||
Prev
|
||||
</a>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-0.5">
|
||||
{paginationPages.map(pageNum => (
|
||||
<a
|
||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`}
|
||||
class={`btn btn-xs ${page === pageNum ? 'btn-active' : ''}`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
@@ -385,10 +362,10 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
|
||||
<a
|
||||
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
|
||||
class={`btn btn-xs ${page === totalPages ? 'btn-disabled' : ''}`}
|
||||
>
|
||||
Next
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||||
<Icon name="heroicons:chevron-right" class="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,48 +7,64 @@ if (Astro.locals.user) {
|
||||
---
|
||||
|
||||
<Layout title="Chronus - Time Tracking">
|
||||
<div class="hero flex-1 bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-4xl">
|
||||
<img src="/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
||||
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
|
||||
Chronus
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- Hero -->
|
||||
<div class="flex-1 flex items-center justify-center px-4 py-16 sm:py-24 bg-base-100">
|
||||
<div class="max-w-3xl text-center">
|
||||
<img src="/logo.webp" alt="Chronus Logo" class="h-20 w-20 mx-auto mb-8" />
|
||||
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-extrabold tracking-tight text-base-content mb-4">
|
||||
Track time,<br />
|
||||
<span class="text-primary">effortlessly.</span>
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto">
|
||||
<p class="text-lg sm:text-xl text-base-content/60 max-w-xl mx-auto mb-10 leading-relaxed">
|
||||
Modern time tracking designed for teams that value simplicity and precision.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center mt-8 flex-wrap">
|
||||
<a href="/signup" class="btn btn-primary btn-lg">
|
||||
<div class="flex gap-3 justify-center flex-wrap">
|
||||
<a href="/signup" class="btn btn-primary btn-lg px-8">
|
||||
Get Started
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/login" class="btn btn-outline btn-lg">Login</a>
|
||||
<a href="/login" class="btn btn-ghost btn-lg px-8">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature highlights -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16">
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body items-start">
|
||||
<div class="text-4xl mb-3">⚡</div>
|
||||
<h3 class="card-title text-lg">Lightning Fast</h3>
|
||||
<p class="text-sm text-base-content/70">Track tasks with a single click.</p>
|
||||
<!-- Features -->
|
||||
<div class="bg-base-200/50 border-t border-base-200 px-4 py-16 sm:py-20">
|
||||
<div class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card bg-base-100 card-border">
|
||||
<div class="card-body">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title text-base">Lightning Fast</h3>
|
||||
<p class="text-sm text-base-content/60">Track tasks with a single click. Start, stop, and organize in seconds.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body items-start">
|
||||
<div class="text-4xl mb-3">📊</div>
|
||||
<h3 class="card-title text-lg">Detailed Reports</h3>
|
||||
<p class="text-sm text-base-content/70">Get actionable insights into your team's tasks.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 card-border">
|
||||
<div class="card-body">
|
||||
<div class="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title text-base">Detailed Reports</h3>
|
||||
<p class="text-sm text-base-content/60">Get actionable insights with charts, filters, and CSV exports.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body items-start">
|
||||
<div class="text-4xl mb-3">👥</div>
|
||||
<h3 class="card-title text-lg">Team Collaboration</h3>
|
||||
<p class="text-sm text-base-content/70">Built for multiple team members.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 card-border">
|
||||
<div class="card-body">
|
||||
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title text-base">Team Collaboration</h3>
|
||||
<p class="text-sm text-base-content/60">Built for teams with roles, permissions, and shared workspaces.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,63 +18,57 @@ const errorMessage =
|
||||
|
||||
<Layout title="Login - Chronus">
|
||||
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||
<div class="card-body">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
||||
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
||||
<div class="card card-border bg-base-100 w-full max-w-sm mx-4">
|
||||
<div class="card-body gap-0">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
|
||||
<h2 class="text-2xl font-extrabold tracking-tight text-center">Welcome Back</h2>
|
||||
<p class="text-center text-base-content/60 text-sm mt-1 mb-5">Sign in to continue to Chronus</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div role="alert" class="alert alert-error mb-4">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||
<div role="alert" class="alert alert-error mb-4 text-sm">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action="/api/auth/login" method="POST" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="email">
|
||||
Email
|
||||
</label>
|
||||
<form action="/api/auth/login" method="POST" class="space-y-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="password">
|
||||
Password
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Password</legend>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button class="btn btn-primary w-full mt-6">Sign In</button>
|
||||
<button class="btn btn-primary w-full my-4">Sign In</button>
|
||||
</form>
|
||||
|
||||
<PasskeyLogin client:idle />
|
||||
|
||||
<div class="divider">OR</div>
|
||||
<div class="divider text-xs">OR</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="link link-primary font-semibold">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-center text-sm text-base-content/60">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="link link-primary font-semibold">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,92 +34,82 @@ const errorMessage =
|
||||
|
||||
<Layout title="Sign Up - Chronus">
|
||||
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||
<div class="card-body">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
||||
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
||||
<div class="card card-border bg-base-100 w-full max-w-sm mx-4">
|
||||
<div class="card-body gap-0">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
|
||||
<h2 class="text-2xl font-extrabold tracking-tight text-center">Create Account</h2>
|
||||
<p class="text-center text-base-content/60 text-sm mt-1 mb-5">Join Chronus to start tracking time</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div role="alert" class="alert alert-error mb-4">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||
<div role="alert" class="alert alert-error mb-4 text-sm">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-5 h-5" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationDisabled ? (
|
||||
<>
|
||||
<div class="alert alert-warning">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
|
||||
<div class="alert alert-warning text-sm">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-5 h-5" />
|
||||
<span>Registration is currently disabled by the site administrator.</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider text-xs"></div>
|
||||
<p class="text-center text-sm text-base-content/60">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form action="/api/auth/signup" method="POST" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="name">
|
||||
Full Name
|
||||
</label>
|
||||
<form action="/api/auth/signup" method="POST" class="space-y-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Full Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="John Doe"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="email">
|
||||
Email
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="password">
|
||||
Password
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Password</legend>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Create a strong password"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button class="btn btn-primary w-full mt-6">Create Account</button>
|
||||
<button class="btn btn-primary w-full mt-4">Create Account</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">OR</div>
|
||||
<div class="divider text-xs">OR</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-center text-sm text-base-content/60">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,10 +52,8 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
case ".gif":
|
||||
contentType = "image/gif";
|
||||
break;
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml";
|
||||
break;
|
||||
// WebP is intentionally omitted as it is not supported in PDF generation
|
||||
// SVG excluded to prevent stored XSS
|
||||
// WebP omitted — not supported in PDF generation
|
||||
}
|
||||
|
||||
return new Response(fileContent, {
|
||||
|
||||
@@ -4,3 +4,14 @@
|
||||
}
|
||||
@plugin "./theme-dark.ts";
|
||||
@plugin "./theme-light.ts";
|
||||
|
||||
/* Smoother transitions globally */
|
||||
@layer base {
|
||||
* {
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
/* Opt out for elements where color transitions are unwanted */
|
||||
input, select, textarea, progress, .loading, .countdown, svg {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user