First pass

This commit is contained in:
2025-12-25 22:10:06 -07:00
parent a2af6195f9
commit 455c3dbd9a
58 changed files with 10299 additions and 3 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
HOST=0.0.0.0
PORT=4321
DATABASE_URL=zamaan.db

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
# Database
*.db
*.db-journal
*.sqlite
*.sqlite3

4
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:lts-alpine AS builder
WORKDIR /app
RUN npm i -g pnpm
RUN apk add --no-cache python3 make g++
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
RUN pnpm run build
FROM node:lts-alpine AS runtime
WORKDIR /app
RUN npm i -g pnpm
COPY --from=builder /app/dist ./dist
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=zamaan.db
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -1,3 +1,2 @@
# zamaan # Zamaan
A modern time tracking application.
Time tracking for Atash Consulting

24
astro.config.mjs Normal file
View File

@@ -0,0 +1,24 @@
// @ts-check
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
import tailwindcss from '@tailwindcss/vite';
import icon from 'astro-icon';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
integrations: [vue(), icon()],
vite: {
plugins: [tailwindcss()],
ssr: {
external: ['better-sqlite3'],
},
},
adapter: node({
mode: 'standalone',
}),
});

4
check_db.js Normal file
View File

@@ -0,0 +1,4 @@
import Database from 'better-sqlite3';
const db = new Database('zamaan.db');
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
console.log('Tables:', tables);

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
zamaan:
build: .
ports:
- "4321:4321"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4321
DATABASE_URL: /app/data/zamaan.db
volumes:
- zamaan_data:/app/data
restart: unless-stopped
volumes:
zamaan_data:

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL || 'zamaan.db',
},
});

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "source",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.1",
"@astrojs/vue": "^5.1.3",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.6",
"astro-icon": "^1.1.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0",
"chart.js": "^4.5.1",
"daisyui": "^5.5.14",
"drizzle-orm": "0.45.1",
"nanoid": "^5.1.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.26",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "0.31.8"
}
}

6580
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

