import { Fragment, useMemo, useState } from "react"; import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react"; import type { BreakdownRow, DayDetailsResponse, PercentageRow, TableReportResponse, UserReportSummary, UserScopedTableReport, } from "../../api/reports"; import { useTranslation } from "../../hooks/useTranslation"; import { Modal } from "../Modal"; const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"; const toPersianDigits = (value: string) => value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit); const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value); const shouldTrimCurrencyDecimals = (currency?: string | null) => { const normalized = (currency || "").toUpperCase(); return normalized === "IRR" || normalized === "IRT"; }; const formatAmount = (value: string, lang: "en" | "fa", currency?: string | null) => { const trimmed = value.trim(); if (!trimmed) return trimmed; const numeric = Number(trimmed.replace(/,/g, "")); if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split("."); const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); const signed = trimmed.startsWith("-") ? `-${grouped}` : grouped; const normalizedFraction = fractionalPart && !shouldTrimCurrencyDecimals(currency) ? fractionalPart.replace(/0+$/, "") : ""; const normalized = normalizedFraction ? `${signed}.${normalizedFraction}` : signed; return localizeDigits(normalized, lang); }; const currencyLabel = (currency: string, lang: "en" | "fa") => { const normalized = currency.toUpperCase(); if (lang !== "fa") return normalized; return ( { USD: "دلار آمریکا", EUR: "یورو", GBP: "پوند", IRR: "ریال", IRT: "تومان", AED: "درهم", TRY: "لیر", }[normalized] || normalized ); }; const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => { if (!totals.length) return "-"; return totals .map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`) .join(" | "); }; const formatHourlyRate = (rate: { currency: string; amount: string } | null, lang: "en" | "fa") => { if (!rate) return "-"; return `${formatAmount(rate.amount, lang, rate.currency)} ${currencyLabel(rate.currency, lang)}`; }; 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 formatRateToLabel = (value: string | null | undefined, lang: "en" | "fa", nowLabel: string) => { if (!value) return nowLabel; return formatDisplayDate(value, lang); }; 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); }; const percentageMap = (rows?: PercentageRow[]) => new Map((rows || []).map((row) => [row.id, `${formatAmount(row.percentage, "en")}%`])); function LoadingBlock({ className }: { className: string }) { return
; } function ReportsTableSkeleton({ labels }: { labels: Record }) { return (
{labels.userSummaryTitle}
{labels.loading}
); } function BreakdownTable({ title, rows, hourPercentages, incomePercentages, labels, lang, financialOnly, }: { title: string; rows: BreakdownRow[]; hourPercentages?: PercentageRow[]; incomePercentages?: PercentageRow[]; labels: Record; lang: "en" | "fa"; financialOnly: boolean; }) { const hourPercentageById = useMemo(() => percentageMap(hourPercentages), [hourPercentages]); const incomePercentageById = useMemo(() => percentageMap(incomePercentages), [incomePercentages]); return (
{title}
{rows.map((row) => (
{row.name}
{labels.workingHours}
{localizeDigits(row.billable_duration, lang)}
{labels.hourPercentage}
{localizeDigits(hourPercentageById.get(row.id) || "0%", lang)}
{!financialOnly ? ( <>
{labels.nonWorkingHours}
{localizeDigits(row.non_billable_duration, lang)}
{labels.totalHours}
{localizeDigits(row.total_duration, lang)}
) : null}
{labels.totalIncome}
{formatMoneyTotals(row.income_totals, lang)}
{labels.incomePercentage}
{localizeDigits(incomePercentageById.get(row.id) || "-", lang)}
))}
{financialOnly ? ( ) : ( )} {!financialOnly ? : null} {!financialOnly ? : null} {rows.map((row) => ( {!financialOnly ? : null} {!financialOnly ? : null} ))}
{labels.name} {labels.workingHours} {labels.hourPercentage}{labels.nonWorkingHours}{labels.totalHours}{labels.totalIncome} {labels.incomePercentage}
{row.name} {localizeDigits(row.billable_duration, lang)} {localizeDigits(hourPercentageById.get(row.id) || "0%", lang)}{localizeDigits(row.non_billable_duration, lang)}{localizeDigits(row.total_duration, lang)}{formatMoneyTotals(row.income_totals, lang)} {localizeDigits(incomePercentageById.get(row.id) || "-", lang)}
); } function UserSummaryDetailsModal({ summary, report, isLoading, errorMessage, labels, lang, onClose, }: { summary: UserReportSummary | null; report: UserScopedTableReport | null; isLoading: boolean; errorMessage: string | null; labels: Record; lang: "en" | "fa"; onClose: () => void; }) { if (!summary) return null; const activeSummary = report?.user_summary || summary; return (
{labels.mobile}
{localizeDigits(activeSummary.user.mobile, lang)}
{labels.workingHours}
{localizeDigits(activeSummary.billable_duration, lang)}
{labels.totalIncome}
{formatMoneyTotals(activeSummary.income_totals, lang)}
{errorMessage ? (
{errorMessage}
) : null}
{labels.rateHistory}
{isLoading ? ( Array.from({ length: 3 }).map((_, index) => ( )) ) : activeSummary.rate_periods.length ? ( activeSummary.rate_periods.map((row, index) => ( )) ) : ( )}
{labels.hourlyRate} {labels.fromDate} {labels.toDate}
{`${formatAmount(row.amount, lang, row.currency)} ${currencyLabel(row.currency, lang)}`} {formatDisplayDate(row.from_date, lang)} {formatRateToLabel(row.to_date, lang, labels.now)}
{labels.noData}
{labels.details}
{isLoading ? (
) : report ? (
{report.days.length ? ( report.days.map((day) => ( )) ) : ( )}
{labels.date} {labels.billableHours} {labels.nonBillableHours} {labels.totalHours} {labels.hourlyRate} {labels.totalIncome}
{formatDisplayDate(day.date, lang)} {localizeDigits(day.billable_duration, lang)} {localizeDigits(day.non_billable_duration, lang)} {localizeDigits(day.total_duration, lang)} {formatHourlyRate(day.latest_hourly_rate, lang)} {formatMoneyTotals(day.income_totals, lang)}
{labels.noData}
) : null}
); } function UserSummarySection({ rows, onLoadUserSummaryReport, labels, lang, financialOnly, }: { rows: UserReportSummary[]; onLoadUserSummaryReport: (userId: string) => Promise; labels: Record; lang: "en" | "fa"; financialOnly: boolean; }) { const [selectedSummary, setSelectedSummary] = useState(null); const [reportCache, setReportCache] = useState>({}); const [selectedReport, setSelectedReport] = useState(null); const [isLoadingDetails, setIsLoadingDetails] = useState(false); const [detailsError, setDetailsError] = useState(null); if (!rows.length) return null; const openSummaryDetails = async (summary: UserReportSummary) => { setSelectedSummary(summary); setDetailsError(null); const cached = reportCache[summary.user.id]; if (cached) { setSelectedReport(cached); setIsLoadingDetails(false); return; } setSelectedReport(null); setIsLoadingDetails(true); try { const nextReport = await onLoadUserSummaryReport(summary.user.id); setReportCache((current) => ({ ...current, [summary.user.id]: nextReport })); setSelectedReport(nextReport); } catch { setDetailsError(labels.loadDetailsError); } finally { setIsLoadingDetails(false); } }; return ( <>
{labels.userSummaryTitle}
{!financialOnly ? : null} {rows.map((row) => ( void openSummaryDetails(row)} > {!financialOnly ? : null} ))}
{labels.name} {labels.mobile} {labels.workingHours}{labels.nonWorkingHours}{labels.totalIncome}
{row.user.name} {localizeDigits(row.user.mobile, lang)} {localizeDigits(row.billable_duration, lang)}{localizeDigits(row.non_billable_duration, lang)}{formatMoneyTotals(row.income_totals, lang)}
{ setSelectedSummary(null); setSelectedReport(null); setDetailsError(null); setIsLoadingDetails(false); }} /> ); } function SummaryCards({ summary, labels, lang, }: { summary: TableReportResponse["summary"]; labels: Record; lang: "en" | "fa"; }) { return (
{labels.workingHours}
{localizeDigits(summary.billable_duration, lang)}
{labels.nonWorkingHours}
{localizeDigits(summary.non_billable_duration, lang)}
{labels.totalIncome}
{formatMoneyTotals(summary.income_totals, lang)}
); } function DailyDetailsSection({ data, dayDetails, openDay, onToggleDay, labels, lang, }: { data: TableReportResponse; dayDetails: DayDetailsResponse | null; openDay: string | null; onToggleDay: (day: string) => void; labels: Record; lang: "en" | "fa"; }) { const days = Array.isArray(data.days) ? data.days : []; const entries = Array.isArray(dayDetails?.entries) ? dayDetails.entries : []; const summary = data.summary; return (
{labels.details}
{days.map((day) => { const isOpen = openDay === day.date; return (
{formatDisplayDate(day.date, lang)}
{labels.totalHours}: {localizeDigits(day.total_duration, lang)}
{labels.billableHours}
{localizeDigits(day.billable_duration, lang)}
{labels.nonBillableHours}
{localizeDigits(day.non_billable_duration, lang)}
{labels.hourlyRate}
{formatHourlyRate(day.latest_hourly_rate, lang)}
{labels.totalIncome}
{formatMoneyTotals(day.income_totals, lang)}
{isOpen && dayDetails?.day === day.date ? (
{entries.map((entry) => (
{entry.description || labels.noDescription}
{entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)}
{formatDisplayDateTime(entry.start_time, lang)}
))}
) : null}
); })}
{labels.total}
{labels.billableHours}: {localizeDigits(summary.billable_duration, lang)}
{labels.nonBillableHours}: {localizeDigits(summary.non_billable_duration, lang)}
{labels.totalIncome}: {formatMoneyTotals(summary.income_totals, lang)}
{days.map((day) => { const isOpen = openDay === day.date; return ( {isOpen && dayDetails?.day === day.date ? ( ) : null} ); })}
{labels.date} {labels.billableHours} {labels.nonBillableHours} {labels.hourlyRate} {labels.totalIncome} {labels.details}
{formatDisplayDate(day.date, lang)} {localizeDigits(day.billable_duration, lang)} {localizeDigits(day.non_billable_duration, lang)} {formatHourlyRate(day.latest_hourly_rate, lang)} {formatMoneyTotals(day.income_totals, lang)}
{entries.map((entry) => (
{entry.description || labels.noDescription} {entry.project ? {entry.project.name} : null} {localizeDigits(entry.duration, lang)} {formatDisplayDateTime(entry.start_time, lang)}
))}
{labels.total} {localizeDigits(summary.billable_duration, lang)} {localizeDigits(summary.non_billable_duration, lang)} - {formatMoneyTotals(summary.income_totals, lang)}
); } export function ReportsTablePanel({ data, dayDetails, openDay, onToggleDay, onLoadUserSummaryReport, onExport, exportState, labels, isLoading, }: { data: TableReportResponse | null; dayDetails: DayDetailsResponse | null; openDay: string | null; onToggleDay: (day: string) => void; onLoadUserSummaryReport: (userId: string) => Promise; onExport: (type: "excel" | "pdf") => void; exportState: { excel: { pending: boolean; cooldownSeconds: number }; pdf: { pending: boolean; cooldownSeconds: number }; }; labels: Record; isLoading: boolean; }) { const { lang } = useTranslation(); if (isLoading) { return ; } if (!data) return null; const clients = Array.isArray(data.clients) ? data.clients : []; const projects = Array.isArray(data.projects) ? data.projects : []; const tags = Array.isArray(data.tags) ? data.tags : []; const userSummaries = Array.isArray(data.user_summaries) ? data.user_summaries : []; const isAllUsersScope = Boolean(data.scope?.is_workspace_scope && !data.scope?.user); return (
{!isAllUsersScope ? ( ) : null}
); }