Compare commits
8 Commits
803c3ce629
...
9fceef3753
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fceef3753 | |||
| a770272ce2 | |||
| 02cd2d67a0 | |||
| eee22ad6fb | |||
| 226faa70c0 | |||
| 1e5f0b6b5e | |||
| 8ecf317700 | |||
| 858aa977f7 |
@@ -25,17 +25,18 @@ export interface ProjectMembership {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
workspace: string;
|
is_deleted?: boolean;
|
||||||
client: ProjectClient | null;
|
workspace: string;
|
||||||
my_role?: string;
|
client: ProjectClient | null;
|
||||||
members?: ProjectMembership[];
|
my_role?: string;
|
||||||
}
|
members?: ProjectMembership[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectPayload {
|
export interface ProjectPayload {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Tag {
|
|||||||
workspace: string;
|
workspace: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
is_deleted?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { authFetch } from "./client";
|
import { authFetch } from "./client";
|
||||||
|
|
||||||
|
export interface TimeEntryProjectDetails {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
client_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryTagDetails {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimeEntry {
|
export interface TimeEntry {
|
||||||
id: string;
|
id: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
user: string;
|
user: string;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
|
project_details: TimeEntryProjectDetails | null;
|
||||||
description: string;
|
description: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string | null;
|
end_time: string | null;
|
||||||
duration: string | null;
|
duration: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
tag_details: TimeEntryTagDetails[];
|
||||||
is_billable: boolean;
|
is_billable: boolean;
|
||||||
hourly_rate: string | null;
|
hourly_rate: string | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|||||||
@@ -24,43 +24,43 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
|
|||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const isRtl = lang === 'fa';
|
const isRtl = lang === 'fa';
|
||||||
|
|
||||||
const ToggleIcon = isRtl
|
const ToggleIcon = isRtl
|
||||||
? (isCollapsed ? PanelRightOpen : PanelRightClose)
|
? (isCollapsed ? PanelRightOpen : PanelRightClose)
|
||||||
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
path: '/timesheet',
|
path: '/timesheet',
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
label: t.sidebar?.timesheet || 'Timesheet'
|
label: t.sidebar?.timesheet || 'Timesheet'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/reports',
|
|
||||||
icon: ChartColumn,
|
|
||||||
label: t.sidebar?.reports || 'Reports'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/tags',
|
path: '/tags',
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
label: t.sidebar?.tags || 'Tags'
|
label: t.sidebar?.tags || 'Tags'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/clients',
|
||||||
|
icon: Users,
|
||||||
|
label: t.sidebar?.clients || 'Clients'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
icon: Briefcase,
|
||||||
|
label: t.sidebar?.projects || 'Projects'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/workspaces',
|
path: '/workspaces',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
label: t.sidebar?.workspaces || 'Workspaces'
|
label: t.sidebar?.workspaces || 'Workspaces'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/clients',
|
path: '/reports',
|
||||||
icon: Users,
|
icon: ChartColumn,
|
||||||
label: t.sidebar?.clients || 'Clients'
|
label: t.sidebar?.reports || 'Reports'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/projects',
|
|
||||||
icon: Briefcase,
|
|
||||||
label: t.sidebar?.projects || 'Projects'
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderNavItems = (mobile = false) =>
|
const renderNavItems = (mobile = false) =>
|
||||||
|
|||||||
@@ -37,9 +37,38 @@ const toPersianDigits = (value: string) =>
|
|||||||
|
|
||||||
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
|
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
|
||||||
|
|
||||||
|
const formatAmount = (value: string, lang: "en" | "fa") => {
|
||||||
|
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 normalized = fractionalPart ? `${signed}.${fractionalPart}` : 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: CurrencyTotal[], lang: "en" | "fa") => {
|
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
|
||||||
if (!totals.length) return "-";
|
if (!totals.length) return "-";
|
||||||
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
|
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSecondsTick = (value: number, lang: "en" | "fa") => {
|
const formatSecondsTick = (value: number, lang: "en" | "fa") => {
|
||||||
|
|||||||
@@ -9,9 +9,38 @@ const toPersianDigits = (value: string) =>
|
|||||||
|
|
||||||
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
|
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
|
||||||
|
|
||||||
|
const formatAmount = (value: string, lang: "en" | "fa") => {
|
||||||
|
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 normalized = fractionalPart ? `${signed}.${fractionalPart}` : 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") => {
|
const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => {
|
||||||
if (!totals.length) return "-";
|
if (!totals.length) return "-";
|
||||||
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
|
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDisplayDate = (value: string, lang: "en" | "fa") => {
|
const formatDisplayDate = (value: string, lang: "en" | "fa") => {
|
||||||
@@ -98,6 +127,7 @@ export function ReportsTablePanel({
|
|||||||
openDay,
|
openDay,
|
||||||
onToggleDay,
|
onToggleDay,
|
||||||
onExport,
|
onExport,
|
||||||
|
exportState,
|
||||||
labels,
|
labels,
|
||||||
}: {
|
}: {
|
||||||
data: TableReportResponse | null;
|
data: TableReportResponse | null;
|
||||||
@@ -105,6 +135,10 @@ export function ReportsTablePanel({
|
|||||||
openDay: string | null;
|
openDay: string | null;
|
||||||
onToggleDay: (day: string) => void;
|
onToggleDay: (day: string) => void;
|
||||||
onExport: (type: "excel" | "pdf") => void;
|
onExport: (type: "excel" | "pdf") => void;
|
||||||
|
exportState: {
|
||||||
|
excel: { pending: boolean; cooldownSeconds: number };
|
||||||
|
pdf: { pending: boolean; cooldownSeconds: number };
|
||||||
|
};
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
const { lang } = useTranslation();
|
const { lang } = useTranslation();
|
||||||
@@ -117,18 +151,28 @@ export function ReportsTablePanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onExport("excel")}
|
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"
|
disabled={exportState.excel.pending || exportState.excel.cooldownSeconds > 0}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="h-4 w-4" />
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
{labels.exportExcel}
|
{exportState.excel.pending
|
||||||
|
? labels.exportExcel
|
||||||
|
: exportState.excel.cooldownSeconds > 0
|
||||||
|
? `${labels.exportExcel} (${localizeDigits(String(exportState.excel.cooldownSeconds), lang)})`
|
||||||
|
: labels.exportExcel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onExport("pdf")}
|
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"
|
disabled={exportState.pdf.pending || exportState.pdf.cooldownSeconds > 0}
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
{labels.exportPdf}
|
{exportState.pdf.pending
|
||||||
|
? labels.exportPdf
|
||||||
|
: exportState.pdf.cooldownSeconds > 0
|
||||||
|
? `${labels.exportPdf} (${localizeDigits(String(exportState.pdf.cooldownSeconds), lang)})`
|
||||||
|
: labels.exportPdf}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export default function TimesheetFilterBar({
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-slate-200 pt-2 dark:border-slate-800">
|
<div className="border-t border-slate-200 pt-2 dark:border-slate-800">
|
||||||
<div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
|
<div className="grid gap-2 md:grid-cols-2 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 date"}>
|
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From date"}>
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={draftFilters.startedAfter}
|
value={draftFilters.startedAfter}
|
||||||
|
|||||||
@@ -157,10 +157,24 @@ export const en = {
|
|||||||
noWorkspaceTitle: "Welcome!",
|
noWorkspaceTitle: "Welcome!",
|
||||||
noWorkspaceDesc: "Please create your first workspace.",
|
noWorkspaceDesc: "Please create your first workspace.",
|
||||||
back: "Back to Workspaces",
|
back: "Back to Workspaces",
|
||||||
roleLabel: "Your Role",
|
roleLabel: "Your Role",
|
||||||
roles: {
|
openReports: "Open reports",
|
||||||
owner: "Owner",
|
statsMembers: "Members",
|
||||||
admin: "Admin",
|
statsRates: "Rates set",
|
||||||
|
statsOwnersAdmins: "Owners & admins",
|
||||||
|
statsGuests: "Guests",
|
||||||
|
membersSectionTitle: "Members",
|
||||||
|
membersSectionSubtitle: "People in this workspace and their current roles.",
|
||||||
|
membersLocked: "Only owners and admins can view the full member list.",
|
||||||
|
manageMembers: "Manage members",
|
||||||
|
joinedLabel: "Joined",
|
||||||
|
resourcesTitle: "Resources",
|
||||||
|
resourceOpen: "Open",
|
||||||
|
roleDistributionTitle: "Role distribution",
|
||||||
|
unknownMember: "Unknown member",
|
||||||
|
roles: {
|
||||||
|
owner: "Owner",
|
||||||
|
admin: "Admin",
|
||||||
member: "Member",
|
member: "Member",
|
||||||
guest: "Guest",
|
guest: "Guest",
|
||||||
},
|
},
|
||||||
@@ -413,6 +427,8 @@ export const en = {
|
|||||||
tagFilterPrefix: "Tag",
|
tagFilterPrefix: "Tag",
|
||||||
fromFilterPrefix: "From",
|
fromFilterPrefix: "From",
|
||||||
toFilterPrefix: "To",
|
toFilterPrefix: "To",
|
||||||
|
deletedProjectLabel: "Deleted project",
|
||||||
|
deletedTagLabel: "Deleted tag",
|
||||||
},
|
},
|
||||||
|
|
||||||
reports: {
|
reports: {
|
||||||
|
|||||||
@@ -159,6 +159,20 @@ export const fa = {
|
|||||||
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
noWorkspaceDesc: "لطفاً اولین ورکاسپیس خود را ایجاد کنید.",
|
||||||
back: "بازگشت به ورکاسپیسها",
|
back: "بازگشت به ورکاسپیسها",
|
||||||
roleLabel: "نقش شما",
|
roleLabel: "نقش شما",
|
||||||
|
openReports: "مشاهده گزارشها",
|
||||||
|
statsMembers: "اعضا",
|
||||||
|
statsRates: "نرخهای ثبتشده",
|
||||||
|
statsOwnersAdmins: "مالکان و ادمینها",
|
||||||
|
statsGuests: "مهمانها",
|
||||||
|
membersSectionTitle: "اعضا",
|
||||||
|
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||||
|
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||||
|
manageMembers: "مدیریت اعضا",
|
||||||
|
joinedLabel: "زمان عضویت",
|
||||||
|
resourcesTitle: "منابع",
|
||||||
|
resourceOpen: "مشاهده",
|
||||||
|
roleDistributionTitle: "توزیع نقشها",
|
||||||
|
unknownMember: "عضو ناشناس",
|
||||||
roles: {
|
roles: {
|
||||||
owner: "مالک",
|
owner: "مالک",
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
@@ -202,10 +216,10 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
title: "مشتریان",
|
title: "مشتریها",
|
||||||
description: (workspaceName: string) => `مدیریت مشتریان برای ${workspaceName}`,
|
description: (workspaceName: string) => `مدیریت مشتریها برای ${workspaceName}`,
|
||||||
addClient: "افزودن مشتری",
|
addClient: "افزودن مشتری",
|
||||||
searchPlaceholder: "جستجوی مشتریان...",
|
searchPlaceholder: "جستجوی مشتریها...",
|
||||||
noClients: "مشتری یافت نشد",
|
noClients: "مشتری یافت نشد",
|
||||||
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
|
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
|
||||||
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
|
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
|
||||||
@@ -225,7 +239,7 @@ export const fa = {
|
|||||||
saveChanges: "ذخیره تغییرات",
|
saveChanges: "ذخیره تغییرات",
|
||||||
errors: {
|
errors: {
|
||||||
createFailed: "خطا در ایجاد مشتری",
|
createFailed: "خطا در ایجاد مشتری",
|
||||||
fetchFailed: "خطا در دریافت لیست مشتریان",
|
fetchFailed: "خطا در دریافت لیست مشتریها",
|
||||||
updateFailed: "خطا در ویرایش مشتری",
|
updateFailed: "خطا در ویرایش مشتری",
|
||||||
deleteFailed: "خطا در حذف مشتری",
|
deleteFailed: "خطا در حذف مشتری",
|
||||||
},
|
},
|
||||||
@@ -245,7 +259,7 @@ export const fa = {
|
|||||||
timesheet: 'تایمشیت',
|
timesheet: 'تایمشیت',
|
||||||
reports: 'گزارشها',
|
reports: 'گزارشها',
|
||||||
workspaces: 'ورکاسپیسها',
|
workspaces: 'ورکاسپیسها',
|
||||||
clients: 'مشتریان',
|
clients: 'مشتریها',
|
||||||
projects: "پروژهها",
|
projects: "پروژهها",
|
||||||
tags: "تگها",
|
tags: "تگها",
|
||||||
expand: 'باز کردن',
|
expand: 'باز کردن',
|
||||||
@@ -287,7 +301,7 @@ export const fa = {
|
|||||||
editProject: "ویرایش پروژه",
|
editProject: "ویرایش پروژه",
|
||||||
restore: "بازیابی",
|
restore: "بازیابی",
|
||||||
archive: "بایگانی",
|
archive: "بایگانی",
|
||||||
clientFetchError: "خطا در دریافت لیست مشتریان.",
|
clientFetchError: "خطا در دریافت لیست مشتریها.",
|
||||||
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
|
||||||
creator: "سازنده",
|
creator: "سازنده",
|
||||||
addUser: "افزودن کاربر",
|
addUser: "افزودن کاربر",
|
||||||
@@ -407,10 +421,12 @@ export const fa = {
|
|||||||
applyFiltersLabel: "اعمال",
|
applyFiltersLabel: "اعمال",
|
||||||
clientFilterPrefix: "مشتری",
|
clientFilterPrefix: "مشتری",
|
||||||
projectFilterPrefix: "پروژه",
|
projectFilterPrefix: "پروژه",
|
||||||
tagFilterPrefix: "تگ",
|
tagFilterPrefix: "تگ",
|
||||||
fromFilterPrefix: "از",
|
fromFilterPrefix: "از",
|
||||||
toFilterPrefix: "تا",
|
toFilterPrefix: "تا",
|
||||||
},
|
deletedProjectLabel: "پروژه حذفشده",
|
||||||
|
deletedTagLabel: "تگ حذفشده",
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "گزارشها",
|
title: "گزارشها",
|
||||||
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
||||||
@@ -470,11 +486,11 @@ export const fa = {
|
|||||||
loadMore: "بارگذاری بیشتر",
|
loadMore: "بارگذاری بیشتر",
|
||||||
markAllRead: "خواندن همه",
|
markAllRead: "خواندن همه",
|
||||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||||
openError: "باز کردن اعلان با خطا مواجه شد.",
|
openError: "باز کردن اعلان با خطا مواجه شد.",
|
||||||
newTitle: "اعلان جدید",
|
newTitle: "اعلان جدید",
|
||||||
openAction: "باز کردن",
|
openAction: "باز کردن",
|
||||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
|
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
|
||||||
import { useWorkspace } from "../context/WorkspaceContext"
|
import { useWorkspace } from "../context/WorkspaceContext"
|
||||||
import { useTranslation } from "../hooks/useTranslation"
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
import {
|
import {
|
||||||
CLIENTS_CREATE,
|
CLIENTS_CREATE,
|
||||||
CLIENTS_DELETE,
|
CLIENTS_DELETE,
|
||||||
CLIENTS_EDIT,
|
CLIENTS_EDIT,
|
||||||
canWorkspace,
|
canWorkspace,
|
||||||
} from "../lib/permissions"
|
} from "../lib/permissions"
|
||||||
import { type Client } from "../types/client"
|
import { type Client } from "../types/client"
|
||||||
import { getClients } from "../api/clients"
|
import { getClients } from "../api/clients"
|
||||||
import CreateClientModal from "../components/CreateClientModal"
|
import CreateClientModal from "../components/CreateClientModal"
|
||||||
@@ -38,12 +38,12 @@ export default function Clients() {
|
|||||||
const [editClient, setEditClient] = useState<Client | null>(null)
|
const [editClient, setEditClient] = useState<Client | null>(null)
|
||||||
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
|
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
|
||||||
|
|
||||||
const { t, lang } = useTranslation()
|
const { t, lang } = useTranslation()
|
||||||
const isFa = lang === "fa"
|
const isFa = lang === "fa"
|
||||||
const workspaceRole = activeWorkspace?.my_role
|
const workspaceRole = activeWorkspace?.my_role
|
||||||
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
|
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
|
||||||
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
|
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
|
||||||
const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE)
|
const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE)
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
||||||
@@ -116,24 +116,24 @@ export default function Clients() {
|
|||||||
|
|
||||||
return (
|
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 p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
||||||
<div className="flex justify-between items-center mb-8 gap-4">
|
<div className="flex justify-between items-center mb-8 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
{t.clients.description(activeWorkspace.name)}
|
{t.clients.description(activeWorkspace.name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCreateClient && (
|
{canCreateClient && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="shadow-sm shrink-0"
|
className="shadow-sm shrink-0"
|
||||||
title={t.clients.addClient}
|
title={t.clients.addClient}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -170,35 +170,32 @@ export default function Clients() {
|
|||||||
{client.notes}
|
{client.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="text-[11px] text-slate-400 mt-3 font-medium">
|
|
||||||
{t.clients.addedOn}: {formatDate(client.created_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(canEditClient || canDeleteClient) && (
|
{(canEditClient || canDeleteClient) && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{canEditClient && (
|
{canEditClient && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setEditClient(client)}
|
onClick={() => setEditClient(client)}
|
||||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canDeleteClient && (
|
{canDeleteClient && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setDeleteClient(client)}
|
onClick={() => setDeleteClient(client)}
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -216,32 +213,32 @@ export default function Clients() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canCreateClient && (
|
{canCreateClient && (
|
||||||
<CreateClientModal
|
<CreateClientModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
workspaceId={activeWorkspace.id}
|
workspaceId={activeWorkspace.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEditClient && (
|
{canEditClient && (
|
||||||
<EditClientModal
|
<EditClientModal
|
||||||
isOpen={!!editClient}
|
isOpen={!!editClient}
|
||||||
onClose={() => setEditClient(null)}
|
onClose={() => setEditClient(null)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
client={editClient}
|
client={editClient}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canDeleteClient && (
|
{canDeleteClient && (
|
||||||
<DeleteClientModal
|
<DeleteClientModal
|
||||||
isOpen={!!deleteClient}
|
isOpen={!!deleteClient}
|
||||||
onClose={() => setDeleteClient(null)}
|
onClose={() => setDeleteClient(null)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
client={deleteClient}
|
client={deleteClient}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,26 @@ import { Plus, Archive, Trash2, Pencil } from "lucide-react";
|
|||||||
|
|
||||||
import FilterBar from "../components/FilterBar";
|
import FilterBar from "../components/FilterBar";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import {
|
import {
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECTS_CREATE,
|
PROJECTS_CREATE,
|
||||||
PROJECTS_DELETE,
|
PROJECTS_DELETE,
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
canWorkspace,
|
canWorkspace,
|
||||||
} from "../lib/permissions";
|
} from "../lib/permissions";
|
||||||
|
|
||||||
export const Projects: React.FC = () => {
|
export const Projects: React.FC = () => {
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
const workspaceRole = activeWorkspace?.my_role;
|
const workspaceRole = activeWorkspace?.my_role;
|
||||||
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
|
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
|
||||||
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
||||||
const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE);
|
const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE);
|
||||||
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
||||||
|
|
||||||
const [projects, setProjects] = useState<any[]>([]);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -100,7 +100,7 @@ export const Projects: React.FC = () => {
|
|||||||
setProjectToDelete(project);
|
setProjectToDelete(project);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!deleteModal.project) return;
|
if (!deleteModal.project) return;
|
||||||
try {
|
try {
|
||||||
const deletedId = deleteModal.project.id;
|
const deletedId = deleteModal.project.id;
|
||||||
@@ -118,20 +118,20 @@ export const Projects: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.projects?.deleteError || 'Failed to delete project');
|
toast.error(t.projects?.deleteError || 'Failed to delete project');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string | undefined) => {
|
const formatDate = (dateStr: string | undefined) => {
|
||||||
if (!dateStr) return "-"
|
if (!dateStr) return "-"
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeZone: "Asia/Tehran",
|
timeZone: "Asia/Tehran",
|
||||||
}).format(date)
|
}).format(date)
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr
|
return dateStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,26 +142,26 @@ export const Projects: React.FC = () => {
|
|||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
{canArchiveProject && (
|
{canArchiveProject && (
|
||||||
<Button
|
<Button
|
||||||
variant={isArchived ? "default" : "secondary"}
|
variant={isArchived ? "default" : "secondary"}
|
||||||
onClick={() => setIsArchived(!isArchived)}
|
onClick={() => setIsArchived(!isArchived)}
|
||||||
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
className="gap-2 shadow-sm flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canCreateProject && (
|
{canCreateProject && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="shadow-sm"
|
className="shadow-sm"
|
||||||
title={t.projects?.createNew || 'Create New'}
|
title={t.projects?.createNew || 'Create New'}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,76 +174,73 @@ export const Projects: React.FC = () => {
|
|||||||
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-12 flex justify-center text-slate-500">
|
<div className="p-12 flex justify-center text-slate-500">
|
||||||
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
|
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
|
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="py-16 flex flex-col items-center justify-center">
|
<div className="py-16 flex flex-col items-center justify-center">
|
||||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
|
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<li
|
<li
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4"
|
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4>
|
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
||||||
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
|
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
|
||||||
</p>
|
</p>
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="text-[11px] text-slate-400 mt-3 font-medium">
|
</div>
|
||||||
{(t.projects as any)?.addedOn || "Added on"}: {formatDate(project.created_at)}
|
|
||||||
</div>
|
{(canEditProject || canDeleteProject) && (
|
||||||
</div>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{canEditProject && (
|
||||||
{(canEditProject || canDeleteProject) && (
|
<Button
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
variant="ghost"
|
||||||
{canEditProject && (
|
size="icon"
|
||||||
<Button
|
onClick={() => setEditingProject(project)}
|
||||||
variant="ghost"
|
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
||||||
size="icon"
|
title={t.actions?.edit || "Edit"}
|
||||||
onClick={() => setEditingProject(project)}
|
>
|
||||||
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
<Pencil className="w-4 h-4" />
|
||||||
title={t.actions?.edit || "Edit"}
|
</Button>
|
||||||
>
|
)}
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
{canDeleteProject && (
|
||||||
)}
|
<Button
|
||||||
|
variant="ghost"
|
||||||
{canDeleteProject && (
|
size="icon"
|
||||||
<Button
|
onClick={() => setDeleteModal({ isOpen: true, project })}
|
||||||
variant="ghost"
|
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||||
size="icon"
|
title={t.actions?.delete || "Delete"}
|
||||||
onClick={() => setDeleteModal({ isOpen: true, project })}
|
>
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
<Trash2 className="w-4 h-4" />
|
||||||
title={t.actions?.delete || "Delete"}
|
</Button>
|
||||||
>
|
)}
|
||||||
<Trash2 className="w-4 h-4" />
|
</div>
|
||||||
</Button>
|
)}
|
||||||
)}
|
</li>
|
||||||
</div>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</li>
|
)}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
<Pagination
|
||||||
</Card>
|
currentPage={currentPage}
|
||||||
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalCount={totalItems}
|
totalCount={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
@@ -254,15 +251,15 @@ export const Projects: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{canCreateProject && isCreateModalOpen && (
|
{canCreateProject && isCreateModalOpen && (
|
||||||
<ProjectCreateModal
|
<ProjectCreateModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEditProject && editingProject && (
|
{canEditProject && editingProject && (
|
||||||
<ProjectEditModal
|
<ProjectEditModal
|
||||||
project={editingProject}
|
project={editingProject}
|
||||||
isOpen={!!editingProject}
|
isOpen={!!editingProject}
|
||||||
onClose={() => setEditingProject(null)}
|
onClose={() => setEditingProject(null)}
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export default function Reports() {
|
|||||||
const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null);
|
const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null);
|
||||||
const [openDay, setOpenDay] = useState<string | null>(null);
|
const [openDay, setOpenDay] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [exportState, setExportState] = useState({
|
||||||
|
excel: { pending: false, cooldownSeconds: 0 },
|
||||||
|
pdf: { pending: false, cooldownSeconds: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
|
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
|
||||||
|
|
||||||
@@ -188,12 +192,44 @@ export default function Reports() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setExportState((current) => ({
|
||||||
|
excel: {
|
||||||
|
pending: current.excel.pending,
|
||||||
|
cooldownSeconds: Math.max(current.excel.cooldownSeconds - 1, 0),
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
pending: current.pdf.pending,
|
||||||
|
cooldownSeconds: Math.max(current.pdf.cooldownSeconds - 1, 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleExport = async (type: "excel" | "pdf") => {
|
const handleExport = async (type: "excel" | "pdf") => {
|
||||||
if (!apiFilters) return;
|
if (!apiFilters) return;
|
||||||
|
if (exportState[type].pending || exportState[type].cooldownSeconds > 0) return;
|
||||||
|
|
||||||
|
setExportState((current) => ({
|
||||||
|
...current,
|
||||||
|
[type]: { pending: true, cooldownSeconds: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createReportExport(apiFilters, type, lang);
|
await createReportExport(apiFilters, type, lang);
|
||||||
|
setExportState((current) => ({
|
||||||
|
...current,
|
||||||
|
[type]: { pending: false, cooldownSeconds: 60 },
|
||||||
|
}));
|
||||||
toast.success(t.reports?.exportQueued || "Export queued. You will receive a notification with the download link.");
|
toast.success(t.reports?.exportQueued || "Export queued. You will receive a notification with the download link.");
|
||||||
} catch {
|
} catch {
|
||||||
|
setExportState((current) => ({
|
||||||
|
...current,
|
||||||
|
[type]: { pending: false, cooldownSeconds: 0 },
|
||||||
|
}));
|
||||||
toast.error(t.reports?.exportError || "Failed to queue report export.");
|
toast.error(t.reports?.exportError || "Failed to queue report export.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -310,6 +346,7 @@ export default function Reports() {
|
|||||||
openDay={openDay}
|
openDay={openDay}
|
||||||
onToggleDay={(day) => void handleToggleDay(day)}
|
onToggleDay={(day) => void handleToggleDay(day)}
|
||||||
onExport={(type) => void handleExport(type)}
|
onExport={(type) => void handleExport(type)}
|
||||||
|
exportState={exportState}
|
||||||
labels={{
|
labels={{
|
||||||
exportExcel: t.reports?.exportExcel || "Export Excel",
|
exportExcel: t.reports?.exportExcel || "Export Excel",
|
||||||
exportPdf: t.reports?.exportPdf || "Export PDF",
|
exportPdf: t.reports?.exportPdf || "Export PDF",
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export default function Tags() {
|
|||||||
</div>
|
</div>
|
||||||
{canCreateTag && (
|
{canCreateTag && (
|
||||||
<Button onClick={openCreateModal} size="icon" className="shadow-sm shrink-0" title={t.tags?.create || "Create Tag"}>
|
<Button onClick={openCreateModal} size="icon" className="shadow-sm shrink-0" title={t.tags?.create || "Create Tag"}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -461,6 +461,124 @@ const buildPayloadFromState = (
|
|||||||
return { payload };
|
return { payload };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildDeletedProjectLabel = (projectName: string, deletedLabel: string) => `${deletedLabel}: ${projectName}`;
|
||||||
|
const buildDeletedTagLabel = (tagName: string, deletedLabel: string) => `${deletedLabel}: ${tagName}`;
|
||||||
|
|
||||||
|
const getEntryProjectOption = (entry?: TimeEntry | null, deletedProjectLabel?: string): Project | null => {
|
||||||
|
if (!entry?.project_details) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.project_details.id,
|
||||||
|
name: entry.project_details.is_deleted
|
||||||
|
? buildDeletedProjectLabel(entry.project_details.name, deletedProjectLabel || "Deleted project")
|
||||||
|
: entry.project_details.name,
|
||||||
|
description: "",
|
||||||
|
color: "",
|
||||||
|
is_archived: false,
|
||||||
|
is_deleted: entry.project_details.is_deleted,
|
||||||
|
workspace: entry.workspace,
|
||||||
|
client: entry.project_details.client_name
|
||||||
|
? { id: "", name: entry.project_details.client_name }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProjectOptionsForEntry = (
|
||||||
|
activeProjects: Project[],
|
||||||
|
entry: TimeEntry | null | undefined,
|
||||||
|
selectedProjectId: string,
|
||||||
|
deletedProjectLabel?: string,
|
||||||
|
) => {
|
||||||
|
const projectsById = new Map(activeProjects.map((project) => [project.id, project]));
|
||||||
|
const currentProject = getEntryProjectOption(entry, deletedProjectLabel);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentProject &&
|
||||||
|
currentProject.id === selectedProjectId &&
|
||||||
|
!projectsById.has(currentProject.id)
|
||||||
|
) {
|
||||||
|
projectsById.set(currentProject.id, currentProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(projectsById.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTagOptionsForEntry = (
|
||||||
|
activeTags: Tag[],
|
||||||
|
entry: TimeEntry | null | undefined,
|
||||||
|
selectedTagIds: string[],
|
||||||
|
) => {
|
||||||
|
const tagsById = new Map(activeTags.map((tag) => [tag.id, tag]));
|
||||||
|
const entryWorkspaceId = entry?.workspace || "";
|
||||||
|
const entryCreatedAt = entry?.created_at || "";
|
||||||
|
const entryUpdatedAt = entry?.updated_at || "";
|
||||||
|
|
||||||
|
(entry?.tag_details || []).forEach((tag) => {
|
||||||
|
if (!selectedTagIds.includes(tag.id) || tagsById.has(tag.id)) return;
|
||||||
|
|
||||||
|
tagsById.set(tag.id, {
|
||||||
|
id: tag.id,
|
||||||
|
workspace: entryWorkspaceId,
|
||||||
|
name: tag.name,
|
||||||
|
color: tag.color,
|
||||||
|
is_deleted: tag.is_deleted,
|
||||||
|
created_at: entryCreatedAt,
|
||||||
|
updated_at: entryUpdatedAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(tagsById.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectDisplayDetails = (entry: TimeEntry, activeProjects: Project[]) => {
|
||||||
|
const activeProject = activeProjects.find((item) => item.id === entry.project);
|
||||||
|
if (activeProject) {
|
||||||
|
return {
|
||||||
|
name: activeProject.name,
|
||||||
|
clientName: activeProject.client?.name || null,
|
||||||
|
isDeleted: Boolean(activeProject.is_deleted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.project_details) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.project_details.name,
|
||||||
|
clientName: entry.project_details.client_name,
|
||||||
|
isDeleted: entry.project_details.is_deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagDisplayDetails = (entry: TimeEntry, activeTags: Tag[]) => {
|
||||||
|
const activeTagsById = new Map(activeTags.map((tag) => [tag.id, tag]));
|
||||||
|
|
||||||
|
return entry.tags.map((tagId) => {
|
||||||
|
const activeTag = activeTagsById.get(tagId);
|
||||||
|
if (activeTag) {
|
||||||
|
return {
|
||||||
|
id: activeTag.id,
|
||||||
|
name: activeTag.name,
|
||||||
|
color: activeTag.color,
|
||||||
|
isDeleted: Boolean(activeTag.is_deleted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedTag = entry.tag_details.find((tag) => tag.id === tagId);
|
||||||
|
if (!deletedTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: deletedTag.id,
|
||||||
|
name: deletedTag.name,
|
||||||
|
color: deletedTag.color,
|
||||||
|
isDeleted: deletedTag.is_deleted,
|
||||||
|
};
|
||||||
|
}).filter(Boolean) as Array<{ id: string; name: string; color: string; isDeleted: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
function TimeField({
|
function TimeField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -532,6 +650,9 @@ function TagMultiSelect({
|
|||||||
title,
|
title,
|
||||||
compact = false,
|
compact = false,
|
||||||
portalOwnerId,
|
portalOwnerId,
|
||||||
|
className = "",
|
||||||
|
buttonClassName = "",
|
||||||
|
compactDisplayMode = "summary",
|
||||||
}: {
|
}: {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
@@ -540,6 +661,9 @@ function TagMultiSelect({
|
|||||||
title: string;
|
title: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
portalOwnerId?: string;
|
portalOwnerId?: string;
|
||||||
|
className?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
compactDisplayMode?: "summary" | "chips";
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -588,12 +712,10 @@ function TagMultiSelect({
|
|||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
window.addEventListener("resize", closeOnViewportChange);
|
window.addEventListener("resize", closeOnViewportChange);
|
||||||
window.addEventListener("scroll", closeOnViewportChange, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", closeOnViewportChange);
|
window.removeEventListener("resize", closeOnViewportChange);
|
||||||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -603,7 +725,8 @@ function TagMultiSelect({
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
const selectedTagObjects = tags.filter((tag) => selectedTags.includes(tag.id));
|
||||||
|
const selectedLabels = selectedTagObjects.map((tag) => tag.name);
|
||||||
const joinedSelectedLabels = selectedLabels.join(" | ");
|
const joinedSelectedLabels = selectedLabels.join(" | ");
|
||||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||||
const filteredTags = normalizedSearch
|
const filteredTags = normalizedSearch
|
||||||
@@ -618,7 +741,7 @@ function TagMultiSelect({
|
|||||||
: title;
|
: title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={compact ? "relative w-fit" : "relative"}>
|
<div ref={wrapperRef} className={`${compact ? "relative min-w-0" : "relative"} ${className}`.trim()}>
|
||||||
{!compact && <p className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{title}</p>}
|
{!compact && <p className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">{title}</p>}
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
@@ -627,13 +750,31 @@ function TagMultiSelect({
|
|||||||
title={selectedLabels.length > 0 ? selectedLabels.join(", ") : title}
|
title={selectedLabels.length > 0 ? selectedLabels.join(", ") : title}
|
||||||
className={`inline-flex items-center gap-1 text-slate-700 dark:text-slate-200 ${
|
className={`inline-flex items-center gap-1 text-slate-700 dark:text-slate-200 ${
|
||||||
compact
|
compact
|
||||||
? "h-12 w-fit border-0 bg-transparent px-2 text-xs"
|
? "h-12 max-w-full border-0 bg-transparent px-2 text-xs overflow-hidden"
|
||||||
: "w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
: "w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||||
}`}
|
} ${buttonClassName}`.trim()}
|
||||||
>
|
>
|
||||||
{compact ? (
|
{compact ? (
|
||||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden">
|
||||||
{buttonLabel ? (
|
{selectedTagObjects.length > 0 && compactDisplayMode === "chips" ? (
|
||||||
|
<span className="flex min-w-0 max-w-full items-center gap-1 overflow-hidden whitespace-nowrap">
|
||||||
|
{selectedTagObjects.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
title={tag.name}
|
||||||
|
className={`shrink rounded-sm px-2 py-1 text-[11px] font-medium ${
|
||||||
|
index === 0 ? "max-w-[96px]" : "max-w-[72px]"
|
||||||
|
} ${
|
||||||
|
tag.is_deleted
|
||||||
|
? "bg-slate-200 text-slate-700 line-through dark:bg-slate-700 dark:text-slate-200"
|
||||||
|
: "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : buttonLabel ? (
|
||||||
<span className="truncate rounded-sm bg-sky-50 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
|
<span className="truncate rounded-sm bg-sky-50 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -673,24 +814,33 @@ function TagMultiSelect({
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
||||||
{filteredTags.map((tag) => {
|
{filteredTags.map((tag) => {
|
||||||
const selected = selectedTags.includes(tag.id);
|
const selected = selectedTags.includes(tag.id);
|
||||||
return (
|
const isUnavailable = Boolean(tag.is_deleted) && !selected;
|
||||||
<button
|
return (
|
||||||
key={tag.id}
|
<button
|
||||||
type="button"
|
key={tag.id}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
type="button"
|
||||||
onClick={() => onToggleTag(tag.id)}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
className={`flex w-full items-center justify-between rounded-xl px-2 py-2 text-sm transition-colors ${
|
onClick={() => {
|
||||||
selected ? "bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-white" : "text-slate-700 dark:text-slate-200"
|
if (isUnavailable) return;
|
||||||
}`}
|
onToggleTag(tag.id);
|
||||||
>
|
}}
|
||||||
<span className="inline-flex min-w-0 items-center gap-2 truncate">
|
className={`flex w-full items-center justify-between rounded-xl px-2 py-2 text-sm transition-colors ${
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
selected
|
||||||
<span className="truncate">{tag.name}</span>
|
? "bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-white"
|
||||||
</span>
|
: isUnavailable
|
||||||
{selected && <Check className="h-4 w-4 shrink-0" />}
|
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
||||||
</button>
|
: "text-slate-700 dark:text-slate-200"
|
||||||
);
|
}`}
|
||||||
|
title={tag.name}
|
||||||
|
>
|
||||||
|
<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.is_deleted ? "line-through text-slate-700 dark:text-slate-300" : ""}`}>{tag.name}</span>
|
||||||
|
</span>
|
||||||
|
{selected && <Check className="h-4 w-4 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
{filteredTags.length === 0 && (
|
{filteredTags.length === 0 && (
|
||||||
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
@@ -762,7 +912,6 @@ function ProjectInlineSelect({
|
|||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
|
top: openUpward ? `${rect.top - 8}px` : `${rect.bottom + 8}px`,
|
||||||
left: `${Math.max(12, rect.left)}px`,
|
left: `${Math.max(12, rect.left)}px`,
|
||||||
width: `${dropdownWidth}px`,
|
|
||||||
transform: openUpward ? "translateY(-100%)" : "none",
|
transform: openUpward ? "translateY(-100%)" : "none",
|
||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
});
|
});
|
||||||
@@ -786,13 +935,13 @@ function ProjectInlineSelect({
|
|||||||
const label = selectedProject?.name || placeholder;
|
const label = selectedProject?.name || placeholder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={`relative shrink-0 ${className}`}>
|
<div ref={wrapperRef} className={`relative min-w-0 ${className}`}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && setIsOpen((current) => !current)}
|
onClick={() => !disabled && setIsOpen((current) => !current)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`inline-flex max-w-full items-center rounded-sm bg-transparent py-0 text-sm transition-colors ${
|
className={`inline-flex w-full max-w-full items-center rounded-sm bg-transparent py-0 text-sm transition-colors ${
|
||||||
selectedProject
|
selectedProject
|
||||||
? "text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
? "text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||||
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
@@ -829,22 +978,27 @@ function ProjectInlineSelect({
|
|||||||
|
|
||||||
{projects.map((project) => {
|
{projects.map((project) => {
|
||||||
const selected = project.id === value;
|
const selected = project.id === value;
|
||||||
|
const unavailable = Boolean(project.is_deleted) && !selected;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (unavailable) return;
|
||||||
onChange(project.id);
|
onChange(project.id);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
className={`flex w-full items-center rounded-xl px-3 py-2 text-sm transition-colors ${
|
||||||
selected
|
selected
|
||||||
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
? "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-700/70"
|
: unavailable
|
||||||
|
? "cursor-not-allowed text-slate-400 opacity-70 dark:text-slate-500"
|
||||||
|
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70"
|
||||||
}`}
|
}`}
|
||||||
|
title={project.name}
|
||||||
>
|
>
|
||||||
<span className="truncate">{project.name}</span>
|
<span className={`truncate ${project.is_deleted ? "italic" : ""}`}>{project.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1062,47 +1216,52 @@ function EntryEditorFields({
|
|||||||
if (compact) {
|
if (compact) {
|
||||||
const selectedProject = projects.find((project) => project.id === state.projectId);
|
const selectedProject = projects.find((project) => project.id === state.projectId);
|
||||||
return (
|
return (
|
||||||
<div className="grid min-w-0 flex-1 grid-cols-[minmax(420px,1fr)_40px_188px_40px] items-center">
|
<div className="grid min-w-0 flex-1 grid-cols-[minmax(430px,1fr)_minmax(0,220px)_40px_188px_40px] 2xl:grid-cols-[minmax(430px,1fr)_minmax(0,max-content)_40px_188px_40px] items-center">
|
||||||
<div className="flex min-w-0 items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
<Input
|
<Input
|
||||||
value={state.description}
|
value={state.description}
|
||||||
onChange={(event) => onChange({ description: event.target.value })}
|
onChange={(event) => onChange({ description: event.target.value })}
|
||||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
className="h-12 w-[220px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100"
|
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="me-2 shrink-0 text-sm font-semibold leading-none text-sky-600 dark:text-sky-400">•</span>
|
<span className="me-2 shrink-0 text-sm font-semibold leading-none text-sky-600 dark:text-sky-400">•</span>
|
||||||
|
|
||||||
<ProjectInlineSelect
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
projects={projects}
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||||
value={state.projectId}
|
<ProjectInlineSelect
|
||||||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
projects={projects}
|
||||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
value={state.projectId}
|
||||||
portalOwnerId={portalOwnerId}
|
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
||||||
className="max-w-[180px]"
|
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||||
/>
|
portalOwnerId={portalOwnerId}
|
||||||
|
className="min-w-0 max-w-[150px] 2xl:max-w-max flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
{selectedProject && (
|
{selectedProject?.client?.name && (
|
||||||
<span className="ms-2 shrink-0 truncate text-sm text-slate-400 dark:text-slate-500">
|
<span className="min-w-0 max-w-[120px] 2xl:max-w-max shrink truncate text-sm text-slate-400 dark:text-slate-500" title={selectedProject.client.name}>
|
||||||
- {selectedProject.client?.name || ""}
|
- {selectedProject.client.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<div className="min-w-[24px] flex-1" />
|
|
||||||
|
|
||||||
<div className="shrink-0">
|
|
||||||
<TagMultiSelect
|
|
||||||
tags={tags}
|
|
||||||
selectedTags={state.tags}
|
|
||||||
onToggleTag={onToggleTag}
|
|
||||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
|
||||||
title={t.tags?.title || "Tags"}
|
|
||||||
compact
|
|
||||||
portalOwnerId={portalOwnerId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 overflow-hidden pe-2" dir="ltr">
|
||||||
|
<TagMultiSelect
|
||||||
|
tags={tags}
|
||||||
|
selectedTags={state.tags}
|
||||||
|
onToggleTag={onToggleTag}
|
||||||
|
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||||
|
title={t.tags?.title || "Tags"}
|
||||||
|
compact
|
||||||
|
compactDisplayMode="chips"
|
||||||
|
portalOwnerId={portalOwnerId}
|
||||||
|
className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
||||||
|
buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-10">
|
<div className="w-10">
|
||||||
<BillableIconButton
|
<BillableIconButton
|
||||||
checked={state.isBillable}
|
checked={state.isBillable}
|
||||||
@@ -1224,6 +1383,8 @@ function RecordedEntryCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRestart,
|
onRestart,
|
||||||
onEntryUpdated,
|
onEntryUpdated,
|
||||||
|
variant = "desktop",
|
||||||
|
lang,
|
||||||
}: {
|
}: {
|
||||||
entry: TimeEntry;
|
entry: TimeEntry;
|
||||||
t: any;
|
t: any;
|
||||||
@@ -1232,6 +1393,8 @@ function RecordedEntryCard({
|
|||||||
onDelete: (entry: TimeEntry) => void;
|
onDelete: (entry: TimeEntry) => void;
|
||||||
onRestart: (entry: TimeEntry) => void;
|
onRestart: (entry: TimeEntry) => void;
|
||||||
onEntryUpdated: (entry: TimeEntry) => void;
|
onEntryUpdated: (entry: TimeEntry) => void;
|
||||||
|
variant?: "desktop" | "tablet";
|
||||||
|
lang: "en" | "fa";
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry));
|
const [draft, setDraft] = useState<EntryFormState>(() => buildEntryFormState(entry));
|
||||||
const [validationMessage, setValidationMessage] = useState("");
|
const [validationMessage, setValidationMessage] = useState("");
|
||||||
@@ -1243,6 +1406,16 @@ function RecordedEntryCard({
|
|||||||
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
|
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
|
||||||
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
|
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
|
||||||
const saveSuccessText = timesheetCopy.saveSuccess || "Time entry saved";
|
const saveSuccessText = timesheetCopy.saveSuccess || "Time entry saved";
|
||||||
|
const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project";
|
||||||
|
const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag";
|
||||||
|
const editorProjects = useMemo(
|
||||||
|
() => buildProjectOptionsForEntry(projects, entry, draft.projectId, deletedProjectLabel),
|
||||||
|
[deletedProjectLabel, draft.projectId, entry, projects],
|
||||||
|
);
|
||||||
|
const editorTags = useMemo(
|
||||||
|
() => buildTagOptionsForEntry(tags, entry, draft.tags),
|
||||||
|
[draft.tags, entry, tags],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextDraft = buildEntryFormState(entry);
|
const nextDraft = buildEntryFormState(entry);
|
||||||
@@ -1362,16 +1535,71 @@ function RecordedEntryCard({
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (variant === "tablet") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
onBlurCapture={handleBlurCapture}
|
||||||
|
className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-950"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<EntryEditorFields
|
||||||
|
state={draft}
|
||||||
|
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||||
|
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||||||
|
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||||||
|
projects={editorProjects}
|
||||||
|
tags={editorTags}
|
||||||
|
t={t}
|
||||||
|
isRtl={false}
|
||||||
|
portalOwnerId={editorOwnerId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{formatDateTime(entry.start_time, lang)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-sm font-semibold text-slate-900 dark:text-white">{formatDuration(entry)}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRestart(entry)}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||||
|
title="Start from this entry"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(entry)}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-red-200 bg-red-50 text-red-600 transition-colors hover:bg-red-100 dark:border-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:hover:bg-red-500/15"
|
||||||
|
title={t.actions?.delete || "Delete"}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationMessage && (
|
||||||
|
<div className="px-1 pb-1 pt-2">
|
||||||
|
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">{validationMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rowRef} onBlurCapture={handleBlurCapture} className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-950">
|
<div ref={rowRef} onBlurCapture={handleBlurCapture} className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-950">
|
||||||
<div className="flex min-w-[1040px] items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
<EntryEditorFields
|
<EntryEditorFields
|
||||||
state={draft}
|
state={draft}
|
||||||
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||||
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||||||
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||||||
projects={projects}
|
projects={editorProjects}
|
||||||
tags={tags}
|
tags={editorTags}
|
||||||
t={t}
|
t={t}
|
||||||
isRtl={false}
|
isRtl={false}
|
||||||
compact
|
compact
|
||||||
@@ -1413,6 +1641,7 @@ function MobileRecordedEntryCard({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRequestRestart,
|
onRequestRestart,
|
||||||
|
lang,
|
||||||
}: {
|
}: {
|
||||||
entry: TimeEntry;
|
entry: TimeEntry;
|
||||||
t: any;
|
t: any;
|
||||||
@@ -1421,9 +1650,12 @@ function MobileRecordedEntryCard({
|
|||||||
onEdit: (entry: TimeEntry) => void;
|
onEdit: (entry: TimeEntry) => void;
|
||||||
onDelete: (entry: TimeEntry) => void;
|
onDelete: (entry: TimeEntry) => void;
|
||||||
onRequestRestart: (entry: TimeEntry) => void;
|
onRequestRestart: (entry: TimeEntry) => void;
|
||||||
|
lang: "en" | "fa";
|
||||||
}) {
|
}) {
|
||||||
const project = projects.find((item) => item.id === entry.project);
|
const deletedProjectLabel = t.timesheet?.deletedProjectLabel || "Deleted project";
|
||||||
const entryTags = tags.filter((tag) => entry.tags.includes(tag.id));
|
const deletedTagLabel = t.timesheet?.deletedTagLabel || "Deleted tag";
|
||||||
|
const project = getProjectDisplayDetails(entry, projects);
|
||||||
|
const entryTags = getTagDisplayDetails(entry, tags);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -1554,18 +1786,20 @@ function MobileRecordedEntryCard({
|
|||||||
{project && (
|
{project && (
|
||||||
<span className="inline-flex min-w-0 items-center gap-2 text-xs">
|
<span className="inline-flex min-w-0 items-center gap-2 text-xs">
|
||||||
<span className="shrink-0 text-sky-600 dark:text-sky-400">{"\u2022"}</span>
|
<span className="shrink-0 text-sky-600 dark:text-sky-400">{"\u2022"}</span>
|
||||||
<span className="max-w-[10rem] truncate font-medium text-sky-600 dark:text-sky-400">{project.name}</span>
|
<span className={`max-w-[10rem] truncate font-medium ${project.isDeleted ? "italic text-slate-500 dark:text-slate-400" : "text-sky-600 dark:text-sky-400"}`}>
|
||||||
|
{project.isDeleted ? buildDeletedProjectLabel(project.name, deletedProjectLabel) : project.name}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{project?.client?.name && (
|
{project?.clientName && (
|
||||||
<span className="max-w-[8rem] truncate text-xs text-slate-500 dark:text-slate-400">
|
<span className="max-w-[8rem] truncate text-xs text-slate-500 dark:text-slate-400">
|
||||||
- {project.client.name}
|
- {project.clientName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>{formatTimeOnly(entry.start_time)} - {formatTimeOnly(entry.end_time)}</span>
|
<span>{formatTimeOnly(entry.start_time, lang)} - {formatTimeOnly(entry.end_time, lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1578,7 +1812,9 @@ function MobileRecordedEntryCard({
|
|||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
{entryTags.length > 0 && (
|
{entryTags.length > 0 && (
|
||||||
<span className="inline-flex min-w-0 items-center rounded-md bg-sky-50 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
|
<span className="inline-flex min-w-0 items-center rounded-md bg-sky-50 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
|
||||||
{entryTags.map((tag) => tag.name).join(" | ")}
|
{entryTags
|
||||||
|
.map((tag) => (tag.isDeleted ? buildDeletedTagLabel(tag.name, deletedTagLabel) : tag.name))
|
||||||
|
.join(" | ")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.is_billable && (
|
{entry.is_billable && (
|
||||||
@@ -1662,6 +1898,8 @@ export default function Timesheet() {
|
|||||||
fromFilterPrefix?: string;
|
fromFilterPrefix?: string;
|
||||||
toFilterPrefix?: string;
|
toFilterPrefix?: string;
|
||||||
restartConfirmMessage?: string;
|
restartConfirmMessage?: string;
|
||||||
|
deletedProjectLabel?: string;
|
||||||
|
deletedTagLabel?: string;
|
||||||
}) || {};
|
}) || {};
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@@ -1708,6 +1946,24 @@ export default function Timesheet() {
|
|||||||
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
|
const [isDiscardingTimer, setIsDiscardingTimer] = useState(false);
|
||||||
|
|
||||||
const runningEntry = activeRunningEntry;
|
const runningEntry = activeRunningEntry;
|
||||||
|
const deletedProjectLabel = extendedTimesheet.deletedProjectLabel || "Deleted project";
|
||||||
|
const deletedTagLabel = extendedTimesheet.deletedTagLabel || "Deleted tag";
|
||||||
|
const runningTimerProjects = useMemo(
|
||||||
|
() => buildProjectOptionsForEntry(projects, runningEntry, timerDraft.projectId, deletedProjectLabel),
|
||||||
|
[deletedProjectLabel, projects, runningEntry, timerDraft.projectId],
|
||||||
|
);
|
||||||
|
const runningTimerTags = useMemo(
|
||||||
|
() => buildTagOptionsForEntry(tags, runningEntry, timerDraft.tags),
|
||||||
|
[runningEntry, tags, timerDraft.tags],
|
||||||
|
);
|
||||||
|
const modalProjects = useMemo(
|
||||||
|
() => buildProjectOptionsForEntry(projects, editingEntry, formState.projectId, deletedProjectLabel),
|
||||||
|
[deletedProjectLabel, editingEntry, formState.projectId, projects],
|
||||||
|
);
|
||||||
|
const modalTags = useMemo(
|
||||||
|
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
|
||||||
|
[editingEntry, formState.tags, tags],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runningEntry) return;
|
if (!runningEntry) return;
|
||||||
@@ -2016,11 +2272,15 @@ export default function Timesheet() {
|
|||||||
if (!activeWorkspace?.id || runningEntry) return;
|
if (!activeWorkspace?.id || runningEntry) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const restartProjectId = entry.project_details?.is_deleted ? null : entry.project;
|
||||||
|
const restartTagIds = (entry.tag_details || [])
|
||||||
|
.filter((tag) => !tag.is_deleted)
|
||||||
|
.map((tag) => tag.id);
|
||||||
await createTimeEntry({
|
await createTimeEntry({
|
||||||
workspace_id: activeWorkspace.id,
|
workspace_id: activeWorkspace.id,
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
project_id: entry.project,
|
project_id: restartProjectId,
|
||||||
tags: entry.tags,
|
tags: restartTagIds,
|
||||||
is_billable: entry.is_billable,
|
is_billable: entry.is_billable,
|
||||||
start_time: new Date().toISOString(),
|
start_time: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -2156,19 +2416,20 @@ export default function Timesheet() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
|
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||||
<h1 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
|
<div>
|
||||||
{t.timesheet?.title || "Timesheet"}
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
|
||||||
</h1>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={desktopTimerRef}
|
ref={desktopTimerRef}
|
||||||
onBlurCapture={handleTimerBlurCapture}
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
className="mb-4 hidden overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block"
|
className="mb-4 hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 xl:block"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-[1040px] items-center h-20 px-3">
|
<div className="flex min-w-0 items-center gap-2 px-3 py-3">
|
||||||
<div className="min-w-[360px] flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Input
|
<Input
|
||||||
value={timerDraft.description}
|
value={timerDraft.description}
|
||||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
@@ -2184,18 +2445,18 @@ export default function Timesheet() {
|
|||||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||||
...projects.map((project) => ({ value: project.id, label: project.name })),
|
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
||||||
]}
|
]}
|
||||||
className="min-w-[170px]"
|
className="min-w-[190px] max-w-[220px]"
|
||||||
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
buttonClassName="h-12 w-full rounded-none border-0 bg-transparent px-3 text-sm text-sky-600 shadow-none outline-none dark:bg-transparent dark:text-sky-400 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
disabled={isStartingTimer}
|
disabled={isStartingTimer}
|
||||||
portalOwnerId={timerEditorOwnerId}
|
portalOwnerId={timerEditorOwnerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex min-w-0 shrink items-center">
|
||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
tags={tags}
|
tags={runningTimerTags}
|
||||||
selectedTags={timerDraft.tags}
|
selectedTags={timerDraft.tags}
|
||||||
onToggleTag={(tagId) =>
|
onToggleTag={(tagId) =>
|
||||||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||||||
@@ -2204,6 +2465,8 @@ export default function Timesheet() {
|
|||||||
title={t.tags?.title || "Tags"}
|
title={t.tags?.title || "Tags"}
|
||||||
compact
|
compact
|
||||||
portalOwnerId={timerEditorOwnerId}
|
portalOwnerId={timerEditorOwnerId}
|
||||||
|
className="max-w-[240px]"
|
||||||
|
buttonClassName="max-w-[240px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2263,6 +2526,106 @@ export default function Timesheet() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
|
className="mb-4 hidden rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-950 md:block xl:hidden"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
value={timerDraft.description}
|
||||||
|
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||||
|
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
className="h-11 border-slate-200 bg-slate-50 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(180px,220px)_auto]">
|
||||||
|
<Select
|
||||||
|
value={timerDraft.projectId}
|
||||||
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||||
|
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
||||||
|
]}
|
||||||
|
className="w-full"
|
||||||
|
buttonClassName="h-11 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 items-center">
|
||||||
|
<TagMultiSelect
|
||||||
|
tags={runningTimerTags}
|
||||||
|
selectedTags={timerDraft.tags}
|
||||||
|
onToggleTag={(tagId) =>
|
||||||
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||||||
|
}
|
||||||
|
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||||
|
title={t.tags?.title || "Tags"}
|
||||||
|
compact
|
||||||
|
portalOwnerId={timerEditorOwnerId}
|
||||||
|
className="max-w-full"
|
||||||
|
buttonClassName="max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-start lg:justify-end">
|
||||||
|
<div className="flex h-11 min-w-[132px] items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white">
|
||||||
|
{runningEntry ? formatDuration(runningEntry, ticker) : "00:00:00"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<BillableIconButton
|
||||||
|
checked={timerDraft.isBillable}
|
||||||
|
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
||||||
|
label={t.timesheet?.billable || "Billable"}
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{runningEntry ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleStop(runningEntry)}
|
||||||
|
className="h-11 w-11 rounded-md"
|
||||||
|
title={t.timesheet?.stopTimer || "Stop"}
|
||||||
|
aria-label={t.timesheet?.stopTimer || "Stop"}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4 fill-current" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={openDiscardTimerModal}
|
||||||
|
disabled={isDiscardingTimer}
|
||||||
|
className="h-11 w-11 rounded-md"
|
||||||
|
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
|
aria-label={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||||
|
>
|
||||||
|
{isDiscardingTimer ? "..." : <Trash2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleStartTimer()}
|
||||||
|
disabled={isStartingTimer}
|
||||||
|
size="icon"
|
||||||
|
className="h-11 w-11 rounded-md"
|
||||||
|
title={t.timesheet?.startTimer || "Start"}
|
||||||
|
aria-label={t.timesheet?.startTimer || "Start"}
|
||||||
|
>
|
||||||
|
{isStartingTimer ? "..." : <Play className="h-4 w-4 fill-current" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={mobileTimerRef}
|
ref={mobileTimerRef}
|
||||||
onBlurCapture={handleTimerBlurCapture}
|
onBlurCapture={handleTimerBlurCapture}
|
||||||
@@ -2283,7 +2646,7 @@ export default function Timesheet() {
|
|||||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||||
...projects.map((project) => ({ value: project.id, label: project.name })),
|
...runningTimerProjects.map((project) => ({ value: project.id, label: project.name })),
|
||||||
]}
|
]}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
buttonClassName="h-10 w-full rounded-md border border-slate-200 bg-slate-50 px-3 text-sm shadow-none outline-none dark:border-slate-700 dark:bg-slate-900 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
@@ -2299,7 +2662,7 @@ export default function Timesheet() {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<TagMultiSelect
|
<TagMultiSelect
|
||||||
tags={tags}
|
tags={runningTimerTags}
|
||||||
selectedTags={timerDraft.tags}
|
selectedTags={timerDraft.tags}
|
||||||
onToggleTag={(tagId) =>
|
onToggleTag={(tagId) =>
|
||||||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||||||
@@ -2427,7 +2790,7 @@ export default function Timesheet() {
|
|||||||
<div>
|
<div>
|
||||||
{day.entries.map((entry) => (
|
{day.entries.map((entry) => (
|
||||||
<div key={entry.id}>
|
<div key={entry.id}>
|
||||||
<div className="hidden md:block">
|
<div className="hidden xl:block">
|
||||||
<RecordedEntryCard
|
<RecordedEntryCard
|
||||||
entry={entry}
|
entry={entry}
|
||||||
t={t}
|
t={t}
|
||||||
@@ -2436,6 +2799,20 @@ export default function Timesheet() {
|
|||||||
onDelete={openDeleteModal}
|
onDelete={openDeleteModal}
|
||||||
onRestart={handleRestartFromEntry}
|
onRestart={handleRestartFromEntry}
|
||||||
onEntryUpdated={handleEntryUpdated}
|
onEntryUpdated={handleEntryUpdated}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block xl:hidden">
|
||||||
|
<RecordedEntryCard
|
||||||
|
entry={entry}
|
||||||
|
t={t}
|
||||||
|
projects={projects}
|
||||||
|
tags={tags}
|
||||||
|
onDelete={openDeleteModal}
|
||||||
|
onRestart={handleRestartFromEntry}
|
||||||
|
onEntryUpdated={handleEntryUpdated}
|
||||||
|
variant="tablet"
|
||||||
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
@@ -2447,6 +2824,7 @@ export default function Timesheet() {
|
|||||||
onEdit={openEditModal}
|
onEdit={openEditModal}
|
||||||
onDelete={openDeleteModal}
|
onDelete={openDeleteModal}
|
||||||
onRequestRestart={openRestartModal}
|
onRequestRestart={openRestartModal}
|
||||||
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2487,8 +2865,8 @@ export default function Timesheet() {
|
|||||||
state={formState}
|
state={formState}
|
||||||
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
||||||
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||||||
projects={projects}
|
projects={modalProjects}
|
||||||
tags={tags}
|
tags={modalTags}
|
||||||
t={t}
|
t={t}
|
||||||
isRtl={isRtl}
|
isRtl={isRtl}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,101 +1,439 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Banknote,
|
||||||
|
BriefcaseBusiness,
|
||||||
|
Edit2,
|
||||||
|
FolderKanban,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { getClients } from '../api/clients';
|
||||||
|
import { getProjects } from '../api/projects';
|
||||||
|
import { getWorkspaceUserRates, type WorkspaceUserRate } from '../api/rates';
|
||||||
|
import { getTags } from '../api/tags';
|
||||||
|
import {
|
||||||
|
deleteWorkspace,
|
||||||
|
fetchWorkspaceMemberships,
|
||||||
|
getWorkspace,
|
||||||
|
type Workspace,
|
||||||
|
type WorkspaceMembership,
|
||||||
|
} from '../api/workspaces';
|
||||||
|
import { useWorkspace } from '../context/WorkspaceContext';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
|
import {
|
||||||
import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions';
|
CLIENTS_VIEW,
|
||||||
|
PROJECTS_VIEW,
|
||||||
export default function WorkspaceDetail() {
|
TAGS_VIEW,
|
||||||
const { id } = useParams<{ id: string }>();
|
WORKSPACE_DELETE,
|
||||||
const navigate = useNavigate();
|
WORKSPACE_EDIT,
|
||||||
const { t, lang } = useTranslation();
|
WORKSPACE_MEMBERS_VIEW,
|
||||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
WORKSPACE_VIEW,
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
canWorkspace,
|
||||||
|
} from '../lib/permissions';
|
||||||
const isRtl = lang === 'fa';
|
|
||||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
type ResourceCounts = {
|
||||||
|
projects: number;
|
||||||
useEffect(() => {
|
clients: number;
|
||||||
if (id) loadWorkspace();
|
tags: number;
|
||||||
}, [id]);
|
};
|
||||||
|
|
||||||
const loadWorkspace = async () => {
|
const roleBadgeStyles: Record<string, string> = {
|
||||||
try {
|
owner: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
const data = await getWorkspace(id!);
|
admin: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
setWorkspace(data);
|
member: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
} catch (error) {
|
guest: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||||
console.error(error);
|
};
|
||||||
navigate('/workspaces');
|
|
||||||
} finally {
|
export default function WorkspaceDetail() {
|
||||||
setIsLoading(false);
|
const { id } = useParams<{ id: string }>();
|
||||||
}
|
const navigate = useNavigate();
|
||||||
};
|
const { t, lang } = useTranslation();
|
||||||
|
const { setActiveWorkspace } = useWorkspace();
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!window.confirm(t.workspace?.confirmDelete) || !id) return;
|
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||||
try {
|
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
|
||||||
await deleteWorkspace(id);
|
const [rates, setRates] = useState<WorkspaceUserRate[]>([]);
|
||||||
navigate('/workspaces');
|
const [counts, setCounts] = useState<ResourceCounts>({ projects: 0, clients: 0, tags: 0 });
|
||||||
} catch (error) {
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
console.error(error);
|
|
||||||
}
|
const isRtl = lang === 'fa';
|
||||||
};
|
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||||
|
|
||||||
if (isLoading || !workspace) {
|
useEffect(() => {
|
||||||
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
|
if (!id) return;
|
||||||
}
|
void loadWorkspace();
|
||||||
|
}, [id]);
|
||||||
const canEdit = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
|
||||||
const canDelete = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
const loadWorkspace = async () => {
|
||||||
|
try {
|
||||||
return (
|
const data = await getWorkspace(id!);
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
setWorkspace(data);
|
||||||
<button
|
|
||||||
onClick={() => navigate('/workspaces')}
|
const canViewMembers = canWorkspace(data.my_role, WORKSPACE_MEMBERS_VIEW);
|
||||||
className="flex items-center gap-2 text-slate-500 hover:text-slate-900 dark:hover:text-white mb-6 transition-colors"
|
const canViewClients = canWorkspace(data.my_role, CLIENTS_VIEW);
|
||||||
>
|
const canViewProjects = canWorkspace(data.my_role, PROJECTS_VIEW);
|
||||||
<BackIcon className="h-5 w-5" />
|
const canViewTags = canWorkspace(data.my_role, TAGS_VIEW);
|
||||||
<span>{t.workspace?.back}</span>
|
const canViewRates = canWorkspace(data.my_role, WORKSPACE_EDIT);
|
||||||
</button>
|
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm relative">
|
|
||||||
<div className="flex justify-between items-start mb-6">
|
if (canViewMembers) {
|
||||||
<div>
|
tasks.push(
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
fetchWorkspaceMemberships({ workspace: id!, limit: 200, offset: 0 }).then((response) => {
|
||||||
{workspace.name}
|
setMembers(response.results || []);
|
||||||
</h1>
|
}),
|
||||||
<span className="inline-block px-3 py-1 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-sm rounded-full font-medium">
|
);
|
||||||
{workspace.my_role ? t.workspace?.roles[workspace.my_role] : "-"}
|
} else {
|
||||||
</span>
|
setMembers([]);
|
||||||
</div>
|
}
|
||||||
|
|
||||||
{canEdit && (
|
if (canViewRates) {
|
||||||
<div className="flex gap-2">
|
tasks.push(
|
||||||
<button
|
getWorkspaceUserRates(id!).then((response) => {
|
||||||
onClick={() => navigate(`/workspaces/${id}/edit`)}
|
setRates(response.results || []);
|
||||||
className="p-2 text-slate-500 hover:text-emerald-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
|
}),
|
||||||
>
|
);
|
||||||
<Edit2 className="h-5 w-5" />
|
} else {
|
||||||
</button>
|
setRates([]);
|
||||||
{canDelete && (
|
}
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
tasks.push(
|
||||||
className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
|
Promise.all([
|
||||||
>
|
canViewProjects ? getProjects(id!, { limit: 1, offset: 0, is_archived: false }) : Promise.resolve({ count: 0 }),
|
||||||
<Trash2 className="h-5 w-5" />
|
canViewClients ? getClients(id!, '', '', 1, 0) : Promise.resolve({ count: 0 }),
|
||||||
</button>
|
canViewTags ? getTags(id!, { limit: 1, offset: 0 }) : Promise.resolve({ count: 0, results: [] }),
|
||||||
)}
|
]).then(([projectsData, clientsData, tagsData]) => {
|
||||||
</div>
|
setCounts({
|
||||||
)}
|
projects: projectsData.count || 0,
|
||||||
</div>
|
clients: clientsData.count || 0,
|
||||||
|
tags: tagsData.count || 0,
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
});
|
||||||
<h3 className="text-lg font-semibold mb-2">{t.workspace?.descriptionLabel}</h3>
|
}),
|
||||||
<p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
|
);
|
||||||
{workspace.description || t.workspace?.noDescription}
|
|
||||||
</p>
|
await Promise.all(tasks);
|
||||||
</div>
|
} catch (error) {
|
||||||
</div>
|
console.error(error);
|
||||||
</div>
|
navigate('/workspaces');
|
||||||
);
|
} finally {
|
||||||
}
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm(t.workspace?.confirmDelete) || !id) return;
|
||||||
|
try {
|
||||||
|
await deleteWorkspace(id);
|
||||||
|
navigate('/workspaces');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeMembers = members.filter((member) => member.is_active);
|
||||||
|
const roleCounts = useMemo(
|
||||||
|
() =>
|
||||||
|
activeMembers.reduce(
|
||||||
|
(acc, member) => {
|
||||||
|
acc[member.role] += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ owner: 0, admin: 0, member: 0, guest: 0 },
|
||||||
|
),
|
||||||
|
[activeMembers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberRateMap = useMemo(
|
||||||
|
() => new Map(rates.map((rate) => [rate.user, rate])),
|
||||||
|
[rates],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayNumber = (value: number) =>
|
||||||
|
new Intl.NumberFormat(lang === 'fa' ? 'fa-IR' : 'en-US').format(value);
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(lang === 'fa' ? 'fa-IR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(new Date(value));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMemberName = (member: WorkspaceMembership) => {
|
||||||
|
const firstName = member.user?.first_name?.trim() || '';
|
||||||
|
const lastName = member.user?.last_name?.trim() || '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
return fullName || member.user?.email || t.workspace?.unknownMember || 'Unknown member';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMemberContact = (member: WorkspaceMembership) => {
|
||||||
|
return member.user?.mobile || member.user?.email || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRateUnit = (rate?: WorkspaceUserRate) => {
|
||||||
|
if (!rate) return t.rates?.noRate || 'No rate';
|
||||||
|
const unitLabel =
|
||||||
|
lang === 'fa'
|
||||||
|
? rate.price_unit?.local_name || rate.price_unit?.code || rate.currency
|
||||||
|
: rate.price_unit?.code || rate.currency;
|
||||||
|
return `${rate.hourly_rate} ${unitLabel}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceRole = workspace?.my_role;
|
||||||
|
const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT);
|
||||||
|
const canDelete = canWorkspace(workspaceRole, WORKSPACE_DELETE);
|
||||||
|
const canViewMembers = canWorkspace(workspaceRole, WORKSPACE_MEMBERS_VIEW);
|
||||||
|
const canViewReports = canWorkspace(workspaceRole, WORKSPACE_VIEW);
|
||||||
|
|
||||||
|
if (isLoading || !workspace) {
|
||||||
|
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWorkspaceRoute = (path: string) => {
|
||||||
|
setActiveWorkspace(workspace);
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceCards = [
|
||||||
|
{
|
||||||
|
key: 'projects',
|
||||||
|
title: t.sidebar?.projects || 'Projects',
|
||||||
|
value: displayNumber(counts.projects),
|
||||||
|
icon: FolderKanban,
|
||||||
|
onClick: () => openWorkspaceRoute('/projects'),
|
||||||
|
visible: canWorkspace(workspace.my_role, PROJECTS_VIEW),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'clients',
|
||||||
|
title: t.sidebar?.clients || 'Clients',
|
||||||
|
value: displayNumber(counts.clients),
|
||||||
|
icon: BriefcaseBusiness,
|
||||||
|
onClick: () => openWorkspaceRoute('/clients'),
|
||||||
|
visible: canWorkspace(workspace.my_role, CLIENTS_VIEW),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
title: t.sidebar?.tags || 'Tags',
|
||||||
|
value: displayNumber(counts.tags),
|
||||||
|
icon: Tag,
|
||||||
|
onClick: () => openWorkspaceRoute('/tags'),
|
||||||
|
visible: canWorkspace(workspace.my_role, TAGS_VIEW),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reports',
|
||||||
|
title: t.sidebar?.reports || 'Reports',
|
||||||
|
value: t.workspace?.resourceOpen || 'Open',
|
||||||
|
icon: Banknote,
|
||||||
|
onClick: () => openWorkspaceRoute('/reports'),
|
||||||
|
visible: canViewReports,
|
||||||
|
},
|
||||||
|
].filter((item) => item.visible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-4 sm:p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/workspaces')}
|
||||||
|
className="flex items-center gap-2 text-slate-500 transition-colors hover:text-slate-900 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<BackIcon className="h-5 w-5" />
|
||||||
|
<span>{t.workspace?.back}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-8">
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
|
||||||
|
{workspace.name}
|
||||||
|
</h1>
|
||||||
|
<span className={`inline-flex rounded-full px-3 py-1 text-sm font-semibold ${roleBadgeStyles[workspace.my_role || 'guest']}`}>
|
||||||
|
{workspace.my_role ? t.workspace?.roles[workspace.my_role] : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-3xl whitespace-pre-wrap text-sm leading-7 text-slate-600 dark:text-slate-400">
|
||||||
|
{workspace.description || t.workspace?.noDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canViewReports && (
|
||||||
|
<button
|
||||||
|
onClick={() => openWorkspaceRoute('/reports')}
|
||||||
|
className="inline-flex h-11 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{t.workspace?.openReports || 'Open reports'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/workspaces/${id}/edit`)}
|
||||||
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-xl border px-4 text-sm font-semibold transition bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400 border-blue-200 hover:border-blue-300 hover:bg-blue-100 dark:border-blue-900/60 dark:hover:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
<span>{t.actions?.edit || 'Edit'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-xl border border-red-200 bg-red-50 px-4 text-sm font-semibold text-red-700 transition hover:border-red-300 hover:bg-red-100 dark:border-red-900/60 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
<span>{t.actions?.delete || 'Delete'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsMembers || 'Members'}</span>
|
||||||
|
<Users className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(activeMembers.length)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsRates || 'Rates set'}</span>
|
||||||
|
<Banknote className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(rates.length)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsOwnersAdmins || 'Owners & admins'}</span>
|
||||||
|
<Users className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{displayNumber(roleCounts.owner + roleCounts.admin)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsGuests || 'Guests'}</span>
|
||||||
|
<Users className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(roleCounts.guest)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.workspace?.resourcesTitle || 'Resources'}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{resourceCards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={card.key}
|
||||||
|
onClick={card.onClick}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 p-4 text-start transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl bg-white p-2 text-slate-600 shadow-sm dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">{card.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BackIcon className="h-4 w-4 rtl:rotate-180 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="border-b border-slate-100 p-6 dark:border-slate-800">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t.workspace?.membersSectionTitle || t.workspace?.members || 'Members'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{canViewMembers
|
||||||
|
? t.workspace?.membersSectionSubtitle || 'People in this workspace and their current roles.'
|
||||||
|
: t.workspace?.membersLocked || 'Only owners and admins can view the full member list.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/workspaces/${id}/edit`)}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{t.workspace?.manageMembers || 'Manage members'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canViewMembers ? (
|
||||||
|
<div className={`divide-y divide-slate-100 dark:divide-slate-800 ${activeMembers.length > 6 ? 'max-h-[36rem] overflow-y-auto' : ''}`}>
|
||||||
|
{activeMembers.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.workspace?.noMembers || 'No members found.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeMembers.map((member) => {
|
||||||
|
const rate = memberRateMap.get(member.user.id);
|
||||||
|
return (
|
||||||
|
<div key={member.id} className="flex flex-col gap-4 p-5 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{getMemberName(member)}
|
||||||
|
</h3>
|
||||||
|
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeStyles[member.role]}`}>
|
||||||
|
{t.workspace?.roles[member.role]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<p>{getMemberContact(member)}</p>
|
||||||
|
<p>
|
||||||
|
{t.workspace?.joinedLabel || 'Joined'}: {formatDate(member.joined_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800/80 sm:min-w-[210px]">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
|
{t.rates?.workspaceRate || 'Workspace rate'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{formatRateUnit(rate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.workspace?.membersLocked || 'Only owners and admins can view the full member list.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user