BIN
src/assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,62 @@
<template>
<Doughnut :data="chartData" :options="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Doughnut } from 'vue-chartjs';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
DoughnutController
} from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, DoughnutController);
interface CategoryData {
name: string;
totalTime: number;
color: string;
}
const props = defineProps<{
categories: CategoryData[];
}>();
const chartData = computed(() => ({
labels: props.categories.map(c => c.name),
datasets: [{
data: props.categories.map(c => c.totalTime),
backgroundColor: props.categories.map(c => c.color || '#3b82f6'),
borderWidth: 2,
borderColor: '#1e293b',
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
color: '#e2e8f0',
padding: 15,
font: { size: 12 }
}
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw / (1000 * 60));
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${context.label}: ${hours}h ${mins}m`;
}
}
}
}
};
</script>

View File

@@ -0,0 +1,83 @@
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
interface ClientData {
name: string;
totalTime: number;
}
const props = defineProps<{
clients: ClientData[];
}>();
const chartData = computed(() => ({
labels: props.clients.map(c => c.name),
datasets: [{
label: 'Time Tracked',
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#6366f1',
borderColor: '#4f46e5',
borderWidth: 1,
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#e2e8f0'
},
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
};
</script>

View File

@@ -0,0 +1,84 @@
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
interface MemberData {
name: string;
totalTime: number;
}
const props = defineProps<{
members: MemberData[];
}>();
const chartData = computed(() => ({
labels: props.members.map(m => m.name),
datasets: [{
label: 'Time Tracked',
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#10b981',
borderColor: '#059669',
borderWidth: 1,
}]
}));
const chartOptions = {
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
grid: {
color: '#334155'
}
},
y: {
ticks: {
color: '#e2e8f0'
},
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
};
</script>

202
src/components/Timer.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const props = defineProps<{
initialRunningEntry: { startTime: number; description: string | null; clientId: string; categoryId: string } | null;
clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[];
}>();
const isRunning = ref(false);
const startTime = ref<number | null>(null);
const elapsedTime = ref(0);
const description = ref('');
const selectedClientId = ref('');
const selectedCategoryId = ref('');
const selectedTags = ref<string[]>([]);
let interval: ReturnType<typeof setInterval> | null = null;
function formatTime(ms: number) {
// Round to nearest minute (10 seconds = 1 minute)
const totalMinutes = Math.round(ms / 1000 / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId);
if (index > -1) {
selectedTags.value.splice(index, 1);
} else {
selectedTags.value.push(tagId);
}
}
onMounted(() => {
if (props.initialRunningEntry) {
isRunning.value = true;
startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || '';
selectedClientId.value = props.initialRunningEntry.clientId;
selectedCategoryId.value = props.initialRunningEntry.categoryId;
elapsedTime.value = Date.now() - startTime.value;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
}, 1000);
}
});
onUnmounted(() => {
if (interval) clearInterval(interval);
});
async function startTimer() {
if (!selectedClientId.value) {
alert('Please select a client');
return;
}
if (!selectedCategoryId.value) {
alert('Please select a category');
return;
}
const res = await fetch('/api/time-entries/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
tags: selectedTags.value,
}),
});
if (res.ok) {
const data = await res.json();
startTime.value = new Date(data.startTime).getTime();
isRunning.value = true;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
}, 1000);
}
}
async function stopTimer() {
const res = await fetch('/api/time-entries/stop', {
method: 'POST',
});
if (res.ok) {
isRunning.value = false;
if (interval) clearInterval(interval);
elapsedTime.value = 0;
startTime.value = null;
description.value = '';
selectedClientId.value = '';
selectedCategoryId.value = '';
selectedTags.value = [];
window.location.reload();
}
}
</script>
<template>
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<div class="card-body gap-6">
<!-- Client and Description Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Client</span>
</label>
<select
v-model="selectedClientId"
class="select select-bordered w-full"
:disabled="isRunning"
>
<option value="">Select a client...</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Category</span>
</label>
<select
v-model="selectedCategoryId"
class="select select-bordered w-full"
:disabled="isRunning"
>
<option value="">Select a category...</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
</div>
<!-- Description Row -->
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Description</span>
</label>
<input
v-model="description"
type="text"
placeholder="What are you working on?"
class="input input-bordered w-full"
:disabled="isRunning"
/>
</div>
<!-- Tags Section -->
<div v-if="tags.length > 0" class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Tags</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in tags"
:key="tag.id"
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all',
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline'
]"
:disabled="isRunning"
type="button"
>
{{ tag.name }}
</button>
</div>
</div>
<!-- Timer and Action Row -->
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
<div class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left flex-grow">
{{ formatTime(elapsedTime) }}
</div>
<button
v-if="!isRunning"
@click="startTimer"
class="btn btn-primary btn-lg min-w-40"
>
Start Timer
</button>
<button
v-else
@click="stopTimer"
class="btn btn-error btn-lg min-w-40"
>
Stop Timer
</button>
</div>
</div>
</div>
</template>

8
src/db/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'zamaan.db');
const sqlite = new Database(dbUrl, { readonly: false });
export const db = drizzle(sqlite, { schema });

135
src/db/schema.ts Normal file
View File

@@ -0,0 +1,135 @@
import { sqliteTable, text, integer, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core';
import { nanoid } from 'nanoid';
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
name: text('name').notNull(),
isSiteAdmin: integer('is_site_admin', { mode: 'boolean' }).default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});
export const organizations = sqliteTable('organizations', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});
export const members = sqliteTable('members', {
userId: text('user_id').notNull(),
organizationId: text('organization_id').notNull(),
role: text('role').notNull().default('member'), // 'owner', 'admin', 'member'
joinedAt: integer('joined_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table: any) => ({
pk: primaryKey({ columns: [table.userId, table.organizationId] }),
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id]
}),
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id]
})
}));
export const clients = sqliteTable('clients', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
organizationId: text('organization_id').notNull(),
name: text('name').notNull(),
email: text('email'),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id]
})
}));
export const categories = sqliteTable('categories', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
organizationId: text('organization_id').notNull(),
name: text('name').notNull(),
color: text('color'),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id]
})
}));
export const timeEntries = sqliteTable('time_entries', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
organizationId: text('organization_id').notNull(),
clientId: text('client_id').notNull(),
categoryId: text('category_id').notNull(),
startTime: integer('start_time', { mode: 'timestamp' }).notNull(),
endTime: integer('end_time', { mode: 'timestamp' }),
description: text('description'),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table: any) => ({
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id]
}),
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id]
}),
clientFk: foreignKey({
columns: [table.clientId],
foreignColumns: [clients.id]
}),
categoryFk: foreignKey({
columns: [table.categoryId],
foreignColumns: [categories.id]
})
}));
export const tags = sqliteTable('tags', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
organizationId: text('organization_id').notNull(),
name: text('name').notNull(),
color: text('color'),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id]
})
}));
export const timeEntryTags = sqliteTable('time_entry_tags', {
timeEntryId: text('time_entry_id').notNull(),
tagId: text('tag_id').notNull(),
}, (table: any) => ({
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
timeEntryFk: foreignKey({
columns: [table.timeEntryId],
foreignColumns: [timeEntries.id]
}),
tagFk: foreignKey({
columns: [table.tagId],
foreignColumns: [tags.id]
})
}));
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
}, (table: any) => ({
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id]
})
}));
export const siteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});

19
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PROD: boolean;
readonly DEV: boolean;
readonly MODE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare namespace App {
interface Locals {
user: import('./db/schema').User | null;
session: import('./db/schema').Session | null;
}
}

View File

@@ -0,0 +1,85 @@
---
import '../styles/global.css';
import { Icon } from 'astro-icon/components';
interface Props {
title: string;
}
const { title } = Astro.props;
const user = Astro.locals.user;
if (!user) {
return Astro.redirect('/login');
}
---
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Zamaan Dashboard" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="bg-base-100 text-base-content">
<div class="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<!-- Navbar -->
<div class="w-full navbar bg-base-100 border-b border-base-200 lg:hidden">
<div class="flex-none lg:hidden">
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
<Icon name="heroicons:bars-3" class="w-6 h-6" />
</label>
</div>
<div class="flex-1 px-2 mx-2">Zamaan</div>
</div>
<!-- Page content here -->
<main class="p-6">
<slot />
</main>
</div>
<div class="drawer-side">
<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">
<!-- Sidebar content here -->
<li class="mb-4">
<a href="/dashboard" class="text-xl font-bold px-2">Zamaan</a>
</li>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/dashboard/tracker">Time Tracker</a></li>
<li><a href="/dashboard/reports">Reports</a></li>
<li><a href="/dashboard/clients">Clients</a></li>
<li><a href="/dashboard/team">Team</a></li>
{user.isSiteAdmin && (
<>
<div class="divider"></div>
<li><a href="/admin" class="font-semibold">⚙️ Site Admin</a></li>
</>
)}
<div class="divider"></div>
<li>
<div class="flex items-center gap-2">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">{user.name.charAt(0)}</span>
</div>
</div>
<span>{user.name}</span>
</div>
</li>
<li>
<form action="/api/auth/logout" method="POST">
<button type="submit">Logout</button>
</form>
</li>
</ul>
</div>
</div>
</body>
</html>

24
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,24 @@
---
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Zamaan Time Tracking" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="min-h-screen bg-base-100 text-base-content">
<slot />
</body>
</html>

58
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,58 @@
import { db } from '../db';
import { users, sessions } from '../db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
export async function createSession(userId: string) {
const sessionId = nanoid();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt,
});
return { sessionId, expiresAt };
}
export async function validateSession(sessionId: string) {
const result = await db.select({
user: users,
session: sessions
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId))
.get();
if (!result) {
return null;
}
const { session, user } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return null;
}
// Extend session if close to expiry (optional, skipping for simplicity)
return { session, user };
}
export async function invalidateSession(sessionId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string) {
return await bcrypt.compare(password, hash);
}

25
src/middleware.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineMiddleware } from 'astro/middleware';
import { validateSession } from './lib/auth';
export const onRequest = defineMiddleware(async (context, next) => {
const sessionId = context.cookies.get('session_id')?.value;
if (!sessionId) {
context.locals.user = null;
context.locals.session = null;
return next();
}
const result = await validateSession(sessionId);
if (result) {
context.locals.user = result.user;
context.locals.session = result.session;
} else {
context.locals.user = null;
context.locals.session = null;
context.cookies.delete('session_id');
}
return next();
});

108
src/pages/admin/index.astro Normal file
View File

@@ -0,0 +1,108 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { siteSettings, users } from '../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user || !user.isSiteAdmin) {
return Astro.redirect('/dashboard');
}
// Get current settings
const registrationSetting = await db.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.get();
const registrationEnabled = registrationSetting?.value === 'true';
// Get all users
const allUsers = await db.select().from(users).all();
---
<DashboardLayout title="Site Admin - Zamaan">
<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">
<!-- Statistics -->
<div class="stats shadow border border-base-200">
<div class="stat">
<div class="stat-title">Total Users</div>
<div class="stat-value">{allUsers.length}</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Site Settings</h2>
<form method="POST" action="/api/admin/settings">
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">
<div class="font-semibold">Allow New Registrations</div>
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
</span>
<input
type="checkbox"
name="registration_enabled"
class="toggle toggle-primary"
checked={registrationEnabled}
/>
</label>
</div>
<div class="card-actions justify-end mt-6">
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
</div>
<!-- Users List -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">All Users</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Site Admin</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{allUsers.map(u => (
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span>{u.name.charAt(0)}</span>
</div>
</div>
<div class="font-bold">{u.name}</div>
</div>
</td>
<td>{u.email}</td>
<td>
{u.isSiteAdmin ? (
<span class="badge badge-primary">Yes</span>
) : (
<span class="badge badge-ghost">No</span>
)}
</td>
<td>{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { siteSettings } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user || !user.isSiteAdmin) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const registrationEnabled = formData.get('registration_enabled') === 'on';
// Check if setting exists
const existingSetting = await db.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.get();
if (existingSetting) {
// Update
await db.update(siteSettings)
.set({
value: registrationEnabled ? 'true' : 'false',
updatedAt: new Date()
})
.where(eq(siteSettings.key, 'registration_enabled'));
} else {
// Insert
await db.insert(siteSettings).values({
id: nanoid(),
key: 'registration_enabled',
value: registrationEnabled ? 'true' : 'false',
});
}
return redirect('/admin');
};

View File

@@ -0,0 +1,33 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { verifyPassword, createSession } from '../../../lib/auth';
import { eq } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const formData = await request.formData();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
if (!email || !password) {
return new Response('Missing fields', { status: 400 });
}
const user = await db.select().from(users).where(eq(users.email, email)).get();
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return new Response('Invalid email or password', { status: 400 });
}
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set('session_id', sessionId, {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
expires: expiresAt,
});
return redirect('/dashboard');
};

View File

@@ -0,0 +1,11 @@
import type { APIRoute } from 'astro';
import { invalidateSession } from '../../../lib/auth';
export const POST: APIRoute = async ({ cookies, redirect }) => {
const sessionId = cookies.get('session_id')?.value;
if (sessionId) {
await invalidateSession(sessionId);
cookies.delete('session_id', { path: '/' });
}
return redirect('/login');
};

View File

@@ -0,0 +1,80 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, organizations, members, siteSettings } from '../../../db/schema';
import { hashPassword, createSession } from '../../../lib/auth';
import { eq, count, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
// Check if this is the first user
const userCountResult = await db.select({ count: count() }).from(users).get();
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
// If not first user, check if registration is enabled
if (!isFirstUser) {
const registrationSetting = await db.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.get();
const registrationEnabled = registrationSetting?.value === 'true';
if (!registrationEnabled) {
return new Response('Registration is currently disabled', { status: 403 });
}
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
if (!name || !email || !password) {
return new Response('Missing fields', { status: 400 });
}
// Check if user exists
const existingUser = await db.select().from(users).where(eq(users.email, email)).get();
if (existingUser) {
return new Response('User already exists', { status: 400 });
}
const passwordHash = await hashPassword(password);
const userId = nanoid();
// Create user
await db.insert(users).values({
id: userId,
name,
email,
passwordHash,
isSiteAdmin: isFirstUser,
});
// Create default organization
const orgId = nanoid();
await db.insert(organizations).values({
id: orgId,
name: `${name}'s Organization`,
});
// Add user to organization
await db.insert(members).values({
userId,
organizationId: orgId,
role: 'owner',
});
// Create session
const { sessionId, expiresAt } = await createSession(userId);
cookies.set('session_id', sessionId, {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
expires: expiresAt,
});
return redirect('/dashboard');
};

