feat(reports): add reports page and export notification downloads
This commit is contained in:
271
src/components/reports/ReportsChartPanel.tsx
Normal file
271
src/components/reports/ReportsChartPanel.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
|
||||
const toPersianDigits = (value: string) =>
|
||||
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit);
|
||||
|
||||
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
|
||||
|
||||
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
|
||||
if (!totals.length) return "-";
|
||||
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
|
||||
};
|
||||
|
||||
const formatSecondsTick = (value: number, lang: "en" | "fa") => {
|
||||
const hours = value / 3600;
|
||||
const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1);
|
||||
return localizeDigits(rounded, lang);
|
||||
};
|
||||
|
||||
const parseIsoDate = (value: string) => {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, (month || 1) - 1, day || 1);
|
||||
};
|
||||
|
||||
const formatIsoDate = (value: Date) => {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
const labelFormatters = {
|
||||
dayShort: (value: Date, lang: "en" | "fa") =>
|
||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
||||
weekday: "short",
|
||||
}).format(value),
|
||||
dayLong: (value: Date, lang: "en" | "fa") =>
|
||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(value),
|
||||
monthShort: (value: Date, lang: "en" | "fa") =>
|
||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
||||
month: "short",
|
||||
}).format(value),
|
||||
monthLong: (value: Date, lang: "en" | "fa") =>
|
||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(value),
|
||||
};
|
||||
|
||||
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
|
||||
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]));
|
||||
const result: ReportChartBucket[] = [];
|
||||
const cursor = parseIsoDate(fromDate);
|
||||
const limit = parseIsoDate(toDate);
|
||||
|
||||
while (cursor.getTime() <= limit.getTime()) {
|
||||
const key = formatIsoDate(cursor);
|
||||
const existingBucket = map.get(key);
|
||||
result.push(
|
||||
existingBucket ?? {
|
||||
bucket_key: key,
|
||||
bucket_label: labelFormatters.dayShort(cursor, lang),
|
||||
total_seconds: 0,
|
||||
total_duration: "00:00:00",
|
||||
},
|
||||
);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return result.map((bucket) => ({
|
||||
...bucket,
|
||||
bucket_label: labelFormatters.dayShort(parseIsoDate(bucket.bucket_key), lang),
|
||||
}));
|
||||
};
|
||||
|
||||
const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
|
||||
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]));
|
||||
const result: ReportChartBucket[] = [];
|
||||
const start = parseIsoDate(fromDate);
|
||||
const end = parseIsoDate(toDate);
|
||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const limit = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
|
||||
while (cursor.getTime() <= limit.getTime()) {
|
||||
const key = monthKeyFromDate(cursor);
|
||||
const existingBucket = map.get(key);
|
||||
result.push(
|
||||
existingBucket ?? {
|
||||
bucket_key: key,
|
||||
bucket_label: labelFormatters.monthShort(cursor, lang),
|
||||
total_seconds: 0,
|
||||
total_duration: "00:00:00",
|
||||
},
|
||||
);
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
|
||||
return result.map((bucket) => {
|
||||
const [year, month] = bucket.bucket_key.split("-").map(Number);
|
||||
const date = new Date(year, (month || 1) - 1, 1);
|
||||
return {
|
||||
...bucket,
|
||||
bucket_label: labelFormatters.monthShort(date, lang),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => {
|
||||
if (!payload) return "";
|
||||
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second";
|
||||
if (useMonth) {
|
||||
const [year, month] = payload.bucket_key.split("-").map(Number);
|
||||
return labelFormatters.monthLong(new Date(year, (month || 1) - 1, 1), lang);
|
||||
}
|
||||
return labelFormatters.dayLong(parseIsoDate(payload.bucket_key), lang);
|
||||
};
|
||||
|
||||
function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
lang,
|
||||
totalSecondsLabel,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>;
|
||||
label: string;
|
||||
lang: "en" | "fa";
|
||||
totalSecondsLabel: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const point = payload[0];
|
||||
const seconds = Number(point.value || 0);
|
||||
const hours = seconds / 3600;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsChartPanel({
|
||||
data,
|
||||
labels,
|
||||
}: {
|
||||
data: ChartReportResponse | null;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const { lang } = useTranslation();
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const useMonthlyBuckets =
|
||||
data.scope.period === "this_year" ||
|
||||
data.scope.period === "half_year_first" ||
|
||||
data.scope.period === "half_year_second";
|
||||
|
||||
const buckets = useMonthlyBuckets
|
||||
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang)
|
||||
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang);
|
||||
|
||||
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 88 : 42));
|
||||
const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalHours}</div>
|
||||
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
|
||||
{localizeDigits(data.summary.total_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.billableHours}</div>
|
||||
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
|
||||
{localizeDigits(data.summary.billable_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.nonBillableHours}</div>
|
||||
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
|
||||
{localizeDigits(data.summary.non_billable_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalIncome}</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-slate-900 dark:text-white sm:text-base">
|
||||
{formatMoneyTotals(data.summary.income_totals, lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{localizeDigits(`${buckets.length}`, lang)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "28%" : "18%"} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bucket_label"
|
||||
interval={interval}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
height={40}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => formatSecondsTick(value, lang)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={44}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(14,165,233,0.08)" }}
|
||||
content={({ active, payload }) => (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={formatTooltipLabel(payload?.[0]?.payload as ReportChartBucket | undefined, lang, data.scope.period)}
|
||||
lang={lang}
|
||||
totalSecondsLabel={labels.totalHours}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Bar dataKey="total_seconds" radius={[12, 12, 4, 4]} maxBarSize={useMonthlyBuckets ? 40 : 22}>
|
||||
{buckets.map((bucket) => (
|
||||
<Cell
|
||||
key={bucket.bucket_key}
|
||||
fill={bucket.total_seconds > 0 ? "#0ea5e9" : "#cbd5e1"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
src/components/reports/ReportsFilterBar.tsx
Normal file
323
src/components/reports/ReportsFilterBar.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Filter, Search, X } from "lucide-react";
|
||||
|
||||
import type { ReportPeriod } from "../../api/reports";
|
||||
import type { Project } from "../../api/projects";
|
||||
import type { Tag } from "../../api/tags";
|
||||
import type { WorkspaceMembership } from "../../api/workspaces";
|
||||
import JalaliDatePicker from "../ui/JalaliDatePicker";
|
||||
import { SearchableSelect } from "../ui/SearchableSelect";
|
||||
import { Select } from "../ui/Select";
|
||||
|
||||
export interface ReportsFilterDraft {
|
||||
period: ReportPeriod;
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
user: string;
|
||||
client: string;
|
||||
project: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function ReportTagMultiSelect({
|
||||
tags,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
}: {
|
||||
tags: Tag[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return tags;
|
||||
return tags.filter((tag) => tag.name.toLowerCase().includes(needle));
|
||||
}, [query, tags]);
|
||||
|
||||
const label = value.length
|
||||
? tags
|
||||
.filter((tag) => value.includes(tag.id))
|
||||
.map((tag) => tag.name)
|
||||
.join(" | ")
|
||||
: placeholder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest("[data-reports-tag-select='true']")) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleOutside);
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" data-reports-tag-select="true">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex h-10 w-full items-center rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute inset-x-0 top-full z-40 mt-2 rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 w-full rounded-xl border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto p-2">
|
||||
{filteredTags.map((tag) => {
|
||||
const selected = value.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onChange(selected ? value.filter((id) => id !== tag.id) : [...value, tag.id])}
|
||||
className={`flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm ${
|
||||
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="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
||||
<span className="truncate">{tag.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsFilterBar({
|
||||
value,
|
||||
onApply,
|
||||
projects,
|
||||
clients,
|
||||
tags,
|
||||
users,
|
||||
canSelectUsers,
|
||||
labels,
|
||||
}: {
|
||||
value: ReportsFilterDraft;
|
||||
onApply: (draft: ReportsFilterDraft) => void;
|
||||
projects: Project[];
|
||||
clients: { id: string; name: string }[];
|
||||
tags: Tag[];
|
||||
users: WorkspaceMembership[];
|
||||
canSelectUsers: boolean;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSelectUsers && draft.user) {
|
||||
setDraft((current) => ({ ...current, user: "" }));
|
||||
}
|
||||
}, [canSelectUsers, draft.user]);
|
||||
|
||||
const filteredProjects = draft.client
|
||||
? projects.filter((project) => project.client?.id === draft.client)
|
||||
: projects;
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="xl:col-span-1">
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.period}
|
||||
</label>
|
||||
<Select
|
||||
value={draft.period}
|
||||
onChange={(period) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
period: period as ReportPeriod,
|
||||
from_date: period === "period" ? current.from_date : "",
|
||||
to_date: period === "period" ? current.to_date : "",
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ value: "this_week", label: labels.thisWeek },
|
||||
{ value: "this_month", label: labels.thisMonth },
|
||||
{ value: "this_year", label: labels.thisYear },
|
||||
{ value: "half_year_first", label: labels.firstHalf },
|
||||
{ value: "half_year_second", label: labels.secondHalf },
|
||||
{ value: "period", label: labels.customPeriod },
|
||||
]}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 w-full rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.period === "period" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.fromDate}
|
||||
</label>
|
||||
<JalaliDatePicker
|
||||
value={draft.from_date}
|
||||
onChange={(nextValue) => setDraft((current) => ({ ...current, from_date: nextValue }))}
|
||||
placeholder="YYYY/MM/DD"
|
||||
inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.toDate}
|
||||
</label>
|
||||
<JalaliDatePicker
|
||||
value={draft.to_date}
|
||||
onChange={(nextValue) => setDraft((current) => ({ ...current, to_date: nextValue }))}
|
||||
placeholder="YYYY/MM/DD"
|
||||
inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canSelectUsers ? (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.user}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={draft.user}
|
||||
onChange={(user) => setDraft((current) => ({ ...current, user }))}
|
||||
options={[
|
||||
{ value: "", label: labels.allUsers },
|
||||
...users.map((membership) => ({
|
||||
value: membership.user.id,
|
||||
label:
|
||||
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
|
||||
membership.user.email ||
|
||||
membership.user.id,
|
||||
searchText: membership.user.mobile || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={labels.allUsers}
|
||||
searchPlaceholder={labels.searchUsers}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.client}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={draft.client}
|
||||
onChange={(client) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
client,
|
||||
project:
|
||||
current.project && !projects.some((project) => project.id === current.project && project.client?.id === client)
|
||||
? ""
|
||||
: current.project,
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: labels.allClients },
|
||||
...clients.map((client) => ({ value: client.id, label: client.name })),
|
||||
]}
|
||||
placeholder={labels.allClients}
|
||||
searchPlaceholder={labels.searchClients}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.project}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={draft.project}
|
||||
onChange={(project) => setDraft((current) => ({ ...current, project }))}
|
||||
options={[
|
||||
{ value: "", label: labels.allProjects },
|
||||
...filteredProjects.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
searchText: project.client?.name || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={labels.allProjects}
|
||||
searchPlaceholder={labels.searchProjects}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.tags}
|
||||
</label>
|
||||
<ReportTagMultiSelect
|
||||
tags={tags}
|
||||
value={draft.tags}
|
||||
onChange={(nextTags) => setDraft((current) => ({ ...current, tags: nextTags }))}
|
||||
placeholder={labels.allTags}
|
||||
searchPlaceholder={labels.searchTags || "Search tags..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
period: "this_month",
|
||||
from_date: "",
|
||||
to_date: "",
|
||||
user: "",
|
||||
client: "",
|
||||
project: "",
|
||||
tags: [],
|
||||
})
|
||||
}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{labels.clear}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(draft)}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl bg-sky-600 px-4 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{labels.apply}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
src/components/reports/ReportsTablePanel.tsx
Normal file
272
src/components/reports/ReportsTablePanel.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Fragment } from "react";
|
||||
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react";
|
||||
|
||||
import type { BreakdownRow, DayDetailsResponse, TableReportResponse } from "../../api/reports";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
|
||||
const toPersianDigits = (value: string) =>
|
||||
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit);
|
||||
|
||||
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
|
||||
|
||||
const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => {
|
||||
if (!totals.length) return "-";
|
||||
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
|
||||
};
|
||||
|
||||
const formatDisplayDate = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
const formatDisplayDateTime = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(value);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
function BreakdownCards({
|
||||
title,
|
||||
rows,
|
||||
labels,
|
||||
lang,
|
||||
}: {
|
||||
title: string;
|
||||
rows: BreakdownRow[];
|
||||
labels: Record<string, string>;
|
||||
lang: "en" | "fa";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{title}</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{row.name}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(row.billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(row.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(row.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.name}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.name}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsTablePanel({
|
||||
data,
|
||||
dayDetails,
|
||||
openDay,
|
||||
onToggleDay,
|
||||
onExport,
|
||||
labels,
|
||||
}: {
|
||||
data: TableReportResponse | null;
|
||||
dayDetails: DayDetailsResponse | null;
|
||||
openDay: string | null;
|
||||
onToggleDay: (day: string) => void;
|
||||
onExport: (type: "excel" | "pdf") => void;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const { lang } = useTranslation();
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExport("excel")}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
{labels.exportExcel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExport("pdf")}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700 transition hover:bg-rose-100 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{labels.exportPdf}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{data.days.map((day) => {
|
||||
const isOpen = openDay === day.date;
|
||||
return (
|
||||
<div key={day.date} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{formatDisplayDate(day.date, lang)}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{labels.totalHours}: {localizeDigits(day.total_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(day.date)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(day.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && dayDetails?.day === day.date ? (
|
||||
<div className="mt-3 space-y-2 border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
{dayDetails.entries.map((entry) => (
|
||||
<div key={entry.id} className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{entry.description || labels.noDescription}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{formatDisplayDateTime(entry.start_time, lang)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-2xl border border-sky-200 bg-sky-50 p-3 dark:border-sky-500/20 dark:bg-sky-500/10">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.total}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-700 dark:text-slate-300">
|
||||
<div>{labels.billableHours}: {localizeDigits(data.summary.billable_duration, lang)}</div>
|
||||
<div>{labels.nonBillableHours}: {localizeDigits(data.summary.non_billable_duration, lang)}</div>
|
||||
<div>{labels.totalIncome}: {formatMoneyTotals(data.summary.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="min-w-[860px] w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="w-[18%] px-3 py-3 text-start font-medium">{labels.date}</th>
|
||||
<th className="w-[16%] px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="w-[20%] px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="w-[36%] px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
<th className="w-[10%] px-3 py-3 text-start font-medium">{labels.details}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.days.map((day) => {
|
||||
const isOpen = openDay === day.date;
|
||||
return (
|
||||
<Fragment key={day.date}>
|
||||
<tr className="border-b border-slate-100 dark:border-slate-800/80">
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{formatDisplayDate(day.date, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(day.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(day.date)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && dayDetails?.day === day.date ? (
|
||||
<tr className="border-b border-slate-100 bg-slate-50/70 dark:border-slate-800/80 dark:bg-slate-950/70">
|
||||
<td colSpan={6} className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{dayDetails.entries.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{entry.description || labels.noDescription}</span>
|
||||
{entry.project ? <span className="text-sky-600 dark:text-sky-300">{entry.project.name}</span> : null}
|
||||
<span className="text-slate-500 dark:text-slate-400">{localizeDigits(entry.duration, lang)}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{formatDisplayDateTime(entry.start_time, lang)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-sky-50/80 font-semibold dark:bg-sky-500/10">
|
||||
<td className="px-3 py-3">{labels.total}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{formatMoneyTotals(data.summary.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BreakdownCards title={labels.clientsTable} rows={data.clients} labels={labels} lang={lang} />
|
||||
<BreakdownCards title={labels.projectsTable} rows={data.projects} labels={labels} lang={lang} />
|
||||
<BreakdownCards title={labels.tagsTable} rows={data.tags} labels={labels} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user