feat(timesheet): add tags management and responsive time tracking flows
This commit is contained in:
@@ -18,15 +18,28 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
||||
loader,
|
||||
}) => {
|
||||
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(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
if (entries[0].isIntersecting && hasMoreRef.current && !isLoadingRef.current) {
|
||||
onLoadMoreRef.current();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
{
|
||||
root: null,
|
||||
rootMargin: "200px",
|
||||
threshold: 0
|
||||
}
|
||||
);
|
||||
|
||||
if (observerTarget.current) {
|
||||
@@ -34,12 +47,13 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoading, onLoadMore]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
{hasMore && <div ref={observerTarget} className="h-2 w-full" />}
|
||||
<div ref={observerTarget} className={`h-4 w-full ${!hasMore ? 'hidden' : ''}`} />
|
||||
|
||||
{isLoading && (
|
||||
loader || (
|
||||
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
|
||||
@@ -3,24 +3,26 @@ import { X } from "lucide-react";
|
||||
import { Card } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
isFa?: boolean;
|
||||
}
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
isFa?: boolean;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
maxWidth = "max-w-lg",
|
||||
}) => {
|
||||
title,
|
||||
children,
|
||||
description,
|
||||
footer,
|
||||
maxWidth = "max-w-lg",
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -34,16 +36,16 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<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`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card
|
||||
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()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
@@ -58,14 +60,21 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||
|
||||
{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">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</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 && (
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Briefcase,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Briefcase,
|
||||
Clock3,
|
||||
Tags,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
|
||||
export const Sidebar = () => {
|
||||
@@ -21,10 +23,20 @@ export const Sidebar = () => {
|
||||
? (isCollapsed ? PanelRightOpen : PanelRightClose)
|
||||
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
path: '/workspaces',
|
||||
icon: LayoutDashboard,
|
||||
const navItems = [
|
||||
{
|
||||
path: '/timesheet',
|
||||
icon: Clock3,
|
||||
label: t.sidebar?.timesheet || 'Timesheet'
|
||||
},
|
||||
{
|
||||
path: '/tags',
|
||||
icon: Tags,
|
||||
label: t.sidebar?.tags || 'Tags'
|
||||
},
|
||||
{
|
||||
path: '/workspaces',
|
||||
icon: LayoutDashboard,
|
||||
label: t.sidebar?.workspaces || 'Workspaces'
|
||||
},
|
||||
{
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,16 @@ import gregorian from "react-date-object/calendars/gregorian"
|
||||
import gregorian_en from "react-date-object/locales/gregorian_en"
|
||||
import "react-multi-date-picker/styles/backgrounds/bg-dark.css"
|
||||
|
||||
interface JalaliDatePickerProps {
|
||||
value: string | null | undefined;
|
||||
onChange: (date: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function JalaliDatePicker({ value, onChange, label, disabled }: JalaliDatePickerProps) {
|
||||
interface JalaliDatePickerProps {
|
||||
value: string | null | undefined;
|
||||
onChange: (date: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
inputClassName?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
|
||||
const isFa = document.documentElement.dir === 'rtl'
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
||||
|
||||
@@ -42,14 +44,17 @@ export default function JalaliDatePicker({ value, onChange, label, disabled }: J
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<DatePicker
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={handleChange}
|
||||
calendar={isFa ? persian : gregorian}
|
||||
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"
|
||||
containerClassName="w-full"
|
||||
className={isDark ? "bg-dark" : ""}
|
||||
<DatePicker
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={handleChange}
|
||||
calendar={isFa ? persian : gregorian}
|
||||
locale={isFa ? persian_fa : gregorian_en}
|
||||
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"
|
||||
className={isDark ? "bg-dark" : ""}
|
||||
calendarPosition="bottom-right"
|
||||
fixMainPosition
|
||||
disabled={disabled}
|
||||
|
||||
@@ -7,16 +7,18 @@ export interface SelectOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
interface SelectProps {
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
loadingText?: string;
|
||||
showChevron?: boolean;
|
||||
portalOwnerId?: string;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
value,
|
||||
@@ -24,10 +26,12 @@ export const Select: React.FC<SelectProps> = ({
|
||||
options,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
loadingText = "",
|
||||
}) => {
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
loadingText = "",
|
||||
showChevron = true,
|
||||
portalOwnerId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -106,30 +110,31 @@ export const Select: React.FC<SelectProps> = ({
|
||||
className={`flex items-center justify-between bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md px-3 py-2 text-sm text-slate-700 dark:text-slate-300 outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${buttonClassName}`}
|
||||
>
|
||||
<span className="truncate">{isLoading ? loadingText : selectedOption?.label}</span>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 animate-spin text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 animate-spin text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</svg>
|
||||
) : showChevron ? (
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 rtl:ml-0 rtl:mr-2 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{isOpen && !isDisabled &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle}
|
||||
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"
|
||||
>
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
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"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
|
||||
Reference in New Issue
Block a user