View File

@@ -0,0 +1,47 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { categories, members, timeEntries } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const { id } = params;
// Get user's organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
}
// Check if category has time entries
const hasEntries = await db.select()
.from(timeEntries)
.where(eq(timeEntries.categoryId, id!))
.get();
if (hasEntries) {
return new Response('Cannot delete category with time entries', { status: 400 });
}
// Delete category
await db.delete(categories)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
return redirect('/dashboard/team/settings');
};

View File

@@ -0,0 +1,48 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { categories, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const { id } = params;
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Get user's organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
}
// Update category
await db.update(categories)
.set({
name,
color: color || null,
})
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
return redirect('/dashboard/team/settings');
};

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { categories, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Get user's first organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
// Create category
await db.insert(categories).values({
id: nanoid(),
organizationId: userOrg.organizationId,
name,
color: color || null,
});
return redirect('/dashboard/team/settings');
};

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { clients, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Get user's first organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
// Create client
await db.insert(clients).values({
id: nanoid(),
organizationId: userOrg.organizationId,
name,
email: email || null,
});
return redirect('/dashboard/clients');
};

View File

@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { organizations, members } from '../../../db/schema';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Create organization
const orgId = nanoid();
await db.insert(organizations).values({
id: orgId,
name,
});
// Add user as owner
await db.insert(members).values({
userId: user.id,
organizationId: orgId,
role: 'owner',
});
return redirect('/dashboard');
};

