Fixed a number of issues
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -23,16 +28,19 @@ function formatTime(ms: number) {
|
|||||||
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`;
|
||||||
}
|
}
|
||||||
@@ -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,18 +77,18 @@ 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,
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -133,7 +141,11 @@ async function stopTimer() {
|
|||||||
: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>
|
||||||
@@ -149,7 +161,11 @@ async function stopTimer() {
|
|||||||
: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>
|
||||||
@@ -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,7 +210,9 @@ 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
|
||||||
@@ -204,11 +222,7 @@ async function stopTimer() {
|
|||||||
>
|
>
|
||||||
▶️ 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>
|
||||||
|
|||||||
@@ -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,7 +54,7 @@ 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>
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<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>
|
||||||
@@ -143,7 +143,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<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>
|
||||||
|
|||||||
71
src/pages/api/clients/[id]/delete.ts
Normal file
71
src/pages/api/clients/[id]/delete.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
68
src/pages/api/clients/[id]/update.ts
Normal file
68
src/pages/api/clients/[id]/update.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
119
src/pages/dashboard/clients/[id]/edit.astro
Normal file
119
src/pages/dashboard/clients/[id]/edit.astro
Normal 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>
|
||||||
203
src/pages/dashboard/clients/[id]/index.astro
Normal file
203
src/pages/dashboard/clients/[id]/index.astro
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -126,9 +130,9 @@ 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'}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -33,21 +33,21 @@ if (Astro.locals.user) {
|
|||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user