Fixed a number of issues

This commit is contained in:
2026-01-01 00:51:00 -07:00
parent 4616645939
commit 756ab2a38f
14 changed files with 648 additions and 141 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "1.0.0", "version": "1.1.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",

View File

@@ -1,8 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from "vue";
const props = defineProps<{ const props = defineProps<{
initialRunningEntry: { startTime: number; description: string | null; clientId: string; categoryId: string } | null; initialRunningEntry: {
startTime: number;
description: string | null;
clientId: string;
categoryId: string;
} | null;
clients: { id: string; name: string }[]; clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[]; categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[]; tags: { id: string; name: string; color: string | null }[];
@@ -11,9 +16,9 @@ const props = defineProps<{
const isRunning = ref(false); const isRunning = ref(false);
const startTime = ref<number | null>(null); const startTime = ref<number | null>(null);
const elapsedTime = ref(0); const elapsedTime = ref(0);
const description = ref(''); const description = ref("");
const selectedClientId = ref(''); const selectedClientId = ref("");
const selectedCategoryId = ref(''); const selectedCategoryId = ref("");
const selectedTags = ref<string[]>([]); const selectedTags = ref<string[]>([]);
let interval: ReturnType<typeof setInterval> | null = null; let interval: ReturnType<typeof setInterval> | null = null;
@@ -22,21 +27,24 @@ function formatTime(ms: number) {
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
// Calculate rounded version // Calculate rounded version
const totalMinutes = Math.round(ms / 1000 / 60); const totalMinutes = Math.round(ms / 1000 / 60);
const roundedHours = Math.floor(totalMinutes / 60); const roundedHours = Math.floor(totalMinutes / 60);
const roundedMinutes = totalMinutes % 60; const roundedMinutes = totalMinutes % 60;
let roundedStr = ''; let roundedStr = "";
if (roundedHours > 0) { if (roundedHours > 0) {
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`; roundedStr =
roundedMinutes > 0
? `${roundedHours}h ${roundedMinutes}m`
: `${roundedHours}h`;
} else { } else {
roundedStr = `${roundedMinutes}m`; roundedStr = `${roundedMinutes}m`;
} }
return `${timeStr} (${roundedStr})`; return `${timeStr} (${roundedStr})`;
} }
@@ -53,7 +61,7 @@ onMounted(() => {
if (props.initialRunningEntry) { if (props.initialRunningEntry) {
isRunning.value = true; isRunning.value = true;
startTime.value = props.initialRunningEntry.startTime; startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || ''; description.value = props.initialRunningEntry.description || "";
selectedClientId.value = props.initialRunningEntry.clientId; selectedClientId.value = props.initialRunningEntry.clientId;
selectedCategoryId.value = props.initialRunningEntry.categoryId; selectedCategoryId.value = props.initialRunningEntry.categoryId;
elapsedTime.value = Date.now() - startTime.value; elapsedTime.value = Date.now() - startTime.value;
@@ -69,26 +77,26 @@ onUnmounted(() => {
async function startTimer() { async function startTimer() {
if (!selectedClientId.value) { if (!selectedClientId.value) {
alert('Please select a client'); alert("Please select a client");
return; return;
} }
if (!selectedCategoryId.value) { if (!selectedCategoryId.value) {
alert('Please select a category'); alert("Please select a category");
return; return;
} }
const res = await fetch('/api/time-entries/start', { const res = await fetch("/api/time-entries/start", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
description: description.value, description: description.value,
clientId: selectedClientId.value, clientId: selectedClientId.value,
categoryId: selectedCategoryId.value, categoryId: selectedCategoryId.value,
tags: selectedTags.value, tags: selectedTags.value,
}), }),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
startTime.value = new Date(data.startTime).getTime(); startTime.value = new Date(data.startTime).getTime();
@@ -100,8 +108,8 @@ async function startTimer() {
} }
async function stopTimer() { async function stopTimer() {
const res = await fetch('/api/time-entries/stop', { const res = await fetch("/api/time-entries/stop", {
method: 'POST', method: "POST",
}); });
if (res.ok) { if (res.ok) {
@@ -109,9 +117,9 @@ async function stopTimer() {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
elapsedTime.value = 0; elapsedTime.value = 0;
startTime.value = null; startTime.value = null;
description.value = ''; description.value = "";
selectedClientId.value = ''; selectedClientId.value = "";
selectedCategoryId.value = ''; selectedCategoryId.value = "";
selectedTags.value = []; selectedTags.value = [];
window.location.reload(); window.location.reload();
} }
@@ -127,13 +135,17 @@ async function stopTimer() {
<label class="label pb-2"> <label class="label pb-2">
<span class="label-text font-medium">Client</span> <span class="label-text font-medium">Client</span>
</label> </label>
<select <select
v-model="selectedClientId" v-model="selectedClientId"
class="select select-bordered w-full" class="select select-bordered w-full"
:disabled="isRunning" :disabled="isRunning"
> >
<option value="">Select a client...</option> <option value="">Select a client...</option>
<option v-for="client in clients" :key="client.id" :value="client.id"> <option
v-for="client in clients"
:key="client.id"
:value="client.id"
>
{{ client.name }} {{ client.name }}
</option> </option>
</select> </select>
@@ -143,13 +155,17 @@ async function stopTimer() {
<label class="label pb-2"> <label class="label pb-2">
<span class="label-text font-medium">Category</span> <span class="label-text font-medium">Category</span>
</label> </label>
<select <select
v-model="selectedCategoryId" v-model="selectedCategoryId"
class="select select-bordered w-full" class="select select-bordered w-full"
:disabled="isRunning" :disabled="isRunning"
> >
<option value="">Select a category...</option> <option value="">Select a category...</option>
<option v-for="category in categories" :key="category.id" :value="category.id"> <option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }} {{ category.name }}
</option> </option>
</select> </select>
@@ -161,11 +177,11 @@ async function stopTimer() {
<label class="label pb-2"> <label class="label pb-2">
<span class="label-text font-medium">Description</span> <span class="label-text font-medium">Description</span>
</label> </label>
<input <input
v-model="description" v-model="description"
type="text" type="text"
placeholder="What are you working on?" placeholder="What are you working on?"
class="input input-bordered w-full" class="input input-bordered w-full"
:disabled="isRunning" :disabled="isRunning"
/> />
</div> </div>
@@ -182,7 +198,7 @@ async function stopTimer() {
@click="toggleTag(tag.id)" @click="toggleTag(tag.id)"
:class="[ :class="[
'badge badge-lg cursor-pointer transition-all', 'badge badge-lg cursor-pointer transition-all',
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline' selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline',
]" ]"
:disabled="isRunning" :disabled="isRunning"
type="button" type="button"
@@ -194,21 +210,19 @@ async function stopTimer() {
<!-- Timer and Action Row --> <!-- Timer and Action Row -->
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4"> <div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
<div class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left flex-grow"> <div
class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left grow"
>
{{ formatTime(elapsedTime) }} {{ formatTime(elapsedTime) }}
</div> </div>
<button <button
v-if="!isRunning" v-if="!isRunning"
@click="startTimer" @click="startTimer"
class="btn btn-primary btn-lg min-w-40" class="btn btn-primary btn-lg min-w-40"
> >
Start Timer Start Timer
</button> </button>
<button <button v-else @click="stopTimer" class="btn btn-error btn-lg min-w-40">
v-else
@click="stopTimer"
class="btn btn-error btn-lg min-w-40"
>
Stop Timer Stop Timer
</button> </button>
</div> </div>

View File

@@ -41,7 +41,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body class="bg-gradient-to-br from-base-100 via-base-200 to-base-100"> <body class="bg-linear-to-br from-base-100 via-base-200 to-base-100">
<div class="drawer lg:drawer-open"> <div class="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" /> <input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col"> <div class="drawer-content flex flex-col">
@@ -54,38 +54,38 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</div> </div>
<div class="flex-1 px-2 flex items-center gap-2"> <div class="flex-1 px-2 flex items-center gap-2">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" /> <img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
<span class="text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Chronus</span> <span class="text-xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">Chronus</span>
</div> </div>
</div> </div>
<!-- Page content here --> <!-- Page content here -->
<main class="p-6 md:p-8"> <main class="p-6 md:p-8">
<slot /> <slot />
</main> </main>
</div> </div>
<div class="drawer-side z-50"> <div class="drawer-side z-50">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label> <label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 min-h-full w-80 p-4"> <ul class="menu bg-base-200 min-h-full w-80 p-4">
<!-- Sidebar content here --> <!-- Sidebar content here -->
<li class="mb-6"> <li class="mb-6">
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent pointer-events-none"> <a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent pointer-events-none">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" /> <img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
Chronus Chronus
</a> </a>
</li> </li>
{/* Team Switcher */} {/* Team Switcher */}
{userMemberships.length > 0 && ( {userMemberships.length > 0 && (
<li class="mb-4"> <li class="mb-4">
<div class="form-control"> <div class="form-control">
<select <select
class="select select-bordered w-full font-semibold" class="select select-bordered w-full font-semibold"
id="team-switcher" id="team-switcher"
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();" onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
> >
{userMemberships.map(({ membership, organization }) => ( {userMemberships.map(({ membership, organization }) => (
<option <option
value={organization.id} value={organization.id}
selected={organization.id === currentTeamId} selected={organization.id === currentTeamId}
> >
{organization.name} {organization.name}
@@ -95,7 +95,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</div> </div>
</li> </li>
)} )}
{userMemberships.length === 0 && ( {userMemberships.length === 0 && (
<li class="mb-4"> <li class="mb-4">
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm"> <a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
@@ -104,9 +104,9 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</a> </a>
</li> </li>
)} )}
<div class="divider my-2"></div> <div class="divider my-2"></div>
<li><a href="/dashboard"> <li><a href="/dashboard">
<Icon name="heroicons:home" class="w-5 h-5" /> <Icon name="heroicons:home" class="w-5 h-5" />
Dashboard Dashboard
@@ -127,7 +127,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<Icon name="heroicons:user-group" class="w-5 h-5" /> <Icon name="heroicons:user-group" class="w-5 h-5" />
Team Team
</a></li> </a></li>
{user.isSiteAdmin && ( {user.isSiteAdmin && (
<> <>
<div class="divider"></div> <div class="divider"></div>
@@ -137,13 +137,13 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</a></li> </a></li>
</> </>
)} )}
<div class="divider"></div> <div class="divider"></div>
<li> <li>
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3"> <a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3">
<div class="avatar placeholder"> <div class="avatar placeholder">
<div class="bg-gradient-to-br from-primary via-secondary to-accent text-primary-content rounded-full w-10 ring ring-primary ring-offset-base-100 ring-offset-2"> <div class="bg-linear-to-br from-primary via-secondary to-accent text-primary-content rounded-full w-10 ring ring-primary ring-offset-base-100 ring-offset-2">
<span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span> <span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span>
</div> </div>
</div> </div>
@@ -154,7 +154,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-50" /> <Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-50" />
</a> </a>
</li> </li>
<li> <li>
<form action="/api/auth/logout" method="POST"> <form action="/api/auth/logout" method="POST">
<button type="submit" class="w-full text-error hover:bg-error/10"> <button type="submit" class="w-full text-error hover:bg-error/10">
@@ -164,7 +164,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</form> </form>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,71 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { clients, members, timeEntries, timeEntryTags } from '../../../../db/schema';
import { eq, and, inArray } from 'drizzle-orm';
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return redirect('/login');
}
const { id } = params;
if (!id) {
return new Response('Client ID is required', { status: 400 });
}
try {
// Get the client to check organization ownership
const client = await db.select()
.from(clients)
.where(eq(clients.id, id))
.get();
if (!client) {
return new Response('Client not found', { status: 404 });
}
// Verify user is a member of the organization
const membership = await db.select()
.from(members)
.where(and(
eq(members.userId, user.id),
eq(members.organizationId, client.organizationId)
))
.get();
if (!membership) {
return new Response('Not authorized', { status: 403 });
}
// Find all time entries for this client to clean up tags
const clientEntries = await db.select({ id: timeEntries.id })
.from(timeEntries)
.where(eq(timeEntries.clientId, id))
.all();
const entryIds = clientEntries.map(e => e.id);
if (entryIds.length > 0) {
// Delete tags associated with these entries
await db.delete(timeEntryTags)
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.run();
// Delete the time entries
await db.delete(timeEntries)
.where(eq(timeEntries.clientId, id))
.run();
}
// Delete the client
await db.delete(clients)
.where(eq(clients.id, id))
.run();
return redirect('/dashboard/clients');
} catch (error) {
console.error('Error deleting client:', error);
return new Response('Failed to delete client', { status: 500 });
}
};

View File

@@ -0,0 +1,68 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { clients, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id } = params;
if (!id) {
return new Response("Client ID is required", { status: 400 });
}
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || name.trim().length === 0) {
return new Response("Client name is required", { status: 400 });
}
try {
// Get the client to check organization ownership
const client = await db
.select()
.from(clients)
.where(eq(clients.id, id))
.get();
if (!client) {
return new Response("Client not found", { status: 404 });
}
// Verify user is a member of the organization
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, client.organizationId),
),
)
.get();
if (!membership) {
return new Response("Not authorized", { status: 403 });
}
// Update client
await db
.update(clients)
.set({
name: name.trim(),
email: email?.trim() || null,
})
.where(eq(clients.id, id))
.run();
return redirect(`/dashboard/clients/${id}`);
} catch (error) {
console.error("Error updating client:", error);
return new Response("Failed to update client", { status: 500 });
}
};

View File

@@ -1,47 +1,59 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from "astro";
import { db } from '../../../db'; import { db } from "../../../db";
import { organizations, members } from '../../../db/schema'; import { organizations, members } from "../../../db/schema";
import { eq } from 'drizzle-orm'; import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
if (!user) { if (!user) {
return redirect('/login'); return redirect("/login");
} }
const formData = await request.formData(); const formData = await request.formData();
const organizationId = formData.get('organizationId') as string; const organizationId = formData.get("organizationId") as string;
const name = formData.get('name') as string; const name = formData.get("name") as string;
if (!organizationId || !name || name.trim().length === 0) { if (!organizationId || !name || name.trim().length === 0) {
return new Response('Organization ID and name are required', { status: 400 }); return new Response("Organization ID and name are required", {
status: 400,
});
} }
try { try {
// Verify user is admin/owner of this organization // Verify user is admin/owner of this organization
const membership = await db.select() const membership = await db
.select()
.from(members) .from(members)
.where(eq(members.userId, user.id)) .where(
and(
eq(members.userId, user.id),
eq(members.organizationId, organizationId),
),
)
.get(); .get();
if (!membership || membership.organizationId !== organizationId) { if (!membership) {
return new Response('Not authorized', { status: 403 }); return new Response("Not authorized", { status: 403 });
} }
const isAdmin = membership.role === 'owner' || membership.role === 'admin'; const isAdmin = membership.role === "owner" || membership.role === "admin";
if (!isAdmin) { if (!isAdmin) {
return new Response('Only owners and admins can update organization settings', { status: 403 }); return new Response(
"Only owners and admins can update organization settings",
{ status: 403 },
);
} }
// Update organization name // Update organization name
await db.update(organizations) await db
.update(organizations)
.set({ name: name.trim() }) .set({ name: name.trim() })
.where(eq(organizations.id, organizationId)) .where(eq(organizations.id, organizationId))
.run(); .run();
return redirect('/dashboard/team/settings?success=org-name'); return redirect("/dashboard/team/settings?success=org-name");
} catch (error) { } catch (error) {
console.error('Error updating organization name:', error); console.error("Error updating organization name:", error);
return new Response('Failed to update organization name', { status: 500 }); return new Response("Failed to update organization name", { status: 500 });
} }
}; };

View File

@@ -0,0 +1,119 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { clients, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
const user = Astro.locals.user;
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 client = await db.select()
.from(clients)
.where(and(
eq(clients.id, id),
eq(clients.organizationId, userMembership.organizationId)
))
.get();
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>
<h1 class="text-3xl font-bold">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">
<span class="label-text">Client Name</span>
</label>
<input
type="text"
id="name"
name="name"
value={client.name}
placeholder="Acme Corp"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
</label>
<input
type="email"
id="email"
name="email"
value={client.email || ''}
placeholder="contact@acme.com"
class="input input-bordered"
/>
</div>
<div class="card-actions justify-between mt-6">
<button
type="button"
class="btn btn-error btn-outline"
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>
</div>
</div>
</div>
</form>
</div>
<!-- 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">
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>
</form>
<form method="POST" action={`/api/clients/${client.id}/delete`}>
<button type="submit" class="btn btn-error">Delete</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</DashboardLayout>

View File

@@ -0,0 +1,203 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime';
const user = Astro.locals.user;
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 client = await db.select()
.from(clients)
.where(and(
eq(clients.id, id),
eq(clients.organizationId, userMembership.organizationId)
))
.get();
if (!client) return Astro.redirect('/dashboard/clients');
// Get recent activity
const recentEntries = await db.select({
entry: timeEntries,
category: categories,
user: users,
})
.from(timeEntries)
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id))
.where(eq(timeEntries.clientId, client.id))
.orderBy(desc(timeEntries.startTime))
.limit(10)
.all();
// Calculate total time tracked
const totalTimeResult = await db.select({
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
})
.from(timeEntries)
.where(eq(timeEntries.clientId, client.id))
.get();
const totalDurationMs = totalTimeResult?.totalDuration || 0;
const totalHours = Math.floor(totalDurationMs / (1000 * 60 * 60));
const totalMinutes = Math.floor((totalDurationMs % (1000 * 60 * 60)) / (1000 * 60));
// Get total entries count
const totalEntriesResult = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries)
.where(eq(timeEntries.clientId, client.id))
.get();
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>
<h1 class="text-3xl font-bold">{client.name}</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- 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="flex justify-between items-start">
<div>
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
{client.email && (
<div class="flex items-center gap-2 text-base-content/70 mb-4">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</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" />
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" />
Delete
</button>
</form>
</div>
</div>
<div class="divider"></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>
</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="space-y-4">
<div>
<div class="text-sm font-medium text-base-content/60">Created</div>
<div>{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>
{recentEntries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Description</th>
<th>Category</th>
<th>User</th>
<th>Date</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{recentEntries.map(({ entry, category, user: entryUser }) => (
<tr>
<td>{entry.description || '-'}</td>
<td>
{category ? (
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
</div>
) : '-'}
</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td>{entry.startTime.toLocaleDateString()}</td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div class="text-center py-8 text-base-content/60">
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">
View All Entries
</a>
</div>
)}
</div>
</div>
</DashboardLayout>

View File

@@ -24,7 +24,7 @@ const userOrgs = await db.select({
.all(); .all();
// Use current team or fallback to first // Use current team or fallback to first
const currentOrg = currentTeamId const currentOrg = currentTeamId
? userOrgs.find(o => o.organizationId === currentTeamId) || userOrgs[0] ? userOrgs.find(o => o.organizationId === currentTeamId) || userOrgs[0]
: userOrgs[0]; : userOrgs[0];
@@ -78,14 +78,14 @@ if (currentOrg) {
isNull(timeEntries.endTime) isNull(timeEntries.endTime)
)) ))
.all(); .all();
stats.activeTimers = activeCount.length; stats.activeTimers = activeCount.length;
const clientCount = await db.select() const clientCount = await db.select()
.from(clients) .from(clients)
.where(eq(clients.organizationId, currentOrg.organizationId)) .where(eq(clients.organizationId, currentOrg.organizationId))
.all(); .all();
stats.totalClients = clientCount.length; stats.totalClients = clientCount.length;
stats.recentEntries = await db.select({ stats.recentEntries = await db.select({
@@ -109,7 +109,7 @@ const hasMembership = userOrgs.length > 0;
<DashboardLayout title="Dashboard - Chronus"> <DashboardLayout title="Dashboard - Chronus">
<div class="flex justify-between items-center mb-8"> <div class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-4xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent mb-2"> <h1 class="text-4xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent mb-2">
Dashboard Dashboard
</h1> </h1>
<p class="text-base-content/60">Welcome back, {user.name}!</p> <p class="text-base-content/60">Welcome back, {user.name}!</p>
@@ -174,7 +174,7 @@ const hasMembership = userOrgs.length > 0;
<div class="stat-desc">Total active</div> <div class="stat-desc">Total active</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">

View File

@@ -19,7 +19,7 @@ const userMemberships = await db.select()
if (userMemberships.length === 0) return Astro.redirect('/dashboard'); if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership // Use current team or fallback to first membership
const userMembership = currentTeamId const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0]; : userMemberships[0];
@@ -101,7 +101,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
<label tabindex="0" class="btn btn-ghost btn-sm"> <label tabindex="0" class="btn btn-ghost btn-sm">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" /> <Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
</label> </label>
<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 shadow bg-base-100 rounded-box w-52 border border-base-200">
<li> <li>
<form method="POST" action={`/api/team/change-role`}> <form method="POST" action={`/api/team/change-role`}>
<input type="hidden" name="userId" value={teamUser.id} /> <input type="hidden" name="userId" value={teamUser.id} />

View File

@@ -8,12 +8,20 @@ import { eq } from 'drizzle-orm';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
const userMembership = await db.select() // Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members) .from(members)
.where(eq(members.userId, user.id)) .where(eq(members.userId, user.id))
.get(); .all();
if (!userMembership) return Astro.redirect('/dashboard'); 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 isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team'); if (!isAdmin) return Astro.redirect('/dashboard/team');
@@ -22,7 +30,7 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
<DashboardLayout title="Invite Team Member - Chronus"> <DashboardLayout title="Invite Team Member - Chronus">
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Invite Team Member</h1> <h1 class="text-3xl font-bold mb-6">Invite Team Member</h1>
<form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200"> <form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body"> <div class="card-body">
<div class="alert alert-info mb-4"> <div class="alert alert-info mb-4">
@@ -34,13 +42,13 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
<label class="label" for="email"> <label class="label" for="email">
<span class="label-text">Email Address</span> <span class="label-text">Email Address</span>
</label> </label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
placeholder="user@example.com" placeholder="user@example.com"
class="input input-bordered" class="input input-bordered"
required required
/> />
</div> </div>

View File

@@ -11,18 +11,22 @@ if (!user) return Astro.redirect('/login');
// Get current team from cookie // Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMembership = await db.select() const userMemberships = await db.select()
.from(members) .from(members)
.where(eq(members.userId, user.id)) .where(eq(members.userId, user.id))
.get(); .all();
if (!userMembership) return Astro.redirect('/dashboard'); 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 isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team'); if (!isAdmin) return Astro.redirect('/dashboard/team');
// Use current team or fallback to membership org const orgId = userMembership.organizationId;
const orgId = currentTeamId || userMembership.organizationId;
const organization = await db.select() const organization = await db.select()
.from(organizations) .from(organizations)
@@ -55,34 +59,34 @@ const successType = url.searchParams.get('success');
<Icon name="heroicons:building-office-2" class="w-6 h-6" /> <Icon name="heroicons:building-office-2" class="w-6 h-6" />
Team Settings Team Settings
</h2> </h2>
{successType === 'org-name' && ( {successType === 'org-name' && (
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
<Icon name="heroicons:check-circle" class="w-6 h-6" /> <Icon name="heroicons:check-circle" class="w-6 h-6" />
<span>Team name updated successfully!</span> <span>Team name updated successfully!</span>
</div> </div>
)} )}
<form action="/api/organizations/update-name" method="POST" class="space-y-4"> <form action="/api/organizations/update-name" method="POST" class="space-y-4">
<input type="hidden" name="organizationId" value={organization.id} /> <input type="hidden" name="organizationId" value={organization.id} />
<label class="form-control"> <label class="form-control">
<div class="label"> <div class="label">
<span class="label-text font-medium">Team Name</span> <span class="label-text font-medium">Team Name</span>
</div> </div>
<input <input
type="text" type="text"
name="name" name="name"
value={organization.name} value={organization.name}
placeholder="Organization name" placeholder="Organization name"
class="input input-bordered w-full" class="input input-bordered w-full"
required required
/> />
<div class="label"> <div class="label">
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span> <span class="label-text-alt text-base-content/60">This name is visible to all team members</span>
</div> </div>
</label> </label>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<Icon name="heroicons:check" class="w-5 h-5" /> <Icon name="heroicons:check" class="w-5 h-5" />
@@ -106,7 +110,7 @@ const successType = url.searchParams.get('success');
Add Category Add Category
</a> </a>
</div> </div>
<p class="text-base-content/70 mb-4"> <p class="text-base-content/70 mb-4">
Categories help organize time tracking by type of work. All team members use the same categories. Categories help organize time tracking by type of work. All team members use the same categories.
</p> </p>
@@ -126,16 +130,16 @@ const successType = url.searchParams.get('success');
<div class="card-body p-4"> <div class="card-body p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{category.color && ( {category.color && (
<span class="w-4 h-4 rounded-full flex-shrink-0" style={`background-color: ${category.color}`}></span> <span class="w-4 h-4 rounded-full shrink-0" style={`background-color: ${category.color}`}></span>
)} )}
<div class="flex-grow min-w-0"> <div class="grow min-w-0">
<h3 class="font-semibold truncate">{category.name}</h3> <h3 class="font-semibold truncate">{category.name}</h3>
<p class="text-xs text-base-content/60"> <p class="text-xs text-base-content/60">
Created {category.createdAt?.toLocaleDateString() ?? 'N/A'} Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}
</p> </p>
</div> </div>
<a <a
href={`/dashboard/team/settings/categories/${category.id}/edit`} href={`/dashboard/team/settings/categories/${category.id}/edit`}
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
> >
<Icon name="heroicons:pencil" class="w-4 h-4" /> <Icon name="heroicons:pencil" class="w-4 h-4" />

View File

@@ -10,12 +10,20 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params; const { id } = Astro.params;
const userMembership = await db.select() // Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members) .from(members)
.where(eq(members.userId, user.id)) .where(eq(members.userId, user.id))
.get(); .all();
if (!userMembership) return Astro.redirect('/dashboard'); 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 isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team/settings'); if (!isAdmin) return Astro.redirect('/dashboard/team/settings');
@@ -39,21 +47,21 @@ if (!category) return Astro.redirect('/dashboard/team/settings');
</a> </a>
<h1 class="text-3xl font-bold">Edit Category</h1> <h1 class="text-3xl font-bold">Edit Category</h1>
</div> </div>
<form method="POST" action={`/api/categories/${id}/update`} class="card bg-base-200 shadow-xl border border-base-300"> <form method="POST" action={`/api/categories/${id}/update`} class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body"> <div class="card-body">
<div class="form-control"> <div class="form-control">
<label class="label pb-2" for="name"> <label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span> <span class="label-text font-medium">Category Name</span>
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
value={category.name} value={category.name}
placeholder="Development" placeholder="Development"
class="input input-bordered w-full" class="input input-bordered w-full"
required required
/> />
</div> </div>
@@ -61,12 +69,12 @@ if (!category) return Astro.redirect('/dashboard/team/settings');
<label class="label pb-2" for="color"> <label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span> <span class="label-text font-medium">Color (optional)</span>
</label> </label>
<input <input
type="color" type="color"
id="color" id="color"
name="color" name="color"
value={category.color || '#3b82f6'} value={category.color || '#3b82f6'}
class="input input-bordered w-full h-12" class="input input-bordered w-full h-12"
/> />
</div> </div>

View File

@@ -7,11 +7,11 @@ if (Astro.locals.user) {
--- ---
<Layout title="Chronus - Time Tracking"> <Layout title="Chronus - Time Tracking">
<div class="hero min-h-screen bg-gradient-to-br from-base-100 via-base-200 to-base-300"> <div class="hero min-h-screen bg-linear-to-br from-base-100 via-base-200 to-base-300">
<div class="hero-content text-center"> <div class="hero-content text-center">
<div class="max-w-4xl"> <div class="max-w-4xl">
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" /> <img src="/src/assets/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 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"> <h1 class="text-6xl md:text-7xl font-bold mb-6 bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">
Chronus Chronus
</h1> </h1>
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto"> <p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto">
@@ -26,28 +26,28 @@ if (Astro.locals.user) {
</a> </a>
<a href="/login" class="btn btn-outline btn-lg">Login</a> <a href="/login" class="btn btn-outline btn-lg">Login</a>
</div> </div>
<!-- Feature highlights --> <!-- Feature highlights -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16"> <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 bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body items-start"> <div class="card-body items-start">
<div class="text-4xl mb-3">⚡</div> <div class="text-4xl mb-3">⚡</div>
<h3 class="card-title text-lg">Lightning Fast</h3> <h3 class="card-title text-lg">Lightning Fast</h3>
<p class="text-sm text-base-content/70">Track time with a single click. No complexity, just efficiency.</p> <p class="text-sm text-base-content/70">Track tasks with a single click.</p>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> <div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body items-start"> <div class="card-body items-start">
<div class="text-4xl mb-3">📊</div> <div class="text-4xl mb-3">📊</div>
<h3 class="card-title text-lg">Insightful Reports</h3> <h3 class="card-title text-lg">Detailed Reports</h3>
<p class="text-sm text-base-content/70">Understand where time goes with beautiful, actionable insights.</p> <p class="text-sm text-base-content/70">Get actionable insights into your team's tasks.</p>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> <div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body items-start"> <div class="card-body items-start">
<div class="text-4xl mb-3">👥</div> <div class="text-4xl mb-3">👥</div>
<h3 class="card-title text-lg">Team Collaboration</h3> <h3 class="card-title text-lg">Team Collaboration</h3>
<p class="text-sm text-base-content/70">Built for teams that need to track, manage, and analyze together.</p> <p class="text-sm text-base-content/70">Built for multiple team members.</p>
</div> </div>
</div> </div>
</div> </div>