Compare commits

..

7 Commits

Author SHA1 Message Date
c7ede31b68 feat(timesheet): add manual time entry action
Some checks are pending
Frontend CI/CD / build (push) Waiting to run
Frontend CI/CD / deploy (push) Blocked by required conditions
2026-06-18 22:59:04 +03:30
55ba274346 feat(workspaces): add bulk member import modal 2026-06-18 22:53:44 +03:30
29cadb83e6 feat(timesheet): improve inline edit autosave
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 15:37:40 +03:30
03c7c07a9f fix(datepicker): avoid offscreen calendar placement 2026-06-07 15:37:26 +03:30
666d04ff26 fix(forms): submit modal actions with enter 2026-06-07 15:37:02 +03:30
132c8c44ef fix(modal): add keyboard close and autofocus 2026-06-07 15:36:43 +03:30
8abfcc9c2b feat(about): submit contact form to api 2026-06-07 14:09:54 +03:30
21 changed files with 1770 additions and 495 deletions

106
package-lock.json generated
View File

@@ -22,7 +22,8 @@
"react-router-dom": "^7.13.1",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2366,6 +2367,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/ajv/-/ajv-6.14.0.tgz",
@@ -2569,6 +2579,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/chalk/-/chalk-4.1.2.tgz",
@@ -2607,6 +2630,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/color-convert/-/color-convert-2.0.1.tgz",
@@ -2666,6 +2698,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://package-mirror.liara.ir/repository/npm/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3326,6 +3370,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -4548,6 +4601,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4870,6 +4935,24 @@
"node": ">= 8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://package-mirror.liara.ir/repository/npm/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4880,6 +4963,27 @@
"node": ">=0.10.0"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://package-mirror.liara.ir/repository/npm/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/yallist/-/yallist-3.1.1.tgz",

View File

@@ -24,7 +24,8 @@
"react-router-dom": "^7.13.1",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

41
src/api/contact.ts Normal file
View File

@@ -0,0 +1,41 @@
import { buildApiError, buildApiUrl } from "./client"
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
export interface ContactSubmissionPayload {
first_name: string
last_name: string
email: string
mobile: string
message: string
}
export interface ContactSubmissionResponse extends ContactSubmissionPayload {
id: string
status: string
created_at: string
}
export const submitContactForm = async (
payload: ContactSubmissionPayload,
): Promise<ContactSubmissionResponse> => {
const response = await fetch(buildApiUrl("/api/contact/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...payload,
mobile: normalizeDigits(payload.mobile),
}),
})
if (!response.ok) {
throw await buildApiError(response)
}
return response.json()
}

View File