View File

@@ -0,0 +1,60 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Check if user is admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership || (userMembership.role !== 'owner' && userMembership.role !== 'admin')) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const targetUserId = formData.get('userId')?.toString();
const newRole = formData.get('role')?.toString();
if (!targetUserId || !newRole) {
return new Response('Missing parameters', { status: 400 });
}
if (!['member', 'admin'].includes(newRole)) {
return new Response('Invalid role', { status: 400 });
}
// Can't change owner's role
const targetMember = await db.select()
.from(members)
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
))
.get();
if (!targetMember) {
return new Response('Member not found', { status: 404 });
}
if (targetMember.role === 'owner') {
return new Response('Cannot change owner role', { status: 403 });
}
// Update role
await db.update(members)
.set({ role: newRole })
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
));
return redirect('/dashboard/team');
};

View File

@@ -0,0 +1,65 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Check if user is admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership || (userMembership.role !== 'owner' && userMembership.role !== 'admin')) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const email = formData.get('email')?.toString();
const role = formData.get('role')?.toString() || 'member';
if (!email) {
return new Response('Email is required', { status: 400 });
}
if (!['member', 'admin'].includes(role)) {
return new Response('Invalid role', { status: 400 });
}
// Find user by email
const invitedUser = await db.select()
.from(users)
.where(eq(users.email, email))
.get();
if (!invitedUser) {
return new Response('User not found. They must create an account first.', { status: 404 });
}
// Check if already a member
const existingMember = await db.select()
.from(members)
.where(and(
eq(members.userId, invitedUser.id),
eq(members.organizationId, userMembership.organizationId)
))
.get();
if (existingMember) {
return new Response('User is already a member', { status: 400 });
}
// Add to organization
await db.insert(members).values({
userId: invitedUser.id,
organizationId: userMembership.organizationId,
role,
});
return redirect('/dashboard/team');
};

View File

@@ -0,0 +1,59 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Check if user is admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership || (userMembership.role !== 'owner' && userMembership.role !== 'admin')) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const targetUserId = formData.get('userId')?.toString();
if (!targetUserId) {
return new Response('Missing user ID', { status: 400 });
}
// Can't remove self
if (targetUserId === user.id) {
return new Response('Cannot remove yourself', { status: 403 });
}
// Can't remove owner
const targetMember = await db.select()
.from(members)
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
))
.get();
if (!targetMember) {
return new Response('Member not found', { status: 404 });
}
if (targetMember.role === 'owner') {
return new Response('Cannot remove owner', { status: 403 });
}
// Remove member
await db.delete(members)
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
));
return redirect('/dashboard/team');
};

View File

@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { timeEntries } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
const entryId = params.id;
if (!entryId) {
return new Response(JSON.stringify({ error: 'Entry ID required' }), { status: 400 });
}
// Verify the entry belongs to the user
const entry = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.id, entryId),
eq(timeEntries.userId, user.id)
))
.get();
if (!entry) {
return new Response(JSON.stringify({ error: 'Entry not found' }), { status: 404 });
}
// Delete the entry
await db.delete(timeEntries)
.where(eq(timeEntries.id, entryId))
.run();
return redirect('/dashboard/tracker');
};

View File

@@ -0,0 +1,78 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
import { eq, and, isNull } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const body = await request.json();
const description = body.description || '';
const clientId = body.clientId;
const categoryId = body.categoryId;
const tags = body.tags || [];
if (!clientId) {
return new Response('Client is required', { status: 400 });
}
if (!categoryId) {
return new Response('Category is required', { status: 400 });
}
// Check for running entry
const runningEntry = await db.select().from(timeEntries).where(
and(
eq(timeEntries.userId, locals.user.id),
isNull(timeEntries.endTime)
)
).get();
if (runningEntry) {
return new Response('Timer already running', { status: 400 });
}
// Get default org (first one)
const member = await db.select().from(members).where(eq(members.userId, locals.user.id)).limit(1).get();
if (!member) {
return new Response('No organization found', { status: 400 });
}
// Verify category belongs to user's organization
const category = await db.select().from(categories).where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
)
).get();
if (!category) {
return new Response('Invalid category', { status: 400 });
}
const startTime = new Date();
const id = nanoid();
await db.insert(timeEntries).values({
id,
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
startTime,
description,
});
// Add tags if provided
if (tags.length > 0) {
await db.insert(timeEntryTags).values(
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
}))
);
}
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
};

View File

@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries } from '../../../db/schema';
import { eq, and, isNull } from 'drizzle-orm';
export const POST: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const runningEntry = await db.select().from(timeEntries).where(
and(
eq(timeEntries.userId, locals.user.id),
isNull(timeEntries.endTime)
)
).get();
if (!runningEntry) {
return new Response('No timer running', { status: 400 });
}
await db.update(timeEntries)
.set({ endTime: new Date() })
.where(eq(timeEntries.id, runningEntry.id));
return new Response(JSON.stringify({ success: true }), { status: 200 });
};

View File

