feat(timesheet): add tags management and responsive time tracking flows

This commit is contained in:
2026-04-24 22:23:50 +03:30
parent c4d8379924
commit 987d2e2b59
13 changed files with 3710 additions and 134 deletions

61
src/api/tags.ts Normal file
View File

@@ -0,0 +1,61 @@
import { authFetch } from "./client";
export interface Tag {
id: string;
workspace: string;
name: string;
color: string;
created_at: string;
updated_at: string;
}
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export const getTags = async (
workspaceId: string,
params: { limit?: number; offset?: number; search?: string; ordering?: string } = {},
): Promise<PaginatedResponse<Tag>> => {
const query = new URLSearchParams({ workspace: workspaceId });
if (params.limit !== undefined) query.append("limit", String(params.limit));
if (params.offset !== undefined) query.append("offset", String(params.offset));
if (params.search) query.append("search", params.search);
if (params.ordering) query.append("ordering", params.ordering);
const response = await authFetch(`/api/tags/?${query.toString()}`);
if (!response.ok) throw new Error("Failed to fetch tags");
return response.json();
};
export const createTag = async (workspaceId: string, data: { name: string; color: string }) => {
const response = await authFetch("/api/tags/", {
method: "POST",
body: JSON.stringify({
workspace_id: workspaceId,
...data,
}),
});
if (!response.ok) throw new Error("Failed to create tag");
return response.json();
};
export const updateTag = async (id: string, data: Partial<Pick<Tag, "name" | "color">>) => {
const response = await authFetch(`/api/tags/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to update tag");
return response.json();
};
export const deleteTag = async (id: string) => {
const response = await authFetch(`/api/tags/${id}/`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete tag");
};

122
src/api/timeEntries.ts Normal file
View File

@@ -0,0 +1,122 @@
import { authFetch } from "./client";
export interface TimeEntry {
id: string;
workspace: string;
user: string;
project: string | null;
description: string;
start_time: string;
end_time: string | null;
duration: string | null;
tags: string[];
is_billable: boolean;
hourly_rate: string | null;
currency: string;
created_at: string;
updated_at: string;
}
export interface TimeEntryGroupDay {
key: string;
date: string;
total_ms: number;
entries: TimeEntry[];
}
export interface TimeEntryGroupWeek {
key: string;
week_start: string;
week_end: string;
total_ms: number;
days: TimeEntryGroupDay[];
}
interface GroupedTimeEntryResponse {
items_per_page: number;
current_page_items_count: number;
total_items: number;
offset: number;
next_offset: number | null;
has_more: boolean;
groups: TimeEntryGroupWeek[];
}
export interface TimeEntryPayload {
workspace_id?: string;
project_id?: string | null;
description?: string;
start_time?: string;
end_time?: string | null;
tags?: string[];
is_billable?: boolean;
}
export interface TimeEntryListParams {
limit?: number;
offset?: number;
search?: string;
status?: "running" | "ended" | "all";
project?: string;
client?: string;
tags?: string[];
started_after?: string;
started_before?: string;
}
export const getTimeEntries = async (
workspaceId: string,
params: TimeEntryListParams = {},
): Promise<GroupedTimeEntryResponse> => {
const query = new URLSearchParams({ workspace: workspaceId });
if (params.limit !== undefined) query.append("limit", String(params.limit));
if (params.offset !== undefined) query.append("offset", String(params.offset));
if (params.search) query.append("search", params.search);
if (params.status) query.append("status", params.status);
if (params.project) query.append("project", params.project);
if (params.client) query.append("client", params.client);
if (params.started_after) query.append("started_after", params.started_after);
if (params.started_before) query.append("started_before", params.started_before);
if (params.tags?.length) {
params.tags.forEach((tagId) => query.append("tags", tagId));
}
const response = await authFetch(`/api/time-entries/?${query.toString()}`);
if (!response.ok) throw new Error("Failed to fetch time entries");
return response.json();
};
export const createTimeEntry = async (payload: TimeEntryPayload) => {
const response = await authFetch("/api/time-entries/", {
method: "POST",
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("Failed to create time entry");
return response.json();
};
export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => {
const response = await authFetch(`/api/time-entries/${id}/`, {
method: "PATCH",
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("Failed to update time entry");
return response.json();
};
export const stopTimeEntry = async (id: string, endTime?: string) => {
const response = await authFetch(`/api/time-entries/${id}/stop/`, {
method: "POST",
body: JSON.stringify(endTime ? { end_time: endTime } : {}),
});
if (!response.ok) throw new Error("Failed to stop time entry");
return response.json();
};
export const deleteTimeEntry = async (id: string) => {
const response = await authFetch(`/api/time-entries/${id}/`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete time entry");
};