This commit is contained in:
3
drizzle/0001_demonic_red_skull.sql
Normal file
3
drizzle/0001_demonic_red_skull.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE `time_entry_tags`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `time_entries` ADD `tag_id` text REFERENCES tags(id);--> statement-breakpoint
|
||||||
|
CREATE INDEX `time_entries_tag_id_idx` ON `time_entries` (`tag_id`);
|
||||||
1219
drizzle/meta/0001_snapshot.json
Normal file
1219
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1768934194146,
|
"when": 1768934194146,
|
||||||
"tag": "0000_lazy_rictor",
|
"tag": "0000_lazy_rictor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768935234392,
|
||||||
|
"tag": "0001_demonic_red_skull",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const description = ref("");
|
const description = ref("");
|
||||||
const selectedClientId = ref("");
|
const selectedClientId = ref("");
|
||||||
const selectedTags = ref<string[]>([]);
|
const selectedTagId = ref<string | null>(null);
|
||||||
const startDate = ref("");
|
const startDate = ref("");
|
||||||
const startTime = ref("");
|
const startTime = ref("");
|
||||||
const endDate = ref("");
|
const endDate = ref("");
|
||||||
@@ -26,11 +26,10 @@ startDate.value = today;
|
|||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
|
|
||||||
function toggleTag(tagId: string) {
|
function toggleTag(tagId: string) {
|
||||||
const index = selectedTags.value.indexOf(tagId);
|
if (selectedTagId.value === tagId) {
|
||||||
if (index > -1) {
|
selectedTagId.value = null;
|
||||||
selectedTags.value.splice(index, 1);
|
|
||||||
} else {
|
} else {
|
||||||
selectedTags.value.push(tagId);
|
selectedTagId.value = tagId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ async function submitManualEntry() {
|
|||||||
clientId: selectedClientId.value,
|
clientId: selectedClientId.value,
|
||||||
startTime: startDateTime,
|
startTime: startDateTime,
|
||||||
endTime: endDateTime,
|
endTime: endDateTime,
|
||||||
tags: selectedTags.value,
|
tagId: selectedTagId.value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +111,7 @@ async function submitManualEntry() {
|
|||||||
|
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedTags.value = [];
|
selectedTagId.value = null;
|
||||||
startDate.value = today;
|
startDate.value = today;
|
||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
startTime.value = "";
|
startTime.value = "";
|
||||||
@@ -136,7 +135,7 @@ async function submitManualEntry() {
|
|||||||
function clearForm() {
|
function clearForm() {
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedTags.value = [];
|
selectedTagId.value = null;
|
||||||
startDate.value = today;
|
startDate.value = today;
|
||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
startTime.value = "";
|
startTime.value = "";
|
||||||
@@ -300,7 +299,7 @@ function clearForm() {
|
|||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
selectedTags.includes(tag.id)
|
selectedTagId === tag.id
|
||||||
? 'badge-primary shadow-lg shadow-primary/20'
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
: 'badge-outline hover:bg-base-300/50',
|
: 'badge-outline hover:bg-base-300/50',
|
||||||
]"
|
]"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const props = defineProps<{
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
tagId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
clients: { id: string; name: string }[];
|
clients: { id: string; name: string }[];
|
||||||
tags: { id: string; name: string; color: string | null }[];
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
@@ -17,7 +18,7 @@ const startTime = ref<number | null>(null);
|
|||||||
const elapsedTime = ref(0);
|
const elapsedTime = ref(0);
|
||||||
const description = ref("");
|
const description = ref("");
|
||||||
const selectedClientId = ref("");
|
const selectedClientId = ref("");
|
||||||
const selectedTags = ref<string[]>([]);
|
const selectedTagId = ref<string | null>(null);
|
||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function formatTime(ms: number) {
|
function formatTime(ms: number) {
|
||||||
@@ -46,11 +47,10 @@ function formatTime(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleTag(tagId: string) {
|
function toggleTag(tagId: string) {
|
||||||
const index = selectedTags.value.indexOf(tagId);
|
if (selectedTagId.value === tagId) {
|
||||||
if (index > -1) {
|
selectedTagId.value = null;
|
||||||
selectedTags.value.splice(index, 1);
|
|
||||||
} else {
|
} else {
|
||||||
selectedTags.value.push(tagId);
|
selectedTagId.value = tagId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ onMounted(() => {
|
|||||||
startTime.value = props.initialRunningEntry.startTime;
|
startTime.value = props.initialRunningEntry.startTime;
|
||||||
description.value = props.initialRunningEntry.description || "";
|
description.value = props.initialRunningEntry.description || "";
|
||||||
selectedClientId.value = props.initialRunningEntry.clientId;
|
selectedClientId.value = props.initialRunningEntry.clientId;
|
||||||
|
selectedTagId.value = props.initialRunningEntry.tagId || null;
|
||||||
elapsedTime.value = Date.now() - startTime.value;
|
elapsedTime.value = Date.now() - startTime.value;
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
elapsedTime.value = Date.now() - startTime.value!;
|
elapsedTime.value = Date.now() - startTime.value!;
|
||||||
@@ -83,7 +84,7 @@ async function startTimer() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
description: description.value,
|
description: description.value,
|
||||||
clientId: selectedClientId.value,
|
clientId: selectedClientId.value,
|
||||||
tags: selectedTags.value,
|
tagId: selectedTagId.value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ async function stopTimer() {
|
|||||||
startTime.value = null;
|
startTime.value = null;
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedTags.value = [];
|
selectedTagId.value = null;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ async function stopTimer() {
|
|||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
selectedTags.includes(tag.id)
|
selectedTagId === tag.id
|
||||||
? 'badge-primary shadow-lg shadow-primary/20'
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
: 'badge-outline hover:bg-base-300/50',
|
: 'badge-outline hover:bg-base-300/50',
|
||||||
]"
|
]"
|
||||||
|
|||||||
@@ -97,47 +97,6 @@ export const clients = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
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(),
|
|
||||||
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
|
||||||
endTime: integer("end_time", { mode: "timestamp" }),
|
|
||||||
description: text("description"),
|
|
||||||
invoiceId: text("invoice_id"),
|
|
||||||
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
|
||||||
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],
|
|
||||||
}),
|
|
||||||
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
|
||||||
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
|
||||||
table.organizationId,
|
|
||||||
),
|
|
||||||
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
|
|
||||||
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
|
|
||||||
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const tags = sqliteTable(
|
export const tags = sqliteTable(
|
||||||
"tags",
|
"tags",
|
||||||
{
|
{
|
||||||
@@ -163,26 +122,50 @@ export const tags = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const timeEntryTags = sqliteTable(
|
export const timeEntries = sqliteTable(
|
||||||
"time_entry_tags",
|
"time_entries",
|
||||||
{
|
{
|
||||||
timeEntryId: text("time_entry_id").notNull(),
|
id: text("id")
|
||||||
tagId: text("tag_id").notNull(),
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
organizationId: text("organization_id").notNull(),
|
||||||
|
clientId: text("client_id").notNull(),
|
||||||
|
tagId: text("tag_id"),
|
||||||
|
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
||||||
|
endTime: integer("end_time", { mode: "timestamp" }),
|
||||||
|
description: text("description"),
|
||||||
|
invoiceId: text("invoice_id"),
|
||||||
|
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
(table: any) => ({
|
(table: any) => ({
|
||||||
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
|
userFk: foreignKey({
|
||||||
timeEntryFk: foreignKey({
|
columns: [table.userId],
|
||||||
columns: [table.timeEntryId],
|
foreignColumns: [users.id],
|
||||||
foreignColumns: [timeEntries.id],
|
}),
|
||||||
|
orgFk: foreignKey({
|
||||||
|
columns: [table.organizationId],
|
||||||
|
foreignColumns: [organizations.id],
|
||||||
|
}),
|
||||||
|
clientFk: foreignKey({
|
||||||
|
columns: [table.clientId],
|
||||||
|
foreignColumns: [clients.id],
|
||||||
}),
|
}),
|
||||||
tagFk: foreignKey({
|
tagFk: foreignKey({
|
||||||
columns: [table.tagId],
|
columns: [table.tagId],
|
||||||
foreignColumns: [tags.id],
|
foreignColumns: [tags.id],
|
||||||
}),
|
}),
|
||||||
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
|
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
||||||
table.timeEntryId,
|
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
),
|
),
|
||||||
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
|
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
|
||||||
|
tagIdIdx: index("time_entries_tag_id_idx").on(table.tagId),
|
||||||
|
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
|
||||||
|
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { clients, tags as tagsTable } from "../db/schema";
|
import { clients, tags as tagsTable } from "../db/schema";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
export async function validateTimeEntryResources({
|
export async function validateTimeEntryResources({
|
||||||
organizationId,
|
organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
tagIds,
|
tagId,
|
||||||
}: {
|
}: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
tagIds?: string[];
|
tagId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const client = await db
|
const client = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -23,20 +23,20 @@ export async function validateTimeEntryResources({
|
|||||||
return { valid: false, error: "Invalid client" };
|
return { valid: false, error: "Invalid client" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagIds && tagIds.length > 0) {
|
if (tagId) {
|
||||||
const validTags = await db
|
const validTag = await db
|
||||||
.select()
|
.select()
|
||||||
.from(tagsTable)
|
.from(tagsTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(tagsTable.id, tagIds),
|
eq(tagsTable.id, tagId),
|
||||||
eq(tagsTable.organizationId, organizationId),
|
eq(tagsTable.organizationId, organizationId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
if (validTags.length !== tagIds.length) {
|
if (!validTag) {
|
||||||
return { valid: false, error: "Invalid tags" };
|
return { valid: false, error: "Invalid tag" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../../db";
|
import { db } from "../../../../db";
|
||||||
import {
|
import { clients, members, timeEntries } from "../../../../db/schema";
|
||||||
clients,
|
import { eq, and } from "drizzle-orm";
|
||||||
members,
|
|
||||||
timeEntries,
|
|
||||||
timeEntryTags,
|
|
||||||
} from "../../../../db/schema";
|
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -57,22 +52,7 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
|||||||
return new Response("Not authorized", { status: 403 });
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientEntries = await db
|
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||||
.select({ id: timeEntries.id })
|
|
||||||
.from(timeEntries)
|
|
||||||
.where(eq(timeEntries.clientId, id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const entryIds = clientEntries.map((e) => e.id);
|
|
||||||
|
|
||||||
if (entryIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(timeEntryTags)
|
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(clients).where(eq(clients.id, id)).run();
|
await db.delete(clients).where(eq(clients.id, id)).run();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
invoiceItems,
|
invoiceItems,
|
||||||
timeEntries,
|
timeEntries,
|
||||||
members,
|
members,
|
||||||
timeEntryTags,
|
|
||||||
tags,
|
tags,
|
||||||
} from "../../../../db/schema";
|
} from "../../../../db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,11 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
// Set end date to end of day
|
// Set end date to end of day
|
||||||
endDate.setHours(23, 59, 59, 999);
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).get();
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
@@ -60,8 +63,8 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(members.userId, user.id),
|
eq(members.userId, user.id),
|
||||||
eq(members.organizationId, invoice.organizationId)
|
eq(members.organizationId, invoice.organizationId),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
@@ -70,7 +73,9 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.status !== "draft") {
|
if (invoice.status !== "draft") {
|
||||||
return new Response("Can only import time into draft invoices", { status: 400 });
|
return new Response("Can only import time into draft invoices", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = await db
|
const entries = await db
|
||||||
@@ -79,8 +84,7 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
tag: tags,
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.leftJoin(timeEntryTags, eq(timeEntries.id, timeEntryTags.timeEntryId))
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.leftJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(timeEntries.organizationId, invoice.organizationId),
|
eq(timeEntries.organizationId, invoice.organizationId),
|
||||||
@@ -88,8 +92,8 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
isNull(timeEntries.invoiceId),
|
isNull(timeEntries.invoiceId),
|
||||||
isNotNull(timeEntries.endTime),
|
isNotNull(timeEntries.endTime),
|
||||||
gte(timeEntries.startTime, startDate),
|
gte(timeEntries.startTime, startDate),
|
||||||
lte(timeEntries.startTime, endDate)
|
lte(timeEntries.startTime, endDate),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(timeEntries.startTime));
|
.orderBy(desc(timeEntries.startTime));
|
||||||
|
|
||||||
@@ -238,16 +242,20 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
|
|
||||||
let discountAmount = 0;
|
let discountAmount = 0;
|
||||||
if (invoice.discountType === "percentage") {
|
if (invoice.discountType === "percentage") {
|
||||||
discountAmount = Math.round(subtotal * ((invoice.discountValue || 0) / 100));
|
discountAmount = Math.round(
|
||||||
|
subtotal * ((invoice.discountValue || 0) / 100),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||||
if (invoice.discountValue && invoice.discountValue > 0) {
|
if (invoice.discountValue && invoice.discountValue > 0) {
|
||||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
||||||
const taxAmount = Math.round(taxableAmount * ((invoice.taxRate || 0) / 100));
|
const taxAmount = Math.round(
|
||||||
|
taxableAmount * ((invoice.taxRate || 0) / 100),
|
||||||
|
);
|
||||||
const total = subtotal - discountAmount + taxAmount;
|
const total = subtotal - discountAmount + taxAmount;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import {
|
import { timeEntries, members, users, clients, tags } from "../../../db/schema";
|
||||||
timeEntries,
|
import { eq, and, gte, lte, desc } from "drizzle-orm";
|
||||||
members,
|
|
||||||
users,
|
|
||||||
clients,
|
|
||||||
tags,
|
|
||||||
timeEntryTags,
|
|
||||||
} from "../../../db/schema";
|
|
||||||
import { eq, and, gte, lte, desc, inArray } from "drizzle-orm";
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -114,37 +107,16 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
user: users,
|
user: users,
|
||||||
client: clients,
|
client: clients,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Fetch tags for these entries
|
|
||||||
const entryIds = entries.map((e) => e.entry.id);
|
|
||||||
const tagsMap = new Map<string, string[]>();
|
|
||||||
|
|
||||||
if (entryIds.length > 0) {
|
|
||||||
const entryTags = await db
|
|
||||||
.select({
|
|
||||||
entryId: timeEntryTags.timeEntryId,
|
|
||||||
tagName: tags.name,
|
|
||||||
})
|
|
||||||
.from(timeEntryTags)
|
|
||||||
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const tag of entryTags) {
|
|
||||||
if (!tagsMap.has(tag.entryId)) {
|
|
||||||
tagsMap.set(tag.entryId, []);
|
|
||||||
}
|
|
||||||
tagsMap.get(tag.entryId)!.push(tag.tagName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate CSV
|
// Generate CSV
|
||||||
const headers = [
|
const headers = [
|
||||||
"Date",
|
"Date",
|
||||||
@@ -153,7 +125,7 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
"Duration (h)",
|
"Duration (h)",
|
||||||
"Member",
|
"Member",
|
||||||
"Client",
|
"Client",
|
||||||
"Tags",
|
"Tag",
|
||||||
"Description",
|
"Description",
|
||||||
];
|
];
|
||||||
const rows = entries.map((e) => {
|
const rows = entries.map((e) => {
|
||||||
@@ -165,7 +137,7 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
|
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsStr = tagsMap.get(e.entry.id)?.join("; ") || "";
|
const tagsStr = e.tag?.name || "";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
start.toLocaleDateString(),
|
start.toLocaleDateString(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../../db";
|
import { db } from "../../../../db";
|
||||||
import { tags, members, timeEntryTags } from "../../../../db/schema";
|
import { tags, members, timeEntries } from "../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||||
@@ -44,8 +44,11 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete associations first
|
// Remove tag from time entries
|
||||||
await db.delete(timeEntryTags).where(eq(timeEntryTags.tagId, id));
|
await db
|
||||||
|
.update(timeEntries)
|
||||||
|
.set({ tagId: null })
|
||||||
|
.where(eq(timeEntries.tagId, id));
|
||||||
|
|
||||||
// Delete the tag
|
// Delete the tag
|
||||||
await db.delete(tags).where(eq(tags.id, id));
|
await db.delete(tags).where(eq(tags.id, id));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
import { timeEntries, members } from "../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { description, clientId, startTime, endTime, tags } = body;
|
const { description, clientId, startTime, endTime, tagId } = body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@@ -74,7 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const resourceValidation = await validateTimeEntryResources({
|
const resourceValidation = await validateTimeEntryResources({
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
tagIds: Array.isArray(tags) ? tags : undefined,
|
tagId: tagId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resourceValidation.valid) {
|
if (!resourceValidation.valid) {
|
||||||
@@ -93,22 +93,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
userId: locals.user.id,
|
userId: locals.user.id,
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
|
tagId: tagId || null,
|
||||||
startTime: startDate,
|
startTime: startDate,
|
||||||
endTime: endDate,
|
endTime: endDate,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
isManual: true,
|
isManual: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert tags if provided
|
|
||||||
if (tags && Array.isArray(tags) && tags.length > 0) {
|
|
||||||
await db.insert(timeEntryTags).values(
|
|
||||||
tags.map((tagId: string) => ({
|
|
||||||
timeEntryId: id,
|
|
||||||
tagId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
import { timeEntries, members } from "../../../db/schema";
|
||||||
import { eq, and, isNull } from "drizzle-orm";
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { validateTimeEntryResources } from "../../../lib/validation";
|
import { validateTimeEntryResources } from "../../../lib/validation";
|
||||||
@@ -11,7 +11,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const description = body.description || "";
|
const description = body.description || "";
|
||||||
const clientId = body.clientId;
|
const clientId = body.clientId;
|
||||||
const tags = body.tags || [];
|
const tagId = body.tagId || null;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return new Response("Client is required", { status: 400 });
|
return new Response("Client is required", { status: 400 });
|
||||||
@@ -42,7 +42,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const validation = await validateTimeEntryResources({
|
const validation = await validateTimeEntryResources({
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
tagIds: tags,
|
tagId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
@@ -57,19 +57,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
userId: locals.user.id,
|
userId: locals.user.id,
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
|
tagId,
|
||||||
startTime,
|
startTime,
|
||||||
description,
|
description,
|
||||||
isManual: false,
|
isManual: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 });
|
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { clients, timeEntries, members, tags, timeEntryTags, users } from '../../../../db/schema';
|
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
|
||||||
import { eq, and, desc, sql, inArray } from 'drizzle-orm';
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -38,44 +38,19 @@ const client = await db.select()
|
|||||||
if (!client) return Astro.redirect('/dashboard/clients');
|
if (!client) return Astro.redirect('/dashboard/clients');
|
||||||
|
|
||||||
// Get recent activity
|
// Get recent activity
|
||||||
const recentEntriesData = await db.select({
|
const recentEntries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
user: users,
|
user: users,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(eq(timeEntries.clientId, client.id))
|
.where(eq(timeEntries.clientId, client.id))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Fetch tags for these entries
|
|
||||||
const entryIds = recentEntriesData.map(e => e.entry.id);
|
|
||||||
const tagsMap = new Map<string, { name: string; color: string | null }[]>();
|
|
||||||
|
|
||||||
if (entryIds.length > 0) {
|
|
||||||
const entryTagsData = await db.select({
|
|
||||||
timeEntryId: timeEntryTags.timeEntryId,
|
|
||||||
tag: tags
|
|
||||||
})
|
|
||||||
.from(timeEntryTags)
|
|
||||||
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const item of entryTagsData) {
|
|
||||||
if (!tagsMap.has(item.timeEntryId)) {
|
|
||||||
tagsMap.set(item.timeEntryId, []);
|
|
||||||
}
|
|
||||||
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentEntries = recentEntriesData.map(e => ({
|
|
||||||
...e,
|
|
||||||
tags: tagsMap.get(e.entry.id) || []
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate total time tracked
|
// Calculate total time tracked
|
||||||
const totalTimeResult = await db.select({
|
const totalTimeResult = await db.select({
|
||||||
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
|
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
|
||||||
@@ -206,27 +181,25 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Tags</th>
|
<th>Tag</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentEntries.map(({ entry, tags, user: entryUser }) => (
|
{recentEntries.map(({ entry, tag, user: entryUser }) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{entry.description || '-'}</td>
|
<td>{entry.description || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex flex-wrap gap-1">
|
{tag ? (
|
||||||
{tags.length > 0 ? tags.map(tag => (
|
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||||
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
{tag.color && (
|
||||||
{tag.color && (
|
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
||||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
)}
|
||||||
)}
|
<span>{tag.name}</span>
|
||||||
<span>{tag.name}</span>
|
</div>
|
||||||
</div>
|
) : '-'}
|
||||||
)) : '-'}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{entryUser?.name || 'Unknown'}</td>
|
<td>{entryUser?.name || 'Unknown'}</td>
|
||||||
<td>{entry.startTime.toLocaleDateString()}</td>
|
<td>{entry.startTime.toLocaleDateString()}</td>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { organizations, members, timeEntries, clients, tags, timeEntryTags } from '../../db/schema';
|
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||||
import { eq, desc, and, isNull, gte, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||||
import { formatDuration } from '../../lib/formatTime';
|
import { formatDuration } from '../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -84,42 +84,18 @@ if (currentOrg) {
|
|||||||
|
|
||||||
stats.totalClients = clientCount?.count || 0;
|
stats.totalClients = clientCount?.count || 0;
|
||||||
|
|
||||||
const recentEntriesData = await db.select({
|
stats.recentEntries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
client: clients,
|
client: clients,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(5)
|
.limit(5)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const entryIds = recentEntriesData.map(e => e.entry.id);
|
|
||||||
const tagsMap = new Map<string, { name: string; color: string | null }[]>();
|
|
||||||
|
|
||||||
if (entryIds.length > 0) {
|
|
||||||
const entryTagsData = await db.select({
|
|
||||||
timeEntryId: timeEntryTags.timeEntryId,
|
|
||||||
tag: tags
|
|
||||||
})
|
|
||||||
.from(timeEntryTags)
|
|
||||||
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const item of entryTagsData) {
|
|
||||||
if (!tagsMap.has(item.timeEntryId)) {
|
|
||||||
tagsMap.set(item.timeEntryId, []);
|
|
||||||
}
|
|
||||||
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.recentEntries = recentEntriesData.map(e => ({
|
|
||||||
...e,
|
|
||||||
tags: tagsMap.get(e.entry.id) || []
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMembership = userOrgs.length > 0;
|
const hasMembership = userOrgs.length > 0;
|
||||||
@@ -229,14 +205,14 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
</h2>
|
</h2>
|
||||||
{stats.recentEntries.length > 0 ? (
|
{stats.recentEntries.length > 0 ? (
|
||||||
<ul class="space-y-3 mt-4">
|
<ul class="space-y-3 mt-4">
|
||||||
{stats.recentEntries.map(({ entry, client, tags }) => (
|
{stats.recentEntries.map(({ entry, client, tag }) => (
|
||||||
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tags[0]?.color || '#3b82f6'}`}>
|
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tag?.color || '#3b82f6'}`}>
|
||||||
<div class="font-semibold text-sm">{client.name}</div>
|
<div class="font-semibold text-sm">{client.name}</div>
|
||||||
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
|
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
|
||||||
<span class="flex gap-1 flex-wrap">
|
<span class="flex gap-1 flex-wrap">
|
||||||
{tags.length > 0 ? tags.map((tag: any) => (
|
{tag ? (
|
||||||
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
||||||
)) : <span class="italic opacity-50">No tags</span>}
|
) : <span class="italic opacity-50">No tag</span>}
|
||||||
</span>
|
</span>
|
||||||
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import TagChart from '../../components/TagChart.vue';
|
|||||||
import ClientChart from '../../components/ClientChart.vue';
|
import ClientChart from '../../components/ClientChart.vue';
|
||||||
import MemberChart from '../../components/MemberChart.vue';
|
import MemberChart from '../../components/MemberChart.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, members, users, clients, tags, timeEntryTags, invoices } from '../../db/schema';
|
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
|
||||||
import { eq, and, gte, lte, sql, desc, inArray, exists } from 'drizzle-orm';
|
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -103,64 +103,27 @@ if (selectedMemberId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTagId) {
|
if (selectedTagId) {
|
||||||
conditions.push(exists(
|
conditions.push(eq(timeEntries.tagId, selectedTagId));
|
||||||
db.select()
|
|
||||||
.from(timeEntryTags)
|
|
||||||
.where(and(
|
|
||||||
eq(timeEntryTags.timeEntryId, timeEntries.id),
|
|
||||||
eq(timeEntryTags.tagId, selectedTagId)
|
|
||||||
))
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedClientId) {
|
if (selectedClientId) {
|
||||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const entriesData = await db.select({
|
const entries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
user: users,
|
user: users,
|
||||||
client: clients,
|
client: clients,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Fetch tags for these entries
|
|
||||||
const entryIds = entriesData.map(e => e.entry.id);
|
|
||||||
const tagsMap = new Map<string, typeof allTags>();
|
|
||||||
|
|
||||||
if (entryIds.length > 0) {
|
|
||||||
// Process in chunks if too many entries, but for now simple inArray
|
|
||||||
// Sqlite has limits on variables, but usually ~999. Assuming reasonable page size or volume.
|
|
||||||
// If entryIds is massive, this might fail, but for a dashboard report it's usually acceptable or needs pagination/limits.
|
|
||||||
// However, `inArray` can be empty, so we checked length.
|
|
||||||
|
|
||||||
const entryTagsData = await db.select({
|
|
||||||
timeEntryId: timeEntryTags.timeEntryId,
|
|
||||||
tag: tags
|
|
||||||
})
|
|
||||||
.from(timeEntryTags)
|
|
||||||
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const item of entryTagsData) {
|
|
||||||
if (!tagsMap.has(item.timeEntryId)) {
|
|
||||||
tagsMap.set(item.timeEntryId, []);
|
|
||||||
}
|
|
||||||
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = entriesData.map(e => ({
|
|
||||||
...e,
|
|
||||||
tags: tagsMap.get(e.entry.id) || []
|
|
||||||
}));
|
|
||||||
|
|
||||||
const statsByMember = teamMembers.map(member => {
|
const statsByMember = teamMembers.map(member => {
|
||||||
const memberEntries = entries.filter(e => e.user.id === member.id);
|
const memberEntries = entries.filter(e => e.user.id === member.id);
|
||||||
const totalTime = memberEntries.reduce((sum, e) => {
|
const totalTime = memberEntries.reduce((sum, e) => {
|
||||||
@@ -178,7 +141,7 @@ const statsByMember = teamMembers.map(member => {
|
|||||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||||
|
|
||||||
const statsByTag = allTags.map(tag => {
|
const statsByTag = allTags.map(tag => {
|
||||||
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
|
const tagEntries = entries.filter(e => e.tag?.id === tag.id);
|
||||||
const totalTime = tagEntries.reduce((sum, e) => {
|
const totalTime = tagEntries.reduce((sum, e) => {
|
||||||
if (e.entry.endTime) {
|
if (e.entry.endTime) {
|
||||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||||
@@ -811,7 +774,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th>Tags</th>
|
<th>Tag</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -828,17 +791,16 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<td>{e.user.name}</td>
|
<td>{e.user.name}</td>
|
||||||
<td>{e.client.name}</td>
|
<td>{e.client.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex flex-wrap gap-1">
|
{e.tag ? (
|
||||||
{e.tags.map(tag => (
|
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||||
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
{e.tag.color && (
|
||||||
{tag.color && (
|
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
|
||||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
)}
|
||||||
)}
|
<span>{e.tag.name}</span>
|
||||||
<span>{tag.name}</span>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
))}
|
<span class="opacity-50">-</span>
|
||||||
{e.tags.length === 0 && <span class="opacity-50">-</span>}
|
)}
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{e.entry.description || '-'}</td>
|
<td>{e.entry.description || '-'}</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Icon } from 'astro-icon/components';
|
|||||||
import Timer from '../../components/Timer.vue';
|
import Timer from '../../components/Timer.vue';
|
||||||
import ManualEntry from '../../components/ManualEntry.vue';
|
import ManualEntry from '../../components/ManualEntry.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, clients, members, tags, timeEntryTags, users } from '../../db/schema';
|
import { timeEntries, clients, members, tags, users } from '../../db/schema';
|
||||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||||
import { formatTimeRange } from '../../lib/formatTime';
|
import { formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
@@ -99,10 +99,12 @@ const entries = await db.select({
|
|||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
client: clients,
|
client: clients,
|
||||||
user: users,
|
user: users,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(orderBy)
|
.orderBy(orderBy)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
@@ -112,9 +114,11 @@ const entries = await db.select({
|
|||||||
const runningEntry = await db.select({
|
const runningEntry = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
client: clients,
|
client: clients,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.userId, user.id),
|
eq(timeEntries.userId, user.id),
|
||||||
sql`${timeEntries.endTime} IS NULL`
|
sql`${timeEntries.endTime} IS NULL`
|
||||||
@@ -165,6 +169,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
startTime: runningEntry.entry.startTime.getTime(),
|
startTime: runningEntry.entry.startTime.getTime(),
|
||||||
description: runningEntry.entry.description,
|
description: runningEntry.entry.description,
|
||||||
clientId: runningEntry.entry.clientId,
|
clientId: runningEntry.entry.clientId,
|
||||||
|
tagId: runningEntry.tag?.id,
|
||||||
} : null}
|
} : null}
|
||||||
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||||
|
|||||||
Reference in New Issue
Block a user