feat(timesheet): add tags management and responsive time tracking flows
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -14,6 +14,10 @@ import WorkspaceDetail from "./pages/WorkspaceDetail"
|
|||||||
import EditWorkspace from "./pages/WorkspaceEdit"
|
import EditWorkspace from "./pages/WorkspaceEdit"
|
||||||
import Clients from "./pages/Clients"
|
import Clients from "./pages/Clients"
|
||||||
import { Projects } from "./pages/Projects"
|
import { Projects } from "./pages/Projects"
|
||||||
|
import ProjectCreate from "./pages/ProjectCreate"
|
||||||
|
import ProjectEdit from "./pages/ProjectEdit"
|
||||||
|
import Tags from "./pages/Tags"
|
||||||
|
import Timesheet from "./pages/Timesheet"
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +37,7 @@ const MainLayout = () => {
|
|||||||
|
|
||||||
const RootRedirect = () => {
|
const RootRedirect = () => {
|
||||||
const isAuthenticated = !!localStorage.getItem("accessToken")
|
const isAuthenticated = !!localStorage.getItem("accessToken")
|
||||||
return isAuthenticated ? <Navigate to="/workspaces" replace /> : <Navigate to="/auth" replace />
|
return isAuthenticated ? <Navigate to="/timesheet" replace /> : <Navigate to="/auth" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@@ -51,12 +55,16 @@ const router = createBrowserRouter([
|
|||||||
element: <MainLayout />,
|
element: <MainLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ path: "/profile", element: <Profile /> },
|
{ path: "/profile", element: <Profile /> },
|
||||||
|
{ path: "/timesheet", element: <Timesheet /> },
|
||||||
|
{ path: "/tags", element: <Tags /> },
|
||||||
{ path: "/workspaces", element: <Workspaces /> },
|
{ path: "/workspaces", element: <Workspaces /> },
|
||||||
{ path: "/workspaces/create", element: <CreateWorkspace /> },
|
{ path: "/workspaces/create", element: <CreateWorkspace /> },
|
||||||
{ path: "/workspaces/:id", element: <WorkspaceDetail /> },
|
{ path: "/workspaces/:id", element: <WorkspaceDetail /> },
|
||||||
{ path: "/workspaces/:id/edit", element: <EditWorkspace /> },
|
{ path: "/workspaces/:id/edit", element: <EditWorkspace /> },
|
||||||
{ path: "/clients", element: <Clients /> },
|
{ path: "/clients", element: <Clients /> },
|
||||||
{ path: "/projects", element: <Projects /> },
|
{ path: "/projects", element: <Projects /> },
|
||||||
|
{ path: "/projects/create", element: <ProjectCreate /> },
|
||||||
|
{ path: "/projects/:id/edit", element: <ProjectEdit /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
61
src/api/tags.ts
Normal file
61
src/api/tags.ts
Normal 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
122
src/api/timeEntries.ts
Normal 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");
|
||||||
|
};
|
||||||
@@ -18,15 +18,28 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
|||||||
loader,
|
loader,
|
||||||
}) => {
|
}) => {
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
const onLoadMoreRef = useRef(onLoadMore);
|
||||||
|
const hasMoreRef = useRef(hasMore);
|
||||||
|
const isLoadingRef = useRef(isLoading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLoadMoreRef.current = onLoadMore;
|
||||||
|
hasMoreRef.current = hasMore;
|
||||||
|
isLoadingRef.current = isLoading;
|
||||||
|
}, [onLoadMore, hasMore, isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
if (entries[0].isIntersecting && hasMoreRef.current && !isLoadingRef.current) {
|
||||||
onLoadMore();
|
onLoadMoreRef.current();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 0.1 }
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: "200px",
|
||||||
|
threshold: 0
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (observerTarget.current) {
|
if (observerTarget.current) {
|
||||||
@@ -34,12 +47,13 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMore, isLoading, onLoadMore]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{children}
|
{children}
|
||||||
{hasMore && <div ref={observerTarget} className="h-2 w-full" />}
|
<div ref={observerTarget} className={`h-4 w-full ${!hasMore ? 'hidden' : ''}`} />
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
loader || (
|
loader || (
|
||||||
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
isFa?: boolean;
|
isFa?: boolean;
|
||||||
@@ -18,6 +19,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
|
description,
|
||||||
footer,
|
footer,
|
||||||
maxWidth = "max-w-lg",
|
maxWidth = "max-w-lg",
|
||||||
}) => {
|
}) => {
|
||||||
@@ -40,7 +42,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`w-full ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||||
@@ -58,12 +60,19 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
<div className="flex-1 overflow-y-auto overscroll-contain p-4 md:p-5">
|
||||||
|
{description && (
|
||||||
|
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/50 shrink-0 flex justify-end gap-3">
|
<div className="shrink-0 border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/50">
|
||||||
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Clock3,
|
||||||
|
Tags,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
@@ -22,6 +24,16 @@ export const Sidebar = () => {
|
|||||||
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{
|
||||||
|
path: '/timesheet',
|
||||||
|
icon: Clock3,
|
||||||
|
label: t.sidebar?.timesheet || 'Timesheet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tags',
|
||||||
|
icon: Tags,
|
||||||
|
label: t.sidebar?.tags || 'Tags'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/workspaces',
|
path: '/workspaces',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
|
|||||||
387
src/components/timesheet/TimesheetFilterBar.tsx
Normal file
387
src/components/timesheet/TimesheetFilterBar.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { BriefcaseBusiness, CalendarRange, Check, ChevronDown, FolderKanban, Search, SlidersHorizontal, Tag as TagIcon, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { Project } from "../../api/projects";
|
||||||
|
import type { Tag } from "../../api/tags";
|
||||||
|
import JalaliDatePicker from "../ui/JalaliDatePicker";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
|
||||||
|
export interface TimeEntryFilters {
|
||||||
|
projectId: string;
|
||||||
|
clientId: string;
|
||||||
|
tagIds: string[];
|
||||||
|
startedAfter: string;
|
||||||
|
startedBefore: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimesheetFilterBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
filters: TimeEntryFilters;
|
||||||
|
onApply: (searchQuery: string, filters: TimeEntryFilters) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
projects: Project[];
|
||||||
|
tags: Tag[];
|
||||||
|
searchPlaceholder: string;
|
||||||
|
labels?: {
|
||||||
|
project?: string;
|
||||||
|
client?: string;
|
||||||
|
tags?: string;
|
||||||
|
clear?: string;
|
||||||
|
customFrom?: string;
|
||||||
|
customTo?: string;
|
||||||
|
allClients?: string;
|
||||||
|
allProjects?: string;
|
||||||
|
allTags?: string;
|
||||||
|
showFilters?: string;
|
||||||
|
hideFilters?: string;
|
||||||
|
apply?: string;
|
||||||
|
clientPrefix?: string;
|
||||||
|
projectPrefix?: string;
|
||||||
|
tagPrefix?: string;
|
||||||
|
fromPrefix?: string;
|
||||||
|
toPrefix?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterTagMultiSelect({
|
||||||
|
tags,
|
||||||
|
selectedTagIds,
|
||||||
|
onChange,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
tags: Tag[];
|
||||||
|
selectedTagIds: string[];
|
||||||
|
onChange: (tagIds: string[]) => void;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (!wrapperRef.current?.contains(target) && !dropdownRef.current?.contains(target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !buttonRef.current) return;
|
||||||
|
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const dropdownWidth = Math.max(rect.width, 260);
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const openUpward = spaceBelow < 280 && rect.top > spaceBelow;
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: "fixed",
|
||||||
|
top: openUpward ? `${rect.top - 6}px` : `${rect.bottom + 6}px`,
|
||||||
|
left: `${Math.max(12, rect.right - dropdownWidth)}px`,
|
||||||
|
width: `${dropdownWidth}px`,
|
||||||
|
transform: openUpward ? "translateY(-100%)" : "none",
|
||||||
|
zIndex: 100000,
|
||||||
|
});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeOnViewportChange = () => setIsOpen(false);
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener("resize", closeOnViewportChange);
|
||||||
|
window.addEventListener("scroll", closeOnViewportChange, true);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", closeOnViewportChange);
|
||||||
|
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
||||||
|
const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
className="flex h-8 w-full items-center gap-2 rounded-md border border-slate-200 bg-white px-2.5 text-sm text-slate-700 transition-colors hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:border-slate-600"
|
||||||
|
>
|
||||||
|
<TagIcon className="h-3.5 w-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() =>
|
||||||
|
onChange(
|
||||||
|
selected
|
||||||
|
? selectedTagIds.filter((tagId) => tagId !== tag.id)
|
||||||
|
: [...selectedTagIds, tag.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
|
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-2 truncate">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
</span>
|
||||||
|
{selected && <Check className="h-4 w-4 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniFilterBlock({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
<div className="mb-1 inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimesheetFilterBar({
|
||||||
|
searchQuery,
|
||||||
|
filters,
|
||||||
|
onApply,
|
||||||
|
onClearFilters,
|
||||||
|
projects,
|
||||||
|
tags,
|
||||||
|
searchPlaceholder,
|
||||||
|
labels,
|
||||||
|
}: TimesheetFilterBarProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [draftSearchQuery, setDraftSearchQuery] = useState(searchQuery);
|
||||||
|
const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftSearchQuery(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftFilters(filters);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const clients = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Map(
|
||||||
|
projects
|
||||||
|
.filter((project) => project.client)
|
||||||
|
.map((project) => [project.client!.id, { value: project.client!.id, label: project.client!.name }]),
|
||||||
|
).values(),
|
||||||
|
),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedClient = clients.find((client) => client.value === filters.clientId) || null;
|
||||||
|
const selectedProject = projects.find((project) => project.id === filters.projectId) || null;
|
||||||
|
const selectedTags = tags.filter((tag) => filters.tagIds.includes(tag.id));
|
||||||
|
|
||||||
|
const activeChips = [
|
||||||
|
filters.startedAfter ? `${labels?.fromPrefix || labels?.customFrom || "From"}: ${filters.startedAfter}` : null,
|
||||||
|
filters.startedBefore ? `${labels?.toPrefix || labels?.customTo || "To"}: ${filters.startedBefore}` : null,
|
||||||
|
selectedClient ? `${labels?.clientPrefix || labels?.client || "Client"}: ${selectedClient.label}` : null,
|
||||||
|
selectedProject ? `${labels?.projectPrefix || labels?.project || "Project"}: ${selectedProject.name}` : null,
|
||||||
|
...selectedTags.map((tag) => `${labels?.tagPrefix || labels?.tags || "Tag"}: ${tag.name}`),
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(
|
||||||
|
searchQuery.trim() ||
|
||||||
|
filters.clientId ||
|
||||||
|
filters.projectId ||
|
||||||
|
filters.tagIds.length ||
|
||||||
|
filters.startedAfter ||
|
||||||
|
filters.startedBefore,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draftSearchQuery}
|
||||||
|
onChange={(event) => setDraftSearchQuery(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-9 w-full rounded-md border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800 rtl:pl-3 rtl:pr-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
|
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors ${
|
||||||
|
isExpanded || hasActiveFilters
|
||||||
|
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
<span>{isExpanded ? (labels?.hideFilters || "Hide filters") : (labels?.showFilters || "Filters")}</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-[11px] font-semibold text-white dark:bg-sky-500">
|
||||||
|
{activeChips.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftSearchQuery("");
|
||||||
|
setDraftFilters({
|
||||||
|
projectId: "",
|
||||||
|
clientId: "",
|
||||||
|
tagIds: [],
|
||||||
|
startedAfter: "",
|
||||||
|
startedBefore: "",
|
||||||
|
});
|
||||||
|
onClearFilters();
|
||||||
|
}}
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
{labels?.clear || "Clear"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onApply(draftSearchQuery, draftFilters)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-sky-600 bg-sky-600 px-3 text-sm font-medium text-white transition hover:border-sky-700 hover:bg-sky-700 dark:border-sky-500 dark:bg-sky-500 dark:hover:border-sky-400 dark:hover:bg-sky-400"
|
||||||
|
>
|
||||||
|
{labels?.apply || "Apply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeChips.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{activeChips.map((chip) => (
|
||||||
|
<span
|
||||||
|
key={chip}
|
||||||
|
className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{chip}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="grid gap-2 border-t border-slate-200 pt-2 dark:border-slate-800 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||||
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From"}>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={draftFilters.startedAfter}
|
||||||
|
onChange={(value) => setDraftFilters((current) => ({ ...current, startedAfter: value }))}
|
||||||
|
placeholder="YYYY/MM/DD"
|
||||||
|
inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customTo || "To"}>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={draftFilters.startedBefore}
|
||||||
|
onChange={(value) => setDraftFilters((current) => ({ ...current, startedBefore: value }))}
|
||||||
|
placeholder="YYYY/MM/DD"
|
||||||
|
inputClassName="h-8 border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<BriefcaseBusiness className="h-3.5 w-3.5" />} label={labels?.client || "Client"}>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.clientId}
|
||||||
|
onChange={(clientId) =>
|
||||||
|
setDraftFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
clientId,
|
||||||
|
projectId:
|
||||||
|
current.projectId &&
|
||||||
|
!projects.some((project) => project.id === current.projectId && project.client?.id === clientId)
|
||||||
|
? ""
|
||||||
|
: current.projectId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
options={[{ value: "", label: labels?.allClients || "All clients" }, ...clients]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<FolderKanban className="h-3.5 w-3.5" />} label={labels?.project || "Project"}>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.projectId}
|
||||||
|
onChange={(projectId) => setDraftFilters((current) => ({ ...current, projectId }))}
|
||||||
|
options={[{ value: "", label: labels?.allProjects || "All projects" }, ...(
|
||||||
|
draftFilters.clientId
|
||||||
|
? projects.filter((project) => project.client?.id === draftFilters.clientId)
|
||||||
|
: projects
|
||||||
|
).map((project) => ({ value: project.id, label: project.name }))]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-sm shadow-none focus:ring-0 dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
|
||||||
|
<MiniFilterBlock icon={<TagIcon className="h-3.5 w-3.5" />} label={labels?.tags || "Tags"}>
|
||||||
|
<FilterTagMultiSelect
|
||||||
|
tags={tags}
|
||||||
|
selectedTagIds={draftFilters.tagIds}
|
||||||
|
onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))}
|
||||||
|
title={labels?.allTags || "All tags"}
|
||||||
|
/>
|
||||||
|
</MiniFilterBlock>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ interface JalaliDatePickerProps {
|
|||||||
onChange: (date: string) => void;
|
onChange: (date: string) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
inputClassName?: string;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JalaliDatePicker({ value, onChange, label, disabled }: JalaliDatePickerProps) {
|
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
|
||||||
const isFa = document.documentElement.dir === 'rtl'
|
const isFa = document.documentElement.dir === 'rtl'
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
||||||
|
|
||||||
@@ -47,7 +49,10 @@ export default function JalaliDatePicker({ value, onChange, label, disabled }: J
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
calendar={isFa ? persian : gregorian}
|
calendar={isFa ? persian : gregorian}
|
||||||
locale={isFa ? persian_fa : gregorian_en}
|
locale={isFa ? persian_fa : gregorian_en}
|
||||||
inputClass="w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
format="YYYY/MM/DD"
|
||||||
|
placeholder={placeholder || "YYYY/MM/DD"}
|
||||||
|
onOpenPickNewDate={false}
|
||||||
|
inputClass={`w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${inputClassName}`}
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
className={isDark ? "bg-dark" : ""}
|
className={isDark ? "bg-dark" : ""}
|
||||||
calendarPosition="bottom-right"
|
calendarPosition="bottom-right"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface SelectProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
|
showChevron?: boolean;
|
||||||
|
portalOwnerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Select: React.FC<SelectProps> = ({
|
export const Select: React.FC<SelectProps> = ({
|
||||||
@@ -27,6 +29,8 @@ export const Select: React.FC<SelectProps> = ({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
loadingText = "",
|
loadingText = "",
|
||||||
|
showChevron = true,
|
||||||
|
portalOwnerId,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
@@ -111,7 +115,7 @@ export const Select: React.FC<SelectProps> = ({
|
|||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : showChevron ? (
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
className={`w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -120,7 +124,7 @@ export const Select: React.FC<SelectProps> = ({
|
|||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && !isDisabled &&
|
{isOpen && !isDisabled &&
|
||||||
@@ -128,6 +132,7 @@ export const Select: React.FC<SelectProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
|
data-entry-editor-owner={portalOwnerId}
|
||||||
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-lg py-1 overflow-y-auto max-h-60"
|
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-lg py-1 overflow-y-auto max-h-60"
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const en = {
|
|||||||
lightMode: "Light Mode",
|
lightMode: "Light Mode",
|
||||||
darkMode: "Dark Mode",
|
darkMode: "Dark Mode",
|
||||||
loadingText: "Loading...",
|
loadingText: "Loading...",
|
||||||
|
loading: "Loading...",
|
||||||
|
add: "Add",
|
||||||
|
create: "Create",
|
||||||
|
remove: "Remove",
|
||||||
|
noMoreResults: "No more results.",
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
create: "Create",
|
create: "Create",
|
||||||
@@ -149,6 +154,8 @@ export const en = {
|
|||||||
detailTitle: "Workspace Details",
|
detailTitle: "Workspace Details",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
|
noWorkspaceTitle: "Welcome!",
|
||||||
|
noWorkspaceDesc: "Please create your first workspace.",
|
||||||
back: "Back to Workspaces",
|
back: "Back to Workspaces",
|
||||||
roleLabel: "Your Role",
|
roleLabel: "Your Role",
|
||||||
roles: {
|
roles: {
|
||||||
@@ -238,9 +245,11 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
timesheet: "Timesheet",
|
||||||
workspaces: 'Workspaces',
|
workspaces: 'Workspaces',
|
||||||
clients: 'Clients',
|
clients: 'Clients',
|
||||||
projects: "Projects",
|
projects: "Projects",
|
||||||
|
tags: "Tags",
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
},
|
},
|
||||||
@@ -281,6 +290,105 @@ export const en = {
|
|||||||
restore: "Restore",
|
restore: "Restore",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
clientFetchError: "Failed to load clients.",
|
clientFetchError: "Failed to load clients.",
|
||||||
|
namePlaceholder: "Project name...",
|
||||||
|
teamMembers: "Team Members",
|
||||||
|
creator: "Creator",
|
||||||
|
addUser: "Add user by mobile",
|
||||||
|
addFromWorkspace: "Add from workspace",
|
||||||
|
searchMembers: "Search members...",
|
||||||
|
addAllWorkspaceMembers: "Add all workspace members",
|
||||||
|
confirmDeleteTitle: "Remove Member",
|
||||||
|
confirmDeleteDesc: "Are you sure you want to remove this member from the project?",
|
||||||
|
createSuccess: "Project created successfully.",
|
||||||
|
createError: "Failed to create project.",
|
||||||
|
updateSuccess: "Project updated successfully.",
|
||||||
|
updateError: "Failed to update project.",
|
||||||
|
edit: "Edit Project",
|
||||||
|
memberAlreadyAdded: "This user is already on the project team.",
|
||||||
|
roles: {
|
||||||
|
member: "Member",
|
||||||
|
manager: "Manager"
|
||||||
|
},
|
||||||
|
projectMembers: "Project Members",
|
||||||
|
removeAllWorkspaceMembers: "Remove All",
|
||||||
|
searchWorkspaceMembers: "Search by name or enter mobile number...",
|
||||||
|
userNotFound: "No user found with this mobile number.",
|
||||||
|
alreadyInProject: "Already Added",
|
||||||
|
addToProject: "Add to Project",
|
||||||
|
noWorkspaceMembers: "No members found.",
|
||||||
|
},
|
||||||
|
|
||||||
|
tags: {
|
||||||
|
title: "Tags",
|
||||||
|
description: (workspaceName: string) => `Manage tags for ${workspaceName}`,
|
||||||
|
create: "Create Tag",
|
||||||
|
createTitle: "Create Tag",
|
||||||
|
editTitle: "Edit Tag",
|
||||||
|
searchPlaceholder: "Search tags...",
|
||||||
|
nameLabel: "Tag Name",
|
||||||
|
namePlaceholder: "e.g. Design",
|
||||||
|
colorLabel: "Color",
|
||||||
|
emptyState: "No tags found",
|
||||||
|
selectWorkspace: "Please select a workspace first.",
|
||||||
|
fetchError: "Failed to load tags",
|
||||||
|
createSuccess: "Tag created successfully.",
|
||||||
|
updateSuccess: "Tag updated successfully.",
|
||||||
|
saveError: "Failed to save tag.",
|
||||||
|
deleteSuccess: "Tag deleted successfully.",
|
||||||
|
deleteError: "Failed to delete tag.",
|
||||||
|
},
|
||||||
|
|
||||||
|
timesheet: {
|
||||||
|
title: "Timesheet",
|
||||||
|
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
|
||||||
|
selectWorkspace: "Please select a workspace first.",
|
||||||
|
addEntry: "Add Entry",
|
||||||
|
startTimer: "Start Timer",
|
||||||
|
stopTimer: "Stop Timer",
|
||||||
|
timerRunning: "Timer Running",
|
||||||
|
runningLabel: "Current timer",
|
||||||
|
runningBadge: "Running",
|
||||||
|
noRunningEntry: "No running entry",
|
||||||
|
searchPlaceholder: "Search time entries...",
|
||||||
|
orderingNewest: "Newest first",
|
||||||
|
orderingOldest: "Oldest first",
|
||||||
|
emptyState: "No time entries found",
|
||||||
|
emptyDescription: "No description",
|
||||||
|
createTitle: "Add Time Entry",
|
||||||
|
startTitle: "Start Timer",
|
||||||
|
editTitle: "Edit Time Entry",
|
||||||
|
createSuccess: "Time entry created successfully.",
|
||||||
|
startSuccess: "Timer started successfully.",
|
||||||
|
updateSuccess: "Time entry updated successfully.",
|
||||||
|
saveError: "Failed to save time entry.",
|
||||||
|
stopSuccess: "Timer stopped successfully.",
|
||||||
|
stopError: "Failed to stop timer.",
|
||||||
|
deleteSuccess: "Time entry deleted successfully.",
|
||||||
|
deleteError: "Failed to delete time entry.",
|
||||||
|
fetchError: "Failed to load time entries.",
|
||||||
|
optionsError: "Failed to load projects and tags.",
|
||||||
|
descriptionLabel: "Description",
|
||||||
|
descriptionPlaceholder: "What are you working on?",
|
||||||
|
projectLabel: "Project",
|
||||||
|
noProject: "No project",
|
||||||
|
startLabel: "Start",
|
||||||
|
endLabel: "End",
|
||||||
|
billable: "Billable",
|
||||||
|
noTagsHint: "Create tags first from the Tags page.",
|
||||||
|
clearFilters: "Clear filters",
|
||||||
|
customFromLabel: "From",
|
||||||
|
customToLabel: "To",
|
||||||
|
allClientsLabel: "All clients",
|
||||||
|
allProjectsLabel: "All projects",
|
||||||
|
allTagsLabel: "All tags",
|
||||||
|
showFiltersLabel: "Show filters",
|
||||||
|
hideFiltersLabel: "Hide filters",
|
||||||
|
applyFiltersLabel: "Apply",
|
||||||
|
clientFilterPrefix: "Client",
|
||||||
|
projectFilterPrefix: "Project",
|
||||||
|
tagFilterPrefix: "Tag",
|
||||||
|
fromFilterPrefix: "From",
|
||||||
|
toFilterPrefix: "To",
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ export const fa = {
|
|||||||
confirmLogoutTitle: "تایید خروج",
|
confirmLogoutTitle: "تایید خروج",
|
||||||
confirmLogoutMessage: "آیا مطمئن هستید که میخواهید از حساب خود خارج شوید؟",
|
confirmLogoutMessage: "آیا مطمئن هستید که میخواهید از حساب خود خارج شوید؟",
|
||||||
confirmLeave: "تغییرات ذخیره نشدهای دارید. آیا مطمئن هستید که میخواهید خارج شوید؟",
|
confirmLeave: "تغییرات ذخیره نشدهای دارید. آیا مطمئن هستید که میخواهید خارج شوید؟",
|
||||||
|
add: "افزودن",
|
||||||
|
create: "ایجاد",
|
||||||
cancel: "لغو",
|
cancel: "لغو",
|
||||||
save: "ذخیره",
|
save: "ذخیره",
|
||||||
|
remove: "حذف",
|
||||||
lightMode: "حالت روشن",
|
lightMode: "حالت روشن",
|
||||||
darkMode: "حالت تاریک",
|
darkMode: "حالت تاریک",
|
||||||
loadingText: "در حال بارگزاری...",
|
loadingText: "در حال بارگذاری...",
|
||||||
|
loading: "در حال بارگذاری...",
|
||||||
|
noMoreResults: "نتیجه دیگری نیست.",
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
create: "ایجاد",
|
create: "ایجاد",
|
||||||
@@ -150,6 +155,8 @@ export const fa = {
|
|||||||
detailTitle: "جزئیات ورکاسپیس",
|
detailTitle: "جزئیات ورکاسپیس",
|
||||||
save: "ذخیره",
|
save: "ذخیره",
|
||||||
create: "ایجاد",
|
create: "ایجاد",
|
||||||
|
noWorkspaceTitle: "خوش آمدید!",
|
||||||
|
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
||||||
back: "بازگشت به ورکاسپیسها",
|
back: "بازگشت به ورکاسپیسها",
|
||||||
roleLabel: "نقش شما",
|
roleLabel: "نقش شما",
|
||||||
roles: {
|
roles: {
|
||||||
@@ -235,9 +242,11 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
timesheet: 'تایمشیت',
|
||||||
workspaces: 'ورکاسپیسها',
|
workspaces: 'ورکاسپیسها',
|
||||||
clients: 'مشتریان',
|
clients: 'مشتریان',
|
||||||
projects: "پروژهها",
|
projects: "پروژهها",
|
||||||
|
tags: "تگها",
|
||||||
expand: 'باز کردن',
|
expand: 'باز کردن',
|
||||||
collapse: 'جمع کردن',
|
collapse: 'جمع کردن',
|
||||||
},
|
},
|
||||||
@@ -278,5 +287,104 @@ export const fa = {
|
|||||||
restore: "بازیابی",
|
restore: "بازیابی",
|
||||||
archive: "بایگانی",
|
archive: "بایگانی",
|
||||||
clientFetchError: "خطا در دریافت لیست مشتریان.",
|
clientFetchError: "خطا در دریافت لیست مشتریان.",
|
||||||
|
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
||||||
|
creator: "سازنده",
|
||||||
|
addUser: "افزودن کاربر",
|
||||||
|
addFromWorkspace: "افزودن از اعضای ورکاسپیس",
|
||||||
|
searchMembers: "جستجوی اعضا",
|
||||||
|
addAllWorkspaceMembers: "افزودن همه اعضای ورکاسپیس",
|
||||||
|
confirmDeleteTitle: "حذف عضو",
|
||||||
|
confirmDeleteDesc: "آیا مطمئن هستید که میخواهید این عضو را حذف کنید؟",
|
||||||
|
roles: {
|
||||||
|
member: "عضو",
|
||||||
|
manager: "مدیر"
|
||||||
|
},
|
||||||
|
namePlaceholder: "نام پروژه...",
|
||||||
|
teamMembers: "اعضای تیم",
|
||||||
|
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
||||||
|
createError: "خطا در ایجاد پروژه.",
|
||||||
|
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
||||||
|
updateError: "بهروزرسانی پروژه با خطا مواجه شد.",
|
||||||
|
edit: "ویرایش پروژه",
|
||||||
|
projectMembers: "اعضای پروژه",
|
||||||
|
removeAllWorkspaceMembers: "حذف همه",
|
||||||
|
searchWorkspaceMembers: "جستجو با نام یا وارد کردن شماره موبایل...",
|
||||||
|
userNotFound: "کاربری با این شماره موبایل یافت نشد.",
|
||||||
|
alreadyInProject: "قبلاً اضافه شده",
|
||||||
|
addToProject: "افزودن به پروژه",
|
||||||
|
noWorkspaceMembers: "عضوی یافت نشد.",
|
||||||
|
},
|
||||||
|
|
||||||
|
tags: {
|
||||||
|
title: "تگها",
|
||||||
|
description: (workspaceName: string) => `مدیریت تگها برای ${workspaceName}`,
|
||||||
|
create: "ایجاد تگ",
|
||||||
|
createTitle: "ایجاد تگ",
|
||||||
|
editTitle: "ویرایش تگ",
|
||||||
|
searchPlaceholder: "جستوجوی تگها...",
|
||||||
|
nameLabel: "نام تگ",
|
||||||
|
namePlaceholder: "مثلاً طراحی",
|
||||||
|
colorLabel: "رنگ",
|
||||||
|
emptyState: "تگی یافت نشد",
|
||||||
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
|
fetchError: "دریافت تگها با خطا مواجه شد.",
|
||||||
|
createSuccess: "تگ با موفقیت ایجاد شد.",
|
||||||
|
updateSuccess: "تگ با موفقیت بهروزرسانی شد.",
|
||||||
|
saveError: "ذخیره تگ با خطا مواجه شد.",
|
||||||
|
deleteSuccess: "تگ با موفقیت حذف شد.",
|
||||||
|
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||||
|
},
|
||||||
|
|
||||||
|
timesheet: {
|
||||||
|
title: "تایمشیت",
|
||||||
|
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
|
||||||
|
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||||
|
addEntry: "افزودن ورودی",
|
||||||
|
startTimer: "شروع تایمر",
|
||||||
|
stopTimer: "توقف تایمر",
|
||||||
|
timerRunning: "تایمر فعال است",
|
||||||
|
runningLabel: "تایمر فعلی",
|
||||||
|
runningBadge: "در حال اجرا",
|
||||||
|
noRunningEntry: "تایمر فعالی وجود ندارد",
|
||||||
|
searchPlaceholder: "جستوجوی ورودیهای زمان...",
|
||||||
|
orderingNewest: "جدیدترین",
|
||||||
|
orderingOldest: "قدیمیترین",
|
||||||
|
emptyState: "ورودی زمانی یافت نشد",
|
||||||
|
emptyDescription: "بدون توضیح",
|
||||||
|
createTitle: "افزودن ورودی زمان",
|
||||||
|
startTitle: "شروع تایمر",
|
||||||
|
editTitle: "ویرایش ورودی زمان",
|
||||||
|
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
|
||||||
|
startSuccess: "تایمر با موفقیت شروع شد.",
|
||||||
|
updateSuccess: "ورودی زمان با موفقیت بهروزرسانی شد.",
|
||||||
|
saveError: "ذخیره ورودی زمان با خطا مواجه شد.",
|
||||||
|
stopSuccess: "تایمر با موفقیت متوقف شد.",
|
||||||
|
stopError: "توقف تایمر با خطا مواجه شد.",
|
||||||
|
deleteSuccess: "ورودی زمان با موفقیت حذف شد.",
|
||||||
|
deleteError: "حذف ورودی زمان با خطا مواجه شد.",
|
||||||
|
fetchError: "دریافت ورودیهای زمان با خطا مواجه شد.",
|
||||||
|
optionsError: "دریافت پروژهها و تگها با خطا مواجه شد.",
|
||||||
|
descriptionLabel: "توضیحات",
|
||||||
|
descriptionPlaceholder: "روی چه چیزی کار میکنید؟",
|
||||||
|
projectLabel: "پروژه",
|
||||||
|
noProject: "بدون پروژه",
|
||||||
|
startLabel: "شروع",
|
||||||
|
endLabel: "پایان",
|
||||||
|
billable: "قابل صورتحساب",
|
||||||
|
noTagsHint: "ابتدا از صفحه تگها، تگ ایجاد کنید.",
|
||||||
|
clearFilters: "پاک کردن فیلترها",
|
||||||
|
customFromLabel: "از",
|
||||||
|
customToLabel: "تا",
|
||||||
|
allClientsLabel: "همه مشتریها",
|
||||||
|
allProjectsLabel: "همه پروژهها",
|
||||||
|
allTagsLabel: "همه تگها",
|
||||||
|
showFiltersLabel: "نمایش فیلترها",
|
||||||
|
hideFiltersLabel: "مخفی کردن فیلترها",
|
||||||
|
applyFiltersLabel: "اعمال",
|
||||||
|
clientFilterPrefix: "مشتری",
|
||||||
|
projectFilterPrefix: "پروژه",
|
||||||
|
tagFilterPrefix: "تگ",
|
||||||
|
fromFilterPrefix: "از",
|
||||||
|
toFilterPrefix: "تا",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
242
src/pages/Tags.tsx
Normal file
242
src/pages/Tags.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
|
||||||
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import FilterBar from "../components/FilterBar";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { Pagination } from "../components/Pagination";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = "#3B82F6";
|
||||||
|
|
||||||
|
export default function Tags() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { activeWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [ordering, setOrdering] = useState("-updated_at");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const orderingOptions = [
|
||||||
|
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
|
||||||
|
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
||||||
|
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
|
||||||
|
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
|
||||||
|
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, ordering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeWorkspace?.id) return;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
void loadTags();
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [activeWorkspace?.id, searchQuery, ordering, currentPage, limit]);
|
||||||
|
|
||||||
|
const loadTags = async () => {
|
||||||
|
if (!activeWorkspace?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await getTags(activeWorkspace.id, {
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
ordering,
|
||||||
|
search: searchQuery,
|
||||||
|
});
|
||||||
|
setTags(data.results || []);
|
||||||
|
setTotalItems(data.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(t.tags?.fetchError || "Failed to load tags");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingTag(null);
|
||||||
|
setFormName("");
|
||||||
|
setFormColor(DEFAULT_COLOR);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (tag: Tag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
setFormName(tag.name);
|
||||||
|
setFormColor(tag.color || DEFAULT_COLOR);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingTag(null);
|
||||||
|
setFormName("");
|
||||||
|
setFormColor(DEFAULT_COLOR);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!activeWorkspace?.id || !formName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
if (editingTag) {
|
||||||
|
await updateTag(editingTag.id, { name: formName.trim(), color: formColor });
|
||||||
|
toast.success(t.tags?.updateSuccess || "Tag updated");
|
||||||
|
} else {
|
||||||
|
await createTag(activeWorkspace.id, { name: formName.trim(), color: formColor });
|
||||||
|
toast.success(t.tags?.createSuccess || "Tag created");
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
await loadTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(t.tags?.saveError || "Failed to save tag");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (tag: Tag) => {
|
||||||
|
try {
|
||||||
|
await deleteTag(tag.id);
|
||||||
|
toast.success(t.tags?.deleteSuccess || "Tag deleted");
|
||||||
|
await loadTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(t.tags?.deleteError || "Failed to delete tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeWorkspace) {
|
||||||
|
return <div className="p-6 text-center text-slate-500">{t.tags?.selectWorkspace || t.clients.selectWorkspace}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t.tags?.create || "Create Tag"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
ordering={ordering}
|
||||||
|
setOrdering={setOrdering}
|
||||||
|
orderingOptions={orderingOptions}
|
||||||
|
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Card key={tag.id} className="dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4 px-6">
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-700" style={{ backgroundColor: tag.color || DEFAULT_COLOR }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-lg truncate text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">{tag.color || DEFAULT_COLOR}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-500 dark:text-slate-400">
|
||||||
|
<TagIcon className="w-10 h-10 mb-3" />
|
||||||
|
<p>{t.tags?.emptyState || "No tags found"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingTag ? (t.tags?.editTitle || "Edit Tag") : (t.tags?.createTitle || "Create Tag")}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={closeModal}>
|
||||||
|
{t.actions?.cancel || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}>
|
||||||
|
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.tags?.nameLabel || "Tag name"}
|
||||||
|
</label>
|
||||||
|
<Input value={formName} onChange={(event) => setFormName(event.target.value)} placeholder={t.tags?.namePlaceholder || "Design"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{t.tags?.colorLabel || "Color"}
|
||||||
|
</label>
|
||||||
|
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2495
src/pages/Timesheet.tsx
Normal file
2495
src/pages/Timesheet.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user