@@ -36,6 +36,47 @@ export interface WorkspaceMembership {
[key: string]: any;
}
export interface WorkspaceMemberImportRowInput {
line: number;
mobile: string;
role?: "admin" | "member" | "guest";
hourly_rate?: string;
currency?: string;
}
export interface WorkspaceMemberImportResultRow {
line: number | null;
mobile: string;
role: "admin" | "member" | "guest" | "owner" | string;
hourly_rate: string;
currency: string;
status: "valid" | "invalid";
action: "add_member" | "none" | string;
user: {
id: string;
full_name: string;
mobile: string;
} | null;
messages: string[];
}
export interface WorkspaceMemberImportValidationResponse {
can_commit: boolean;
import_token: string | null;
summary: {
total: number;
valid: number;
invalid: number;
};
rows: WorkspaceMemberImportResultRow[];
}
export interface WorkspaceMemberImportCommitResponse {
created_memberships: number;
created_or_updated_rates: number;
memberships: WorkspaceMembership[];
}
type QueryValue = string | number | boolean | undefined | null;
@@ -225,3 +266,36 @@ export const updateWorkspaceMembership = async (membershipId: string | number, d
invalidateApiCache(["workspace-memberships", "reports"]);
return payload;
};
export const validateWorkspaceMemberImport = async (data: {
workspace: string;
rows: WorkspaceMemberImportRowInput[];
}): Promise<WorkspaceMemberImportValidationResponse> => {
const response = await authFetch("/api/workspace-memberships/import/validate/", {
method: "POST",
body: JSON.stringify(data),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.detail || payload?.message || "Failed to validate member import");
}
return payload;
};
export const commitWorkspaceMemberImport = async (data: {
workspace: string;
import_token: string;
}): Promise<WorkspaceMemberImportCommitResponse> => {
const response = await authFetch("/api/workspace-memberships/import/commit/", {
method: "POST",
body: JSON.stringify(data),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.detail || payload?.message || "Failed to import workspace members");
}
invalidateApiCache(["workspace-memberships", "workspace-rates", "reports"]);
return payload;
};

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { createClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation";
@@ -48,7 +48,8 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
setThumbnailFile(file);
};
const handleSubmit = async () => {
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!name.trim()) return;
setIsLoading(true);
try {
@@ -72,7 +73,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel}
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
<Button type="submit" form="create-client-form" disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.create}
</Button>
</>
@@ -80,7 +81,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
<div className="space-y-4">
<form id="create-client-form" onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
@@ -117,7 +118,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
placeholder={t.clients.notesPlaceholder}
/>
</div>
</div>
</form>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { type Client } from "../types/client";
import { deleteClient } from "../api/clients";
@@ -17,7 +17,8 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const handleDelete = async () => {
const handleDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!client) return;
setIsLoading(true);
try {
@@ -39,8 +40,9 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
{t.actions?.cancel}
</Button>
<Button
type="submit"
form="delete-client-form"
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
{isLoading ? "..." : t.clients.delete}
@@ -56,9 +58,11 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
footer={footer}
maxWidth="max-w-sm"
>
<form id="delete-client-form" onSubmit={handleDelete}>
<p className="text-slate-500 dark:text-slate-400">
{client ? t.clients.deleteConfirmMessage(client.name) : ""}
</p>
</form>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, type FormEvent } from "react";
import { toast } from "sonner";
import { type Client } from "../types/client";
import { updateClient } from "../api/clients";
@@ -59,7 +59,8 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
setClearThumbnail(false);
};
const handleSubmit = async () => {
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!client || !name.trim()) return;
setIsLoading(true);
try {
@@ -80,7 +81,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel}
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
<Button type="submit" form="edit-client-form" disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.saveChanges}
</Button>
</>
@@ -88,7 +89,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
<div className="space-y-4">
<form id="edit-client-form" onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
@@ -138,7 +139,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
placeholder={t.clients.notesPlaceholder}
/>
</div>
</div>
</form>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useRef } from "react";
import { X } from "lucide-react";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
@@ -23,15 +23,43 @@ export const Modal: React.FC<ModalProps> = ({
footer,
maxWidth = "max-w-lg",
}) => {
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.body.style.overflow = "hidden";
document.addEventListener("keydown", handleKeyDown);
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) return;
const focusTimer = window.setTimeout(() => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && cardRef.current?.contains(activeElement)) {
return;
}
const firstTextInput = cardRef.current?.querySelector<HTMLElement>(
'input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="tel"], input[type="password"], input[type="number"], textarea',
);
firstTextInput?.focus();
}, 0);
return () => window.clearTimeout(focusTimer);
}, [isOpen]);
if (!isOpen) return null;
@@ -42,6 +70,7 @@ export const Modal: React.FC<ModalProps> = ({
onClick={onClose}
>
<Card
ref={cardRef}
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
onClick={(e) => e.stopPropagation()}
>

View File

@@ -100,7 +100,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700">
{t.actions?.cancel || "Cancel"}
</button>
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
<button form="create-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{loading ? "..." : t.projects?.create}
</button>
</>
@@ -110,7 +110,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
<form onSubmit={handleSubmit} className="space-y-4">
<form id="create-project-form" onSubmit={handleSubmit} className="space-y-4">
{/* ردیف اول: عنوان و انتخاب رنگ */}
<div className="flex items-end gap-3">
<div className="flex-1">

View File

@@ -154,7 +154,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
{t.actions?.cancel || "Cancel"}
</button>
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
<button form="edit-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{loading ? "..." : t.save || "Save"}
</button>
</div>
@@ -165,7 +165,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
<form id="edit-project-form" onSubmit={handleSubmit} className="space-y-4 mb-6">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"
import React, { useEffect, useRef, useState } from "react"
import DatePicker, { DateObject } from "react-multi-date-picker"
import persian from "react-date-object/calendars/persian"
import persian_fa from "react-date-object/locales/persian_fa"
@@ -18,6 +18,8 @@ interface JalaliDatePickerProps {
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
const isFa = document.documentElement.dir === 'rtl'
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
const containerRef = useRef<HTMLDivElement>(null)
const [calendarPosition, setCalendarPosition] = useState("bottom-right")
// Listen for dark mode changes dynamically (optional but good for UX)
useEffect(() => {
@@ -37,8 +39,19 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
}
}
const updateCalendarPosition = () => {
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const estimatedHeight = 340
const hasMoreSpaceAbove = rect.top > window.innerHeight - rect.bottom
const shouldOpenTop = window.innerHeight - rect.bottom < estimatedHeight && hasMoreSpaceAbove
const horizontal = isFa ? "left" : "right"
setCalendarPosition(`${shouldOpenTop ? "top" : "bottom"}-${horizontal}`)
}
return (
<div className="w-full">
<div ref={containerRef} className="w-full">
{label && (
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
{label}
@@ -52,10 +65,11 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
format="YYYY/MM/DD"
placeholder={placeholder || "YYYY/MM/DD"}
onOpenPickNewDate={false}
onOpen={updateCalendarPosition}
inputClass={`w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${inputClassName}`}
containerClassName="w-full"
className={isDark ? "bg-dark" : ""}
calendarPosition="bottom-right"
calendarPosition={calendarPosition}
fixMainPosition
disabled={disabled}
/>

View File

@@ -0,0 +1,595 @@
import { Fragment, useMemo, useRef, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, UploadCloud, XCircle } from "lucide-react";
import { toast } from "sonner";
import * as XLSX from "xlsx";
import {
commitWorkspaceMemberImport,
validateWorkspaceMemberImport,
type WorkspaceMemberImportResultRow,
type WorkspaceMemberImportRowInput,
type WorkspaceMemberImportValidationResponse,
} from "../../api/workspaces";
import type { PriceUnit } from "../../api/rates";
import { Button } from "../ui/button";
type ImportLabels = {
title?: string;
description?: string;
uploadTitle?: string;
uploadDescription?: string;
sampleCsv?: string;
sampleTsv?: string;
sampleTxt?: string;
sampleXlsx?: string;
validate?: string;
validating?: string;
import?: string;
importing?: string;
close?: string;
chooseFile?: string;
selectedFile?: string;
validRows?: string;
invalidRows?: string;
totalRows?: string;
line?: string;
mobile?: string;
user?: string;
role?: string;
hourlyRate?: string;
currency?: string;
status?: string;
messages?: string;
valid?: string;
invalid?: string;
noRows?: string;
localErrors?: string;
success?: string;
parseFailed?: string;
missingMobile?: string;
duplicateMobile?: string;
invalidRole?: string;
invalidRate?: string;
rateCurrencyPair?: string;
tooManyRows?: string;
};
type ParsedRow = WorkspaceMemberImportRowInput & {
local_messages: string[];
};
type Props = {
isOpen: boolean;
onClose: () => void;
workspaceId: string;
priceUnits: PriceUnit[];
labels: ImportLabels;
onImported: () => void | Promise<void>;
};
const MAX_ROWS = 500;
const ROLE_VALUES = new Set(["admin", "member", "guest"]);
const DIGIT_MAP: Record<string, string> = {
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
"٠": "0",
"١": "1",
"٢": "2",
"٣": "3",
"٤": "4",
"٥": "5",
"٦": "6",
"٧": "7",
"٨": "8",
"٩": "9",
};
const normalizeDigits = (value: unknown) =>
String(value ?? "")
.replace(/[۰-۹٠-٩]/g, (digit) => DIGIT_MAP[digit] || digit)
.trim();
const normalizeHeader = (value: unknown) =>
String(value ?? "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
const parseDelimited = (text: string, delimiter: string) => {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (char === '"' && inQuotes && next === '"') {
cell += '"';
index += 1;
continue;
}
if (char === '"') {
inQuotes = !inQuotes;
continue;
}
if (char === delimiter && !inQuotes) {
row.push(cell.trim());
cell = "";
continue;
}
if ((char === "\n" || char === "\r") && !inQuotes) {
if (char === "\r" && next === "\n") {
index += 1;
}
row.push(cell.trim());
if (row.some((item) => item !== "")) {
rows.push(row);
}
row = [];
cell = "";
continue;
}
cell += char;
}
row.push(cell.trim());
if (row.some((item) => item !== "")) {
rows.push(row);
}
return rows;
};
const detectDelimiter = (text: string, extension: string) => {
if (extension === "tsv") return "\t";
const firstLine = text.split(/\r?\n/).find((line) => line.trim()) || "";
const candidates = [",", ";", "\t", "|"];
return candidates
.map((delimiter) => ({
delimiter,
count: firstLine.split(delimiter).length,
}))
.sort((a, b) => b.count - a.count)[0]?.delimiter || ",";
};
const tableRowsToObjects = (rows: string[][]) => {
const [headers = [], ...body] = rows;
const normalizedHeaders = headers.map(normalizeHeader);
return body.map((row, index) => {
const record: Record<string, string> = {};
normalizedHeaders.forEach((header, headerIndex) => {
if (header) {
record[header] = row[headerIndex] || "";
}
});
return { line: index + 2, record };
});
};
const buildParsedRows = (records: Array<{ line: number; record: Record<string, string> }>, labels: ImportLabels) => {
const seenMobiles = new Set<string>();
return records
.filter(({ record }) => Object.values(record).some((value) => String(value || "").trim()))
.map(({ line, record }) => {
const mobile = normalizeDigits(record.mobile);
const role = normalizeDigits(record.role || "member").toLowerCase() as ParsedRow["role"];
const hourlyRate = normalizeDigits(record.hourly_rate);
const currency = normalizeDigits(record.currency).toUpperCase();
const localMessages: string[] = [];
if (!mobile) {
localMessages.push(labels.missingMobile || "Mobile is required.");
} else if (seenMobiles.has(mobile)) {
localMessages.push(labels.duplicateMobile || "This mobile appears more than once.");
} else {
seenMobiles.add(mobile);
}
if (!ROLE_VALUES.has(role || "member")) {
localMessages.push(labels.invalidRole || "Role must be admin, member, or guest.");
}
if (hourlyRate && Number.isNaN(Number(hourlyRate.replace(/,/g, "")))) {
localMessages.push(labels.invalidRate || "Hourly rate must be a valid number.");
}
if (hourlyRate && Number(hourlyRate.replace(/,/g, "")) <= 0) {
localMessages.push(labels.invalidRate || "Hourly rate must be greater than zero.");
}
if (Boolean(hourlyRate) !== Boolean(currency)) {
localMessages.push(labels.rateCurrencyPair || "Hourly rate and currency must be provided together.");
}
return {
line,
mobile,
role: ROLE_VALUES.has(role || "") ? role : "member",
hourly_rate: hourlyRate ? hourlyRate.replace(/,/g, "") : "",
currency,
local_messages: localMessages,
};
});
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const sampleRows = [
["mobile", "role", "hourly_rate", "currency"],
["09999999999", "member", "150000", "IRT"],
["09999999998", "guest", "", ""],
];
export function WorkspaceMemberImportModal({
isOpen,
onClose,
workspaceId,
priceUnits,
labels,
onImported,
}: Props) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [parseError, setParseError] = useState("");
const [validation, setValidation] = useState<WorkspaceMemberImportValidationResponse | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const localInvalidCount = parsedRows.filter((row) => row.local_messages.length > 0).length;
const canValidate = parsedRows.length > 0 && localInvalidCount === 0 && !isValidating;
const canCommit = Boolean(validation?.can_commit && validation.import_token) && !isImporting;
const currencyCodes = useMemo(
() => priceUnits.map((unit) => unit.code).filter(Boolean).join(", "),
[priceUnits],
);
const reset = () => {
setFileName("");
setParsedRows([]);
setParseError("");
setValidation(null);
setIsValidating(false);
setIsImporting(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClose = () => {
reset();
onClose();
};
const parseFile = async (file: File) => {
setParseError("");
setValidation(null);
setFileName(file.name);
const extension = file.name.split(".").pop()?.toLowerCase() || "";
try {
let records: Array<{ line: number; record: Record<string, string> }> = [];
if (extension === "xlsx") {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: "array" });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, defval: "" });
records = tableRowsToObjects(rows);
} else if (["csv", "tsv", "txt"].includes(extension)) {
const text = await file.text();
const delimiter = detectDelimiter(text, extension);
records = tableRowsToObjects(parseDelimited(text, delimiter));
} else {
throw new Error("Unsupported file type.");
}
const hasMobileHeader = records.length > 0 && Object.prototype.hasOwnProperty.call(records[0].record, "mobile");
if (!hasMobileHeader && records.length === 0) {
throw new Error(labels.noRows || "No rows were found in this file.");
}
if (!hasMobileHeader) {
throw new Error(labels.missingMobile || "The file must include a mobile column.");
}
const nextRows = buildParsedRows(records, labels);
if (nextRows.length > MAX_ROWS) {
setParseError(labels.tooManyRows || `Import is limited to ${MAX_ROWS} rows.`);
setParsedRows([]);
return;
}
setParsedRows(nextRows);
} catch (error) {
setParsedRows([]);
setParseError(error instanceof Error ? error.message : labels.parseFailed || "Failed to parse the file.");
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
void parseFile(file);
}
};
const handleValidate = async () => {
setIsValidating(true);
setValidation(null);
try {
const response = await validateWorkspaceMemberImport({
workspace: workspaceId,
rows: parsedRows.map(({ local_messages: _localMessages, ...row }) => row),
});
setValidation(response);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.parseFailed || "Failed to validate member import");
} finally {
setIsValidating(false);
}
};
const handleImport = async () => {
if (!validation?.import_token) return;
setIsImporting(true);
try {
await commitWorkspaceMemberImport({
workspace: workspaceId,
import_token: validation.import_token,
});
toast.success(labels.success || "Members imported successfully.");
await onImported();
handleClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to import members");
} finally {
setIsImporting(false);
}
};
const downloadSample = (format: "csv" | "tsv" | "txt" | "xlsx") => {
if (format === "xlsx") {
const worksheet = XLSX.utils.aoa_to_sheet(sampleRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "members");
const output = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
downloadBlob(
new Blob([output], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
"workspace-members-sample.xlsx",
);
return;
}
const delimiter = format === "tsv" ? "\t" : ",";
const content = sampleRows.map((row) => row.join(delimiter)).join("\n");
downloadBlob(new Blob([content], { type: "text/plain;charset=utf-8" }), `workspace-members-sample.${format}`);
};
const displayedRows: WorkspaceMemberImportResultRow[] =
validation?.rows ||
parsedRows.map((row) => ({
line: row.line,
mobile: row.mobile,
role: row.role || "member",
hourly_rate: row.hourly_rate || "",
currency: row.currency || "",
status: row.local_messages.length ? "invalid" : "valid",
action: row.local_messages.length ? "none" : "add_member",
user: null,
messages: row.local_messages,
}));
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-5xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-950">
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-800">
<Dialog.Title className="text-lg font-semibold text-slate-950 dark:text-white">
{labels.title || "Import members"}
</Dialog.Title>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{labels.description ||
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member."}
</p>
</div>
<div className="grid gap-5 p-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-5 text-center dark:border-slate-700 dark:bg-slate-900/60">
<UploadCloud className="mx-auto h-9 w-9 text-blue-500" />
<h3 className="mt-3 text-sm font-semibold text-slate-900 dark:text-white">
{labels.uploadTitle || "Upload file"}
</h3>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">
{labels.uploadDescription || "CSV, TSV, TXT, or XLSX. The first row must be headers."}
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.tsv,.txt,.xlsx"
className="hidden"
onChange={handleFileChange}
/>
<Button type="button" className="mt-4 gap-2" onClick={() => fileInputRef.current?.click()}>
<FileSpreadsheet className="h-4 w-4" />
{labels.chooseFile || "Choose file"}
</Button>
{fileName ? (
<p className="mt-3 truncate text-xs text-slate-500 dark:text-slate-400">
{(labels.selectedFile || "Selected file")}: {fileName}
</p>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 p-4 dark:border-slate-800">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{labels.currency || "Currency"}: {currencyCodes || "-"}
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("csv")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleCsv || "CSV sample"}
</Button>
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("tsv")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleTsv || "TSV sample"}
</Button>
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("txt")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleTxt || "TXT sample"}
</Button>
<Button type="button" variant="secondary" size="sm" className="gap-1" onClick={() => downloadSample("xlsx")}>
<Download className="h-3.5 w-3.5" />
{labels.sampleXlsx || "XLSX sample"}
</Button>
</div>
</div>
{parseError ? (
<div className="flex gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{parseError}
</div>
) : null}
</div>
<div className="min-w-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-slate-100 p-3 dark:bg-slate-900">
<p className="text-xs text-slate-500">{labels.totalRows || "Total rows"}</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{validation?.summary.total ?? parsedRows.length}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3 dark:bg-emerald-950/30">
<p className="text-xs text-emerald-700 dark:text-emerald-300">{labels.validRows || "Valid rows"}</p>
<p className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">
{validation?.summary.valid ?? parsedRows.length - localInvalidCount}
</p>
</div>
<div className="rounded-xl bg-red-50 p-3 dark:bg-red-950/30">
<p className="text-xs text-red-700 dark:text-red-300">{labels.invalidRows || "Invalid rows"}</p>
<p className="text-lg font-semibold text-red-700 dark:text-red-300">
{validation?.summary.invalid ?? localInvalidCount}
</p>
</div>
</div>
{localInvalidCount > 0 && !validation ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300">
{labels.localErrors || "Fix local file errors before backend validation."}
</div>
) : null}
<div className="max-h-[440px] overflow-auto rounded-xl border border-slate-200 dark:border-slate-800">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="sticky top-0 bg-slate-100 text-xs uppercase text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-3 py-2 text-start">{labels.line || "Line"}</th>
<th className="px-3 py-2 text-start">{labels.mobile || "Mobile"}</th>
<th className="px-3 py-2 text-start">{labels.user || "User"}</th>
<th className="px-3 py-2 text-start">{labels.role || "Role"}</th>
<th className="px-3 py-2 text-start">{labels.hourlyRate || "Hourly rate"}</th>
<th className="px-3 py-2 text-start">{labels.currency || "Currency"}</th>
<th className="px-3 py-2 text-start">{labels.status || "Status"}</th>
<th className="px-3 py-2 text-start">{labels.messages || "Messages"}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{displayedRows.length ? (
displayedRows.map((row) => (
<tr key={`${row.line}-${row.mobile}`} className="bg-white dark:bg-slate-950">
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.line ?? "-"}</td>
<td className="px-3 py-2 font-medium text-slate-900 dark:text-white">{row.mobile || "-"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.user?.full_name || "-"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.role || "member"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.hourly_rate || "-"}</td>
<td className="px-3 py-2 text-slate-600 dark:text-slate-300">{row.currency || "-"}</td>
<td className="px-3 py-2">
{row.status === "valid" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
{labels.valid || "Valid"}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300">
<XCircle className="h-3 w-3" />
{labels.invalid || "Invalid"}
</span>
)}
</td>
<td className="min-w-[220px] px-3 py-2 text-xs text-slate-500 dark:text-slate-400">
{row.messages.length ? row.messages.join(" | ") : "-"}
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-slate-500 dark:text-slate-400">
{labels.noRows || "No rows loaded yet."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" onClick={handleClose}>
{labels.close || "Close"}
</Button>
<Button type="button" variant="secondary" disabled={!canValidate} onClick={handleValidate}>
{isValidating ? labels.validating || "Validating..." : labels.validate || "Validate file"}
</Button>
<Button type="button" disabled={!canCommit} onClick={handleImport}>
{isImporting ? labels.importing || "Importing..." : labels.import || "Import members"}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -67,7 +67,7 @@
"contact": {
"eyebrow": "Contact",
"title": "Need help or want to talk about Qlockify?",
"description": "Send a note or reach out through the support channels below. The form opens an email draft with your message so you can review it before sending.",
"description": "Send a note or reach out through the support channels below. Contact form submissions are stored securely so the team can review and follow up.",
"formTitle": "Send a note",
"fields": {
"firstName": "First name",
@@ -83,7 +83,10 @@
"mobile": "09...",
"message": "Tell us what you need help with..."
},
"submit": "Prepare email",
"submit": "Send message",
"submitting": "Sending...",
"success": "Your message was saved. We will contact you soon.",
"error": "Could not send your message. Please try again.",
"channels": [
{
"label": "Telegram support",
@@ -181,7 +184,7 @@
"contact": {
"eyebrow": "تماس",
"title": "کمک می‌خواهید یا می‌خواهید درباره Qlockify صحبت کنیم؟",
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. فرم یک پیش‌نویس ایمیل با پیام شما آماده می‌کند تا قبل از ارسال آن را بررسی کنید.",
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. پیام‌های فرم تماس ذخیره می‌شوند تا تیم بتواند آن‌ها را بررسی و پیگیری کند.",
"formTitle": "ارسال پیام",
"fields": {
"firstName": "نام",
@@ -197,7 +200,10 @@
"mobile": "09...",
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
},
"submit": "آماده‌سازی ایمیل",
"submit": "ارسال پیام",
"submitting": "در حال ارسال...",
"success": "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.",
"error": "ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.",
"channels": [
{
"label": "پشتیبانی تلگرام",

View File

@@ -284,6 +284,46 @@ export const en = {
membersSectionTitle: "Members",
membersSectionSubtitle: "People in this workspace and their current roles.",
projectRateHint: "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set a custom rate that overrides the workspace rate for that project.",
memberImport: {
button: "Import members",
title: "Import members",
description: "Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member.",
uploadTitle: "Upload member file",
uploadDescription: "CSV, TSV, TXT, or XLSX. The first row must contain headers.",
sampleCsv: "CSV sample",
sampleTsv: "TSV sample",
sampleTxt: "TXT sample",
sampleXlsx: "XLSX sample",
validate: "Validate file",
validating: "Validating...",
import: "Import members",
importing: "Importing...",
chooseFile: "Choose file",
selectedFile: "Selected file",
validRows: "Valid rows",
invalidRows: "Invalid rows",
totalRows: "Total rows",
line: "Line",
mobile: "Mobile",
user: "User",
role: "Role",
hourlyRate: "Hourly rate",
currency: "Currency",
status: "Status",
messages: "Messages",
valid: "Valid",
invalid: "Invalid",
noRows: "No rows loaded yet.",
localErrors: "Fix local file errors before backend validation.",
success: "Members imported successfully.",
parseFailed: "Failed to parse the file.",
missingMobile: "Mobile is required.",
duplicateMobile: "This mobile appears more than once.",
invalidRole: "Role must be admin, member, or guest.",
invalidRate: "Hourly rate must be a valid positive number.",
rateCurrencyPair: "Hourly rate and currency must be provided together.",
tooManyRows: "Import is limited to 500 rows.",
},
membersLocked: "Only owners and admins can view the full member list.",
manageMembers: "Manage members",
mobileNumber: "Mobile Number",
@@ -642,6 +682,7 @@ export const en = {
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
selectWorkspace: "Please select a workspace first.",
addEntry: "Add Entry",
addManualEntry: "Add manual entry",
startTimer: "Start Timer",
stopTimer: "Stop Timer",
timerRunning: "Timer Running",
@@ -656,6 +697,7 @@ export const en = {
noEntriesSearch: "Try adjusting your search query or filters.",
emptyDescription: "No description",
createTitle: "Add Time Entry",
manualCreateTitle: "Add Manual Time Entry",
startTitle: "Start Timer",
editTitle: "Edit Time Entry",
createSuccess: "Time entry created successfully.",
@@ -701,6 +743,10 @@ export const en = {
noProjectsFoundLabel: "No projects found.",
deletedProjectLabel: "Deleted project",
deletedTagLabel: "Deleted tag",
startRequiredError: "Start date and time are required.",
endRequiredError: "End date and time must both be filled.",
invalidEndTimeError: "End time is invalid.",
endBeforeStartError: "End must be after start.",
},
reports: {

View File

@@ -286,6 +286,46 @@ export const fa = {
membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.",
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.",
memberImport: {
button: "درون‌ریزی اعضا",
title: "درون‌ریزی اعضا",
description: "فایلی با ستون‌های mobile، role، hourly_rate و currency بارگذاری کنید. موبایل الزامی است و نقش در صورت خالی بودن عضو در نظر گرفته می‌شود.",
uploadTitle: "بارگذاری فایل اعضا",
uploadDescription: "فرمت‌های CSV، TSV، TXT یا XLSX پشتیبانی می‌شوند. ردیف اول باید عنوان ستون‌ها باشد.",
sampleCsv: "نمونه CSV",
sampleTsv: "نمونه TSV",
sampleTxt: "نمونه TXT",
sampleXlsx: "نمونه XLSX",
validate: "اعتبارسنجی فایل",
validating: "در حال اعتبارسنجی...",
import: "درون‌ریزی اعضا",
importing: "در حال درون‌ریزی...",
chooseFile: "انتخاب فایل",
selectedFile: "فایل انتخاب‌شده",
validRows: "ردیف‌های معتبر",
invalidRows: "ردیف‌های نامعتبر",
totalRows: "کل ردیف‌ها",
line: "ردیف",
mobile: "موبایل",
user: "کاربر",
role: "نقش",
hourlyRate: "نرخ ساعتی",
currency: "واحد پول",
status: "وضعیت",
messages: "پیام‌ها",
valid: "معتبر",
invalid: "نامعتبر",
noRows: "هنوز ردیفی بارگذاری نشده است.",
localErrors: "قبل از اعتبارسنجی سمت سرور، خطاهای فایل را اصلاح کنید.",
success: "اعضا با موفقیت درون‌ریزی شدند.",
parseFailed: "خواندن فایل ناموفق بود.",
missingMobile: "موبایل الزامی است.",
duplicateMobile: "این موبایل بیش از یک بار در فایل آمده است.",
invalidRole: "نقش باید admin، member یا guest باشد.",
invalidRate: "نرخ ساعتی باید عددی معتبر و بزرگ‌تر از صفر باشد.",
rateCurrencyPair: "نرخ ساعتی و واحد پول باید با هم وارد شوند.",
tooManyRows: "درون‌ریزی به ۵۰۰ ردیف محدود است.",
},
manageMembers: "مدیریت اعضا",
mobileNumber: "شماره تماس",
youLabel: "شما",
@@ -639,6 +679,7 @@ export const fa = {
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
addEntry: "افزودن ورودی",
addManualEntry: "افزودن دستی زمان",
startTimer: "شروع تایمر",
stopTimer: "توقف تایمر",
timerRunning: "تایمر فعال است",
@@ -653,6 +694,7 @@ export const fa = {
emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.",
noEntriesSearch: "عبارت جست‌وجو یا فیلترهای خود را تغییر دهید.",
createTitle: "افزودن ورودی زمان",
manualCreateTitle: "افزودن دستی زمان",
startTitle: "شروع تایمر",
editTitle: "ویرایش ورودی زمان",
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
@@ -698,6 +740,10 @@ export const fa = {
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده",
startRequiredError: "تاریخ و زمان شروع الزامی است.",
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
invalidEndTimeError: "زمان پایان معتبر نیست.",
endBeforeStartError: "پایان باید بعد از شروع باشد.",
},
reports: {
title: "گزارش‌ها",

View File

@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import {
ArrowLeft,
ArrowRight,
@@ -29,6 +30,7 @@ import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput"
import { useTheme } from "../components/ThemeProvider"
import { useTranslation } from "../hooks/useTranslation"
import { submitContactForm } from "../api/contact"
import aboutContent from "../content/about.json"
import { cn } from "../lib/utils"
@@ -50,6 +52,7 @@ export default function About() {
mobile: "",
message: "",
})
const [isContactSubmitting, setIsContactSubmitting] = useState(false)
const content = aboutContent[lang] as AboutContent
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
@@ -62,21 +65,30 @@ export default function About() {
setContactForm((current) => ({ ...current, [field]: value }))
}
const submitContactForm = (event: FormEvent<HTMLFormElement>) => {
const handleContactSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const subject = encodeURIComponent("Qlockify contact request")
const body = encodeURIComponent(
[
`First name: ${contactForm.firstName}`,
`Last name: ${contactForm.lastName}`,
`Email: ${contactForm.email}`,
`Mobile: ${contactForm.mobile}`,
"",
"Message:",
contactForm.message,
].join("\n"),
)
window.location.href = `mailto:qlockify@gmail.com?subject=${subject}&body=${body}`
setIsContactSubmitting(true)
try {
await submitContactForm({
first_name: contactForm.firstName.trim(),
last_name: contactForm.lastName.trim(),
email: contactForm.email.trim(),
mobile: contactForm.mobile.trim(),
message: contactForm.message.trim(),
})
setContactForm({
firstName: "",
lastName: "",
email: "",
mobile: "",
message: "",
})
toast.success(content.contact.success)
} catch (error) {
toast.error(error instanceof Error ? error.message : content.contact.error)
} finally {
setIsContactSubmitting(false)
}
}
return (
@@ -377,7 +389,7 @@ export default function About() {
</div>
<form
onSubmit={submitContactForm}
onSubmit={handleContactSubmit}
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/85 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
>
<div className="mb-6 flex items-center gap-3">
@@ -446,10 +458,11 @@ export default function About() {
<Button
type="submit"
disabled={isContactSubmitting}
className="mt-6 h-14 w-full rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<Send className="me-2 h-4 w-4" />
{content.contact.submit}
{isContactSubmitting ? content.contact.submitting : content.contact.submit}
</Button>
</form>
</section>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects";
@@ -149,7 +149,8 @@ export const Projects: React.FC = () => {
};
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
const confirmDelete = async () => {
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.project) return;
try {
const deletedId = deleteModal.project.id;
@@ -543,7 +544,8 @@ export const Projects: React.FC = () => {
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.project.name}
onClick={confirmDelete}
type="submit"
form="delete-project-form"
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
@@ -551,7 +553,7 @@ export const Projects: React.FC = () => {
</>
}
>
<div className="flex flex-col gap-4">
<form id="delete-project-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
</p>
@@ -562,7 +564,7 @@ export const Projects: React.FC = () => {
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name}
/>
</div>
</form>
</Modal>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -105,7 +105,8 @@ export default function Tags() {
setFormColor(DEFAULT_COLOR);
};
const handleSubmit = async () => {
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!activeWorkspace?.id || !formName.trim()) return;
try {
@@ -284,13 +285,13 @@ export default function Tags() {
<Button variant="secondary" onClick={closeModal}>
{t.actions?.cancel || "Cancel"}
</Button>
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}>
<Button type="submit" form="tag-form" disabled={isSaving || !formName.trim()}>
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
</Button>
</>
}
>
<div className="space-y-4">
<form id="tag-form" onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t.tags?.nameLabel || "Tag name"}
@@ -304,7 +305,7 @@ export default function Tags() {
</label>
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
</div>
</div>
</form>
</Modal>
{deleteModal.tag && (
@@ -320,21 +321,28 @@ export default function Tags() {
</Button>
<Button
variant="destructive"
onClick={() => {
if (!deleteModal.tag) return;
void handleDelete(deleteModal.tag);
setDeleteModal({ isOpen: false, tag: null });
}}
type="submit"
form="delete-tag-form"
>
{t.actions?.delete || "Delete"}
</Button>
</>
}
>
<form
id="delete-tag-form"
onSubmit={(event) => {
event.preventDefault();
if (!deleteModal.tag) return;
void handleDelete(deleteModal.tag);
setDeleteModal({ isOpen: false, tag: null });
}}
>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) ||
`Are you sure you want to delete "${deleteModal.tag.name}"?`}
</p>
</form>
</Modal>
)}
</div>

View File

@@ -152,6 +152,18 @@ const handleFormattedTimeInputChange = (
});
};
const selectTimeSegment = (input: HTMLInputElement) => {
const cursor = input.selectionStart ?? 0;
const start = cursor <= 2 ? 0 : cursor <= 5 ? 3 : 6;
const end = start + 2;
window.requestAnimationFrame(() => {
if (document.activeElement === input) {
input.setSelectionRange(start, end);
}
});
};
const isValidTimeValue = (value: string) => {
if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false;
const [hours, minutes, seconds] = value.split(":").map(Number);
@@ -392,11 +404,17 @@ const updateGroupedHistoryEntry = (
const buildEntryFormState = (entry?: TimeEntry | null): EntryFormState => {
if (!entry) {
const now = getLocalDateParts(new Date().toISOString());
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setHours(startDate.getHours() - 1);
const start = getLocalDateParts(startDate.toISOString());
const end = getLocalDateParts(endDate.toISOString());
return {
...EMPTY_FORM,
startDate: now.date,
startTime: now.time,
startDate: start.date,
startTime: start.time,
endDate: end.date,
endTime: end.time,
};
}
@@ -447,23 +465,42 @@ const toggleTagId = (currentTags: string[], tagId: string) =>
const buildPayloadFromState = (
state: EntryFormState,
options: { includeWorkspace: boolean; workspaceId?: string },
options: {
includeWorkspace: boolean;
workspaceId?: string;
requireEnd?: boolean;
messages?: Partial<Record<"startRequired" | "endRequired" | "invalidEndTime" | "endBeforeStart", string>>;
},
): { payload?: TimeEntryPayload; error?: string } => {
const messages = {
startRequired: "Start date and time are required.",
endRequired: "End date and time must both be filled.",
invalidEndTime: "End time is invalid.",
endBeforeStart: "End must be after start.",
...options.messages,
};
const startDateTime = combineDateAndTime(state.startDate, state.startTime);
if (!startDateTime) {
return { error: "Start date and time are required." };
return { error: messages.startRequired };
}
let endDateTime: string | null = null;
const hasEndValue = Boolean(state.endDate || state.endTime);
if (options.requireEnd && !hasEndValue) {
return { error: messages.endRequired };
}
if (hasEndValue) {
if (!state.endDate || !state.endTime) {
return { error: "End date and time must both be filled." };
return { error: messages.endRequired };
}
endDateTime = combineDateAndTime(state.endDate, state.endTime);
if (!endDateTime) {
return { error: "End time is invalid." };
return { error: messages.invalidEndTime };
}
if (new Date(endDateTime).getTime() <= new Date(startDateTime).getTime()) {
return { error: messages.endBeforeStart };
}
}
@@ -605,12 +642,14 @@ function TimeField({
label,
value,
onChange,
onBlur,
placeholder,
compact = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
placeholder?: string;
compact?: boolean;
}) {
@@ -626,6 +665,9 @@ function TimeField({
placeholder={placeholder || "HH:MM:SS"}
className={compact ? "h-9 px-2 text-xs" : ""}
onChange={(event) => handleFormattedTimeInputChange(event, onChange)}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onBlur}
/>
</div>
);
@@ -670,6 +712,7 @@ function TagMultiSelect({
onToggleTag,
emptyHint,
title,
onDropdownClose,
compact = false,
portalOwnerId,
className = "",
@@ -681,6 +724,7 @@ function TagMultiSelect({
onToggleTag: (tagId: string) => void;
emptyHint: string;
title: string;
onDropdownClose?: () => void;
compact?: boolean;
portalOwnerId?: string;
className?: string;
@@ -692,6 +736,7 @@ function TagMultiSelect({
const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
useEffect(() => {
@@ -741,6 +786,13 @@ function TagMultiSelect({
};
}, [isOpen]);
useEffect(() => {
if (wasOpenRef.current && !isOpen) {
onDropdownClose?.();
}
wasOpenRef.current = isOpen;
}, [isOpen, onDropdownClose]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
@@ -1072,12 +1124,14 @@ function CompactDateTimeField({
timeValue,
onDateChange,
onTimeChange,
onTimeBlur,
}: {
label: string;
dateValue: string;
timeValue: string;
onDateChange: (value: string) => void;
onTimeChange: (value: string) => void;
onTimeBlur?: () => void;
}) {
return (
<div className="min-w-[208px]">
@@ -1100,6 +1154,9 @@ function CompactDateTimeField({
placeholder="HH:MM:SS"
className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onTimeBlur}
/>
</div>
</div>
@@ -1111,11 +1168,15 @@ function InlineTimeRangeField({
endTime,
onStartTimeChange,
onEndTimeChange,
onStartTimeBlur,
onEndTimeBlur,
}: {
startTime: string;
endTime: string;
onStartTimeChange: (value: string) => void;
onEndTimeChange: (value: string) => void;
onStartTimeBlur?: () => void;
onEndTimeBlur?: () => void;
}) {
return (
<div className="flex h-12 items-center justify-center border-s border-slate-200 px-2 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-200">
@@ -1128,6 +1189,9 @@ function InlineTimeRangeField({
placeholder="HH:MM:SS"
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onStartTimeBlur}
/>
<span className="px-1 text-slate-400">-</span>
<Input
@@ -1139,6 +1203,9 @@ function InlineTimeRangeField({
placeholder="HH:MM:SS"
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onEndTimeBlur}
/>
</div>
);
@@ -1149,12 +1216,14 @@ function DateRangePopover({
endDate,
onStartDateChange,
onEndDateChange,
onCommit,
portalOwnerId,
}: {
startDate: string;
endDate: string;
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
onCommit?: () => void;
portalOwnerId?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
@@ -1219,8 +1288,22 @@ function DateRangePopover({
className="rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-700 dark:bg-slate-950"
>
<div className="grid gap-3">
<JalaliDatePicker label="Start date" value={startDate} onChange={onStartDateChange} />
<JalaliDatePicker label="End date" value={endDate} onChange={onEndDateChange} />
<JalaliDatePicker
label="Start date"
value={startDate}
onChange={(value) => {
onStartDateChange(value);
window.setTimeout(() => onCommit?.(), 0);
}}
/>
<JalaliDatePicker
label="End date"
value={endDate}
onChange={(value) => {
onEndDateChange(value);
window.setTimeout(() => onCommit?.(), 0);
}}
/>
</div>
</div>,
document.body,
@@ -1251,6 +1334,7 @@ function EntryEditorFields({
onChange,
onToggleTag,
onProjectChange,
onCommit,
projects,
tags,
t,
@@ -1259,9 +1343,10 @@ function EntryEditorFields({
portalOwnerId,
}: {
state: EntryFormState;
onChange: (patch: Partial<EntryFormState>) => void;
onChange: (patch: Partial<EntryFormState>, saveMode?: "none" | "immediate" | "debounce") => void;
onToggleTag: (tagId: string) => void;
onProjectChange?: (projectId: string) => void;
onCommit?: () => void;
projects: Project[];
tags: Tag[];
t: any;
@@ -1276,7 +1361,9 @@ function EntryEditorFields({
<div className="flex min-w-0 items-center">
<Input
value={state.description}
onChange={(event) => onChange({ description: event.target.value })}
onChange={(event) => {
onChange({ description: event.target.value }, "debounce");
}}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600"
/>
@@ -1288,7 +1375,7 @@ function EntryEditorFields({
<ProjectInlineSelect
projects={projects}
value={state.projectId}
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }, "immediate"))}
placeholder={t.timesheet?.projectLabel || "Project"}
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
@@ -1317,13 +1404,14 @@ function EntryEditorFields({
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"
onDropdownClose={onCommit}
/>
</div>
<div className="w-10">
<BillableIconButton
checked={state.isBillable}
onChange={(checked) => onChange({ isBillable: checked })}
onChange={(checked) => onChange({ isBillable: checked }, "immediate")}
label={t.timesheet?.billable || "Billable"}
compact
/>
@@ -1335,6 +1423,8 @@ function EntryEditorFields({
endTime={state.endTime}
onStartTimeChange={(value) => onChange({ startTime: value })}
onEndTimeChange={(value) => onChange({ endTime: value })}
onStartTimeBlur={onCommit}
onEndTimeBlur={onCommit}
/>
</div>
@@ -1342,8 +1432,8 @@ function EntryEditorFields({
<DateRangePopover
startDate={state.startDate}
endDate={state.endDate}
onStartDateChange={(value) => onChange({ startDate: value })}
onEndDateChange={(value) => onChange({ endDate: value })}
onStartDateChange={(value) => onChange({ startDate: value }, "immediate")}
onEndDateChange={(value) => onChange({ endDate: value }, "immediate")}
portalOwnerId={portalOwnerId}
/>
</div>
@@ -1359,7 +1449,9 @@ function EntryEditorFields({
</label>
<Input
value={state.description}
onChange={(event) => onChange({ description: event.target.value })}
onChange={(event) => {
onChange({ description: event.target.value }, "debounce");
}}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"}
/>
@@ -1371,7 +1463,9 @@ function EntryEditorFields({
</label>
<SearchableSelect
value={state.projectId}
onChange={(value) => onChange({ projectId: String(value) })}
onChange={(value) => {
onChange({ projectId: String(value) }, "immediate");
}}
options={[
{ value: "", label: t.timesheet?.noProject || "No project" },
...projects.map((project) => ({
@@ -1393,13 +1487,16 @@ function EntryEditorFields({
<JalaliDatePicker
label={t.timesheet?.startLabel || "Start"}
value={state.startDate}
onChange={(date) => onChange({ startDate: date })}
onChange={(date) => {
onChange({ startDate: date }, "immediate");
}}
inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
/>
<TimeField
label={t.timesheet?.timeLabel || "Time"}
value={state.startTime}
onChange={(value) => onChange({ startTime: value })}
onBlur={onCommit}
compact={compact}
/>
</div>
@@ -1408,13 +1505,16 @@ function EntryEditorFields({
<JalaliDatePicker
label={t.timesheet?.endLabel || "End"}
value={state.endDate}
onChange={(date) => onChange({ endDate: date })}
onChange={(date) => {
onChange({ endDate: date }, "immediate");
}}
inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
/>
<TimeField
label={t.timesheet?.timeLabel || "Time"}
value={state.endTime}
onChange={(value) => onChange({ endTime: value })}
onBlur={onCommit}
compact={compact}
/>
</div>
@@ -1423,7 +1523,9 @@ function EntryEditorFields({
<div>
<BillableIconButton
checked={state.isBillable}
onChange={(checked) => onChange({ isBillable: checked })}
onChange={(checked) => {
onChange({ isBillable: checked }, "immediate");
}}
label={t.timesheet?.billable || "Billable"}
/>
</div>
@@ -1432,6 +1534,7 @@ function EntryEditorFields({
tags={tags}
selectedTags={state.tags}
onToggleTag={onToggleTag}
onDropdownClose={onCommit}
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
title={t.tags?.title || "Tags"}
compact={compact}
@@ -1467,6 +1570,7 @@ function RecordedEntryCard({
const rowRef = useRef<HTMLDivElement>(null);
const isSavingRef = useRef(false);
const pendingSignatureRef = useRef<string | null>(null);
const descriptionSaveTimeoutRef = useRef<number | null>(null);
const editorOwnerId = `time-entry-editor-${entry.id}`;
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
@@ -1483,6 +1587,10 @@ function RecordedEntryCard({
);
useEffect(() => {
if (descriptionSaveTimeoutRef.current) {
window.clearTimeout(descriptionSaveTimeoutRef.current);
descriptionSaveTimeoutRef.current = null;
}
const nextDraft = buildEntryFormState(entry);
const nextSignature = serializeEntryDraft(nextDraft);
syncedSignatureRef.current = nextSignature;
@@ -1491,14 +1599,27 @@ function RecordedEntryCard({
setValidationMessage("");
}, [entry]);
useEffect(() => () => {
if (descriptionSaveTimeoutRef.current) {
window.clearTimeout(descriptionSaveTimeoutRef.current);
}
}, []);
const isInsideEditorContext = useCallback((target: EventTarget | null) => {
if (!(target instanceof Node)) return false;
if (rowRef.current?.contains(target)) return true;
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`));
}, [editorOwnerId]);
const commitDraft = useCallback(async () => {
const currentSignature = serializeEntryDraft(draft);
const validationMessages = useMemo(() => ({
startRequired: t.timesheet?.startRequiredError || "Start date and time are required.",
endRequired: t.timesheet?.endRequiredError || "End date and time must both be filled.",
invalidEndTime: t.timesheet?.invalidEndTimeError || "End time is invalid.",
endBeforeStart: t.timesheet?.endBeforeStartError || "End must be after start.",
}), [t.timesheet]);
const commitDraftState = useCallback(async (nextDraft: EntryFormState) => {
const currentSignature = serializeEntryDraft(nextDraft);
if (currentSignature === syncedSignatureRef.current) {
setValidationMessage("");
return false;
@@ -1508,7 +1629,7 @@ function RecordedEntryCard({
return false;
}
const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false });
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false, messages: validationMessages });
if (!payload) {
setValidationMessage(error || "");
return false;
@@ -1530,56 +1651,40 @@ function RecordedEntryCard({
} catch (error) {
console.error(error);
pendingSignatureRef.current = null;
toast.error(saveErrorText);
toast.error(error instanceof Error ? error.message : saveErrorText);
return false;
} finally {
isSavingRef.current = false;
}
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
}, [entry.id, onEntryUpdated, saveErrorText, saveSuccessText, validationMessages]);
const commitDraft = useCallback(async () => commitDraftState(draft), [commitDraftState, draft]);
const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => {
const nextDraft = { ...draft, ...patch };
const nextSignature = serializeEntryDraft(nextDraft);
setDraft(nextDraft);
return commitDraftState(nextDraft);
}, [commitDraftState, draft]);
if (nextSignature === syncedSignatureRef.current) {
setValidationMessage("");
return false;
const handleDraftChange = useCallback((patch: Partial<EntryFormState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
setDraft((current) => {
const nextDraft = { ...current, ...patch };
if (descriptionSaveTimeoutRef.current) {
window.clearTimeout(descriptionSaveTimeoutRef.current);
descriptionSaveTimeoutRef.current = null;
}
if (isSavingRef.current || pendingSignatureRef.current === nextSignature) {
return false;
if (saveMode === "immediate") {
window.setTimeout(() => void commitDraftState(nextDraft), 0);
} else if (saveMode === "debounce") {
descriptionSaveTimeoutRef.current = window.setTimeout(() => {
void commitDraftState(nextDraft);
}, 700);
}
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false });
if (!payload) {
setValidationMessage(error || "");
return false;
}
setValidationMessage("");
isSavingRef.current = true;
pendingSignatureRef.current = nextSignature;
try {
const updatedEntry = await updateTimeEntry(entry.id, payload);
const updatedDraft = buildEntryFormState(updatedEntry);
const updatedSignature = serializeEntryDraft(updatedDraft);
syncedSignatureRef.current = updatedSignature;
pendingSignatureRef.current = updatedSignature;
setDraft(updatedDraft);
onEntryUpdated(updatedEntry);
toast.success(saveSuccessText);
return true;
} catch (error) {
console.error(error);
pendingSignatureRef.current = null;
toast.error(saveErrorText);
return false;
} finally {
isSavingRef.current = false;
}
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
return nextDraft;
});
}, [commitDraftState]);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
@@ -1610,8 +1715,9 @@ function RecordedEntryCard({
<div className="space-y-4">
<EntryEditorFields
state={draft}
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
onChange={handleDraftChange}
onCommit={commitDraft}
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
projects={editorProjects}
tags={editorTags}
@@ -1660,8 +1766,9 @@ function RecordedEntryCard({
<div className="flex min-w-0 items-center">
<EntryEditorFields
state={draft}
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
onChange={handleDraftChange}
onCommit={commitDraft}
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
projects={editorProjects}
tags={editorTags}
@@ -1725,8 +1832,11 @@ function MobileRecordedEntryCard({
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const touchStartXRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const touchGestureRef = useRef<"pending" | "swipe" | "scroll">("pending");
const [menuOpen, setMenuOpen] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const [touchGesture, setTouchGesture] = useState<"pending" | "swipe" | "scroll">("pending");
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
useEffect(() => {
@@ -1780,6 +1890,9 @@ function MobileRecordedEntryCard({
const closeSwipe = () => {
touchStartXRef.current = null;
touchStartYRef.current = null;
touchGestureRef.current = "pending";
setTouchGesture("pending");
setSwipeOffset(0);
};
@@ -1788,15 +1901,40 @@ function MobileRecordedEntryCard({
setMenuOpen(false);
}
touchStartXRef.current = event.touches[0]?.clientX ?? null;
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchGestureRef.current = "pending";
setTouchGesture("pending");
};
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
if (touchStartXRef.current === null) return;
const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
setSwipeOffset(Math.max(-88, Math.min(88, delta)));
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
const deltaX = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
const deltaY = (event.touches[0]?.clientY ?? 0) - touchStartYRef.current;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (touchGestureRef.current === "pending" && (absX > 8 || absY > 8)) {
touchGestureRef.current = absX > absY + 8 ? "swipe" : "scroll";
setTouchGesture(touchGestureRef.current);
}
if (touchGestureRef.current === "scroll") {
setSwipeOffset(0);
return;
}
if (touchGestureRef.current === "swipe") {
event.preventDefault();
setSwipeOffset(Math.max(-88, Math.min(88, deltaX)));
}
};
const handleTouchEnd = () => {
if (touchGestureRef.current !== "swipe") {
closeSwipe();
return;
}
if (swipeOffset <= -72) {
closeSwipe();
onDelete(entry);
@@ -1823,7 +1961,7 @@ function MobileRecordedEntryCard({
<div
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95"
style={{ transform: `translateX(${swipeOffset}px)` }}
style={{ transform: `translateX(${swipeOffset}px)`, touchAction: touchGesture === "swipe" ? "none" : "pan-y" }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
@@ -2065,6 +2203,10 @@ export default function Timesheet() {
noTagsFoundLabel?: string;
searchProjectsLabel?: string;
noProjectsFoundLabel?: string;
startRequiredError?: string;
endRequiredError?: string;
invalidEndTimeError?: string;
endBeforeStartError?: string;
}) || {};
const [projects, setProjects] = useState<Project[]>([]);
@@ -2111,6 +2253,7 @@ export default function Timesheet() {
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
const isTimerSavingRef = useRef(false);
const timerDescriptionSaveTimeoutRef = useRef<number | null>(null);
const timerEditorOwnerId = "running-timer-editor";
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
@@ -2149,6 +2292,17 @@ export default function Timesheet() {
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
[editingEntry, formState.tags, tags],
);
const entryValidationMessages = useMemo(() => ({
startRequired: extendedTimesheet.startRequiredError || "Start date and time are required.",
endRequired: extendedTimesheet.endRequiredError || "End date and time must both be filled.",
invalidEndTime: extendedTimesheet.invalidEndTimeError || "End time is invalid.",
endBeforeStart: extendedTimesheet.endBeforeStartError || "End must be after start.",
}), [
extendedTimesheet.endBeforeStartError,
extendedTimesheet.endRequiredError,
extendedTimesheet.invalidEndTimeError,
extendedTimesheet.startRequiredError,
]);
useEffect(() => {
if (!runningEntry) return;
@@ -2299,6 +2453,11 @@ export default function Timesheet() {
}, [loadRunningEntry]);
useEffect(() => {
if (timerDescriptionSaveTimeoutRef.current) {
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
timerDescriptionSaveTimeoutRef.current = null;
}
if (!runningEntry) {
setTimerClockAnchor(null);
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
@@ -2313,19 +2472,25 @@ export default function Timesheet() {
setTimerDraft(nextDraft);
}, [runningEntry]);
useEffect(() => () => {
if (timerDescriptionSaveTimeoutRef.current) {
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
}
}, []);
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
if (!(target instanceof Node)) return false;
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
}, [timerEditorOwnerId]);
const commitTimerDraft = useCallback(async () => {
const commitTimerDraftState = useCallback(async (nextDraft: TimerDraftState) => {
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
if (!runningEntry) return false;
const currentSignature = serializeTimerDraft(timerDraft);
const currentSignature = serializeTimerDraft(nextDraft);
if (currentSignature === timerDraftSignatureRef.current) {
return false;
}
@@ -2339,10 +2504,10 @@ export default function Timesheet() {
try {
const updatedEntry = await updateTimeEntry(runningEntry.id, {
description: timerDraft.description.trim(),
project_id: timerDraft.projectId || null,
tags: timerDraft.tags,
is_billable: timerDraft.isBillable,
description: nextDraft.description.trim(),
project_id: nextDraft.projectId || null,
tags: nextDraft.tags,
is_billable: nextDraft.isBillable,
});
const syncedDraft = buildTimerDraftState(updatedEntry);
@@ -2360,12 +2525,34 @@ export default function Timesheet() {
} catch (error) {
console.error(error);
pendingTimerSignatureRef.current = null;
toast.error(saveErrorText);
toast.error(error instanceof Error ? error.message : saveErrorText);
return false;
} finally {
isTimerSavingRef.current = false;
}
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry]);
const commitTimerDraft = useCallback(async () => commitTimerDraftState(timerDraft), [commitTimerDraftState, timerDraft]);
const updateTimerDraft = useCallback((patch: Partial<TimerDraftState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
setTimerDraft((current) => {
const nextDraft = { ...current, ...patch };
if (timerDescriptionSaveTimeoutRef.current) {
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
timerDescriptionSaveTimeoutRef.current = null;
}
if (saveMode === "immediate") {
window.setTimeout(() => void commitTimerDraftState(nextDraft), 0);
} else if (saveMode === "debounce") {
timerDescriptionSaveTimeoutRef.current = window.setTimeout(() => {
void commitTimerDraftState(nextDraft);
}, 700);
}
return nextDraft;
});
}, [commitTimerDraftState]);
useEffect(() => {
if (!runningEntry) return;
@@ -2407,12 +2594,15 @@ export default function Timesheet() {
setFormState(buildEntryFormState(entry));
};
const handleSaveEntryModal = async () => {
const handleSaveEntryModal = async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (modalMode === "manual" && !activeWorkspace?.id) return;
const { payload, error } = buildPayloadFromState(formState, {
includeWorkspace: modalMode === "manual",
workspaceId: activeWorkspace?.id,
requireEnd: modalMode === "manual",
messages: entryValidationMessages,
});
if (!payload) {
@@ -2438,7 +2628,7 @@ export default function Timesheet() {
setFormState(EMPTY_FORM);
} catch (error) {
console.error(error);
toast.error(t.timesheet?.saveError || "Failed to save time entry");
toast.error(error instanceof Error ? error.message : (t.timesheet?.saveError || "Failed to save time entry"));
} finally {
setIsSaving(false);
}
@@ -2535,7 +2725,8 @@ export default function Timesheet() {
setDiscardTimerModal({ isOpen: false, entry: null });
};
const confirmDelete = async () => {
const confirmDelete = async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.entry) return;
try {
@@ -2567,7 +2758,8 @@ export default function Timesheet() {
}
};
const confirmRestart = async () => {
const confirmRestart = async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!restartModal.entry) return;
try {
@@ -2693,11 +2885,17 @@ export default function Timesheet() {
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={openCreateModal} className="gap-2">
<Plus className="h-4 w-4" />
{t.timesheet?.addManualEntry || t.timesheet?.addEntry || "Add manual entry"}
</Button>
<Button type="button" variant="secondary" onClick={() => void openRatesPanel()} className="gap-2">
<Banknote className="h-4 w-4" />
{t.rates?.myRatesTitle || "My rates"}
</Button>
</div>
</div>
<div
ref={desktopTimerRef}
@@ -2709,7 +2907,7 @@ export default function Timesheet() {
<Input
value={timerDraft.description}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
disabled={isStartingTimer}
className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600"
/>
@@ -2718,7 +2916,7 @@ export default function Timesheet() {
<div className="flex shrink-0 items-center">
<SearchableSelect
value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({
@@ -2740,9 +2938,8 @@ export default function Timesheet() {
<TagMultiSelect
tags={runningTimerTags}
selectedTags={timerDraft.tags}
onToggleTag={(tagId) =>
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
}
onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
onDropdownClose={() => void commitTimerDraft()}
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
title={t.tags?.title || "Tags"}
compact
@@ -2755,7 +2952,7 @@ export default function Timesheet() {
<div className="flex shrink-0 items-center">
<BillableIconButton
checked={timerDraft.isBillable}
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
label={t.timesheet?.billable || "Billable"}
disabled={isStartingTimer}
compact
@@ -2817,7 +3014,7 @@ export default function Timesheet() {
<Input
value={timerDraft.description}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
disabled={isStartingTimer}
className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600"
/>
@@ -2825,7 +3022,7 @@ export default function Timesheet() {
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<SearchableSelect
value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({
@@ -2852,9 +3049,8 @@ export default function Timesheet() {
<TagMultiSelect
tags={runningTimerTags}
selectedTags={timerDraft.tags}
onToggleTag={(tagId) =>
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
}
onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
onDropdownClose={() => void commitTimerDraft()}
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
title={t.tags?.title || "Tags"}
compact
@@ -2863,7 +3059,7 @@ export default function Timesheet() {
<BillableIconButton
checked={timerDraft.isBillable}
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
label={t.timesheet?.billable || "Billable"}
disabled={isStartingTimer}
compact
@@ -3038,19 +3234,24 @@ export default function Timesheet() {
<Modal
isOpen={modalMode === "manual" || modalMode === "edit"}
onClose={closeCreateModal}
title={modalMode === "edit" ? (t.timesheet?.editTitle || "Edit Time Entry") : (t.timesheet?.createTitle || "Add Time Entry")}
title={
modalMode === "edit"
? (t.timesheet?.editTitle || "Edit Time Entry")
: (t.timesheet?.manualCreateTitle || t.timesheet?.createTitle || "Add Manual Time Entry")
}
maxWidth="max-w-2xl"
footer={
<>
<Button variant="secondary" onClick={closeCreateModal}>
{t.actions?.cancel || "Cancel"}
</Button>
<Button onClick={() => void handleSaveEntryModal()} disabled={isSaving}>
{isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.create || "Create"))}
<Button type="submit" form="time-entry-modal-form" disabled={isSaving}>
{isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.timesheet?.addManualEntry || t.create || "Add manual entry"))}
</Button>
</>
}
>
<form id="time-entry-modal-form" onSubmit={handleSaveEntryModal}>
<EntryEditorFields
state={formState}
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
@@ -3060,6 +3261,7 @@ export default function Timesheet() {
t={t}
isRtl={isRtl}
/>
</form>
</Modal>
{deleteModal.entry && (
@@ -3073,13 +3275,13 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeDeleteModal}>
{t.actions?.cancel || "Cancel"}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={isDeleting}>
<Button type="submit" form="delete-time-entry-form" variant="destructive" disabled={isDeleting}>
{isDeleting ? "..." : (t.actions?.delete || "Delete")}
</Button>
</>
}
>
<div className="space-y-3">
<form id="delete-time-entry-form" onSubmit={confirmDelete} className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
</p>
@@ -3092,7 +3294,7 @@ export default function Timesheet() {
{deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}
</p>
</div>
</div>
</form>
</Modal>
)}
@@ -3107,13 +3309,13 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeRestartModal}>
{t.actions?.cancel || "Cancel"}
</Button>
<Button onClick={() => void confirmRestart()} disabled={isRestarting}>
<Button type="submit" form="restart-time-entry-form" disabled={isRestarting}>
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
</Button>
</>
}
>
<div className="space-y-3">
<form id="restart-time-entry-form" onSubmit={confirmRestart} className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
</p>
@@ -3126,7 +3328,7 @@ export default function Timesheet() {
{restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""}
</p>
</div>
</div>
</form>
</Modal>
)}
@@ -3141,13 +3343,20 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeDiscardTimerModal}>
{t.actions?.cancel || "Cancel"}
</Button>
<Button variant="destructive" onClick={() => void handleDiscardTimerDraft()} disabled={isDiscardingTimer}>
<Button type="submit" form="discard-timer-form" variant="destructive" disabled={isDiscardingTimer}>
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
</Button>
</>
}
>
<div className="space-y-3">
<form
id="discard-timer-form"
onSubmit={(event) => {
event.preventDefault();
void handleDiscardTimerDraft();
}}
className="space-y-3"
>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
</p>
@@ -3159,7 +3368,7 @@ export default function Timesheet() {
{formatDateTime(discardTimerModal.entry.start_time, lang)}
</p>
</div>
</div>
</form>
</Modal>
)}

View File

@@ -30,6 +30,7 @@ import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
import { WorkspaceMemberImportModal } from '../components/workspaces/WorkspaceMemberImportModal';
const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -84,6 +85,7 @@ export default function EditWorkspace() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const [isMemberImportModalOpen, setIsMemberImportModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -199,6 +201,19 @@ export default function EditWorkspace() {
}
};
const refreshMembersAndRates = async () => {
if (!id) return;
const [membersData, ratesData] = await Promise.all([
fetchWorkspaceMemberships({ workspace: id, limit: LIMIT, offset: 0 }),
getWorkspaceUserRates(id),
]);
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
setMembers(results);
setWorkspaceRates(ratesData.results || []);
setOffset(LIMIT);
setHasMore(membersData.next ? true : results.length >= LIMIT);
};
const loadMoreMembers = useCallback(async () => {
if (isLoadingMembers || !hasMore || !id) return;
try {
@@ -439,18 +454,31 @@ export default function EditWorkspace() {
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{ t.workspace?.members || "Members" }
</h2>
<div className="flex flex-wrap gap-2 self-start sm:self-auto">
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsMemberImportModalOpen(true)}
className="gap-2"
>
<UploadCloud className="h-4 w-4" />
{t.workspace?.memberImport?.button || "Import members"}
</Button>
) : null}
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsProjectAccessModalOpen(true)}
className="gap-2 self-start sm:self-auto"
className="gap-2"
>
<ShieldCheck className="h-4 w-4" />
{t.projects?.manageAccess || "Projects & Rates"}
</Button>
) : null}
</div>
</div>
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
@@ -726,6 +754,57 @@ export default function EditWorkspace() {
}}
/>
) : null}
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && id ? (
<WorkspaceMemberImportModal
isOpen={isMemberImportModalOpen}
onClose={() => setIsMemberImportModalOpen(false)}
workspaceId={id}
priceUnits={priceUnits}
onImported={refreshMembersAndRates}
labels={{
title: t.workspace?.memberImport?.title || "Import members",
description:
t.workspace?.memberImport?.description ||
"Upload a file with mobile, role, hourly_rate, and currency columns. Mobile is required; role defaults to member.",
uploadTitle: t.workspace?.memberImport?.uploadTitle || "Upload member file",
uploadDescription: t.workspace?.memberImport?.uploadDescription || "CSV, TSV, TXT, or XLSX. The first row must contain headers.",
sampleCsv: t.workspace?.memberImport?.sampleCsv || "CSV sample",
sampleTsv: t.workspace?.memberImport?.sampleTsv || "TSV sample",
sampleTxt: t.workspace?.memberImport?.sampleTxt || "TXT sample",
sampleXlsx: t.workspace?.memberImport?.sampleXlsx || "XLSX sample",
validate: t.workspace?.memberImport?.validate || "Validate file",
validating: t.workspace?.memberImport?.validating || "Validating...",
import: t.workspace?.memberImport?.import || "Import members",
importing: t.workspace?.memberImport?.importing || "Importing...",
close: t.actions?.cancel || "Close",
chooseFile: t.workspace?.memberImport?.chooseFile || "Choose file",
selectedFile: t.workspace?.memberImport?.selectedFile || "Selected file",
validRows: t.workspace?.memberImport?.validRows || "Valid rows",
invalidRows: t.workspace?.memberImport?.invalidRows || "Invalid rows",
totalRows: t.workspace?.memberImport?.totalRows || "Total rows",
line: t.workspace?.memberImport?.line || "Line",
mobile: t.workspace?.memberImport?.mobile || "Mobile",
user: t.workspace?.memberImport?.user || "User",
role: t.workspace?.memberImport?.role || "Role",
hourlyRate: t.workspace?.memberImport?.hourlyRate || "Hourly rate",
currency: t.workspace?.memberImport?.currency || "Currency",
status: t.workspace?.memberImport?.status || "Status",
messages: t.workspace?.memberImport?.messages || "Messages",
valid: t.workspace?.memberImport?.valid || "Valid",
invalid: t.workspace?.memberImport?.invalid || "Invalid",
noRows: t.workspace?.memberImport?.noRows || "No rows loaded yet.",
localErrors: t.workspace?.memberImport?.localErrors || "Fix local file errors before backend validation.",
success: t.workspace?.memberImport?.success || "Members imported successfully.",
parseFailed: t.workspace?.memberImport?.parseFailed || "Failed to parse the file.",
missingMobile: t.workspace?.memberImport?.missingMobile || "Mobile is required.",
duplicateMobile: t.workspace?.memberImport?.duplicateMobile || "This mobile appears more than once.",
invalidRole: t.workspace?.memberImport?.invalidRole || "Role must be admin, member, or guest.",
invalidRate: t.workspace?.memberImport?.invalidRate || "Hourly rate must be a valid positive number.",
rateCurrencyPair: t.workspace?.memberImport?.rateCurrencyPair || "Hourly rate and currency must be provided together.",
tooManyRows: t.workspace?.memberImport?.tooManyRows || "Import is limited to 500 rows.",
}}
/>
) : null}
</>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
import { toast } from 'sonner';
@@ -94,7 +94,8 @@ export default function Workspaces() {
}
};
const confirmDelete = async () => {
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.workspace) return;
try {
const deletedId = deleteModal.workspace.id;
@@ -275,7 +276,8 @@ export default function Workspaces() {
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.workspace.name}
onClick={confirmDelete}
type="submit"
form="delete-workspace-form"
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
@@ -283,7 +285,7 @@ export default function Workspaces() {
</>
}
>
<div className="flex flex-col gap-4">
<form id="delete-workspace-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
</p>
@@ -294,7 +296,7 @@ export default function Workspaces() {
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.workspace.name}
/>
</div>
</form>
</Modal>
)}
</div>