Updated
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=4321
|
PORT=4321
|
||||||
DATABASE_URL=zamaan.db
|
DATABASE_URL=chronus.db
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ RUN pnpm install --prod
|
|||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
ENV DATABASE_URL=zamaan.db
|
ENV DATABASE_URL=chronus.db
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Zamaan
|
# Chronus
|
||||||
A modern time tracking application.
|
A modern time tracking application built with Astro, Vue, and DaisyUI.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
zamaan:
|
chronus:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "4321:4321"
|
- "4321:4321"
|
||||||
@@ -7,10 +7,10 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 4321
|
PORT: 4321
|
||||||
DATABASE_URL: /app/data/zamaan.db
|
DATABASE_URL: /app/data/chronus.db
|
||||||
volumes:
|
volumes:
|
||||||
- zamaan_data:/app/data
|
- chronus_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
zamaan_data:
|
chronus_data:
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
|||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL || 'zamaan.db',
|
url: process.env.DATABASE_URL || 'chronus.db',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
description = "Zamaan dev shell";
|
description = "Chronus dev shell";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "Zamaan dev shell"
|
echo "Chronus dev shell"
|
||||||
echo "Node version: $(node --version)"
|
echo "Node version: $(node --version)"
|
||||||
echo "pnpm version: $(pnpm --version)"
|
echo "pnpm version: $(pnpm --version)"
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "source",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Doughnut :data="chartData" :options="chartOptions" />
|
<div style="position: relative; height: 100%; width: 100%;">
|
||||||
|
<Doughnut :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<div style="position: relative; height: 100%; width: 100%;">
|
||||||
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<div style="position: relative; height: 100%; width: 100%;">
|
||||||
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -18,10 +18,26 @@ const selectedTags = ref<string[]>([]);
|
|||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function formatTime(ms: number) {
|
function formatTime(ms: number) {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Calculate rounded version
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const roundedHours = Math.floor(totalMinutes / 60);
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const roundedMinutes = totalMinutes % 60;
|
||||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
|
||||||
|
let roundedStr = '';
|
||||||
|
if (roundedHours > 0) {
|
||||||
|
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`;
|
||||||
|
} else {
|
||||||
|
roundedStr = `${roundedMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${timeStr} (${roundedStr})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTag(tagId: string) {
|
function toggleTag(tagId: string) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let _db: ReturnType<typeof drizzle> | null = null;
|
|||||||
|
|
||||||
function initDb() {
|
function initDb() {
|
||||||
if (!_db) {
|
if (!_db) {
|
||||||
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'zamaan.db');
|
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'chronus.db');
|
||||||
const sqlite = new Database(dbUrl, { readonly: false });
|
const sqlite = new Database(dbUrl, { readonly: false });
|
||||||
_db = drizzle(sqlite, { schema });
|
_db = drizzle(sqlite, { schema });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { members, organizations } from '../db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -12,69 +15,148 @@ const user = Astro.locals.user;
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return Astro.redirect('/login');
|
return Astro.redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get user's team memberships
|
||||||
|
const userMemberships = await db.select({
|
||||||
|
membership: members,
|
||||||
|
organization: organizations,
|
||||||
|
})
|
||||||
|
.from(members)
|
||||||
|
.innerJoin(organizations, eq(members.organizationId, organizations.id))
|
||||||
|
.where(eq(members.userId, user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get current team from cookie or use first membership
|
||||||
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
||||||
|
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="description" content="Zamaan Dashboard" />
|
<meta name="description" content="Chronus Dashboard" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-base-100 text-base-content">
|
<body class="bg-gradient-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">
|
||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<div class="w-full navbar bg-base-100 border-b border-base-200 lg:hidden">
|
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-300">
|
||||||
<div class="flex-none lg:hidden">
|
<div class="flex-none lg:hidden">
|
||||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
<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" />
|
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 px-2 mx-2">Zamaan</div>
|
<div class="flex-1 px-2">
|
||||||
|
<span class="text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Chronus</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page content here -->
|
<!-- Page content here -->
|
||||||
<main class="p-6">
|
<main class="p-6 md:p-8">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-side">
|
<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 p-4 w-80 min-h-full bg-base-300 text-base-content">
|
<ul class="menu bg-base-200 min-h-full w-80 p-4">
|
||||||
<!-- Sidebar content here -->
|
<!-- Sidebar content here -->
|
||||||
<li class="mb-4">
|
<li class="mb-6">
|
||||||
<a href="/dashboard" class="text-xl font-bold px-2">Zamaan</a>
|
<a href="/dashboard" class="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent pointer-events-none">Chronus</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/dashboard">Dashboard</a></li>
|
|
||||||
<li><a href="/dashboard/tracker">Time Tracker</a></li>
|
{/* Team Switcher */}
|
||||||
<li><a href="/dashboard/reports">Reports</a></li>
|
{userMemberships.length > 0 && (
|
||||||
<li><a href="/dashboard/clients">Clients</a></li>
|
<li class="mb-4">
|
||||||
<li><a href="/dashboard/team">Team</a></li>
|
<div class="form-control">
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full font-semibold"
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userMemberships.length === 0 && (
|
||||||
|
<li class="mb-4">
|
||||||
|
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||||
|
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||||
|
Create Team
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
|
<li><a href="/dashboard">
|
||||||
|
<Icon name="heroicons:home" class="w-5 h-5" />
|
||||||
|
Dashboard
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/dashboard/tracker">
|
||||||
|
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||||
|
Time Tracker
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/dashboard/reports">
|
||||||
|
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||||
|
Reports
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/dashboard/clients">
|
||||||
|
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
||||||
|
Clients
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/dashboard/team">
|
||||||
|
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
||||||
|
Team
|
||||||
|
</a></li>
|
||||||
|
|
||||||
{user.isSiteAdmin && (
|
{user.isSiteAdmin && (
|
||||||
<>
|
<>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<li><a href="/admin" class="font-semibold">⚙️ Site Admin</a></li>
|
<li><a href="/admin" class="font-semibold">
|
||||||
|
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||||
|
Site Admin
|
||||||
|
</a></li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<div class="flex items-center gap-2">
|
<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-neutral text-neutral-content rounded-full w-8">
|
<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">
|
||||||
<span class="text-xs">{user.name.charAt(0)}</span>
|
<span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span>{user.name}</span>
|
<div class="flex-1 min-w-0">
|
||||||
</div>
|
<div class="font-semibold text-sm truncate">{user.name}</div>
|
||||||
|
<div class="text-xs text-base-content/60 truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-50" />
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<form action="/api/auth/logout" method="POST">
|
<form action="/api/auth/logout" method="POST">
|
||||||
<button type="submit">Logout</button>
|
<button type="submit" class="w-full text-error hover:bg-error/10">
|
||||||
|
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const { title } = Astro.props;
|
|||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="description" content="Zamaan Time Tracking" />
|
<meta name="description" content="Chronus Time Tracking" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
|||||||
39
src/lib/formatTime.ts
Normal file
39
src/lib/formatTime.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Formats milliseconds into a readable time string with full precision and rounded version.
|
||||||
|
* @param ms - Time in milliseconds
|
||||||
|
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Calculate rounded version for easy reading
|
||||||
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
|
const roundedHours = Math.floor(totalMinutes / 60);
|
||||||
|
const roundedMinutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
let roundedStr = '';
|
||||||
|
if (roundedHours > 0) {
|
||||||
|
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`;
|
||||||
|
} else {
|
||||||
|
roundedStr = `${roundedMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${timeStr} (${roundedStr})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a time range between two dates.
|
||||||
|
* @param start - Start date
|
||||||
|
* @param end - End date (null if still running)
|
||||||
|
* @returns Formatted duration string or "Running..."
|
||||||
|
*/
|
||||||
|
export function formatTimeRange(start: Date, end: Date | null): string {
|
||||||
|
if (!end) return 'Running...';
|
||||||
|
const ms = end.getTime() - start.getTime();
|
||||||
|
return formatDuration(ms);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const registrationEnabled = registrationSetting?.value === 'true';
|
|||||||
const allUsers = await db.select().from(users).all();
|
const allUsers = await db.select().from(users).all();
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Site Admin - Zamaan">
|
<DashboardLayout title="Site Admin - Chronus">
|
||||||
<h1 class="text-3xl font-bold mb-6">Site Administration</h1>
|
<h1 class="text-3xl font-bold mb-6">Site Administration</h1>
|
||||||
|
|
||||||
<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-6 mb-6">
|
||||||
|
|||||||
47
src/pages/api/organizations/update-name.ts
Normal file
47
src/pages/api/organizations/update-name.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { db } from '../../../db';
|
||||||
|
import { organizations, members } from '../../../db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const organizationId = formData.get('organizationId') as string;
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
|
||||||
|
if (!organizationId || !name || name.trim().length === 0) {
|
||||||
|
return new Response('Organization ID and name are required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify user is admin/owner of this organization
|
||||||
|
const membership = await db.select()
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.userId, user.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership || membership.organizationId !== organizationId) {
|
||||||
|
return new Response('Not authorized', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = membership.role === 'owner' || membership.role === 'admin';
|
||||||
|
if (!isAdmin) {
|
||||||
|
return new Response('Only owners and admins can update organization settings', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update organization name
|
||||||
|
await db.update(organizations)
|
||||||
|
.set({ name: name.trim() })
|
||||||
|
.where(eq(organizations.id, organizationId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return redirect('/dashboard/team/settings?success=org-name');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating organization name:', error);
|
||||||
|
return new Response('Failed to update organization name', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
61
src/pages/api/user/change-password.ts
Normal file
61
src/pages/api/user/change-password.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { db } from '../../../db';
|
||||||
|
import { users } from '../../../db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const currentPassword = formData.get('currentPassword') as string;
|
||||||
|
const newPassword = formData.get('newPassword') as string;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
return new Response('All fields are required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return new Response('New passwords do not match', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return new Response('Password must be at least 8 characters', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current user from database
|
||||||
|
const dbUser = await db.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return new Response('User not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return new Response('Current password is incorrect', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await db.update(users)
|
||||||
|
.set({ passwordHash: hashedPassword })
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return redirect('/dashboard/settings?success=password');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error);
|
||||||
|
return new Response('Failed to change password', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
30
src/pages/api/user/update-profile.ts
Normal file
30
src/pages/api/user/update-profile.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { db } from '../../../db';
|
||||||
|
import { users } from '../../../db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return new Response('Name is required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update(users)
|
||||||
|
.set({ name: name.trim() })
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return redirect('/dashboard/settings?success=profile');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
return new Response('Failed to update profile', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,12 +7,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 allCategories = await db.select()
|
const allCategories = await db.select()
|
||||||
.from(categories)
|
.from(categories)
|
||||||
@@ -20,7 +28,7 @@ const allCategories = await db.select()
|
|||||||
.all();
|
.all();
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Categories - Zamaan">
|
<DashboardLayout title="Categories - Chronus">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-3xl font-bold">Categories</h1>
|
<h1 class="text-3xl font-bold">Categories</h1>
|
||||||
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>
|
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>
|
||||||
|
|||||||
@@ -7,22 +7,30 @@ import { eq, and } 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 userOrgs = 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))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const orgIds = userOrgs.map(m => m.organizationId);
|
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const allClients = orgIds.length > 0
|
// Use current team or fallback to first membership
|
||||||
? await db.select()
|
const userMembership = currentTeamId
|
||||||
.from(clients)
|
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||||
.where(eq(clients.organizationId, orgIds[0]))
|
: userMemberships[0];
|
||||||
.all()
|
|
||||||
: [];
|
const organizationId = userMembership.organizationId;
|
||||||
|
|
||||||
|
const allClients = await db.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.organizationId, organizationId))
|
||||||
|
.all();
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Clients - Zamaan">
|
<DashboardLayout title="Clients - Chronus">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-3xl font-bold">Clients</h1>
|
<h1 class="text-3xl font-bold">Clients</h1>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
|
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const user = Astro.locals.user;
|
|||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="New Client - Zamaan">
|
<DashboardLayout title="New Client - Chronus">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { Icon } from 'astro-icon/components';
|
|||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
|
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
|
||||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||||
|
import { formatDuration } from '../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
|
// Get current team from cookie or first membership
|
||||||
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||||
|
|
||||||
const userOrgs = await db.select({
|
const userOrgs = await db.select({
|
||||||
id: organizations.id,
|
id: organizations.id,
|
||||||
name: organizations.name,
|
name: organizations.name,
|
||||||
@@ -19,7 +23,11 @@ const userOrgs = await db.select({
|
|||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const firstOrg = userOrgs[0];
|
// Use current team or fallback to first
|
||||||
|
const currentOrg = currentTeamId
|
||||||
|
? userOrgs.find(o => o.organizationId === currentTeamId) || userOrgs[0]
|
||||||
|
: userOrgs[0];
|
||||||
|
|
||||||
let stats = {
|
let stats = {
|
||||||
totalTimeThisWeek: 0,
|
totalTimeThisWeek: 0,
|
||||||
totalTimeThisMonth: 0,
|
totalTimeThisMonth: 0,
|
||||||
@@ -28,7 +36,7 @@ let stats = {
|
|||||||
recentEntries: [] as any[],
|
recentEntries: [] as any[],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (firstOrg) {
|
if (currentOrg) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -36,7 +44,7 @@ if (firstOrg) {
|
|||||||
const weekEntries = await db.select()
|
const weekEntries = await db.select()
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, firstOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
gte(timeEntries.startTime, weekAgo)
|
gte(timeEntries.startTime, weekAgo)
|
||||||
))
|
))
|
||||||
.all();
|
.all();
|
||||||
@@ -51,7 +59,7 @@ if (firstOrg) {
|
|||||||
const monthEntries = await db.select()
|
const monthEntries = await db.select()
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, firstOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
gte(timeEntries.startTime, monthAgo)
|
gte(timeEntries.startTime, monthAgo)
|
||||||
))
|
))
|
||||||
.all();
|
.all();
|
||||||
@@ -66,7 +74,7 @@ if (firstOrg) {
|
|||||||
const activeCount = await db.select()
|
const activeCount = await db.select()
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, firstOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
isNull(timeEntries.endTime)
|
isNull(timeEntries.endTime)
|
||||||
))
|
))
|
||||||
.all();
|
.all();
|
||||||
@@ -75,7 +83,7 @@ if (firstOrg) {
|
|||||||
|
|
||||||
const clientCount = await db.select()
|
const clientCount = await db.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.organizationId, firstOrg.organizationId))
|
.where(eq(clients.organizationId, currentOrg.organizationId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
stats.totalClients = clientCount.length;
|
stats.totalClients = clientCount.length;
|
||||||
@@ -88,144 +96,137 @@ if (firstOrg) {
|
|||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||||
.where(eq(timeEntries.userId, user.id))
|
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(5)
|
.limit(5)
|
||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number) {
|
const hasMembership = userOrgs.length > 0;
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
|
||||||
const minutes = totalMinutes % 60;
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Dashboard - Zamaan">
|
<DashboardLayout title="Dashboard - Chronus">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-8">
|
||||||
<h1 class="text-3xl font-bold">Dashboard</h1>
|
<div>
|
||||||
<a href="/dashboard/organizations/new" class="btn btn-outline btn-sm">
|
<h1 class="text-4xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent mb-2">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p class="text-base-content/60">Welcome back, {user.name}!</p>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/organizations/new" class="btn btn-outline">
|
||||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||||
New Organization
|
New Team
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Overview -->
|
{!hasMembership && (
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div class="alert alert-info mb-8">
|
||||||
<div class="stats shadow border border-base-300">
|
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||||
<div class="stat">
|
<div>
|
||||||
<div class="stat-figure text-primary">
|
<h3 class="font-bold">Welcome to Chronus!</h3>
|
||||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
<div class="text-sm">You're not part of any team yet. Create one or wait for an invitation.</div>
|
||||||
</div>
|
|
||||||
<div class="stat-title">This Week</div>
|
|
||||||
<div class="stat-value text-primary text-2xl">{formatDuration(stats.totalTimeThisWeek)}</div>
|
|
||||||
<div class="stat-desc">Total tracked time</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||||
|
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||||
|
New Team
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
{hasMembership && (
|
||||||
<div class="stat">
|
<>
|
||||||
<div class="stat-figure text-secondary">
|
<!-- Stats Overview -->
|
||||||
<Icon name="heroicons:calendar" class="w-8 h-8" />
|
<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>
|
||||||
<div class="stat-title">This Month</div>
|
|
||||||
<div class="stat-value text-secondary text-2xl">{formatDuration(stats.totalTimeThisMonth)}</div>
|
|
||||||
<div class="stat-desc">Total tracked time</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stat">
|
||||||
<div class="stat">
|
<div class="stat-figure text-secondary">
|
||||||
<div class="stat-figure text-accent">
|
<Icon name="heroicons:calendar" class="w-8 h-8" />
|
||||||
<Icon name="heroicons:play-circle" 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>
|
||||||
<div class="stat-title">Active Timers</div>
|
|
||||||
<div class="stat-value text-accent text-2xl">{stats.activeTimers}</div>
|
|
||||||
<div class="stat-desc">Currently running</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stat">
|
||||||
<div class="stat">
|
<div class="stat-figure text-accent">
|
||||||
<div class="stat-figure text-info">
|
<Icon name="heroicons:play-circle" class="w-8 h-8" />
|
||||||
<Icon name="heroicons:building-office" 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>
|
||||||
<div class="stat-title">Clients</div>
|
|
||||||
<div class="stat-value text-info text-2xl">{stats.totalClients}</div>
|
|
||||||
<div class="stat-desc">Total active</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="stat">
|
||||||
<!-- Organizations -->
|
<div class="stat-figure text-info">
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<Icon name="heroicons:building-office" class="w-8 h-8" />
|
||||||
<div class="card-body">
|
</div>
|
||||||
<h2 class="card-title">
|
<div class="stat-title">Clients</div>
|
||||||
<Icon name="heroicons:building-office-2" class="w-6 h-6" />
|
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
|
||||||
Your Organizations
|
<div class="stat-desc">Total active</div>
|
||||||
</h2>
|
</div>
|
||||||
<ul class="menu bg-base-100 w-full p-0">
|
</div>
|
||||||
{userOrgs.map(org => (
|
|
||||||
<li>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<a class="flex justify-between">
|
<!-- Quick Actions -->
|
||||||
<span>{org.name}</span>
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<span class="badge badge-sm">{org.role}</span>
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<Icon name="heroicons:bolt" class="w-6 h-6 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" />
|
||||||
|
Start Timer
|
||||||
</a>
|
</a>
|
||||||
</li>
|
<a href="/dashboard/clients/new" class="btn btn-outline">
|
||||||
))}
|
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||||
</ul>
|
Add Client
|
||||||
</div>
|
</a>
|
||||||
</div>
|
<a href="/dashboard/reports" class="btn btn-outline">
|
||||||
|
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||||
|
View Reports
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Recent Activity -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<Icon name="heroicons:bolt" class="w-6 h-6" />
|
<Icon name="heroicons:clock" class="w-6 h-6 text-success" />
|
||||||
Quick Actions
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-col gap-2">
|
{stats.recentEntries.length > 0 ? (
|
||||||
<a href="/dashboard/tracker" class="btn btn-primary">
|
<ul class="space-y-3 mt-4">
|
||||||
<Icon name="heroicons:play" class="w-5 h-5" />
|
{stats.recentEntries.map(({ entry, client, category }) => (
|
||||||
Start Timer
|
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
|
||||||
</a>
|
<div class="font-semibold text-sm">{client.name}</div>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-outline">
|
<div class="text-xs text-base-content/60 mt-1">
|
||||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
|
||||||
Add Client
|
</div>
|
||||||
</a>
|
</li>
|
||||||
<a href="/dashboard/reports" class="btn btn-outline">
|
))}
|
||||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
</ul>
|
||||||
View Reports
|
) : (
|
||||||
</a>
|
<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>
|
||||||
|
)}
|
||||||
|
</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">
|
|
||||||
<Icon name="heroicons:clock" class="w-6 h-6" />
|
|
||||||
Recent Activity
|
|
||||||
</h2>
|
|
||||||
{stats.recentEntries.length > 0 ? (
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{stats.recentEntries.map(({ entry, client, category }) => (
|
|
||||||
<li class="text-sm border-l-2 pl-2" style={`border-color: ${category.color || '#3b82f6'}`}>
|
|
||||||
<div class="font-semibold">{client.name}</div>
|
|
||||||
<div class="text-xs text-base-content/60">
|
|
||||||
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p class="text-base-content/60 text-sm">No recent time entries</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
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 } 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');
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Create Organization - Zamaan">
|
<DashboardLayout title="Create Team - Chronus">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<a href="/dashboard" class="btn btn-ghost btn-sm">
|
<a href="/dashboard" class="btn btn-ghost btn-sm">
|
||||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-3xl font-bold">Create New Organization</h1>
|
<h1 class="text-3xl font-bold">Create New Team</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
|
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="alert alert-info mb-4">
|
<div class="alert alert-info mb-4">
|
||||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||||
<span>Create a new organization to manage separate teams and projects. You'll be the owner.</span>
|
<span>Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">Organization Name</span>
|
<span class="label-text font-medium">Team Name</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -41,7 +41,7 @@ if (!user) return Astro.redirect('/login');
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-6">
|
<div class="card-actions justify-end mt-6">
|
||||||
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
|
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Create Organization</button>
|
<button type="submit" class="btn btn-primary">Create Team</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,16 +7,25 @@ import MemberChart from '../../components/MemberChart.vue';
|
|||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, members, users, clients, categories } from '../../db/schema';
|
import { timeEntries, members, users, clients, categories } from '../../db/schema';
|
||||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||||
|
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
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 teamMembers = await db.select({
|
const teamMembers = await db.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
@@ -158,12 +167,6 @@ const totalTime = entries.reduce((sum, e) => {
|
|||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
function formatDuration(ms: number) {
|
|
||||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimeRangeLabel(range: string) {
|
function getTimeRangeLabel(range: string) {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': return 'Today';
|
case 'today': return 'Today';
|
||||||
@@ -177,7 +180,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Reports - Zamaan">
|
<DashboardLayout title="Reports - Chronus">
|
||||||
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
|
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -279,249 +282,277 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts Section -->
|
{/* Charts Section - Only show if there's data */}
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
{totalTime > 0 && (
|
||||||
<!-- Category Distribution Chart -->
|
<>
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* Category Distribution Chart - Only show when no category filter */}
|
||||||
|
{!selectedCategoryId && statsByCategory.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" />
|
||||||
|
Category Distribution
|
||||||
|
</h2>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<CategoryChart
|
||||||
|
client:load
|
||||||
|
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||||
|
name: s.category.name,
|
||||||
|
totalTime: s.totalTime,
|
||||||
|
color: s.category.color || '#3b82f6'
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
Time by Client
|
||||||
|
</h2>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<ClientChart
|
||||||
|
client:load
|
||||||
|
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||||
|
name: s.client.name,
|
||||||
|
totalTime: s.totalTime
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
Time by Team Member
|
||||||
|
</h2>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<MemberChart
|
||||||
|
client:load
|
||||||
|
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||||
|
name: s.member.name,
|
||||||
|
totalTime: s.totalTime
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<h2 class="card-title mb-4">
|
||||||
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
|
<Icon name="heroicons:users" class="w-6 h-6" />
|
||||||
Category Distribution
|
By Team Member
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64">
|
<div class="overflow-x-auto">
|
||||||
<CategoryChart
|
<table class="table">
|
||||||
client:load
|
<thead>
|
||||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
<tr>
|
||||||
name: s.category.name,
|
<th>Member</th>
|
||||||
totalTime: s.totalTime,
|
<th>Total Time</th>
|
||||||
color: s.category.color || '#3b82f6'
|
<th>Entries</th>
|
||||||
}))}
|
<th>Avg per Entry</th>
|
||||||
/>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{statsByMember.filter(s => s.totalTime > 0).map(stat => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold">{stat.member.name}</div>
|
||||||
|
<div class="text-sm opacity-50">{stat.member.email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||||
|
<td>{stat.entryCount}</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<!-- Client Distribution Chart -->
|
{/* Stats by Category - Only show if there's data and no category filter */}
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
{!selectedCategoryId && statsByCategory.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">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<h2 class="card-title mb-4">
|
||||||
<Icon name="heroicons:chart-bar" class="w-6 h-6" />
|
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||||
Time by Client
|
By Category
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64">
|
<div class="overflow-x-auto">
|
||||||
<ClientChart
|
<table class="table">
|
||||||
client:load
|
<thead>
|
||||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
<tr>
|
||||||
name: s.client.name,
|
<th>Category</th>
|
||||||
totalTime: s.totalTime
|
<th>Total Time</th>
|
||||||
}))}
|
<th>Entries</th>
|
||||||
/>
|
<th>% of Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{stat.category.color && (
|
||||||
|
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
|
||||||
|
)}
|
||||||
|
<span>{stat.category.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||||
|
<td>{stat.entryCount}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<progress
|
||||||
|
class="progress progress-primary w-20"
|
||||||
|
value={stat.totalTime}
|
||||||
|
max={totalTime}
|
||||||
|
></progress>
|
||||||
|
<span class="text-sm">
|
||||||
|
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<!-- Team Member Chart -->
|
{/* Stats by Client - Only show if there's data and no client filter */}
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
|
||||||
<div class="card-body">
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
<h2 class="card-title mb-4">
|
<div class="card-body">
|
||||||
<Icon name="heroicons:users" class="w-6 h-6" />
|
<h2 class="card-title mb-4">
|
||||||
Time by Team Member
|
<Icon name="heroicons:building-office" class="w-6 h-6" />
|
||||||
</h2>
|
By Client
|
||||||
<div class="h-64">
|
</h2>
|
||||||
<MemberChart
|
<div class="overflow-x-auto">
|
||||||
client:load
|
<table class="table">
|
||||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
<thead>
|
||||||
name: s.member.name,
|
|
||||||
totalTime: s.totalTime
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats by Member -->
|
|
||||||
<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" />
|
|
||||||
By Team Member
|
|
||||||
</h2>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Total Time</th>
|
|
||||||
<th>Entries</th>
|
|
||||||
<th>Avg per Entry</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{statsByMember.map(stat => (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<th>Client</th>
|
||||||
<div>
|
<th>Total Time</th>
|
||||||
<div class="font-bold">{stat.member.name}</div>
|
<th>Entries</th>
|
||||||
<div class="text-sm opacity-50">{stat.member.email}</div>
|
<th>% of Total</th>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
|
||||||
<td>{stat.entryCount}</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '0h 0m'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{statsByClient.filter(s => s.totalTime > 0).map(stat => (
|
||||||
|
<tr>
|
||||||
|
<td>{stat.client.name}</td>
|
||||||
|
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||||
|
<td>{stat.entryCount}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<progress
|
||||||
|
class="progress progress-secondary w-20"
|
||||||
|
value={stat.totalTime}
|
||||||
|
max={totalTime}
|
||||||
|
></progress>
|
||||||
|
<span class="text-sm">
|
||||||
|
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<!-- Stats by Category -->
|
{/* Detailed Entries */}
|
||||||
<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" />
|
|
||||||
By Category
|
|
||||||
</h2>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Total Time</th>
|
|
||||||
<th>Entries</th>
|
|
||||||
<th>% of Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{statsByCategory.map(stat => (
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{stat.category.color && (
|
|
||||||
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
|
|
||||||
)}
|
|
||||||
<span>{stat.category.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
|
||||||
<td>{stat.entryCount}</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<progress
|
|
||||||
class="progress progress-primary w-20"
|
|
||||||
value={stat.totalTime}
|
|
||||||
max={totalTime}
|
|
||||||
></progress>
|
|
||||||
<span class="text-sm">
|
|
||||||
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats by Client -->
|
|
||||||
<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" />
|
|
||||||
By Client
|
|
||||||
</h2>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Total Time</th>
|
|
||||||
<th>Entries</th>
|
|
||||||
<th>% of Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{statsByClient.map(stat => (
|
|
||||||
<tr>
|
|
||||||
<td>{stat.client.name}</td>
|
|
||||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
|
||||||
<td>{stat.entryCount}</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<progress
|
|
||||||
class="progress progress-secondary w-20"
|
|
||||||
value={stat.totalTime}
|
|
||||||
max={totalTime}
|
|
||||||
></progress>
|
|
||||||
<span class="text-sm">
|
|
||||||
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Detailed Entries -->
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<h2 class="card-title mb-4">
|
||||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||||
Detailed Entries ({entries.length})
|
Detailed Entries ({entries.length})
|
||||||
</h2>
|
</h2>
|
||||||
<div class="overflow-x-auto">
|
{entries.length > 0 ? (
|
||||||
<table class="table table-zebra">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<table class="table table-zebra">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Date</th>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{entries.map(e => (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap">
|
<th>Date</th>
|
||||||
{e.entry.startTime.toLocaleDateString()}<br/>
|
<th>Member</th>
|
||||||
<span class="text-xs opacity-50">
|
<th>Client</th>
|
||||||
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
<th>Category</th>
|
||||||
</span>
|
<th>Description</th>
|
||||||
</td>
|
<th>Duration</th>
|
||||||
<td>{e.user.name}</td>
|
|
||||||
<td>{e.client.name}</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{e.category.color && (
|
|
||||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
|
|
||||||
)}
|
|
||||||
<span>{e.category.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{e.entry.description || '-'}</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{e.entry.endTime
|
|
||||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
|
||||||
: 'Running...'
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{entries.map(e => (
|
||||||
</div>
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
{e.entry.startTime.toLocaleDateString()}<br/>
|
||||||
|
<span class="text-xs opacity-50">
|
||||||
|
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{e.user.name}</td>
|
||||||
|
<td>{e.client.name}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{e.category.color && (
|
||||||
|
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
|
||||||
|
)}
|
||||||
|
<span>{e.category.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{e.entry.description || '-'}</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{e.entry.endTime
|
||||||
|
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||||
|
: 'Running...'
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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" />
|
||||||
|
Start Tracking Time
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
173
src/pages/dashboard/settings.astro
Normal file
173
src/pages/dashboard/settings.astro
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
|
||||||
|
const user = Astro.locals.user;
|
||||||
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
|
const url = new URL(Astro.request.url);
|
||||||
|
const successType = url.searchParams.get('success');
|
||||||
|
---
|
||||||
|
|
||||||
|
<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 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
|
Account Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Success Messages */}
|
||||||
|
{successType === 'profile' && (
|
||||||
|
<div class="alert alert-success mb-6">
|
||||||
|
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||||
|
<span class="text-sm sm:text-base">Profile updated successfully!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successType === 'password' && (
|
||||||
|
<div class="alert alert-success mb-6">
|
||||||
|
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||||
|
<span class="text-sm sm:text-base">Password changed successfully!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Profile Information -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
|
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
Profile Information
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form action="/api/user/update-profile" method="POST" class="space-y-5">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={user.name}
|
||||||
|
placeholder="Your full name"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={user.email}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<div class="label pt-2">
|
||||||
|
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||||
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
|
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
Change Password
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form action="/api/user/change-password" method="POST" class="space-y-5">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="currentPassword"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
/>
|
||||||
|
<div class="label pt-2">
|
||||||
|
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||||
|
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
|
||||||
|
Update Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Info -->
|
||||||
|
<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" />
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<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"}>
|
||||||
|
{user.isSiteAdmin ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
@@ -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 teamMembers = await db.select({
|
const teamMembers = await db.select({
|
||||||
member: members,
|
member: members,
|
||||||
@@ -28,7 +36,7 @@ const currentUserMember = teamMembers.find(m => m.user.id === user.id);
|
|||||||
const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?.member.role === 'admin';
|
const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?.member.role === 'admin';
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Team - Zamaan">
|
<DashboardLayout title="Team - Chronus">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-3xl font-bold">Team Members</h1>
|
<h1 class="text-3xl font-bold">Team Members</h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admi
|
|||||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Invite Team Member - Zamaan">
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../db';
|
import { db } from '../../../db';
|
||||||
import { categories, members } from '../../../db/schema';
|
import { categories, members, organizations } from '../../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
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');
|
||||||
|
|
||||||
|
// Get current team from cookie
|
||||||
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||||
|
|
||||||
const userMembership = await db.select()
|
const userMembership = await db.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
@@ -18,13 +21,26 @@ if (!userMembership) return Astro.redirect('/dashboard');
|
|||||||
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 = currentTeamId || userMembership.organizationId;
|
||||||
|
|
||||||
|
const organization = await db.select()
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.id, orgId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!organization) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const allCategories = await db.select()
|
const allCategories = await db.select()
|
||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
.where(eq(categories.organizationId, orgId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const url = new URL(Astro.request.url);
|
||||||
|
const successType = url.searchParams.get('success');
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Team Settings - Zamaan">
|
<DashboardLayout title="Team Settings - Chronus">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<a href="/dashboard/team" class="btn btn-ghost btn-sm">
|
<a href="/dashboard/team" class="btn btn-ghost btn-sm">
|
||||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||||
@@ -32,6 +48,51 @@ const allCategories = await db.select()
|
|||||||
<h1 class="text-3xl font-bold">Team Settings</h1>
|
<h1 class="text-3xl font-bold">Team Settings</h1>
|
||||||
</div>
|
</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" />
|
||||||
|
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 name updated successfully!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action="/api/organizations/update-name" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="organizationId" value={organization.id} />
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-medium">Team Name</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={organization.name}
|
||||||
|
placeholder="Organization name"
|
||||||
|
class="input input-bordered 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>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Categories Section -->
|
<!-- Categories Section -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -88,16 +149,4 @@ const allCategories = await db.select()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Future Settings Sections -->
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">
|
|
||||||
<Icon name="heroicons:cog-6-tooth" class="w-6 h-6" />
|
|
||||||
Organization Settings
|
|
||||||
</h2>
|
|
||||||
<p class="text-base-content/70">
|
|
||||||
Additional organization settings coming soon...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const category = await db.select()
|
|||||||
if (!category) return Astro.redirect('/dashboard/team/settings');
|
if (!category) return Astro.redirect('/dashboard/team/settings');
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Edit Category - Zamaan">
|
<DashboardLayout title="Edit Category - Chronus">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
|
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const user = Astro.locals.user;
|
|||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="New Category - Zamaan">
|
<DashboardLayout title="New Category - Chronus">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
|
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
|
||||||
|
|||||||
@@ -5,30 +5,41 @@ import Timer from '../../components/Timer.vue';
|
|||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
||||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||||
|
import { formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
const userOrg = 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 (!userOrg) 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 organizationId = userMembership.organizationId;
|
||||||
|
|
||||||
const allClients = await db.select()
|
const allClients = await db.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.organizationId, userOrg.organizationId))
|
.where(eq(clients.organizationId, organizationId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const allCategories = await db.select()
|
const allCategories = await db.select()
|
||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.organizationId, userOrg.organizationId))
|
.where(eq(categories.organizationId, organizationId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const allTags = await db.select()
|
const allTags = await db.select()
|
||||||
.from(tags)
|
.from(tags)
|
||||||
.where(eq(tags.organizationId, userOrg.organizationId))
|
.where(eq(tags.organizationId, organizationId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Query params
|
// Query params
|
||||||
@@ -43,7 +54,7 @@ const filterStatus = url.searchParams.get('status') || '';
|
|||||||
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
||||||
const searchTerm = url.searchParams.get('search') || '';
|
const searchTerm = url.searchParams.get('search') || '';
|
||||||
|
|
||||||
const conditions = [eq(timeEntries.organizationId, userOrg.organizationId)];
|
const conditions = [eq(timeEntries.organizationId, organizationId)];
|
||||||
|
|
||||||
if (filterClient) {
|
if (filterClient) {
|
||||||
conditions.push(eq(timeEntries.clientId, filterClient));
|
conditions.push(eq(timeEntries.clientId, filterClient));
|
||||||
@@ -113,15 +124,6 @@ const runningEntry = await db.select({
|
|||||||
))
|
))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
function formatDuration(start: Date, end: Date | null) {
|
|
||||||
if (!end) return 'Running...';
|
|
||||||
const ms = end.getTime() - start.getTime();
|
|
||||||
const minutes = Math.round(ms / (1000 * 60));
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
return `${hours}h ${mins}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
||||||
const pages: number[] = [];
|
const pages: number[] = [];
|
||||||
const numPagesToShow = Math.min(5, totalPages);
|
const numPagesToShow = Math.min(5, totalPages);
|
||||||
@@ -146,7 +148,7 @@ function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
|||||||
const paginationPages = getPaginationPages(page, totalPages);
|
const paginationPages = getPaginationPages(page, totalPages);
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Time Tracker - Zamaan">
|
<DashboardLayout title="Time Tracker - Chronus">
|
||||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
||||||
|
|
||||||
{allClients.length === 0 ? (
|
{allClients.length === 0 ? (
|
||||||
@@ -313,7 +315,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<span class="badge badge-success">Running</span>
|
<span class="badge badge-success">Running</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono">{formatDuration(entry.startTime, entry.endTime)}</td>
|
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,15 +6,49 @@ if (Astro.locals.user) {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Zamaan - Time Tracking">
|
<Layout title="Chronus - Time Tracking">
|
||||||
<div class="hero min-h-screen bg-base-200">
|
<div class="hero min-h-screen bg-gradient-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-md">
|
<div class="max-w-4xl">
|
||||||
<h1 class="text-5xl font-bold">Zamaan</h1>
|
<h1 class="text-6xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
<p class="py-6">Modern time tracking for your organization.</p>
|
Chronus
|
||||||
<div class="flex gap-4 justify-center">
|
</h1>
|
||||||
<a href="/login" class="btn btn-primary">Login</a>
|
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto">
|
||||||
<a href="/signup" class="btn btn-secondary">Sign Up</a>
|
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">
|
||||||
|
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>
|
||||||
|
</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 time with a single click. No complexity, just efficiency.</p>
|
||||||
|
</div>
|
||||||
|
</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">Insightful Reports</h3>
|
||||||
|
<p class="text-sm text-base-content/70">Understand where time goes with beautiful, actionable insights.</p>
|
||||||
|
</div>
|
||||||
|
</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 teams that need to track, manage, and analyze together.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,30 +6,50 @@ if (Astro.locals.user) {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Login - Zamaan">
|
<Layout title="Login - Chronus">
|
||||||
<div class="flex justify-center items-center min-h-screen bg-base-200">
|
<div class="flex justify-center items-center min-h-screen bg-gradient-to-br from-base-100 via-base-200 to-base-300">
|
||||||
<div class="card w-96 bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title justify-center">Login</h2>
|
<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>
|
||||||
|
|
||||||
<form action="/api/auth/login" method="POST" class="space-y-4">
|
<form action="/api/auth/login" method="POST" class="space-y-4">
|
||||||
<div class="form-control">
|
<label class="form-control">
|
||||||
<label class="label">
|
<div class="label">
|
||||||
<span class="label-text">Email</span>
|
<span class="label-text font-medium">Email</span>
|
||||||
</label>
|
</div>
|
||||||
<input type="email" name="email" placeholder="email@example.com" class="input input-bordered" required />
|
<input
|
||||||
</div>
|
type="email"
|
||||||
<div class="form-control">
|
name="email"
|
||||||
<label class="label">
|
placeholder="your@email.com"
|
||||||
<span class="label-text">Password</span>
|
class="input input-bordered w-full"
|
||||||
</label>
|
required
|
||||||
<input type="password" name="password" placeholder="********" class="input input-bordered" required />
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div class="form-control mt-6">
|
|
||||||
<button class="btn btn-primary">Login</button>
|
<label class="form-control">
|
||||||
</div>
|
<div class="label">
|
||||||
|
<span class="label-text font-medium">Password</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn btn-primary w-full mt-6">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center mt-4">
|
|
||||||
<a href="/signup" class="link link-hover">Don't have an account? Sign up</a>
|
<div class="divider">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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,47 +22,79 @@ if (!isFirstUser) {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Sign Up - Zamaan">
|
<Layout title="Sign Up - Chronus">
|
||||||
<div class="flex justify-center items-center min-h-screen bg-base-200">
|
<div class="flex justify-center items-center min-h-screen bg-gradient-to-br from-base-100 via-base-200 to-base-300">
|
||||||
<div class="card w-96 bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title justify-center">Sign Up</h2>
|
<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>
|
||||||
|
|
||||||
{registrationDisabled ? (
|
{registrationDisabled ? (
|
||||||
<div class="alert alert-warning">
|
<>
|
||||||
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
|
<div class="alert alert-warning">
|
||||||
<span>Registration is currently disabled.</span>
|
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
|
||||||
</div>
|
<span>Registration is currently disabled by the site administrator.</span>
|
||||||
<div class="text-center mt-4">
|
</div>
|
||||||
<a href="/login" class="link link-hover">Already have an account? Login</a>
|
<div class="divider"></div>
|
||||||
</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>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<form action="/api/auth/signup" method="POST" class="space-y-4">
|
<form action="/api/auth/signup" method="POST" class="space-y-4">
|
||||||
<div class="form-control">
|
<label class="form-control">
|
||||||
<label class="label">
|
<div class="label">
|
||||||
<span class="label-text">Name</span>
|
<span class="label-text font-medium">Full Name</span>
|
||||||
</label>
|
</div>
|
||||||
<input type="text" name="name" placeholder="John Doe" class="input input-bordered" required />
|
<input
|
||||||
</div>
|
type="text"
|
||||||
<div class="form-control">
|
name="name"
|
||||||
<label class="label">
|
placeholder="John Doe"
|
||||||
<span class="label-text">Email</span>
|
class="input input-bordered w-full"
|
||||||
</label>
|
required
|
||||||
<input type="email" name="email" placeholder="email@example.com" class="input input-bordered" required />
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
<label class="form-control">
|
||||||
<span class="label-text">Password</span>
|
<div class="label">
|
||||||
</label>
|
<span class="label-text font-medium">Email</span>
|
||||||
<input type="password" name="password" placeholder="********" class="input input-bordered" required />
|
</div>
|
||||||
</div>
|
<input
|
||||||
<div class="form-control mt-6">
|
type="email"
|
||||||
<button class="btn btn-primary">Sign Up</button>
|
name="email"
|
||||||
</div>
|
placeholder="your@email.com"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-medium">Password</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn btn-primary w-full mt-6">Create Account</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center mt-4">
|
|
||||||
<a href="/login" class="link link-hover">Already have an account? Login</a>
|
<div class="divider">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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user