@@ -0,0 +1,56 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { categories, members } from '../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's first organization
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership) return Astro.redirect('/dashboard');
// Get all categories for the organization
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.all();
---
<DashboardLayout title="Categories - Zamaan">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Categories</h1>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allCategories.map(category => (
<div class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<h2 class="card-title">
{category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${category.color}`}></span>
)}
{category.name}
</h2>
<p class="text-xs text-base-content/60">Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-4">
<a href={`/dashboard/categories/${category.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
</div>
</div>
</div>
))}
</div>
{allCategories.length === 0 && (
<div class="text-center py-12">
<p class="text-base-content/60 mb-4">No categories yet</p>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Your First Category</a>
</div>
)}
</DashboardLayout>

View File

@@ -0,0 +1,55 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { clients, members } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's organizations
const userOrgs = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
const orgIds = userOrgs.map(m => m.organizationId);
// Get all clients for user's organizations
const allClients = orgIds.length > 0
? await db.select()
.from(clients)
.where(eq(clients.organizationId, orgIds[0]))
.all()
: [];
---
<DashboardLayout title="Clients - Zamaan">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Clients</h1>
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allClients.map(client => (
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">{client.name}</h2>
{client.email && <p class="text-sm text-gray-500">{client.email}</p>}
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-4">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-sm btn-ghost">View</a>
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
</div>
</div>
</div>
))}
</div>
{allClients.length === 0 && (
<div class="text-center py-12">
<p class="text-gray-500 mb-4">No clients yet</p>
<a href="/dashboard/clients/new" class="btn btn-primary">Add Your First Client</a>
</div>
)}
</DashboardLayout>

View File

