Compare commits

..

18 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
69908887c1 fix(landing): change navbar active link style
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 13:19:57 +03:30
e4ab9d2a12 feat(brand): add qlockify profile images
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 12:50:04 +03:30
b4e06b641d feat(about): add contact section 2026-06-07 12:49:53 +03:30
e8eff6c2cb fix(routing): simplify not found page 2026-06-07 12:49:38 +03:30
a0190bc7ad feat(demo): show sandbox status controls
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 00:51:50 +03:30
c6b1712486 feat(demo): start sandbox from landing 2026-06-07 00:51:35 +03:30
ce6cd6cccc chore(projects): remove unused route pages 2026-06-06 23:43:34 +03:30
3645d60730 refactor(landing): align public navigation 2026-06-06 23:43:22 +03:30
549f6aff86 fix(workspaces): show load error before setup prompt 2026-06-06 23:43:10 +03:30
64b240bf26 refactor(routing): isolate public and protected routes 2026-06-06 23:34:19 +03:30
870d198cc8 feat(about): add static public about page 2026-06-06 23:32:55 +03:30
37 changed files with 2884 additions and 969 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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -5,6 +5,7 @@ import { LanguageProvider } from "./components/LanguageProvider"
import { Toaster } from "./components/ui/toaster"
import { Navbar } from "./components/Navbar"
import { Sidebar } from './components/Sidebar';
import { AppProvider } from "./context/AppContext"
import { NotificationsProvider } from "./context/NotificationsContext"
import { WorkspaceProvider } from "./context/WorkspaceContext"
import Auth from "./pages/Auth"
@@ -17,8 +18,6 @@ import WorkspaceDetail from "./pages/WorkspaceDetail"
import EditWorkspace from "./pages/WorkspaceEdit"
import Clients from "./pages/Clients"
import { Projects } from "./pages/Projects"
import ProjectCreate from "./pages/ProjectCreate"
import ProjectEdit from "./pages/ProjectEdit"
import Tags from "./pages/Tags"
import Reports from "./pages/Reports"
import Timesheet from "./pages/Timesheet"
@@ -26,7 +25,10 @@ import Logs from "./pages/Logs"
import NotificationsPage from "./pages/Notifications"
import RateLimitPage from "./pages/RateLimit"
import Landing from "./pages/Landing"
import About from "./pages/About"
import NotFound from "./pages/NotFound"
import { isRateLimitActive } from "./lib/rateLimit"
import { getAccessToken } from "./lib/session"
import { AuthFlowProvider } from "./context/AuthFlowContext"
import { LoginMobilePage } from "./pages/auth/LoginMobilePage"
import { LoginOtpPage } from "./pages/auth/LoginOtpPage"
@@ -64,10 +66,18 @@ const AppRedirect = () => {
return <Navigate to="/rate-limit" replace />
}
const isAuthenticated = !!localStorage.getItem("accessToken")
const isAuthenticated = !!getAccessToken()
return isAuthenticated ? <Navigate to="/timesheet" replace /> : <Navigate to="/auth" replace />
}
const AuthenticatedRedirectGuard = () => {
return getAccessToken() ? <Navigate to="/timesheet" replace /> : <Outlet />
}
const AuthRequiredGuard = () => {
return getAccessToken() ? <Outlet /> : <Navigate to="/auth" replace />
}
const RateLimitGuard = () => {
const location = useLocation()
@@ -78,27 +88,38 @@ const RateLimitGuard = () => {
return <Outlet />
}
const AuthLayout = () => (
<AuthFlowProvider>
<Auth />
</AuthFlowProvider>
)
const ProtectedAppLayout = () => (
<AppProvider>
<WorkspaceProvider>
<NotificationsProvider>
<MainLayout />
</NotificationsProvider>
</WorkspaceProvider>
</AppProvider>
)
const router = createBrowserRouter([
{ path: "/", element: <Landing /> },
{ path: "/about", element: <About /> },
{ path: "/terms", element: <Terms /> },
{ path: "/rate-limit", element: <RateLimitPage /> },
{
element: <RateLimitGuard />,
children: [
{
element: (
<WorkspaceProvider>
<Outlet />
</WorkspaceProvider>
),
children: [
{ path: "/", element: <Landing /> },
{ path: "/app", element: <AppRedirect /> },
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
{
path: "/auth",
element: (
<AuthFlowProvider>
<Auth />
</AuthFlowProvider>
),
element: <AuthenticatedRedirectGuard />,
children: [
{
element: <AuthLayout />,
children: [
{ index: true, element: <Navigate to="/auth/login" replace /> },
{ path: "login", element: <LoginMobilePage /> },
@@ -112,10 +133,13 @@ const router = createBrowserRouter([
{ path: "forgot-password/password", element: <ForgotPasswordPasswordPage /> },
],
},
{ path: "/terms", element: <Terms /> },
{ path: "/rate-limit", element: <RateLimitPage /> },
],
},
{
element: <MainLayout />,
element: <AuthRequiredGuard />,
children: [
{
element: <ProtectedAppLayout />,
children: [
{ path: "/profile", element: <Profile /> },
{ path: "/timesheet", element: <Timesheet /> },
@@ -129,23 +153,20 @@ const router = createBrowserRouter([
{ path: "/workspaces/:id/edit", element: <EditWorkspace /> },
{ path: "/clients", element: <Clients /> },
{ path: "/projects", element: <Projects /> },
{ path: "/projects/create", element: <ProjectCreate /> },
{ path: "/projects/:id/edit", element: <ProjectEdit /> },
],
},
],
},
],
},
{ path: "*", element: <NotFound /> },
]);
function App() {
return (
<ThemeProvider>
<LanguageProvider>
<NotificationsProvider>
<RouterProvider router={router} />
</NotificationsProvider>
<Toaster />
</LanguageProvider>
</ThemeProvider>

View File

@@ -10,6 +10,7 @@ import {
emitSessionChanged,
getAccessToken,
getRefreshToken,
isDemoSession,
} from "../lib/session"
let refreshRequest: Promise<string | null> | null = null
@@ -88,9 +89,10 @@ const normalizeJsonResponse = (response: Response) => {
}
const clearSessionAndRedirect = () => {
const redirectTarget = isDemoSession() ? "/" : "/auth"
clearSessionTokens()
if (window.location.pathname !== "/auth") {
window.location.href = "/auth"
if (window.location.pathname !== redirectTarget) {
window.location.href = redirectTarget
}
}
@@ -145,6 +147,7 @@ const shouldAttemptRefresh = (endpoint: string) => {
"/api/users/otp/send/",
"/api/users/otp/login/",
"/api/users/token/refresh/",
"/api/demo/start/",
].includes(normalizedEndpoint)
}

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()
}

17
src/api/demo.ts Normal file
View File

@@ -0,0 +1,17 @@
import { authFetch, buildApiError } from "./client"
export interface DemoStartResponse {
access: string
refresh: string
workspace_id: string
expires_at: string
demo_environment_id: string
}
export const startDemo = async (): Promise<DemoStartResponse> => {
const response = await authFetch("/api/demo/start/", {
method: "POST",
})
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

@@ -3,13 +3,14 @@ import { useNavigate } from "react-router-dom"
import { useTranslation } from "../hooks/useTranslation"
import { Button } from "./ui/button"
import { SettingsMenu } from "./SettingsMenu"
import { LogOut, User, Moon, Sun, Globe, Command, Menu } from "lucide-react"
import { FlaskConical, LogOut, User, Moon, Sun, Globe, Command, Menu, RefreshCcw } from "lucide-react"
import { useTheme } from "./ThemeProvider"
import { logoutUser, getUserProfile } from "../api/users"
import { WorkspaceSelector } from "./WorkspaceSelector"
import { toast } from "sonner"
import { NotificationBell } from "./notifications/NotificationBell"
import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session"
import { clearSessionTokens, getAccessToken, getRefreshToken, setDemoSessionMeta, setSessionTokens } from "../lib/session"
import { startDemo } from "../api/demo"
type NavbarProps = {
onOpenSidebar?: () => void
@@ -22,6 +23,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
const [showLogoutModal, setShowLogoutModal] = useState(false)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [isResettingDemo, setIsResettingDemo] = useState(false)
const [user, setUser] = useState<any>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
@@ -30,6 +32,13 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
const isDarkMode =
theme === "dark" ||
(theme === "system" && document.documentElement.classList.contains("dark"))
const isDemoUser = Boolean(user?.is_demo)
const demoExpiryLabel = user?.demo_expires_at
? new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(user.demo_expires_at))
: null
useEffect(() => {
const handleProfileUpdated = ((e: CustomEvent) => {
@@ -86,6 +95,23 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
}
}
const handleResetDemo = async () => {
if (isResettingDemo) return
setIsResettingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.reset || "Fresh demo environment is ready.")
window.location.href = "/timesheet"
} catch (error) {
console.error("Demo reset failed:", error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsResettingDemo(false)
}
}
const toggleTheme = () => {
setTheme(isDarkMode ? "light" : "dark")
}
@@ -142,6 +168,12 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
{/* Desktop navbar: keep the old controls here */}
<div className="hidden items-center gap-4 md:flex">
{user && <WorkspaceSelector />}
{isDemoUser && (
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-2 text-xs font-semibold text-cyan-800 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<FlaskConical className="h-4 w-4" />
<span>{t.demo?.badge || "Demo environment"}</span>
</div>
)}
{user ? (
<>
@@ -178,8 +210,24 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
? `${user.first_name || ""} ${user.last_name || ""}`.trim()
: user.email}
</p>
{isDemoUser && demoExpiryLabel && (
<p className="mt-1 text-xs text-cyan-700 dark:text-cyan-300">
{(t.demo?.expiresAt || "Expires at")}: {demoExpiryLabel}
</p>
)}
</div>
{isDemoUser && (
<button
onClick={handleResetDemo}
disabled={isResettingDemo}
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-cyan-700 transition-colors hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-70 dark:text-cyan-300 dark:hover:bg-cyan-950/40"
>
<RefreshCcw className="h-4 w-4" />
<span>{isResettingDemo ? t.demo?.starting || "Preparing demo..." : t.demo?.resetAction || "Reset demo"}</span>
</button>
)}
<button
onClick={() => {
navigate("/profile")

View File

@@ -17,6 +17,8 @@ import {
Globe,
LogOut,
LogIn,
FlaskConical,
RefreshCcw,
} from "lucide-react"
import { toast } from "sonner"
@@ -27,7 +29,8 @@ import { WorkspaceSelector } from "./WorkspaceSelector"
import { SettingsMenu } from "./SettingsMenu"
import { Button } from "./ui/button"
import { getUserProfile, logoutUser } from "../api/users"
import { clearSessionTokens, getAccessToken, getRefreshToken } from "../lib/session"
import { clearSessionTokens, getAccessToken, getRefreshToken, setDemoSessionMeta, setSessionTokens } from "../lib/session"
import { startDemo } from "../api/demo"
type SidebarProps = {
mobileOpen?: boolean
@@ -37,6 +40,7 @@ type SidebarProps = {
export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const [showLogoutModal, setShowLogoutModal] = useState(false)
const [isResettingDemo, setIsResettingDemo] = useState(false)
const [user, setUser] = useState<any>(null)
const navigate = useNavigate()
@@ -47,6 +51,13 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
const isRtl = lang === "fa"
const canViewLogs = canWorkspace(activeWorkspace?.my_role, WORKSPACE_LOGS_VIEW)
const isDemoUser = Boolean(user?.is_demo)
const demoExpiryLabel = user?.demo_expires_at
? new Intl.DateTimeFormat(isRtl ? "fa-IR" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(user.demo_expires_at))
: null
const ToggleIcon = isRtl
? isCollapsed
@@ -101,6 +112,23 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
}
}
const handleResetDemo = async () => {
if (isResettingDemo) return
setIsResettingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.reset || "Fresh demo environment is ready.")
window.location.href = "/timesheet"
} catch (error) {
console.error("Demo reset failed:", error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsResettingDemo(false)
}
}
const toggleLanguage = () => {
const newLang = isRtl ? "en" : "fa"
@@ -199,6 +227,19 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
<div className="w-full">
<WorkspaceSelector className="w-full" />
</div>
{isDemoUser && (
<div className="mt-3 rounded-2xl border border-cyan-200 bg-cyan-50 p-3 text-xs font-medium text-cyan-800 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<div className="flex items-center gap-2 font-semibold">
<FlaskConical className="h-4 w-4" />
{t.demo?.badge || "Demo environment"}
</div>
{demoExpiryLabel && (
<div className="mt-1 text-cyan-700 dark:text-cyan-300">
{(t.demo?.expiresAt || "Expires at")}: {demoExpiryLabel}
</div>
)}
</div>
)}
</div>
)
}
@@ -256,6 +297,19 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
</span>
</button>
{isDemoUser && (
<button
onClick={handleResetDemo}
disabled={isResettingDemo}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-cyan-700 transition-colors hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-70 dark:text-cyan-300 dark:hover:bg-cyan-950/40"
>
<RefreshCcw size={20} className="shrink-0" />
<span className="truncate whitespace-nowrap">
{isResettingDemo ? t.demo?.starting || "Preparing demo..." : t.demo?.resetAction || "Reset demo"}
</span>
</button>
)}
<button
onClick={() => setShowLogoutModal(true)}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-500 dark:hover:bg-red-950/50"
@@ -300,6 +354,28 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) =>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto overflow-x-hidden p-4">
{isDemoUser && !isCollapsed && (
<div className="mb-3 rounded-2xl border border-cyan-200 bg-cyan-50 p-3 text-xs font-medium text-cyan-800 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<div className="flex items-center gap-2 font-semibold">
<FlaskConical className="h-4 w-4" />
{t.demo?.badge || "Demo environment"}
</div>
{demoExpiryLabel && (
<div className="mt-1 text-cyan-700 dark:text-cyan-300">
{(t.demo?.expiresAt || "Expires at")}: {demoExpiryLabel}
</div>
)}
<button
type="button"
onClick={handleResetDemo}
disabled={isResettingDemo}
className="mt-2 inline-flex items-center gap-2 rounded-full bg-cyan-600 px-3 py-1.5 text-white transition hover:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-70 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<RefreshCcw className="h-3.5 w-3.5" />
{isResettingDemo ? t.demo?.starting || "Preparing demo..." : t.demo?.resetAction || "Reset demo"}
</button>
</div>
)}
{renderNavItems(false)}
</nav>
</aside>

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>
);
}

236
src/content/about.json Normal file
View File

@@ -0,0 +1,236 @@
{
"en": {
"eyebrow": "About Qlockify",
"hero": {
"title": "A focused operating layer for time, work, and accountability.",
"accent": "Qlockify turns daily work into context your team can trust.",
"description": "Qlockify is built for teams that need more than a stopwatch. It connects time entries to workspaces, projects, clients, tags, rates, exports, and activity logs so time becomes useful operational data."
},
"sections": [
{
"title": "Why it exists",
"description": "Most teams lose context between the moment work happens and the moment reports are reviewed. Qlockify keeps that context attached from the first timer click to the final exported report."
},
{
"title": "What it protects",
"description": "The product is designed to reduce missing billable work, unclear project history, manual spreadsheet cleanup, and undocumented workspace changes."
},
{
"title": "How it feels",
"description": "The interface stays minimal because time tracking should not become another heavy workflow. Teams can record work quickly while managers still receive structured, reviewable data."
}
],
"principles": [
{
"title": "Capture first",
"description": "Recording work should be fast enough to happen at the source, with project and billing context attached immediately."
},
{
"title": "Explain every change",
"description": "Workspace roles, rates, access changes, and report actions should remain visible instead of disappearing into chat or memory."
},
{
"title": "Make reports usable",
"description": "Reports should be ready for review, export, and decision-making without rebuilding them in spreadsheets."
}
],
"capabilities": [
{
"title": "Time tracking",
"description": "Run timers, adjust historical entries, and keep work tied to the correct project, client, and tags."
},
{
"title": "Workspace control",
"description": "Manage members, roles, project access, and rates without making everyday users deal with unnecessary complexity."
},
{
"title": "Operational reports",
"description": "Review daily summaries, project distribution, billable work, and exportable reports for management or client review."
},
{
"title": "Activity history",
"description": "Keep a clear trail of important actions so teams can understand what changed and when."
}
],
"audience": [
"Agencies",
"Consulting teams",
"Product teams",
"Operations teams",
"Client-service businesses"
],
"trust": [
"Data is organized around workspaces so each team can keep its own operational context.",
"Project access and user roles help limit what each person can see or use.",
"Exports and logs are designed to make review easier, not to hide important details."
],
"contact": {
"eyebrow": "Contact",
"title": "Need help or want to talk about Qlockify?",
"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",
"lastName": "Last name",
"email": "Email",
"mobile": "Mobile",
"message": "Message"
},
"placeholders": {
"firstName": "Your first name",
"lastName": "Your last name",
"email": "you@example.com",
"mobile": "09...",
"message": "Tell us what you need help with..."
},
"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",
"value": "qlockify_support",
"href": "https://t.me/qlockify_support"
},
{
"label": "Telegram channel",
"value": "qlockify",
"href": "https://t.me/qlockify"
},
{
"label": "Support email",
"value": "qlockify@gmail.com",
"href": "mailto:qlockify@gmail.com"
},
{
"label": "Mobile (message or call)",
"value": "09938228438",
"href": "tel:09938228438"
}
]
},
"cta": {
"title": "Start with one workspace and make time easier to explain.",
"description": "Open Qlockify, track a real workday, and compare the report with the way your team reviews work today.",
"button": "Open Qlockify"
}
},
"fa": {
"eyebrow": "درباره Qlockify",
"hero": {
"title": "یک لایه عملیاتی متمرکز برای زمان، کار و پاسخ‌گویی.",
"accent": "Qlockify کار روزانه را به داده‌ای تبدیل می‌کند که تیم بتواند به آن اعتماد کند.",
"description": "Qlockify برای تیم‌هایی ساخته شده که فقط یک تایمر ساده نمی‌خواهند. این محصول ورودی‌های زمان را به ورک‌اسپیس، پروژه، مشتری، تگ، نرخ، خروجی گزارش و لاگ فعالیت‌ها وصل می‌کند تا زمان به داده عملیاتی قابل استفاده تبدیل شود."
},
"sections": [
{
"title": "چرا ساخته شده است",
"description": "بیشتر تیم‌ها بین لحظه انجام کار و زمان مرور گزارش‌ها، بستر و جزئیات مهم را از دست می‌دهند. Qlockify این بستر را از اولین کلیک تایمر تا گزارش خروجی نهایی نگه می‌دارد."
},
{
"title": "چه چیزی را حفظ می‌کند",
"description": "محصول برای کاهش کارکرد ثبت‌نشده، تاریخچه نامشخص پروژه، پاک‌سازی دستی فایل‌های گزارش و تغییرات ثبت‌نشده در ورک‌اسپیس طراحی شده است."
},
{
"title": "چه حسی دارد",
"description": "رابط کاربری مینیمال می‌ماند، چون ردیابی زمان نباید خودش به یک فرایند سنگین تبدیل شود. تیم می‌تواند سریع کار را ثبت کند و مدیر همچنان داده ساختارمند و قابل بررسی داشته باشد."
}
],
"principles": [
{
"title": "اول ثبت دقیق",
"description": "ثبت کار باید آن‌قدر سریع باشد که در همان لحظه انجام شود و پروژه و بستر مالی همان ابتدا به آن وصل شود."
},
{
"title": "هر تغییر قابل توضیح",
"description": "نقش‌ها، نرخ‌ها، دسترسی پروژه‌ها و عملیات گزارش باید قابل مشاهده بمانند، نه این‌که در چت یا حافظه افراد گم شوند."
},
{
"title": "گزارش قابل استفاده",
"description": "گزارش باید برای بررسی، خروجی گرفتن و تصمیم‌گیری آماده باشد، بدون این‌که دوباره در فایل‌های اکسل ساخته شود."
}
],
"capabilities": [
{
"title": "ردیابی زمان",
"description": "تایمر اجرا کنید، ورودی‌های گذشته را اصلاح کنید و کار را به پروژه، مشتری و تگ درست وصل نگه دارید."
},
{
"title": "کنترل ورک‌اسپیس",
"description": "اعضا، نقش‌ها، دسترسی پروژه‌ها و نرخ‌ها را مدیریت کنید، بدون این‌که کاربران روزمره درگیر پیچیدگی اضافه شوند."
},
{
"title": "گزارش عملیاتی",
"description": "خلاصه روزانه، توزیع پروژه‌ها، کارکرد قابل صورت‌حساب و گزارش‌های خروجی را برای مدیریت یا مرور مشتری بررسی کنید."
},
{
"title": "تاریخچه فعالیت‌ها",
"description": "رد روشنی از عملیات مهم نگه دارید تا تیم بداند چه چیزی، چه زمانی و توسط چه کسی تغییر کرده است."
}
],
"audience": [
"آژانس‌ها",
"تیم‌های مشاوره",
"تیم‌های محصول",
"تیم‌های عملیات",
"کسب‌وکارهای مشتری‌محور"
],
"trust": [
"داده‌ها حول ورک‌اسپیس سازمان‌دهی می‌شوند تا هر تیم بستر عملیاتی خودش را داشته باشد.",
"دسترسی پروژه و نقش کاربران کمک می‌کند هر فرد فقط چیزی را ببیند یا استفاده کند که باید.",
"خروجی‌ها و لاگ‌ها برای ساده‌تر کردن بررسی طراحی شده‌اند، نه برای پنهان کردن جزئیات مهم."
],
"contact": {
"eyebrow": "تماس",
"title": "کمک می‌خواهید یا می‌خواهید درباره Qlockify صحبت کنیم؟",
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. پیام‌های فرم تماس ذخیره می‌شوند تا تیم بتواند آن‌ها را بررسی و پیگیری کند.",
"formTitle": "ارسال پیام",
"fields": {
"firstName": "نام",
"lastName": "نام خانوادگی",
"email": "ایمیل",
"mobile": "موبایل",
"message": "پیام"
},
"placeholders": {
"firstName": "نام شما",
"lastName": "نام خانوادگی شما",
"email": "you@example.com",
"mobile": "09...",
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
},
"submit": "ارسال پیام",
"submitting": "در حال ارسال...",
"success": "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.",
"error": "ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.",
"channels": [
{
"label": "پشتیبانی تلگرام",
"value": "qlockify_support",
"href": "https://t.me/qlockify_support"
},
{
"label": "کانال تلگرام",
"value": "qlockify",
"href": "https://t.me/qlockify"
},
{
"label": "ایمیل پشتیبانی",
"value": "qlockify@gmail.com",
"href": "mailto:qlockify@gmail.com"
},
{
"label": "موبایل (پیام یا تماس)",
"value": "09938228438",
"href": "tel:09938228438"
}
]
},
"cta": {
"title": "با یک ورک‌اسپیس شروع کنید و توضیح زمان را ساده‌تر کنید.",
"description": "Qlockify را باز کنید، یک روز کاری واقعی را ثبت کنید و گزارش آن را با روش فعلی مرور کار در تیم مقایسه کنید.",
"button": "باز کردن Qlockify"
}
}
}

View File

@@ -8,6 +8,8 @@ interface User {
last_name: string;
email?: string;
profile_picture?: string | null;
is_demo?: boolean;
demo_expires_at?: string | null;
}
interface AppContextType {

View File

@@ -30,6 +30,8 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspaceState] = useState<Workspace | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [hasLoadedWorkspaces, setHasLoadedWorkspaces] = useState(false)
const [workspaceLoadError, setWorkspaceLoadError] = useState(false)
const [newWorkspaceName, setNewWorkspaceName] = useState("")
const [isCreatingFirst, setIsCreatingFirst] = useState(false)
@@ -38,16 +40,20 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
const refreshWorkspaces = async () => {
if (!isAuthenticated || isRateLimitActive()) {
setHasLoadedWorkspaces(false)
setWorkspaceLoadError(false)
setIsLoading(false)
return
}
try {
setIsLoading(true)
setWorkspaceLoadError(false)
const response = await fetchWorkspaces()
const data = Array.isArray(response) ? response : (response?.results || [])
setWorkspaces(data)
setHasLoadedWorkspaces(true)
if (data.length > 0) {
const storedId = localStorage.getItem("activeWorkspaceId")
@@ -64,6 +70,8 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
}
} catch (error) {
console.error(error)
setWorkspaceLoadError(true)
setHasLoadedWorkspaces(false)
} finally {
setIsLoading(false)
}
@@ -71,6 +79,8 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
if (!isAuthenticated || rateLimited) {
setHasLoadedWorkspaces(false)
setWorkspaceLoadError(false)
setIsLoading(false)
return
}
@@ -92,6 +102,8 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
setIsCreatingFirst(true)
const newWs = await createWorkspace({ name, description: "" })
setWorkspaces((prev) => [...prev, newWs])
setHasLoadedWorkspaces(true)
setWorkspaceLoadError(false)
setActiveWorkspace(newWs)
toast.success(t.workspace?.successCreate || t.workspace?.toast?.successCreate || "Workspace created!")
} catch (error) {
@@ -103,8 +115,29 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
}
}
if (!rateLimited && !isLoading && isAuthenticated && workspaceLoadError) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
{t.workspace?.fetchError || "Failed to load workspace data"}
</h2>
<p className="text-slate-600 dark:text-slate-400 mb-6">
{t.workspace?.loadErrorDescription || "The backend service may be unavailable. Please try again in a moment."}
</p>
<Button
onClick={() => void refreshWorkspaces()}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{t.workspace?.retry || "Try again"}
</Button>
</div>
</div>
)
}
// Force workspace creation if authenticated but none exist
if (!rateLimited && !isLoading && isAuthenticated && workspaces.length === 0) {
if (!rateLimited && !isLoading && isAuthenticated && hasLoadedWorkspaces && workspaces.length === 0) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700">

View File

@@ -1,4 +1,5 @@
export const SESSION_CHANGED_EVENT = "auth_session_changed"
const DEMO_EXPIRES_AT_KEY = "demoExpiresAt"
export const getAccessToken = () => localStorage.getItem("accessToken")
@@ -14,8 +15,21 @@ export const setSessionTokens = (accessToken: string, refreshToken: string) => {
emitSessionChanged()
}
export const setDemoSessionMeta = (expiresAt: string | null | undefined) => {
if (expiresAt) {
localStorage.setItem(DEMO_EXPIRES_AT_KEY, expiresAt)
} else {
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
}
}
export const getDemoSessionExpiresAt = () => localStorage.getItem(DEMO_EXPIRES_AT_KEY)
export const isDemoSession = () => !!getDemoSessionExpiresAt()
export const clearSessionTokens = () => {
localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken")
localStorage.removeItem(DEMO_EXPIRES_AT_KEY)
emitSessionChanged()
}

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",
@@ -301,6 +341,8 @@ export const en = {
createdSuccess: "Workspace created successfully",
updatedSuccess: "Workspace updated successfully",
fetchError: "Failed to load workspace data",
loadErrorDescription: "The backend service may be unavailable. Please try again in a moment.",
retry: "Try again",
remove: "Remove",
noUsersFound: "No user found",
selectRole: "Select Role",
@@ -401,6 +443,7 @@ export const en = {
demo: "Product demo",
features: "Core capabilities",
workflow: "How it works",
about: "About us",
},
actions: {
switchToEnglish: "English",
@@ -411,6 +454,7 @@ export const en = {
startNow: "Start tracking with control",
watchDemo: "See the product demo",
readTerms: "Read terms",
readAbout: "About Qlockify",
},
hero: {
titleTop: "Turn every working hour into a reliable operating signal.",
@@ -481,6 +525,15 @@ export const en = {
finalCtaDescription:
"Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.",
},
demo: {
badge: "Demo environment",
starting: "Preparing demo...",
started: "Demo environment is ready.",
startError: "Could not start the demo environment.",
expiresAt: "Expires at",
resetAction: "Reset demo",
reset: "Fresh demo environment is ready.",
},
ordering: {
createdAtDesc: "Newest First",
@@ -629,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",
@@ -643,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.",
@@ -688,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: "شما",
@@ -302,6 +342,8 @@ export const fa = {
createdSuccess: "ورک‌اسپیس با موفقیت ایجاد شد",
updatedSuccess: "ورک‌اسپیس با موفقیت ویرایش شد",
fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس",
loadErrorDescription: "ممکن است سرویس بک‌اند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.",
retry: "تلاش دوباره",
remove: "حذف",
noUsersFound: "کاربری یافت نشد",
selectRole: "انتخاب نقش",
@@ -398,6 +440,7 @@ export const fa = {
demo: "دموی محصول",
features: "قابلیت‌ها",
workflow: "فرآیند کار",
about: "درباره ما",
},
actions: {
switchToEnglish: "English",
@@ -408,6 +451,7 @@ export const fa = {
startNow: "شروع با کنترل کامل",
watchDemo: "مشاهده دموی محصول",
readTerms: "مطالعه قوانین",
readAbout: "درباره Qlockify",
},
hero: {
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
@@ -478,6 +522,15 @@ export const fa = {
finalCtaDescription:
"اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.",
},
demo: {
badge: "محیط دمو",
starting: "در حال آماده‌سازی دمو...",
started: "محیط دمو آماده شد.",
startError: "امکان ساخت محیط دمو وجود ندارد.",
expiresAt: "زمان انقضا",
resetAction: "شروع دوباره دمو",
reset: "محیط دموی تازه آماده شد.",
},
ordering: {
createdAtDesc: "جدیدترین",
@@ -626,6 +679,7 @@ export const fa = {
description: (workspaceName: string) => `ثبت زمان در ${workspaceName}`,
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
addEntry: "افزودن ورودی",
addManualEntry: "افزودن دستی زمان",
startTimer: "شروع تایمر",
stopTimer: "توقف تایمر",
timerRunning: "تایمر فعال است",
@@ -640,6 +694,7 @@ export const fa = {
emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.",
noEntriesSearch: "عبارت جست‌وجو یا فیلترهای خود را تغییر دهید.",
createTitle: "افزودن ورودی زمان",
manualCreateTitle: "افزودن دستی زمان",
startTitle: "شروع تایمر",
editTitle: "ویرایش ورودی زمان",
createSuccess: "ورودی زمان با موفقیت ایجاد شد.",
@@ -685,6 +740,10 @@ export const fa = {
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده",
startRequiredError: "تاریخ و زمان شروع الزامی است.",
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
invalidEndTimeError: "زمان پایان معتبر نیست.",
endBeforeStartError: "پایان باید بعد از شروع باشد.",
},
reports: {
title: "گزارش‌ها",

View File

@@ -1,13 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AppProvider } from './context/AppContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AppProvider>
<App />
</AppProvider>
</React.StrictMode>
);

499
src/pages/About.tsx Normal file
View File

@@ -0,0 +1,499 @@
import { useState, type FormEvent } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import {
ArrowLeft,
ArrowRight,
AtSign,
BarChart3,
CheckCircle2,
Command,
FileText,
Globe2,
Layers3,
LockKeyhole,
Mail,
MessageCircle,
Moon,
Phone,
Send,
ShieldCheck,
Sparkles,
Sun,
TimerReset,
Users,
Waypoints,
} from "lucide-react"
import { Button } from "../components/ui/button"
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"
type AboutContent = typeof aboutContent.en
const sectionIcons = [Sparkles, ShieldCheck, Layers3]
const principleIcons = [TimerReset, Waypoints, BarChart3]
const capabilityIcons = [TimerReset, Users, FileText, LockKeyhole]
const contactIcons = [MessageCircle, MessageCircle, Mail, Phone]
export default function About() {
const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme()
const [contactForm, setContactForm] = useState({
firstName: "",
lastName: "",
email: "",
mobile: "",
message: "",
})
const [isContactSubmitting, setIsContactSubmitting] = useState(false)
const content = aboutContent[lang] as AboutContent
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
const isDarkMode =
theme === "dark" ||
(theme === "system" && document.documentElement.classList.contains("dark"))
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth"
const updateContactField = (field: keyof typeof contactForm, value: string) => {
setContactForm((current) => ({ ...current, [field]: value }))
}
const handleContactSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
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 (
<div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" />
<div className="landing-hero-grid pointer-events-none fixed inset-0 opacity-70 dark:opacity-40" />
<div className="pointer-events-none fixed left-[-12rem] top-20 h-80 w-80 rounded-full bg-cyan-400/20 blur-3xl dark:bg-cyan-500/10" />
<div className="pointer-events-none fixed right-[-10rem] top-52 h-72 w-72 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-400/10" />
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-4 pb-14 pt-5 sm:px-6 lg:px-8">
<header className="animate-landing-rise flex items-center justify-between rounded-full border border-white/70 bg-white/75 px-4 py-3 shadow-[0_20px_60px_-36px_rgba(15,23,42,0.45)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/55">
<button
type="button"
onClick={() => navigate("/")}
className="inline-flex items-center gap-3 rounded-full px-2 py-1 text-left"
>
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-950 text-white shadow-lg shadow-cyan-500/20 dark:bg-white dark:text-slate-950">
<Command className="h-5 w-5" />
</span>
<span className="hidden sm:block">
<span className="block text-lg font-semibold">{t.title}</span>
</span>
</button>
<nav className="hidden items-center gap-2 md:flex">
<Link
to="/"
className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition dark:text-slate-200 dark:hover:text-white"
>
{lang === "fa" ? "خانه" : "Home"}
</Link>
<Link
to="/about"
className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold"
>
{lang === "fa" ? "درباره ما" : "About us"}
</Link>
</nav>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLanguage(lang === "fa" ? "en" : "fa")}
className="inline-flex h-11 items-center gap-2 rounded-full border border-slate-200/80 bg-white/80 px-4 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-950/80 dark:text-slate-200 dark:hover:bg-slate-900"
>
<Globe2 className="h-4 w-4" />
{lang === "fa" ? t.landing.actions.switchToEnglish : t.landing.actions.switchToPersian}
</button>
<button
type="button"
onClick={() => setTheme(isDarkMode ? "light" : "dark")}
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200/80 bg-white/80 text-slate-700 transition hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-950/80 dark:text-slate-200 dark:hover:bg-slate-900"
aria-label={isDarkMode ? t.lightMode : t.darkMode}
>
{isDarkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
</div>
</header>
<main className="flex-1">
<section className="grid items-center gap-10 py-12 lg:grid-cols-[1.05fr_0.95fr] lg:py-20">
<div className="space-y-7">
<div className="animate-landing-rise">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-cyan-200/70 bg-white/75 px-4 py-2 text-sm font-medium text-cyan-900 shadow-sm backdrop-blur dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<Sparkles className="h-4 w-4" />
{content.eyebrow}
</div>
<h1 className="max-w-4xl text-4xl font-semibold leading-[1.08] text-slate-950 sm:text-5xl lg:text-6xl dark:text-white">
{content.hero.title}
<span className="landing-shimmer mt-4 block bg-[linear-gradient(120deg,#0f172a_15%,#0891b2_48%,#0f766e_78%,#0f172a_100%)] bg-clip-text pb-4 text-transparent dark:bg-[linear-gradient(120deg,#ffffff_18%,#67e8f9_48%,#2dd4bf_78%,#ffffff_100%)]">
{content.hero.accent}
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-300 sm:text-xl">
{content.hero.description}
</p>
</div>
<div className="animate-landing-rise flex flex-col gap-3 sm:flex-row [animation-delay:140ms]">
<Button
asChild
className="h-14 rounded-full bg-slate-950 px-7 text-base text-white shadow-[0_30px_80px_-28px_rgba(8,145,178,0.45)] hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<Link to={ctaTarget}>
{content.cta.button}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rotate-180")} />
</Link>
</Button>
<Button
asChild
variant="outline"
className="h-14 rounded-full border-slate-200 bg-white/85 px-7 text-base text-slate-800 shadow-sm backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
<Link to="/">
{lang === "fa" ? "بازگشت به صفحه اصلی" : "Back to home"}
{lang === "fa" ? <ArrowLeft className="ms-2 h-4 w-4" /> : <ArrowRight className="ms-2 h-4 w-4" />}
</Link>
</Button>
</div>
</div>
<div className="animate-landing-rise [animation-delay:180ms]">
<div className="relative overflow-hidden rounded-[2rem] border border-white/70 bg-white/80 p-5 shadow-[0_45px_110px_-48px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/70 sm:p-6">
<div className="absolute inset-x-0 top-0 h-24 bg-[linear-gradient(90deg,rgba(34,211,238,0.16),rgba(16,185,129,0.12),rgba(245,158,11,0.12))]" />
<div className="relative">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-slate-400 dark:text-slate-500">
{lang === "fa" ? "مدل محصول" : "Product model"}
</div>
<div className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{lang === "fa" ? "از ثبت تا تصمیم" : "From capture to decision"}
</div>
</div>
<div className="rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200">
{lang === "fa" ? "شفاف" : "Clear"}
</div>
</div>
<div className="space-y-3">
{content.sections.map((section, index) => {
const Icon = sectionIcons[index] ?? Sparkles
return (
<div
key={section.title}
className="rounded-[1.5rem] border border-slate-200/80 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/80"
>
<div className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
{section.title}
</h2>
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-300">
{section.description}
</p>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
<section className="grid gap-4 py-8 md:grid-cols-3">
{content.principles.map((principle, index) => {
const Icon = principleIcons[index] ?? CheckCircle2
return (
<article
key={principle.title}
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65"
style={{ animationDelay: `${index * 120}ms` }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</div>
<h2 className="mt-5 text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{principle.title}
</h2>
<p className="mt-3 text-base leading-7 text-slate-600 dark:text-slate-300">
{principle.description}
</p>
</article>
)
})}
</section>
<section className="grid gap-6 py-8 lg:grid-cols-[0.9fr_1.1fr]">
<div className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{lang === "fa" ? "برای چه تیم‌هایی" : "Who it serves"}
</div>
<h2 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{lang === "fa" ? "برای تیم‌هایی که زمان را بخشی از عملیات می‌دانند." : "For teams that treat time as part of operations."}
</h2>
<div className="mt-6 flex flex-wrap gap-3">
{content.audience.map((item) => (
<span
key={item}
className="rounded-full border border-cyan-200/70 bg-cyan-50/70 px-4 py-2 text-sm font-medium text-cyan-900 backdrop-blur dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100"
>
{item}
</span>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{content.capabilities.map((capability, index) => {
const Icon = capabilityIcons[index] ?? Waypoints
return (
<article
key={capability.title}
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-gradient-to-br from-white/95 to-slate-50/80 p-6 shadow-[0_26px_70px_-48px_rgba(15,23,42,0.65)] backdrop-blur-xl dark:border-white/10 dark:from-slate-950/80 dark:to-slate-900/55"
style={{ animationDelay: `${index * 90}ms` }}
>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-white dark:text-slate-950">
<Icon className="h-5 w-5" />
</div>
<h3 className="text-xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{capability.title}
</h3>
</div>
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-300">
{capability.description}
</p>
</article>
)
})}
</div>
</section>
<section className="py-8">
<div className="rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{lang === "fa" ? "اعتماد و کنترل" : "Trust and control"}
</div>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{lang === "fa" ? "جزئیات مهم باید قابل بررسی بمانند." : "Important details should remain reviewable."}
</h2>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{content.trust.map((item, index) => (
<div
key={item}
className="rounded-[1.5rem] border border-slate-200/80 bg-white/80 p-4 text-sm leading-7 text-slate-600 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/80 dark:text-slate-300"
>
<CheckCircle2 className="mb-4 h-5 w-5 text-emerald-500" />
{item}
<div className="mt-5 text-xs font-semibold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500">
{lang === "fa" ? `نکته ${new Intl.NumberFormat("fa-IR").format(index + 1)}` : `Note ${index + 1}`}
</div>
</div>
))}
</div>
</div>
</section>
<section className="grid gap-6 py-8 lg:grid-cols-[0.95fr_1.05fr]">
<div className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{content.contact.eyebrow}
</div>
<h2 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{content.contact.title}
</h2>
<p className="mt-4 text-base leading-8 text-slate-600 dark:text-slate-300">
{content.contact.description}
</p>
<div className="mt-7 grid gap-3">
{content.contact.channels.map((channel, index) => {
const Icon = contactIcons[index] ?? AtSign
return (
<a
key={channel.label}
href={channel.href}
target={channel.href.startsWith("http") ? "_blank" : undefined}
rel={channel.href.startsWith("http") ? "noreferrer" : undefined}
className="group flex items-center justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white/80 p-4 text-start shadow-sm transition hover:border-cyan-300 hover:bg-cyan-50/70 dark:border-slate-800 dark:bg-slate-900/75 dark:hover:border-cyan-500/40 dark:hover:bg-cyan-950/30"
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-950 dark:text-white">
{channel.label}
</span>
<span className="block truncate text-sm text-slate-600 dark:text-slate-300">
{channel.value}
</span>
</span>
</span>
{lang === "fa" ? (
<ArrowLeft className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300" />
) : (
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300" />
)}
</a>
)
})}
</div>
</div>
<form
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">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Send className="h-5 w-5" />
</span>
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{content.contact.formTitle}
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.firstName}
<Input
value={contactForm.firstName}
onChange={(event) => updateContactField("firstName", event.target.value)}
placeholder={content.contact.placeholders.firstName}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.lastName}
<Input
value={contactForm.lastName}
onChange={(event) => updateContactField("lastName", event.target.value)}
placeholder={content.contact.placeholders.lastName}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.email}
<Input
type="email"
value={contactForm.email}
onChange={(event) => updateContactField("email", event.target.value)}
placeholder={content.contact.placeholders.email}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.mobile}
<Input
value={contactForm.mobile}
onChange={(event) => updateContactField("mobile", event.target.value)}
placeholder={content.contact.placeholders.mobile}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
</div>
<label className="mt-4 block text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.message}
<TextAreaInput
value={contactForm.message}
onChange={(event) => updateContactField("message", event.target.value)}
placeholder={content.contact.placeholders.message}
className="mt-2 min-h-40 rounded-2xl"
required
/>
</label>
<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" />
{isContactSubmitting ? content.contact.submitting : content.contact.submit}
</Button>
</form>
</section>
<section className="py-8">
<div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10">
<div className="pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.24),transparent_45%)]" />
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-300">
{lang === "fa" ? "شروع ساده" : "Simple start"}
</div>
<h2 className="mt-4 text-4xl font-semibold leading-[1.1] tracking-[-0.05em] sm:text-5xl">
{content.cta.title}
</h2>
<p className="mt-4 text-lg leading-8 text-slate-300">{content.cta.description}</p>
</div>
<Button
asChild
className="h-14 rounded-full bg-white px-7 text-base font-semibold text-slate-950 hover:bg-slate-100"
>
<Link to={ctaTarget}>
{content.cta.button}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rotate-180")} />
</Link>
</Button>
</div>
</div>
</section>
</main>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import {
ArrowRight,
@@ -15,11 +15,14 @@ import {
TimerReset,
Waypoints,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "../components/ui/button"
import { useTheme } from "../components/ThemeProvider"
import { useTranslation } from "../hooks/useTranslation"
import { cn } from "../lib/utils"
import { startDemo } from "../api/demo"
import { setDemoSessionMeta, setSessionTokens } from "../lib/session"
const formatNumber = (value: number, lang: "en" | "fa") =>
new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value)
@@ -28,6 +31,7 @@ export default function Landing() {
const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme()
const [isStartingDemo, setIsStartingDemo] = useState(false)
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
const isDarkMode =
@@ -87,6 +91,23 @@ export default function Landing() {
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth"
const handleStartDemo = async () => {
if (isStartingDemo) return
setIsStartingDemo(true)
try {
const demo = await startDemo()
setSessionTokens(demo.access, demo.refresh)
setDemoSessionMeta(demo.expires_at)
toast.success(t.demo?.started || "Demo environment is ready.")
navigate("/timesheet")
} catch (error) {
console.error(error)
toast.error(t.demo?.startError || "Could not start the demo environment.")
} finally {
setIsStartingDemo(false)
}
}
return (
<div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" />
@@ -110,15 +131,12 @@ export default function Landing() {
</button>
<div className="hidden items-center gap-2 md:flex">
<a href="#demo" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white">
{t.landing.nav.demo}
</a>
<a href="#features" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white">
{t.landing.nav.features}
</a>
<a href="#workflow" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white">
{t.landing.nav.workflow}
</a>
<Link to="/" className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold">
{lang === "fa" ? "خانه" : "Home"}
</Link>
<Link to="/about" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition dark:text-slate-200 dark:hover:text-white">
{t.landing.nav.about}
</Link>
</div>
<div className="flex items-center gap-2">
@@ -167,12 +185,14 @@ export default function Landing() {
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} />
</Button>
<a
href="#demo"
className="inline-flex h-14 items-center justify-center rounded-full border border-slate-200 bg-white/85 px-7 text-base font-medium text-slate-800 shadow-sm backdrop-blur transition hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
<button
type="button"
onClick={handleStartDemo}
disabled={isStartingDemo}
className="inline-flex h-14 items-center justify-center rounded-full border border-slate-200 bg-white/85 px-7 text-base font-medium text-slate-800 shadow-sm backdrop-blur transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-70 dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
{t.landing.actions.watchDemo}
</a>
{isStartingDemo ? t.demo?.starting || "Preparing demo..." : t.landing.actions.watchDemo}
</button>
</div>
<div className="animate-landing-rise grid gap-3 sm:grid-cols-3 [animation-delay:320ms]">
@@ -385,16 +405,16 @@ export default function Landing() {
<section className="py-8">
<div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10">
<div className="pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.22),transparent_45%)]" />
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="flex flex-row pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.22),transparent_45%)]" />
<div className="relative flex flex-col lg:flex-row gap-6 justify-between">
<div className="max-w-4xl">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-300">{t.landing.finalCtaTag}</div>
<h2 className="mt-4 text-4xl font-semibold leading-[1.1] tracking-[-0.05em] sm:text-5xl">
{t.landing.finalCtaTitle}
</h2>
<p className="mt-4 text-lg leading-8 text-slate-300">{t.landing.finalCtaDescription}</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex flex-row lg:flex-col gap-3">
<Button
onClick={() => navigate(ctaTarget)}
className="h-14 rounded-full bg-white px-7 text-base font-semibold text-slate-950 hover:bg-slate-100"
@@ -408,6 +428,13 @@ export default function Landing() {
>
<Link to="/terms">{t.landing.actions.readTerms}</Link>
</Button>
<Button
variant="outline"
asChild
className="h-14 rounded-full border-white/20 bg-white/5 px-7 text-base text-white hover:bg-white/10 dark:border-white/20 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
>
<Link to="/about">{t.landing.actions.readAbout}</Link>
</Button>
</div>
</div>
</div>

48
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, Home } from "lucide-react"
import { Button } from "../components/ui/button"
import { useTranslation } from "../hooks/useTranslation"
export default function NotFound() {
const navigate = useNavigate()
const { lang } = useTranslation()
const isFa = lang === "fa"
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_42%,#eef2ff_100%)] px-4 text-center text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_44%,#020617_100%)] dark:text-slate-50">
<section className="mx-auto max-w-2xl">
<div className="text-[7rem] font-semibold leading-none tracking-[-0.08em] text-slate-950 sm:text-[10rem] dark:text-white">
404
</div>
<h1 className="mt-5 text-3xl font-semibold tracking-[-0.04em] sm:text-5xl">
{isFa ? "صفحه پیدا نشد" : "Page not found"}
</h1>
<p className="mx-auto mt-5 max-w-xl text-base leading-8 text-slate-600 sm:text-lg dark:text-slate-300">
{isFa
? "این صفحه وجود ندارد یا آدرس آن تغییر کرده است."
: "This page does not exist or its address has changed."}
</p>
<div className="mt-9 flex flex-col justify-center gap-3 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="h-14 rounded-full border-slate-200 bg-white/80 px-7 text-base text-slate-800 backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
{isFa ? <ArrowRight className="me-2 h-4 w-4" /> : <ArrowLeft className="me-2 h-4 w-4" />}
{isFa ? "بازگشت" : "Go back"}
</Button>
<Button
type="button"
onClick={() => navigate("/")}
className="h-14 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"
>
<Home className="me-2 h-4 w-4" />
{isFa ? "صفحه اصلی" : "Home page"}
</Button>
</div>
</section>
</main>
)
}

View File

@@ -1,189 +0,0 @@
import { useEffect, useState } from "react";
import { useBlocker, useNavigate } from "react-router-dom";
import { Briefcase, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { getClients } from "../api/clients";
import { createProject } from "../api/projects";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
const COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#14B8A6",
"#64748B",
];
export default function ProjectCreate() {
const navigate = useNavigate();
const { t } = useTranslation();
const { activeWorkspace } = useWorkspace();
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || client !== "" || color !== COLORS[0];
useEffect(() => {
if (activeWorkspace && !canCreateProject) {
toast.error("You do not have permission to create projects.");
navigate("/projects");
}
}, [activeWorkspace, canCreateProject, navigate]);
useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
}
return false;
});
useEffect(() => {
if (!activeWorkspace?.id) return;
setName("");
setDescription("");
setColor(COLORS[0]);
setClient("");
setClientsList([]);
setIsLoadingData(true);
const loadInitialData = async () => {
try {
const clientsRes = await getClients(activeWorkspace.id);
setClientsList(clientsRes.results || []);
} catch {
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
} finally {
setIsLoadingData(false);
}
};
void loadInitialData();
}, [activeWorkspace?.id, t.projects?.clientFetchError]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace) return;
try {
setIsSaving(true);
const newProject = await createProject({
workspace: activeWorkspace.id,
name,
description,
color,
client: client || null,
is_archived: false,
});
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
toast.success(t.projects?.createSuccess || "Project created successfully.");
navigate("/projects");
} catch (error: any) {
toast.error(error.message || t.projects?.createError || "Failed to create project.");
} finally {
setIsSaving(false);
}
};
if (!activeWorkspace) return null;
return (
<div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
{t.projects?.createNew || "Create New Project"}
</h1>
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<form onSubmit={handleSubmit} className="space-y-6 p-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{COLORS.map((paletteColor) => (
<button
key={paletteColor}
type="button"
onClick={() => setColor(paletteColor)}
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
color === paletteColor
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
: "shadow-sm hover:scale-110"
}`}
style={{ backgroundColor: paletteColor }}
aria-label={`Select color ${paletteColor}`}
/>
))}
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((item) => ({ value: item.id, label: item.name })),
]}
isLoading={isLoadingData}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="mb-2 block text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || "Description"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={5}
/>
</div>
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"}
</Button>
<Button type="submit" disabled={isSaving || !name.trim()}>
{isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t.create || "Create"}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,200 +0,0 @@
import { useEffect, useState } from "react";
import { useBlocker, useNavigate, useParams } from "react-router-dom";
import { Briefcase, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { getClients } from "../api/clients";
import { getProject, updateProject } from "../api/projects";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/Select";
import { TextAreaInput } from "../components/ui/TextAreaInput";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
const COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#14B8A6",
"#64748B",
];
export default function ProjectEdit() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const { activeWorkspace } = useWorkspace();
const canEditProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_EDIT);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<{ id: string; name: string }[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isProjectLoading, setIsProjectLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const hasUnsavedChanges = name.trim() !== "";
useEffect(() => {
if (activeWorkspace && !canEditProject) {
toast.error("You do not have permission to edit projects.");
navigate("/projects");
}
}, [activeWorkspace, canEditProject, navigate]);
useBlocker(({ currentLocation, nextLocation }) => {
if (hasUnsavedChanges && !isSaving && !isProjectLoading && currentLocation.pathname !== nextLocation.pathname) {
return !window.confirm(t.confirmLeave || "You have unsaved changes. Are you sure you want to leave?");
}
return false;
});
useEffect(() => {
if (!activeWorkspace?.id || !id) return;
const loadInitialData = async () => {
try {
const [clientsRes, projectRes] = await Promise.all([
getClients(activeWorkspace.id),
getProject(id),
]);
setClientsList(clientsRes.results || []);
setName(projectRes.name || "");
setDescription(projectRes.description || "");
setColor(projectRes.color || COLORS[0]);
setClient(projectRes.client?.id || projectRes.client || "");
} catch {
toast.error("Failed to load project data.");
navigate("/projects");
} finally {
setIsLoadingData(false);
setIsProjectLoading(false);
}
};
void loadInitialData();
}, [activeWorkspace?.id, id, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace || !id) return;
try {
setIsSaving(true);
const updatedProject = await updateProject(id, {
name,
description,
color,
client: client || null,
});
window.dispatchEvent(new CustomEvent("project_updated", { detail: updatedProject }));
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
navigate("/projects");
} catch (error: any) {
toast.error(error.message || t.projects?.updateError || "Failed to update project.");
} finally {
setIsSaving(false);
}
};
if (!activeWorkspace) return null;
if (isProjectLoading) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}
return (
<div className="absolute inset-0 overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 sm:p-6">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold text-slate-800 dark:text-slate-200">
{t.projects?.edit || "Edit Project"}
</h1>
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<form onSubmit={handleSubmit} className="space-y-6 p-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 shrink-0 rounded-lg shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{COLORS.map((paletteColor) => (
<button
key={paletteColor}
type="button"
onClick={() => setColor(paletteColor)}
className={`h-5 w-5 shrink-0 rounded-full transition-all duration-150 ${
color === paletteColor
? "scale-110 ring-2 ring-blue-500 ring-offset-2 ring-offset-white shadow-md dark:ring-offset-slate-900"
: "shadow-sm hover:scale-110"
}`}
style={{ backgroundColor: paletteColor }}
aria-label={`Select color ${paletteColor}`}
/>
))}
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((item) => ({ value: item.id, label: item.name })),
]}
isLoading={isLoadingData}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="mb-2 block text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || "Description"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={5}
/>
</div>
<div className="flex justify-end gap-3 border-t border-slate-200 pt-4 dark:border-slate-700">
<Button variant="ghost" type="button" onClick={() => navigate("/projects")}>
{t.cancel || "Cancel"}
</Button>
<Button type="submit" disabled={isSaving || !name.trim()}>
{isSaving ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t.save || "Save"}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

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>

View File

@@ -11,6 +11,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,