@@ -0,0 +1,48 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="New Client - Zamaan">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Client Name</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Acme Corp"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
</label>
<input
type="email"
id="email"
name="email"
placeholder="contact@acme.com"
class="input input-bordered"
/>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Client</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,238 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const userOrgs = await db.select({
id: organizations.id,
name: organizations.name,
role: members.role,
organizationId: members.organizationId,
})
.from(members)
.innerJoin(organizations, eq(members.organizationId, organizations.id))
.where(eq(members.userId, user.id))
.all();
// Get stats for first organization
const firstOrg = userOrgs[0];
let stats = {
totalTimeThisWeek: 0,
totalTimeThisMonth: 0,
activeTimers: 0,
totalClients: 0,
recentEntries: [] as any[],
};
if (firstOrg) {
// Calculate date ranges
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Get time entries for this week
const weekEntries = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, firstOrg.organizationId),
gte(timeEntries.startTime, weekAgo)
))
.all();
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
// Get time entries for this month
const monthEntries = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, firstOrg.organizationId),
gte(timeEntries.startTime, monthAgo)
))
.all();
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
// Count active timers
const activeCount = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, firstOrg.organizationId),
isNull(timeEntries.endTime)
))
.all();
stats.activeTimers = activeCount.length;
// Count clients
const clientCount = await db.select()
.from(clients)
.where(eq(clients.organizationId, firstOrg.organizationId))
.all();
stats.totalClients = clientCount.length;
// Get recent entries
stats.recentEntries = await db.select({
entry: timeEntries,
client: clients,
category: categories,
})
.from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(eq(timeEntries.userId, user.id))
.orderBy(desc(timeEntries.startTime))
.limit(5)
.all();
}
function formatDuration(ms: number) {
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">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Dashboard</h1>
<a href="/dashboard/organizations/new" class="btn btn-outline btn-sm">
<Icon name="heroicons:plus" class="w-5 h-5" />
New Organization
</a>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">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>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:calendar" class="w-8 h-8" />
</div>
<div class="stat-title">This Month</div>
<div class="stat-value text-secondary text-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-figure text-accent">
<Icon name="heroicons:play-circle" class="w-8 h-8" />
</div>
<div class="stat-title">Active Timers</div>
<div class="stat-value text-accent text-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-figure text-info">
<Icon name="heroicons:building-office" class="w-8 h-8" />
</div>
<div class="stat-title">Clients</div>
<div class="stat-value text-info text-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">
<!-- Organizations -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:building-office-2" class="w-6 h-6" />
Your Organizations
</h2>
<ul class="menu bg-base-100 w-full p-0">
{userOrgs.map(org => (
<li>
<a class="flex justify-between">
<span>{org.name}</span>
<span class="badge badge-sm">{org.role}</span>
</a>
</li>
))}
</ul>
</div>
</div>
<!-- Quick Actions -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:bolt" class="w-6 h-6" />
Quick Actions
</h2>
<div class="flex flex-col gap-2">
<a href="/dashboard/tracker" class="btn btn-primary">
<Icon name="heroicons:play" class="w-5 h-5" />
Start Timer
</a>
<a href="/dashboard/clients/new" class="btn btn-outline">
<Icon name="heroicons:plus" class="w-5 h-5" />
Add Client
</a>
<a href="/dashboard/reports" class="btn btn-outline">
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
View Reports
</a>
</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>

View File

@@ -0,0 +1,49 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { organizations, members } from '../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="Create Organization - Zamaan">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Create New Organization</h1>
</div>
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<div class="alert alert-info mb-4">
<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>
</div>
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Organization Name</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Acme Corp"
class="input input-bordered w-full"
required
/>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Organization</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,539 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import CategoryChart from '../../components/CategoryChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, categories } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's organization
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership) return Astro.redirect('/dashboard');
// Get all team members
const teamMembers = await db.select({
id: users.id,
name: users.name,
email: users.email,
})
.from(members)
.innerJoin(users, eq(members.userId, users.id))
.where(eq(members.organizationId, userMembership.organizationId))
.all();
// Get all categories
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.all();
// Get all clients
const allClients = await db.select()
.from(clients)
.where(eq(clients.organizationId, userMembership.organizationId))
.all();
// Parse filter parameters
const url = new URL(Astro.request.url);
const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
// Calculate date range
const now = new Date();
let startDate = new Date();
let endDate = new Date();
switch (timeRange) {
case 'today':
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
break;
case 'week':
startDate.setDate(now.getDate() - 7);
break;
case 'month':
startDate.setMonth(now.getMonth() - 1);
break;
case 'mtd':
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'ytd':
startDate = new Date(now.getFullYear(), 0, 1);
break;
case 'last-month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
}
// Build query conditions
const conditions = [
eq(timeEntries.organizationId, userMembership.organizationId),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate),
];
if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
// Fetch detailed entries
const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
// Calculate statistics by member
const statsByMember = teamMembers.map(member => {
const memberEntries = entries.filter(e => e.user.id === member.id);
const totalTime = memberEntries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
}
return sum;
}, 0);
return {
member,
totalTime,
entryCount: memberEntries.length,
};
}).sort((a, b) => b.totalTime - a.totalTime);
// Calculate statistics by category
const statsByCategory = allCategories.map(category => {
const categoryEntries = entries.filter(e => e.category.id === category.id);
const totalTime = categoryEntries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
}
return sum;
}, 0);
return {
category,
totalTime,
entryCount: categoryEntries.length,
};
}).sort((a, b) => b.totalTime - a.totalTime);
// Calculate statistics by client
const statsByClient = allClients.map(client => {
const clientEntries = entries.filter(e => e.client.id === client.id);
const totalTime = clientEntries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
}
return sum;
}, 0);
return {
client,
totalTime,
entryCount: clientEntries.length,
};
}).sort((a, b) => b.totalTime - a.totalTime);
// Calculate total time
const totalTime = entries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
}
return sum;
}, 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) {
switch (range) {
case 'today': return 'Today';
case 'week': return 'Last 7 Days';
case 'month': return 'Last 30 Days';
case 'mtd': return 'Month to Date';
case 'ytd': return 'Year to Date';
case 'last-month': return 'Last Month';
default: return 'Last 7 Days';
}
}
---
<DashboardLayout title="Reports - Zamaan">
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
<!-- Filters -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<div class="card-body">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Time Range</span>
</label>
<select name="range" class="select select-bordered" onchange="this.form.submit()">
<option value="today" selected={timeRange === 'today'}>Today</option>
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Team Member</span>
</label>
<select name="member" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Members</option>
{teamMembers.map(member => (
<option value={member.id} selected={selectedMemberId === member.id}>
{member.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Category</span>
</label>
<select name="category" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={selectedCategoryId === category.id}>
{category.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Client</span>
</label>
<select name="client" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={selectedClientId === client.id}>
{client.name}
</option>
))}
</select>
</div>
</form>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">Total Time</div>
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
</div>
<div class="stat-title">Total Entries</div>
<div class="stat-value text-secondary">{entries.length}</div>
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:user-group" class="w-8 h-8" />
</div>
<div class="stat-title">Active Members</div>
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
<div class="stat-desc">of {teamMembers.length} total</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Category Distribution Chart -->
<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">
<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 -->
<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">
<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 -->
<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">
<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 -->
<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>
<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) : '0h 0m'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<!-- Stats by Category -->
<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-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:document-text" class="w-6 h-6" />
Detailed Entries ({entries.length})
</h2>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<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>
<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>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,126 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { members, users } from '../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's first organization
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership) return Astro.redirect('/dashboard');
// Get all team members for this organization
const teamMembers = await db.select({
member: members,
user: users,
})
.from(members)
.innerJoin(users, eq(members.userId, users.id))
.where(eq(members.organizationId, userMembership.organizationId))
.all();
const currentUserMember = teamMembers.find(m => m.user.id === user.id);
const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?.member.role === 'admin';
---
<DashboardLayout title="Team - Zamaan">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Team Members</h1>
<div class="flex gap-2">
{isAdmin && (
<>
<a href="/dashboard/team/settings" class="btn btn-ghost">
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
Settings
</a>
<a href="/dashboard/team/invite" class="btn btn-primary">Invite Member</a>
</>
)}
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Joined</th>
{isAdmin && <th>Actions</th>}
</tr>
</thead>
<tbody>
{teamMembers.map(({ member, user: teamUser }) => (
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span>{teamUser.name.charAt(0)}</span>
</div>
</div>
<div>
<div class="font-bold">{teamUser.name}</div>
{teamUser.id === user.id && (
<span class="badge badge-sm">You</span>
)}
</div>
</div>
</td>
<td>{teamUser.email}</td>
<td>
<span class={`badge ${
member.role === 'owner' ? 'badge-primary' :
member.role === 'admin' ? 'badge-secondary' :
'badge-ghost'
}`}>
{member.role}
</span>
</td>
<td>{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
{isAdmin && (
<td>
{teamUser.id !== user.id && member.role !== 'owner' && (
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
<li>
<form method="POST" action={`/api/team/change-role`}>
<input type="hidden" name="userId" value={teamUser.id} />
<input type="hidden" name="role" value={member.role === 'admin' ? 'member' : 'admin'} />
<button type="submit">
{member.role === 'admin' ? 'Make Member' : 'Make Admin'}
</button>
</form>
</li>
<li>
<form method="POST" action={`/api/team/remove`}>
<input type="hidden" name="userId" value={teamUser.id} />
<button type="submit" class="text-error">Remove</button>
</form>
</li>
</ul>
</div>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,68 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's membership to check if they're admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership) return Astro.redirect('/dashboard');
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team');
---
<DashboardLayout title="Invite Team Member - Zamaan">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Invite Team Member</h1>
<form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="alert alert-info mb-4">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<span>The user must already have an account. They'll be added to your organization.</span>
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email Address</span>
</label>
<input
type="email"
id="email"
name="email"
placeholder="user@example.com"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label" for="role">
<span class="label-text">Role</span>
</label>
<select id="role" name="role" class="select select-bordered" required>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<label class="label">
<span class="label-text-alt">Members can track time. Admins can manage team and clients.</span>
</label>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard/team" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Invite Member</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,105 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { categories, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's membership to check if they're admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership) return Astro.redirect('/dashboard');
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team');
// Get all categories for the organization
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.all();
---
<DashboardLayout title="Team Settings - Zamaan">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Team Settings</h1>
</div>
<!-- Categories Section -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:tag" class="w-6 h-6" />
Work Categories
</h2>
<a href="/dashboard/team/settings/categories/new" class="btn btn-primary btn-sm">
<Icon name="heroicons:plus" class="w-5 h-5" />
Add Category
</a>
</div>
<p class="text-base-content/70 mb-4">
Categories help organize time tracking by type of work. All team members use the same categories.
</p>
{allCategories.length === 0 ? (
<div class="alert alert-info">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<div>
<div class="font-bold">No categories yet</div>
<div class="text-sm">Create your first category to start organizing time entries.</div>
</div>
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allCategories.map(category => (
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center gap-3">
{category.color && (
<span class="w-4 h-4 rounded-full flex-shrink-0" style={`background-color: ${category.color}`}></span>
)}
<div class="flex-grow min-w-0">
<h3 class="font-semibold truncate">{category.name}</h3>
<p class="text-xs text-base-content/60">
Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}
</p>
</div>
<a
href={`/dashboard/team/settings/categories/${category.id}/edit`}
class="btn btn-ghost btn-xs"
>
<Icon name="heroicons:pencil" class="w-4 h-4" />
</a>
</div>
</div>
</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>

View File

@@ -0,0 +1,87 @@
---
import DashboardLayout from '../../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../../db';
import { categories, members } from '../../../../../db/schema';
import { eq, and } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
// Get user's membership
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership) return Astro.redirect('/dashboard');
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team/settings');
// Get category
const category = await db.select()
.from(categories)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userMembership.organizationId)
))
.get();
if (!category) return Astro.redirect('/dashboard/team/settings');
---
<DashboardLayout title="Edit Category - Zamaan">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Edit Category</h1>
</div>
<form method="POST" action={`/api/categories/${id}/update`} class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span>
</label>
<input
type="text"
id="name"
name="name"
value={category.name}
placeholder="Development"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span>
</label>
<input
type="color"
id="color"
name="color"
value={category.color || '#3b82f6'}
class="input input-bordered w-full h-12"
/>
</div>
<div class="card-actions justify-between mt-6">
<form method="POST" action={`/api/categories/${id}/delete`}>
<button type="submit" class="btn btn-error btn-outline">Delete Category</button>
</form>
<div class="flex gap-2">
<a href="/dashboard/team/settings" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,53 @@
---
import DashboardLayout from '../../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="New Category - Zamaan">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Add New Category</h1>
</div>
<form method="POST" action="/api/categories/create" class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Development"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span>
</label>
<input
type="color"
id="color"
name="color"
class="input input-bordered w-full h-12"
/>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard/team/settings" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Category</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,375 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue';
import { db } from '../../db';
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get user's first organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) return Astro.redirect('/dashboard');
// Get all clients for the organization
const allClients = await db.select()
.from(clients)
.where(eq(clients.organizationId, userOrg.organizationId))
.all();
// Get all categories for the organization
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userOrg.organizationId))
.all();
// Get all tags for the organization
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, userOrg.organizationId))
.all();
// Parse query parameters for filtering, sorting, and pagination
const url = new URL(Astro.request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = 20;
const offset = (page - 1) * pageSize;
const filterClient = url.searchParams.get('client') || '';
const filterCategory = url.searchParams.get('category') || '';
const filterStatus = url.searchParams.get('status') || ''; // completed, running, all
const sortBy = url.searchParams.get('sort') || 'start-desc'; // start-desc, start-asc, duration-desc, duration-asc
const searchTerm = url.searchParams.get('search') || '';
// Build query conditions
const conditions = [eq(timeEntries.organizationId, userOrg.organizationId)];
if (filterClient) {
conditions.push(eq(timeEntries.clientId, filterClient));
}
if (filterCategory) {
conditions.push(eq(timeEntries.categoryId, filterCategory));
}
if (filterStatus === 'completed') {
conditions.push(sql`${timeEntries.endTime} IS NOT NULL`);
} else if (filterStatus === 'running') {
conditions.push(sql`${timeEntries.endTime} IS NULL`);
}
if (searchTerm) {
conditions.push(like(timeEntries.description, `%${searchTerm}%`));
}
// Get total count for pagination
const totalCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries)
.where(and(...conditions))
.get();
const totalPages = Math.ceil((totalCount?.count || 0) / pageSize);
// Build order by
let orderBy;
switch (sortBy) {
case 'start-asc':
orderBy = asc(timeEntries.startTime);
break;
case 'duration-desc':
orderBy = desc(sql`(CASE WHEN ${timeEntries.endTime} IS NULL THEN 0 ELSE ${timeEntries.endTime} - ${timeEntries.startTime} END)`);
break;
case 'duration-asc':
orderBy = asc(sql`(CASE WHEN ${timeEntries.endTime} IS NULL THEN 0 ELSE ${timeEntries.endTime} - ${timeEntries.startTime} END)`);
break;
default: // start-desc
orderBy = desc(timeEntries.startTime);
}
const entries = await db.select({
entry: timeEntries,
client: clients,
category: categories,
user: users,
})
.from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id))
.where(and(...conditions))
.orderBy(orderBy)
.limit(pageSize)
.offset(offset)
.all();
const runningEntry = await db.select({
entry: timeEntries,
client: clients,
})
.from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
.where(and(
eq(timeEntries.userId, user.id),
sql`${timeEntries.endTime} IS NULL`
))
.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`;
}
// Generate pagination page numbers
function getPaginationPages(currentPage: number, totalPages: number): number[] {
const pages: number[] = [];
const numPagesToShow = Math.min(5, totalPages);
for (let i = 0; i < numPagesToShow; i++) {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
pages.push(pageNum);
}
return pages;
}
const paginationPages = getPaginationPages(page, totalPages);
---
<DashboardLayout title="Time Tracker - Zamaan">
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
{allClients.length === 0 ? (
<div class="alert alert-warning mb-6">
<span>You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning mb-6">
<span>You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
</div>
) : (
<Timer
client:load
initialRunningEntry={runningEntry ? {
startTime: runningEntry.entry.startTime.getTime(),
description: runningEntry.entry.description,
clientId: runningEntry.entry.clientId,
categoryId: runningEntry.entry.categoryId,
} : null}
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
<!-- Filters and Search -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<div class="card-body">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Search</span>
</label>
<input
type="text"
name="search"
placeholder="Search descriptions..."
class="input input-bordered"
value={searchTerm}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Client</span>
</label>
<select name="client" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}>
{client.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Category</span>
</label>
<select name="category" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={filterCategory === category.id}>
{category.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select name="status" class="select select-bordered" onchange="this.form.submit()">
<option value="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Sort By</span>
</label>
<select name="sort" class="select select-bordered" onchange="this.form.submit()">
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select>
</div>
<input type="hidden" name="page" value="1" />
<div class="form-control md:col-span-2 lg:col-span-5">
<button type="submit" class="btn btn-primary">
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
Search
</button>
</div>
</form>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
Time Entries ({totalCount?.count || 0} total)
</h2>
{(filterClient || filterCategory || filterStatus || searchTerm) && (
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
Clear Filters
</a>
)}
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Client</th>
<th>Category</th>
<th>Description</th>
<th>Member</th>
<th>Start Time</th>
<th>End Time</th>
<th>Duration</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{entries.map(({ entry, client, category, user: entryUser }) => (
<tr>
<td>{client?.name || 'Unknown'}</td>
<td>
{category ? (
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
</div>
) : '-'}
</td>
<td>{entry.description || '-'}</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap">
{entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50">
{entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</td>
<td class="whitespace-nowrap">
{entry.endTime ? (
<>
{entry.endTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50">
{entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</>
) : (
<span class="badge badge-success">Running</span>
)}
</td>
<td class="font-mono">{formatDuration(entry.startTime, entry.endTime)}</td>
<td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button
type="submit"
class="btn btn-ghost btn-sm text-error"
onclick="return confirm('Are you sure you want to delete this entry?')"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
<!-- Pagination -->
{totalPages > 1 && (
<div class="flex justify-center items-center gap-2 mt-6">
<a
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === 1 ? 'btn-disabled' : ''}`}
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
Previous
</a>
<div class="flex gap-1">
{paginationPages.map(pageNum => (
<a
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === pageNum ? 'btn-active' : ''}`}
>
{pageNum}
</a>
))}
</div>
<a
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === totalPages ? 'btn-disabled' : ''}`}
>
Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</DashboardLayout>

22
src/pages/index.astro Normal file
View File

@@ -0,0 +1,22 @@
---
import Layout from '../layouts/Layout.astro';
if (Astro.locals.user) {
return Astro.redirect('/dashboard');
}
---
<Layout title="Zamaan - Time Tracking">
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Zamaan</h1>
<p class="py-6">Modern time tracking for your organization.</p>
<div class="flex gap-4 justify-center">
<a href="/login" class="btn btn-primary">Login</a>
<a href="/signup" class="btn btn-secondary">Sign Up</a>
</div>
</div>
</div>
</div>
</Layout>

37
src/pages/login.astro Normal file
View File

@@ -0,0 +1,37 @@
---
import Layout from '../layouts/Layout.astro';
if (Astro.locals.user) {
return Astro.redirect('/dashboard');
}
---
<Layout title="Login - Zamaan">
<div class="flex justify-center items-center min-h-screen bg-base-200">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center">Login</h2>
<form action="/api/auth/login" method="POST" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email" name="email" placeholder="email@example.com" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input type="password" name="password" placeholder="********" class="input input-bordered" required />
</div>
<div class="form-control mt-6">
<button class="btn btn-primary">Login</button>
</div>
</form>
<div class="text-center mt-4">
<a href="/signup" class="link link-hover">Don't have an account? Sign up</a>
</div>
</div>
</div>
</div>
</Layout>

74
src/pages/signup.astro Normal file
View File

@@ -0,0 +1,74 @@
---
import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../db';
import { siteSettings, users } from '../db/schema';
import { eq, count } from 'drizzle-orm';
if (Astro.locals.user) {
return Astro.redirect('/dashboard');
}
// Check if this would be the first user
const userCountResult = await db.select({ count: count() }).from(users).get();
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
// Check if registration is enabled (only if not first user)
let registrationDisabled = false;
if (!isFirstUser) {
const registrationSetting = await db.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.get();
registrationDisabled = registrationSetting?.value !== 'true';
}
---
<Layout title="Sign Up - Zamaan">
<div class="flex justify-center items-center min-h-screen bg-base-200">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center">Sign Up</h2>
{registrationDisabled ? (
<div class="alert alert-warning">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
<span>Registration is currently disabled.</span>
</div>
<div class="text-center mt-4">
<a href="/login" class="link link-hover">Already have an account? Login</a>
</div>
) : (
<>
<form action="/api/auth/signup" method="POST" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" name="name" placeholder="John Doe" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email" name="email" placeholder="email@example.com" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input type="password" name="password" placeholder="********" class="input input-bordered" required />
</div>
<div class="form-control mt-6">
<button class="btn btn-primary">Sign Up</button>
</div>
</form>
<div class="text-center mt-4">
<a href="/login" class="link link-hover">Already have an account? Login</a>
</div>
</>
)}
</div>
</div>
</div>
</Layout>

4
src/styles/global.css Normal file
View File

@@ -0,0 +1,4 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: dark --default, light;
};

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "preserve",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true
}
}