Compare commits

...

94 Commits

Author SHA1 Message Date
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
ef3eaf1206 fix(timezone): fix timer clock-skew
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-26 13:00:35 +03:30
177b20e8ea fix(reports): clarify summary actions and chart data
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:16:19 +03:30
f30ea5d395 feat(media): manage client and project thumbnails 2026-05-26 12:16:06 +03:30
c895b8f44d feat(reports): sort web breakdown tables
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-25 00:03:52 +03:30
854f439bf9 fix(pagination): remove floating style from pagination component 2026-05-24 21:28:03 +03:30
215425dede feat(projects): improve list filters
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 15:22:04 +03:30
22390592eb fix(project-rate): add vertical transition on desktop view sidebar closing
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:44:02 +03:30
eb41c8528d refactor(auth): replace escaped persian digits
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:17:55 +03:30
c673159032 feat(projects): expose implicit-access roles in projects and rates modal 2026-05-24 10:31:32 +03:30
9a217fcd54 feat(reports): enrich all-user report details
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-23 20:49:08 +03:30
993dffb51d feat(workspaces): add current user rates panel 2026-05-23 20:44:39 +03:30
35c46ea460 feat(projects): add per-project rate overrides to access modal 2026-05-23 20:29:06 +03:30
065360b7a8 fix(oauth): add callback error page for google oauth flow
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-22 00:59:19 +03:30
dbc0ebb118 fix(workspace): remove redundant buttons in workspace detail page
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-21 19:21:05 +03:30
c69b9d1520 fix(timesheet): soften editable description placeholder 2026-05-21 19:12:10 +03:30
08359041ed fix(timesheet): stop sending client clock for live timers 2026-05-21 13:01:51 +03:30
3d706da457 fix(projects): improve project access modal UI and UX
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-15 12:02:44 +03:30
8584807be1 fix(auth): harden google callback otp step
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 23:56:41 +03:30
2ab42c287f feat(auth): verify google signup mobile before account creation
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 23:24:21 +03:30
cd5409c9b2 feat(auth): handle google oauth account claim conflicts
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 21:15:30 +03:30
38ba89b82f ci(frontend): add gitea actions pipeline
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-14 18:18:26 +03:30
84b7290fe8 feat(frontend): add project access ui and report summaries 2026-05-14 17:06:34 +03:30
eaafb6c3b4 feat(reports): render multi-user chart series 2026-05-13 09:59:23 +03:30
64a949e44f feat(auth): improve otp delivery and verification flow 2026-05-13 09:58:59 +03:30
be0619f5d9 fix(landing): add items-start to title/demo section 2026-05-04 10:17:30 +03:30
a81f8acab7 fix(landing): decrease the size of the hero title 2026-05-03 23:40:03 +03:30
040ee4b1f7 feat(auth): enforce password policy in reset and change flows 2026-05-03 20:02:14 +03:30
de74db2703 fix(sidebar) change Qlockify typo in head title tag and sidebar component 2026-05-03 19:42:40 +03:30
380b794ab1 feat(auth): add stepped auth and password recovery flows 2026-05-03 17:10:02 +03:30
9b1cd772fb chore(readme): add README.md 2026-05-01 10:47:47 +03:30
6fbfd419ea style(landing): tighten hero title sizing 2026-05-01 02:17:32 +03:30
95d6cc192d feat(auth): add branded google sign-in button 2026-05-01 02:17:31 +03:30
319b3da294 chore(frontend): add branded document title and favicon 2026-05-01 02:01:36 +03:30
b688bb1ec3 feat(auth): add google sign-in onboarding flow 2026-05-01 01:54:26 +03:30
2aa4b2b4cd feat(frontend): improve responsive navigation shell 2026-05-01 00:27:03 +03:30
bba1be1f71 feat(frontend): add public landing page 2026-04-30 20:32:05 +03:30
a5a7a01da0 feat(cache): add stale get caching for report filters and summaries 2026-04-30 16:13:35 +03:30
0e8d43f1ea fix(reports): guard table rendering against stale payloads 2026-04-30 16:13:11 +03:30
e635fd9c2c feat(throttling): add global rate limit lockout flow 2026-04-30 15:27:09 +03:30
2772b36447 style(theme): configure multilingual app font 2026-04-29 13:44:21 +03:30
9ed7cb73e2 fix(timesheet): localize week total label 2026-04-29 13:36:51 +03:30
1c97339648 style(theme): configure multilingual app font 2026-04-29 13:36:41 +03:30
d348eed47d style(lists): remove skeleton card wrapper 2026-04-29 13:36:33 +03:30
60aa9c035a feat(filters): debounce search input updates 2026-04-29 13:35:03 +03:30
4ac0fd22e5 feat(timesheet): improve empty state copy and layout 2026-04-29 12:17:08 +03:30
a2bc1aa91f refactor(frontend): share list empty state card 2026-04-29 12:16:59 +03:30
013c78a46d chore(frontend): update locale dictionaries 2026-04-29 11:31:19 +03:30
06d083c818 feat(frontend): persist page filters in query params 2026-04-29 11:31:12 +03:30
06c05ba8e9 chore(frontend): remove unused starter files 2026-04-29 11:31:02 +03:30
eb468333c1 fix(workspaces): preserve workspace thumbnail layout 2026-04-29 10:42:50 +03:30
e60a4c9ab4 style(timesheet): add loading skeleton and soften dark surfaces 2026-04-29 02:05:53 +03:30
cb4a7ae118 style(tags): align tags page with list layout 2026-04-29 02:05:46 +03:30
5082dab99e style(lists): refresh pagination and loading states 2026-04-29 02:05:39 +03:30
e4b1dcf3c0 fix(theme): sync toast theme with shared theme state 2026-04-29 01:34:14 +03:30
b2101a2e22 feat(notifications): add dedicated page and localized rendering 2026-04-29 01:31:15 +03:30
05f2b4a4bb feat(timesheet): add live search and searchable project selectors 2026-04-29 01:25:05 +03:30
8868b7d1cc style(workspaces): align workspaces page with report layout 2026-04-29 00:56:03 +03:30
d57f0b05e3 feat(projects): add client strip filtering and page refresh 2026-04-29 00:53:55 +03:30
36a8c0e24c feat(clients): refresh clients page layout and toast feedback 2026-04-28 21:53:26 +03:30
2b5ee2abf1 feat(reports): add daily rate to report tables and exports 2026-04-28 20:26:21 +03:30
3efa04094d refactor(projects): remove project member management ui 2026-04-28 19:35:23 +03:30
8bd0e908a1 feat(logs): add workspace activity log page 2026-04-28 18:49:14 +03:30
088ad8760b fix(timesheet): extend mobile layout to xl and tighten row field widths 2026-04-28 14:21:04 +03:30
5d313a9663 refactor(workspaces): use eye icon for view action 2026-04-28 11:52:48 +03:30
fa242b6206 fix(workspaces): improve detail header action responsiveness 2026-04-28 11:52:27 +03:30
7348af28a1 i18n(workspaces): add thumbnail labels and validation messages 2026-04-28 11:38:43 +03:30
f45038d398 feat(workspaces): add thumbnail UI across workspace surfaces 2026-04-28 11:38:35 +03:30
599e25e836 fix(reports): add controlled fetching + change chart buckets to localized weekday names 2026-04-28 11:03:51 +03:30
581cfab1ac feat(workspaces): expand detail page member list 2026-04-28 10:46:15 +03:30
b1ad372474 fix(permissions): align workspace resource actions with role rules 2026-04-28 10:02:37 +03:30
9fceef3753 fix(timesheet): prevent entry fields from overlapping and improve responsive layout 2026-04-28 00:34:35 +03:30
a770272ce2 fix(timesheet): improve tablet layout and deleted relation handling 2026-04-27 22:58:27 +03:30
02cd2d67a0 fix(reports): format localized income totals 2026-04-27 21:14:12 +03:30
eee22ad6fb feat(workspaces): turn workspace detail into a management hub 2026-04-27 20:52:19 +03:30
226faa70c0 refactor(timesheet): align page header with workspace views 2026-04-27 20:52:18 +03:30
1e5f0b6b5e refactor(lists): align client and project page controls 2026-04-27 20:52:18 +03:30
8ecf317700 refactor(sidebar): reorder workspace navigation items 2026-04-27 20:52:18 +03:30
858aa977f7 fix(reports): throttle export actions after queueing 2026-04-27 20:52:17 +03:30
105 changed files with 15203 additions and 4704 deletions

View File

@@ -0,0 +1,80 @@
name: Frontend CI/CD
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: qlockify-node
steps:
- name: Install system dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends git
- name: Checkout repository
env:
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
REPO_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
WORKSPACE: ${{ gitea.workspace }}
run: |
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
git init
git remote add origin "$REPO_URL"
git -c http.extraHeader="Authorization: Bearer $GITEA_TOKEN" fetch --depth 1 origin "$REPO_SHA"
git checkout --detach FETCH_HEAD
- name: Install dependencies
working-directory: ${{ gitea.workspace }}
run: npm ci
- name: Lint frontend
working-directory: ${{ gitea.workspace }}
run: npm run lint
- name: Build frontend
working-directory: ${{ gitea.workspace }}
run: npm run build
deploy:
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- build
runs-on: qlockify-deploy
steps:
- name: Install SSH client
run: |
apt-get update
apt-get install -y --no-install-recommends bash openssh-client
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy frontend service
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH }}
BACKEND_BRANCH: ${{ vars.BACKEND_BRANCH }}
FRONTEND_BRANCH: ${{ vars.FRONTEND_BRANCH }}
run: |
ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_ROOT='${DEPLOY_PATH}' DEPLOY_BRANCH='${DEPLOY_BRANCH}' BACKEND_BRANCH='${BACKEND_BRANCH}' FRONTEND_BRANCH='${FRONTEND_BRANCH}' bash '${DEPLOY_PATH}/scripts/deploy.sh' frontend"

4
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# Logs
logs
!src/components/logs/
!src/components/logs/**
*.log
npm-debug.log*
yarn-debug.log*
@@ -23,4 +25,4 @@ dist-ssr
*.sln
*.sw?
.env
.env

217
README.md
View File

@@ -1,73 +1,172 @@
# React + TypeScript + Vite
# Qlockify Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Frontend web application for Qlockify.
Currently, two official plugins are available:
## Repository
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- Main deployment entrypoint: `https://git.amiirkhl.ir/Qlockify/qlockify-core-deployment.git`
- Frontend repository declared by `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-frontend-deployment.git`
- Backend repository declared by `origin`: `https://git.amiirkhl.ir/Qlockify/qlockify-backend-deployment.git`
## React Compiler
## What This Repo Contains
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
- React application
- authenticated product UI
- public landing page
- workspace-based business screens
- Persian and English localization
- Google sign-in onboarding UI
- client-side caching and API integration layer
## Expanding the ESLint configuration
## Stack
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
- React `19`
- TypeScript
- Vite
- React Router
- Tailwind CSS
- Headless UI / custom UI primitives
- Sonner toasts
- Recharts
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
## Project Layout
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```text
qlockify-frontend/
public/
src/
api/
components/
context/
hooks/
lib/
locales/
pages/
index.html
package.json
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
## Main Areas
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
- `src/pages`: route-level screens
- `src/components`: reusable UI and page modules
- `src/api`: backend request layer
- `src/context`: workspace and notification state
- `src/locales`: English and Persian dictionaries
- `src/lib`: session, permissions, caching, helpers
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
## Local Development
### 1. Install dependencies
```powershell
npm install
```
### 2. Configure environment
Copy and fill:
```text
.env.sample -> .env
```
Default local API base:
```text
VITE_API_BASE_URL=http://localhost:8000
```
If your backend runs behind `/api`, use:
```text
VITE_API_BASE_URL=http://localhost:8000/api
```
### 3. Start development server
```powershell
npm run dev
```
Default local URL:
- `http://localhost:5173`
## Available Scripts
Development:
```powershell
npm run dev
```
Production build:
```powershell
npm run build
```
Preview production build:
```powershell
npm run preview
```
Lint:
```powershell
npm run lint
```
## Product Routes
Main application routes include:
- `/`
- `/auth`
- `/auth/google/callback`
- `/timesheet`
- `/reports`
- `/logs`
- `/notifications`
- `/workspaces`
- `/projects`
- `/clients`
- `/tags`
- `/profile`
## Authentication UX
Supported flows in the UI:
- password login
- OTP login
- OTP registration
- Google sign-in
Google sign-in flow:
- start from the auth page
- backend performs OAuth callback handling
- frontend callback page loads the flow state
- new users complete mobile onboarding
- existing mobile owners verify claim via OTP
## Localization
The application is bilingual:
- English
- Persian
Translation dictionaries live in:
- `src/locales/en.ts`
- `src/locales/fa.ts`
## Notes
- the frontend expects the backend API contract defined in the backend repo
- deployment, domain, SSL, and Nginx details belong in the deployment repo
- this repo focuses on application UI and browser runtime behavior

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Qlockify</title>
</head>
<body>
<div id="root"></div>

20
public/favicon.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="18" fill="#0F172A"/>
<path
d="M24 14C18.4772 14 14 18.4772 14 24V28H20V24C20 21.7909 21.7909 20 24 20H28V14H24Z"
fill="#60A5FA"
/>
<path
d="M40 14H36V20H40C42.2091 20 44 21.7909 44 24V28H50V24C50 18.4772 45.5228 14 40 14Z"
fill="#60A5FA"
/>
<path
d="M44 40C44 42.2091 42.2091 44 40 44H36V50H40C45.5228 50 50 45.5228 50 40V36H44V40Z"
fill="#38BDF8"
/>
<path
d="M20 40V36H14V40C14 45.5228 18.4772 50 24 50H28V44H24C21.7909 44 20 42.2091 20 40Z"
fill="#38BDF8"
/>
<rect x="23" y="23" width="18" height="18" rx="5" fill="white" fill-opacity="0.08"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,13 +1,15 @@
import { createBrowserRouter, RouterProvider, Navigate, Outlet } from "react-router-dom"
import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLocation } from "react-router-dom"
import { useState } from "react"
import { ThemeProvider } from "./components/ThemeProvider"
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"
import GoogleAuthCallback from "./pages/GoogleAuthCallback"
import Profile from "./pages/Profile"
import Terms from "./pages/Terms"
import Workspaces from "./pages/Workspaces"
@@ -16,11 +18,27 @@ 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"
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"
import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage"
import { SignupMobilePage } from "./pages/auth/SignupMobilePage"
import { SignupOtpPage } from "./pages/auth/SignupOtpPage"
import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage"
import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage"
import { ForgotPasswordOtpPage } from "./pages/auth/ForgotPasswordOtpPage"
import { ForgotPasswordPasswordPage } from "./pages/auth/ForgotPasswordPasswordPage"
const MainLayout = () => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
@@ -43,50 +61,112 @@ const MainLayout = () => {
);
};
const RootRedirect = () => {
const isAuthenticated = !!localStorage.getItem("accessToken")
const AppRedirect = () => {
if (isRateLimitActive()) {
return <Navigate to="/rate-limit" replace />
}
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()
if (isRateLimitActive() && location.pathname !== "/rate-limit") {
return <Navigate to="/rate-limit" replace />
}
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: (
<WorkspaceProvider>
<Outlet />
</WorkspaceProvider>
),
element: <RateLimitGuard />,
children: [
{ path: "/", element: <RootRedirect /> },
{ path: "/auth", element: <Auth /> },
{ path: "/terms", element: <Terms /> },
{ path: "/app", element: <AppRedirect /> },
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
{
element: <MainLayout />,
path: "/auth",
element: <AuthenticatedRedirectGuard />,
children: [
{ path: "/profile", element: <Profile /> },
{ path: "/timesheet", element: <Timesheet /> },
{ path: "/reports", element: <Reports /> },
{ path: "/tags", element: <Tags /> },
{ path: "/workspaces", element: <Workspaces /> },
{ path: "/workspaces/create", element: <CreateWorkspace /> },
{ path: "/workspaces/:id", element: <WorkspaceDetail /> },
{ 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 /> },
{
element: <AuthLayout />,
children: [
{ index: true, element: <Navigate to="/auth/login" replace /> },
{ path: "login", element: <LoginMobilePage /> },
{ path: "login/verify", element: <LoginOtpPage /> },
{ path: "login/password", element: <LoginPasswordPage /> },
{ path: "signup", element: <SignupMobilePage /> },
{ path: "signup/verify", element: <SignupOtpPage /> },
{ path: "signup/password", element: <SignupPasswordPage /> },
{ path: "forgot-password", element: <ForgotPasswordMobilePage /> },
{ path: "forgot-password/verify", element: <ForgotPasswordOtpPage /> },
{ path: "forgot-password/password", element: <ForgotPasswordPasswordPage /> },
],
},
],
},
{
element: <AuthRequiredGuard />,
children: [
{
element: <ProtectedAppLayout />,
children: [
{ path: "/profile", element: <Profile /> },
{ path: "/timesheet", element: <Timesheet /> },
{ path: "/reports", element: <Reports /> },
{ path: "/notifications", element: <NotificationsPage /> },
{ path: "/logs", element: <Logs /> },
{ path: "/tags", element: <Tags /> },
{ path: "/workspaces", element: <Workspaces /> },
{ path: "/workspaces/create", element: <CreateWorkspace /> },
{ path: "/workspaces/:id", element: <WorkspaceDetail /> },
{ path: "/workspaces/:id/edit", element: <EditWorkspace /> },
{ path: "/clients", element: <Clients /> },
{ path: "/projects", element: <Projects /> },
],
},
],
},
],
},
{ path: "*", element: <NotFound /> },
]);
function App() {
return (
<ThemeProvider>
<LanguageProvider>
<NotificationsProvider>
<RouterProvider router={router} />
</NotificationsProvider>
<RouterProvider router={router} />
<Toaster />
</LanguageProvider>
</ThemeProvider>

View File

@@ -1,13 +0,0 @@
import axios from 'axios';
export const api = axios.create({
baseURL: 'http://localhost:8000',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

147
src/api/cache.ts Normal file
View File

@@ -0,0 +1,147 @@
import { buildApiError, authFetch } from "./client"
import { getAccessToken, SESSION_CHANGED_EVENT } from "../lib/session"
const CACHE_PREFIX = "api-get-cache:v2:"
interface CacheEntry<T> {
expiresAt: number
data: T
namespaces: string[]
}
const memoryCache = new Map<string, CacheEntry<unknown>>()
const cloneData = <T>(value: T): T => {
if (typeof structuredClone === "function") {
return structuredClone(value)
}
return JSON.parse(JSON.stringify(value)) as T
}
const decodeBase64Url = (value: string) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/")
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=")
return atob(padded)
}
const getCurrentUserCacheKey = () => {
const token = getAccessToken()
if (!token) return "anon"
try {
const payload = JSON.parse(decodeBase64Url(token.split(".")[1] || ""))
return String(payload.user_id || payload.sub || "anon")
} catch {
return "anon"
}
}
const normalizeEndpoint = (endpoint: string) => {
const base = endpoint.startsWith("http") ? undefined : "https://cache.local"
const url = new URL(endpoint, base)
const entries = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey === bKey) return aValue.localeCompare(bValue)
return aKey.localeCompare(bKey)
})
const normalizedSearch = new URLSearchParams()
entries.forEach(([key, value]) => normalizedSearch.append(key, value))
const queryString = normalizedSearch.toString()
return `${url.pathname}${queryString ? `?${queryString}` : ""}`
}
const buildStorageKey = (endpoint: string) => `${CACHE_PREFIX}${getCurrentUserCacheKey()}:${normalizeEndpoint(endpoint)}`
const readStoredEntry = <T>(key: string): CacheEntry<T> | null => {
const cached = memoryCache.get(key)
if (cached) {
if (cached.expiresAt > Date.now()) {
return cached as CacheEntry<T>
}
memoryCache.delete(key)
}
const raw = sessionStorage.getItem(key)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as CacheEntry<T>
if (parsed.expiresAt <= Date.now()) {
sessionStorage.removeItem(key)
return null
}
memoryCache.set(key, parsed as CacheEntry<unknown>)
return parsed
} catch {
sessionStorage.removeItem(key)
return null
}
}
const writeEntry = <T>(key: string, entry: CacheEntry<T>) => {
memoryCache.set(key, entry as CacheEntry<unknown>)
sessionStorage.setItem(key, JSON.stringify(entry))
}
export const invalidateApiCache = (namespaces: string[]) => {
if (!namespaces.length) return
const namespaceSet = new Set(namespaces)
const removeIfMatches = (key: string, entry: CacheEntry<unknown>) => {
if (entry.namespaces.some((namespace) => namespaceSet.has(namespace))) {
memoryCache.delete(key)
sessionStorage.removeItem(key)
}
}
memoryCache.forEach((entry, key) => removeIfMatches(key, entry))
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
const key = sessionStorage.key(index)
if (!key || !key.startsWith(CACHE_PREFIX)) continue
try {
const parsed = JSON.parse(sessionStorage.getItem(key) || "null") as CacheEntry<unknown> | null
if (parsed) {
removeIfMatches(key, parsed)
}
} catch {
sessionStorage.removeItem(key)
}
}
}
export const clearApiCache = () => {
memoryCache.clear()
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
const key = sessionStorage.key(index)
if (key?.startsWith(CACHE_PREFIX)) {
sessionStorage.removeItem(key)
}
}
}
export const cachedGetJson = async <T>(
endpoint: string,
options: { ttlMs: number; namespaces: string[]; bypass?: boolean },
): Promise<T> => {
const storageKey = buildStorageKey(endpoint)
if (!options.bypass) {
const cached = readStoredEntry<T>(storageKey)
if (cached) {
return cloneData(cached.data)
}
}
const response = await authFetch(endpoint)
if (!response.ok) {
throw await buildApiError(response)
}
const data = (await response.json()) as T
writeEntry(storageKey, {
expiresAt: Date.now() + options.ttlMs,
data,
namespaces: options.namespaces,
})
return cloneData(data)
}
window.addEventListener(SESSION_CHANGED_EVENT, clearApiCache)

View File

@@ -1,16 +1,59 @@
import { API_BASE_URL } from "../config/constants"
import {
activateRateLimitLock,
getRateLimitRemainingSeconds,
getStoredRateLimitLock,
isRateLimitActive,
} from "../lib/rateLimit"
import {
clearSessionTokens,
emitSessionChanged,
getAccessToken,
getRefreshToken,
isDemoSession,
} from "../lib/session"
let refreshRequest: Promise<string | null> | null = null
export interface ApiErrorMessage {
attr?: string | null
detail: string
code?: string | null
}
export interface ApiErrorPayload {
error?: string
status_code?: number
messages?: ApiErrorMessage[]
code?: string
retry_after_seconds?: number | null
throttled_until?: string | null
}
export class ApiError extends Error {
status: number
error: string
messages: ApiErrorMessage[]
code: string | null
retryAfterSeconds: number | null
throttledUntil: string | null
constructor(status: number, payload: ApiErrorPayload, fallbackMessage: string) {
const detailMessage = payload.messages?.[0]?.detail
super(detailMessage || payload.error || fallbackMessage)
this.name = "ApiError"
this.status = status
this.error = payload.error || fallbackMessage
this.messages = payload.messages || []
this.code = payload.code || null
this.retryAfterSeconds = payload.retry_after_seconds ?? null
this.throttledUntil = payload.throttled_until ?? null
}
}
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
const buildUrl = (endpoint: string) => {
export const buildApiUrl = (endpoint: string) => {
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
return `${cleanBaseUrl}${cleanEndpoint}`
}
@@ -46,12 +89,57 @@ 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
}
}
const toIntOrNull = (value: string | number | null | undefined) => {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.ceil(value))
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10)
if (Number.isFinite(parsed)) {
return Math.max(0, parsed)
}
}
return null
}
const redirectToRateLimitPage = () => {
if (window.location.pathname !== "/rate-limit") {
window.location.replace("/rate-limit")
}
}
const createLockedResponse = () => {
const lock = getStoredRateLimitLock()
const retryAfterSeconds = getRateLimitRemainingSeconds(lock)
const payload = {
error: lock?.message || "Too many requests",
status_code: 429,
messages: [{ detail: lock?.message || "Too many requests" }],
code: lock?.code || "throttled",
retry_after_seconds: retryAfterSeconds,
throttled_until: lock?.throttledUntil || null,
}
return normalizeJsonResponse(
new Response(JSON.stringify(payload), {
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSeconds),
},
}),
)
}
const shouldAttemptRefresh = (endpoint: string) => {
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
return ![
@@ -59,6 +147,7 @@ const shouldAttemptRefresh = (endpoint: string) => {
"/api/users/otp/send/",
"/api/users/otp/login/",
"/api/users/token/refresh/",
"/api/demo/start/",
].includes(normalizedEndpoint)
}
@@ -68,7 +157,7 @@ const refreshAccessToken = async () => {
if (!refreshRequest) {
refreshRequest = (async () => {
const response = await fetch(buildUrl("/api/users/token/refresh/"), {
const response = await fetch(buildApiUrl("/api/users/token/refresh/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -103,7 +192,34 @@ const refreshAccessToken = async () => {
return refreshRequest
}
export const buildApiError = async (response: Response) => {
let payload: ApiErrorPayload = {}
try {
payload = await response.clone().json()
} catch {
payload = {}
}
if (payload.retry_after_seconds == null) {
const retryAfter = toIntOrNull(response.headers.get("Retry-After"))
if (retryAfter != null) {
payload.retry_after_seconds = retryAfter
}
}
const fallbackMessage =
response.statusText || (response.status === 429 ? "Too many requests" : "Request failed")
return new ApiError(response.status, payload, fallbackMessage)
}
export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise<Response> => {
if (isRateLimitActive()) {
redirectToRateLimitPage()
return createLockedResponse()
}
const token = getAccessToken()
const isFormData = options.body instanceof FormData
@@ -113,7 +229,7 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}, all
...options.headers,
}
const response = await fetch(buildUrl(endpoint), {
const response = await fetch(buildApiUrl(endpoint), {
...options,
headers,
})
@@ -144,5 +260,24 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}, all
return response
}
if (!response.ok) {
const apiError = await buildApiError(response)
if (
response.status === 429 ||
apiError.code === "throttled" ||
apiError.retryAfterSeconds != null ||
apiError.throttledUntil
) {
activateRateLimitLock({
status: response.status,
code: apiError.code,
message: apiError.message,
retryAfterSeconds: apiError.retryAfterSeconds,
throttledUntil: apiError.throttledUntil,
})
redirectToRateLimitPage()
}
}
return normalizeJsonResponse(response)
}

View File

@@ -1,12 +1,27 @@
import { authFetch } from "./client";
export const getClients = async (
workspaceId: string,
search: string = "",
ordering: string = "",
limit: number = 10,
offset: number = 0
) => {
import { authFetch } from "./client";
import { cachedGetJson, invalidateApiCache } from "./cache";
export interface Client {
id: string
name: string
notes?: string
thumbnail?: string | null
}
interface PaginatedResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}
export const getClients = async (
workspaceId: string,
search: string = "",
ordering: string = "",
limit: number = 10,
offset: number = 0
): Promise<PaginatedResponse<Client>> => {
const queryParams = new URLSearchParams({
workspace: workspaceId,
limit: limit.toString(),
@@ -16,54 +31,84 @@ export const getClients = async (
if (search) queryParams.append("search", search);
if (ordering) queryParams.append("ordering", ordering);
const response = await authFetch(`/api/clients/?${queryParams.toString()}`);
if (!response.ok) {
throw new Error("Failed to fetch clients");
}
return response.json();
};
return cachedGetJson<PaginatedResponse<Client>>(`/api/clients/?${queryParams.toString()}`, {
ttlMs: 5 * 60 * 1000,
namespaces: ["clients"],
});
};
export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => {
const response = await authFetch("/api/clients/", {
method: "POST",
body: JSON.stringify({
workspace_id: workspaceId,
...data,
}),
});
const buildClientBody = (
workspaceId: string | null,
data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
) => {
const hasFile = data.thumbnail instanceof File;
const shouldClear = Boolean(data.clear_thumbnail);
if (!hasFile && !shouldClear) {
return {
body: JSON.stringify({
...(workspaceId ? { workspace_id: workspaceId } : {}),
...data,
}),
};
}
const formData = new FormData();
if (workspaceId) formData.append("workspace_id", workspaceId);
if (data.name !== undefined) formData.append("name", data.name);
if (data.notes !== undefined) formData.append("notes", data.notes);
if (data.thumbnail) formData.append("thumbnail", data.thumbnail);
if (shouldClear) formData.append("clear_thumbnail", "true");
return { body: formData };
};
export const createClient = async (workspaceId: string, data: { name: string; notes: string; thumbnail?: File | null }) => {
const requestBody = buildClientBody(workspaceId, data);
const response = await authFetch("/api/clients/", {
method: "POST",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to create client");
}
return response.json();
};
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to create client");
}
const payload = await response.json();
invalidateApiCache(["clients", "reports"]);
return payload;
};
export const updateClient = async (id: string, data: { name?: string; notes?: string }) => {
const response = await authFetch(`/api/clients/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
export const updateClient = async (
id: string,
data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
) => {
const requestBody = buildClientBody(null, data);
const response = await authFetch(`/api/clients/${id}/`, {
method: "PATCH",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update client");
}
return response.json();
};
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update client");
}
const payload = await response.json();
invalidateApiCache(["clients", "reports"]);
return payload;
};
export const deleteClient = async (id: string) => {
const response = await authFetch(`/api/clients/${id}/`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to delete client");
}
if (response.status === 204) {
return { success: true };
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to delete client");
}
invalidateApiCache(["clients", "reports"]);
if (response.status === 204) {
return { success: true };
}
return response.json().catch(() => ({ success: true }));

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

139
src/api/logs.ts Normal file
View File

@@ -0,0 +1,139 @@
import { authFetch } from "./client";
export type WorkspaceLogSection =
| "workspace"
| "workspace_members"
| "clients"
| "projects"
| "tags"
| "time_entries"
| "rates"
| "report_exports";
export type WorkspaceLogEvent =
| "create"
| "update"
| "delete"
| "restore"
| "archive"
| "unarchive"
| "activate"
| "deactivate";
export interface WorkspaceLogActor {
id: string;
full_name: string;
mobile?: string | null;
profile_picture?: string | null;
}
export interface WorkspaceLogTarget {
id: string;
name: string;
section: WorkspaceLogSection;
workspace_id: string;
}
export interface WorkspaceLogPreviewChange {
field: string;
label: string;
summary: string;
}
export interface WorkspaceLogItem {
id: number;
timestamp: string;
section: WorkspaceLogSection;
model: string | null;
event: WorkspaceLogEvent;
audit_action: string;
actor: WorkspaceLogActor | null;
target: WorkspaceLogTarget;
changed_fields: string[];
preview_changes: WorkspaceLogPreviewChange[];
}
export interface WorkspaceLogChangeRow {
field: string;
label: string;
change_type: "field" | "m2m";
operation: string;
old_value: string | null;
new_value: string | null;
summary: string;
}
export interface WorkspaceLogDetail extends WorkspaceLogItem {
remote_addr?: string | null;
changes: WorkspaceLogChangeRow[];
raw_changes: Record<string, unknown>;
serialized_snapshot?: unknown;
additional_data: Record<string, unknown>;
}
export interface WorkspaceLogFilters {
workspace: string;
section?: WorkspaceLogSection | "";
actor?: string;
event?: WorkspaceLogEvent | "";
search?: string;
from?: string;
to?: string;
ordering?: "-timestamp" | "timestamp";
}
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
const toQueryString = (params: Record<string, string | number | undefined>) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
query.set(key, String(value));
}
});
return query.toString();
};
export const listWorkspaceLogs = async (
filters: WorkspaceLogFilters,
pagination?: { limit?: number; offset?: number },
): Promise<PaginatedResponse<WorkspaceLogItem>> => {
const query = toQueryString({
workspace: filters.workspace,
section: filters.section || undefined,
actor: filters.actor || undefined,
event: filters.event || undefined,
search: filters.search || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
ordering: filters.ordering || "-timestamp",
limit: pagination?.limit,
offset: pagination?.offset,
});
const response = await authFetch(`/api/logs/${query ? `?${query}` : ""}`);
if (!response.ok) {
throw new Error("Failed to fetch workspace logs.");
}
const data = await response.json();
return {
count: data.count || 0,
next: data.next || null,
previous: data.previous || null,
results: data.results || [],
};
};
export const getWorkspaceLogDetail = async (id: number): Promise<WorkspaceLogDetail> => {
const response = await authFetch(`/api/logs/${id}/`);
if (!response.ok) {
throw new Error("Failed to fetch workspace log detail.");
}
return await response.json();
};

View File

@@ -1,69 +1,130 @@
import { authFetch } from "./client";
export interface ProjectClient {
id: string;
name: string;
}
export interface ProjectMemberPayload {
user_id: string;
role: "manager" | "member" | string;
}
export interface ProjectMembership {
id: string;
project: string;
user: string;
user_details: {
id: string;
first_name: string;
last_name: string;
phone_number: string;
avatar?: string;
};
role: "manager" | "member" | string;
is_active: boolean;
}
export interface Project {
id: string;
name: string;
description: string;
color: string;
is_archived: boolean;
workspace: string;
client: ProjectClient | null;
my_role?: string;
members?: ProjectMembership[];
}
import { authFetch } from "./client";
import { cachedGetJson, invalidateApiCache } from "./cache";
interface AuditUser {
id: string;
first_name?: string;
last_name?: string;
mobile?: string;
}
export interface ProjectClient {
id: string;
name: string;
thumbnail?: string | null;
}
export interface ProjectAccessRateValue {
id: string;
hourly_rate: string;
currency: string;
effective_from: string | null;
}
export interface Project {
id: string;
name: string;
description: string;
color: string;
thumbnail?: string | null;
created_at?: string;
is_archived: boolean;
is_deleted?: boolean;
workspace: string;
created_by?: AuditUser | null;
client: ProjectClient | null;
}
export interface ProjectAccessItem {
id: string;
name: string;
description: string;
color: string;
is_archived: boolean;
client: ProjectClient | null;
has_access: boolean;
workspace_rate: ProjectAccessRateValue | null;
project_rate: ProjectAccessRateValue | null;
}
export interface ProjectAccessState {
workspace: { id: string; name: string };
user: { id: string; name: string; mobile: string; role: "owner" | "admin" | "member" | "guest" };
items: ProjectAccessItem[];
}
interface ProjectAccessRateMutationResponse {
removed: boolean;
item: ProjectAccessItem;
}
export interface ProjectPayload {
name: string;
description: string;
color: string;
is_archived: boolean;
workspace: string;
client: string | null;
}
is_archived: boolean;
workspace: string;
client: string | null;
thumbnail?: File | null;
clear_thumbnail?: boolean;
}
const buildProjectBody = (data: Partial<ProjectPayload> & { workspace?: string; name?: string }) => {
const hasFile = data.thumbnail instanceof File;
const shouldClear = Boolean(data.clear_thumbnail);
if (!hasFile && !shouldClear) {
return { body: JSON.stringify(data) };
}
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value === undefined || value === null || key === "clear_thumbnail") return;
if (key === "thumbnail" && value instanceof File) {
formData.append(key, value);
return;
}
formData.append(key, String(value));
});
if (shouldClear) formData.append("clear_thumbnail", "true");
return { body: formData };
};
export const getProjects = async (
workspaceId: string,
params: { limit?: number; offset?: number; search?: string; client?: string; is_archived?: boolean, ordering?: string } = {}
) => {
const queryParams = new URLSearchParams({ workspace: workspaceId });
export const getProjects = async (
workspaceId: string,
params: {
limit?: number;
offset?: number;
search?: string;
client?: string;
clients?: string[];
is_archived?: boolean;
ordering?: string;
} = {}
) => {
const queryParams = new URLSearchParams({ workspace: workspaceId });
if (params.limit !== undefined) queryParams.append("limit", params.limit.toString());
if (params.offset !== undefined) queryParams.append("offset", params.offset.toString());
if (params.search) queryParams.append("search", params.search);
if (params.client) queryParams.append("client", params.client);
if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString());
if (params.search) queryParams.append("search", params.search);
if (params.client) queryParams.append("client", params.client);
params.clients?.forEach((clientId) => queryParams.append("clients", clientId));
if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString());
if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString());
const response = await authFetch(`/api/projects/?${queryParams.toString()}`);
if (!response.ok) throw new Error("Failed to fetch projects");
return response.json();
};
const data = await cachedGetJson<any>(`/api/projects/?${queryParams.toString()}`, {
ttlMs: 5 * 60 * 1000,
namespaces: ["projects"],
});
if (Array.isArray(data)) return data;
if (Array.isArray(data?.items)) {
return {
...data,
results: data.items,
count: data.total_items ?? data.items.length,
};
}
return data;
};
export const getProject = async (id: string) => {
const response = await authFetch(`/api/projects/${id}/`);
@@ -75,109 +136,131 @@ export const getProject = async (id: string) => {
return response.json();
};
export const createProject = async (
data: Partial<ProjectPayload> & {
workspace: string;
name: string;
members?: ProjectMemberPayload[];
}
) => {
const response = await authFetch("/api/projects/", {
method: "POST",
body: JSON.stringify(data),
});
export const createProject = async (
data: Partial<ProjectPayload> & { workspace: string; name: string }
) => {
const requestBody = buildProjectBody(data);
const response = await authFetch("/api/projects/", {
method: "POST",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to create project");
}
return response.json();
};
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to create project");
}
const payload = await response.json();
invalidateApiCache(["projects", "reports"]);
return payload;
};
export const updateProject = async (
id: string,
data: Partial<ProjectPayload> & { members?: ProjectMemberPayload[] }
) => {
const response = await authFetch(`/api/projects/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
export const updateProject = async (
id: string,
data: Partial<ProjectPayload>
) => {
const requestBody = buildProjectBody(data);
const response = await authFetch(`/api/projects/${id}/`, {
method: "PATCH",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update project");
}
return response.json();
};
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update project");
}
const payload = await response.json();
invalidateApiCache(["projects", "reports"]);
return payload;
};
export const deleteProject = async (id: string) => {
const response = await authFetch(`/api/projects/${id}/`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to delete project");
}
if (response.status === 204) return { success: true };
return response.json().catch(() => ({ success: true }));
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to delete project");
}
invalidateApiCache(["projects", "reports"]);
if (response.status === 204) return { success: true };
return response.json().catch(() => ({ success: true }));
};
export const toggleArchiveProject = async (id: string) => {
export const toggleArchiveProject = async (id: string) => {
const response = await authFetch(`/api/projects/${id}/archive/`, {
method: "POST",
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`);
}
return response.json();
};
export const getProjectMemberships = async (projectId: string) => {
const response = await authFetch(`/api/memberships/?project=${projectId}`);
if (!response.ok) throw new Error("Failed to fetch project memberships");
return response.json();
};
export const addProjectMembership = async (projectId: string, userId: string, role: string) => {
const response = await authFetch(`/api/memberships/`, {
method: "POST",
body: JSON.stringify({ project_id: projectId, user_id: userId, role }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to add project member");
}
return response.json();
};
export const updateProjectMembership = async (membershipId: string, role: string, isActive: boolean = true) => {
const response = await authFetch(`/api/memberships/${membershipId}/`, {
method: "PATCH",
body: JSON.stringify({ role, is_active: isActive }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update project member");
}
return response.json();
};
export const removeProjectMembership = async (membershipId: string) => {
const response = await authFetch(`/api/memberships/${membershipId}/`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to remove member");
}
if (response.status === 204) return { success: true };
return response.json().catch(() => ({ success: true }));
};
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`);
}
const payload = await response.json();
invalidateApiCache(["projects", "reports"]);
return payload;
};
export const getProjectAccessState = async (workspaceId: string, userId: string): Promise<ProjectAccessState> => {
const query = new URLSearchParams({ workspace: workspaceId, user: userId });
const response = await authFetch(`/api/projects/access/?${query.toString()}`);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to fetch project access");
}
return response.json();
};
const mutateProjectAccess = async (
path: string,
workspaceId: string,
userId: string,
projectIds: string[],
) => {
const response = await authFetch(path, {
method: "POST",
body: JSON.stringify({
workspace: workspaceId,
user: userId,
project_ids: projectIds,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update project access");
}
invalidateApiCache(["projects", "reports"]);
return response.json();
};
export const grantProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
mutateProjectAccess("/api/projects/access/grant/", workspaceId, userId, projectIds);
export const revokeProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
mutateProjectAccess("/api/projects/access/revoke/", workspaceId, userId, projectIds);
export const saveProjectAccessRate = async (
workspaceId: string,
userId: string,
projectId: string,
hourlyRate: string | null,
currency: string,
) => {
const response = await authFetch("/api/projects/access/rate/", {
method: "POST",
body: JSON.stringify({
workspace: workspaceId,
user: userId,
project: projectId,
hourly_rate: hourlyRate,
currency,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to save project user rate");
}
invalidateApiCache(["projects", "reports"]);
return response.json() as Promise<ProjectAccessRateMutationResponse>;
};

View File

@@ -1,4 +1,5 @@
import { authFetch } from "./client";
import { cachedGetJson, invalidateApiCache } from "./cache";
export interface RateUser {
id: string;
@@ -29,6 +30,36 @@ export interface WorkspaceUserRate {
effective_from: string;
}
export interface WorkspaceProjectRateView {
project: {
id: string;
name: string;
client: { id: string; name: string } | null;
};
rate: {
id: string;
hourly_rate: string;
currency: string;
price_unit?: PriceUnit | null;
effective_from: string | null;
};
}
export interface MyWorkspaceRatesResponse {
workspace: { id: string; name: string };
workspace_rate: {
id: string;
hourly_rate: string;
currency: string;
price_unit?: PriceUnit | null;
effective_from: string | null;
} | null;
accessible_project_count: number;
project_override_count: number;
workspace_fallback_project_count: number;
project_rates: WorkspaceProjectRateView[];
}
interface PaginatedResponse<T> {
count: number;
next: string | null;
@@ -55,13 +86,44 @@ const ensurePaginated = async <T>(response: Response): Promise<PaginatedResponse
};
export const getPriceUnits = async () => {
const response = await authFetch("/api/price-units/");
return ensurePaginated<PriceUnit>(response);
const data = await cachedGetJson<any>("/api/price-units/", {
ttlMs: 5 * 60 * 1000,
namespaces: ["price-units"],
});
if (Array.isArray(data)) {
return { count: data.length, next: null, previous: null, results: data };
}
return {
count: data.count || data.results?.length || 0,
next: data.next || null,
previous: data.previous || null,
results: data.results || [],
};
};
export const getWorkspaceUserRates = async (workspaceId: string) => {
const response = await authFetch(`/api/workspace-user-rates/?workspace=${workspaceId}`);
return ensurePaginated<WorkspaceUserRate>(response);
const data = await cachedGetJson<any>(`/api/workspace-user-rates/?workspace=${workspaceId}`, {
ttlMs: 5 * 60 * 1000,
namespaces: ["workspace-rates"],
});
if (Array.isArray(data)) {
return { count: data.length, next: null, previous: null, results: data };
}
return {
count: data.count || data.results?.length || 0,
next: data.next || null,
previous: data.previous || null,
results: data.results || [],
};
};
export const getMyWorkspaceRates = async (workspaceId: string) => {
const response = await authFetch(`/api/workspaces/${workspaceId}/my-rates/`);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to load your workspace rates");
}
return response.json() as Promise<MyWorkspaceRatesResponse>;
};
export const createWorkspaceUserRate = async (data: {
@@ -78,6 +140,7 @@ export const createWorkspaceUserRate = async (data: {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to save workspace user rate");
}
invalidateApiCache(["workspace-rates", "reports"]);
return response.json() as Promise<WorkspaceUserRate>;
};
@@ -93,6 +156,7 @@ export const updateWorkspaceUserRate = async (
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to update workspace user rate");
}
invalidateApiCache(["workspace-rates", "reports"]);
return response.json() as Promise<WorkspaceUserRate>;
};
@@ -104,4 +168,5 @@ export const deleteWorkspaceUserRate = async (rateId: string) => {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail || errorData?.message || "Failed to delete workspace user rate");
}
invalidateApiCache(["workspace-rates", "reports"]);
};

View File

@@ -1,4 +1,5 @@
import { authFetch } from "./client";
import { cachedGetJson } from "./cache";
export type ReportPeriod =
| "this_week"
@@ -44,10 +45,15 @@ export interface ReportChartBucket {
total_duration: string;
}
export interface ChartReportSeries {
user: { id: string; name: string; mobile: string } | null;
buckets: ReportChartBucket[];
}
export interface ChartReportResponse {
scope: ReportScope;
summary: ReportSummary;
buckets: ReportChartBucket[];
series: ChartReportSeries[];
}
export interface DailyReportRow {
@@ -58,6 +64,7 @@ export interface DailyReportRow {
billable_duration: string;
non_billable_duration: string;
total_duration: string;
latest_hourly_rate: CurrencyTotal | null;
income_totals: CurrencyTotal[];
}
@@ -73,6 +80,40 @@ export interface BreakdownRow {
income_totals: CurrencyTotal[];
}
export interface PercentageRow {
id: string;
name: string;
percentage: string;
}
export interface RatePeriodRow {
amount: string;
currency: string;
from_date: string;
to_date: string | null;
project_name?: string | null;
is_current?: boolean;
}
export interface UserReportSummary {
user: { id: string; name: string; mobile: string };
hourly_rates: CurrencyTotal[];
rate_periods: RatePeriodRow[];
total_seconds: number;
total_duration: string;
billable_seconds: number;
billable_duration: string;
non_billable_seconds: number;
non_billable_duration: string;
income_totals: CurrencyTotal[];
project_percentages: PercentageRow[];
client_percentages: PercentageRow[];
tag_percentages: PercentageRow[];
project_income_percentages: PercentageRow[];
client_income_percentages: PercentageRow[];
tag_income_percentages: PercentageRow[];
}
export interface DayDetailEntry {
id: string;
description: string;
@@ -107,6 +148,31 @@ export interface TableReportResponse {
clients: BreakdownRow[];
projects: BreakdownRow[];
tags: BreakdownRow[];
client_percentages?: PercentageRow[];
project_percentages?: PercentageRow[];
tag_percentages?: PercentageRow[];
client_income_percentages?: PercentageRow[];
project_income_percentages?: PercentageRow[];
tag_income_percentages?: PercentageRow[];
user_summary?: UserReportSummary;
user_summaries?: UserReportSummary[];
per_user_reports?: UserScopedTableReport[];
}
export interface UserScopedTableReport {
scope: ReportScope;
summary: ReportSummary;
days: DailyReportRow[];
clients: BreakdownRow[];
projects: BreakdownRow[];
tags: BreakdownRow[];
client_percentages?: PercentageRow[];
project_percentages?: PercentageRow[];
tag_percentages?: PercentageRow[];
client_income_percentages?: PercentageRow[];
project_income_percentages?: PercentageRow[];
tag_income_percentages?: PercentageRow[];
user_summary: UserReportSummary;
}
export interface ReportExportJob {
@@ -149,15 +215,17 @@ const toQueryString = (filters: ReportFilters) => {
};
export const getChartReport = async (filters: ReportFilters): Promise<ChartReportResponse> => {
const response = await authFetch(`/api/reports/chart/?${toQueryString(filters)}`);
if (!response.ok) throw new Error("Failed to load chart report");
return response.json();
return cachedGetJson(`/api/reports/chart/?${toQueryString(filters)}`, {
ttlMs: 60 * 1000,
namespaces: ["reports"],
});
};
export const getTableReport = async (filters: ReportFilters): Promise<TableReportResponse> => {
const response = await authFetch(`/api/reports/table/?${toQueryString(filters)}`);
if (!response.ok) throw new Error("Failed to load table report");
return response.json();
return cachedGetJson(`/api/reports/table/?${toQueryString(filters)}`, {
ttlMs: 60 * 1000,
namespaces: ["reports"],
});
};
export const getDayDetailsReport = async (
@@ -165,9 +233,21 @@ export const getDayDetailsReport = async (
day: string,
): Promise<DayDetailsResponse> => {
const query = `${toQueryString(filters)}&day=${encodeURIComponent(day)}`;
const response = await authFetch(`/api/reports/day-details/?${query}`);
if (!response.ok) throw new Error("Failed to load day details");
return response.json();
return cachedGetJson(`/api/reports/day-details/?${query}`, {
ttlMs: 30 * 1000,
namespaces: ["reports"],
});
};
export const getUserSummaryReport = async (
filters: ReportFilters,
userId: string,
): Promise<UserScopedTableReport> => {
const query = `${toQueryString({ ...filters, user: userId })}`;
return cachedGetJson(`/api/reports/user-summary/?${query}`, {
ttlMs: 60 * 1000,
namespaces: ["reports"],
});
};
export const createReportExport = async (

View File

@@ -1,10 +1,20 @@
import { authFetch } from "./client";
import { cachedGetJson, invalidateApiCache } from "./cache";
interface AuditUser {
id: string;
first_name?: string;
last_name?: string;
mobile?: string;
}
export interface Tag {
id: string;
workspace: string;
name: string;
color: string;
is_deleted?: boolean;
created_by?: AuditUser | null;
created_at: string;
updated_at: string;
}
@@ -27,9 +37,10 @@ export const getTags = async (
if (params.search) query.append("search", params.search);
if (params.ordering) query.append("ordering", params.ordering);
const response = await authFetch(`/api/tags/?${query.toString()}`);
if (!response.ok) throw new Error("Failed to fetch tags");
return response.json();
return cachedGetJson(`/api/tags/?${query.toString()}`, {
ttlMs: 5 * 60 * 1000,
namespaces: ["tags"],
});
};
export const createTag = async (workspaceId: string, data: { name: string; color: string }) => {
@@ -41,7 +52,9 @@ export const createTag = async (workspaceId: string, data: { name: string; color
}),
});
if (!response.ok) throw new Error("Failed to create tag");
return response.json();
const payload = await response.json();
invalidateApiCache(["tags", "reports"]);
return payload;
};
export const updateTag = async (id: string, data: Partial<Pick<Tag, "name" | "color">>) => {
@@ -50,7 +63,9 @@ export const updateTag = async (id: string, data: Partial<Pick<Tag, "name" | "co
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to update tag");
return response.json();
const payload = await response.json();
invalidateApiCache(["tags", "reports"]);
return payload;
};
export const deleteTag = async (id: string) => {
@@ -58,4 +73,5 @@ export const deleteTag = async (id: string) => {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete tag");
invalidateApiCache(["tags", "reports"]);
};

View File

@@ -1,15 +1,35 @@
import { authFetch } from "./client";
import { invalidateApiCache } from "./cache";
export interface TimeEntryProjectDetails {
id: string;
name: string;
is_deleted: boolean;
client_name: string | null;
}
export interface TimeEntryTagDetails {
id: string;
name: string;
color: string;
is_deleted: boolean;
}
export interface TimeEntry {
id: string;
workspace: string;
user: string;
project: string | null;
project_details: TimeEntryProjectDetails | null;
description: string;
start_time: string;
start_time_ms: number;
end_time: string | null;
end_time_ms: number | null;
server_now_ms: number;
duration: string | null;
tags: string[];
tag_details: TimeEntryTagDetails[];
is_billable: boolean;
hourly_rate: string | null;
currency: string;
@@ -39,6 +59,8 @@ interface GroupedTimeEntryResponse {
offset: number;
next_offset: number | null;
has_more: boolean;
server_now_ms: number;
server_now: string;
groups: TimeEntryGroupWeek[];
}
@@ -93,7 +115,9 @@ export const createTimeEntry = async (payload: TimeEntryPayload) => {
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("Failed to create time entry");
return response.json();
const data = await response.json();
invalidateApiCache(["reports"]);
return data;
};
export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => {
@@ -102,7 +126,9 @@ export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) =>
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("Failed to update time entry");
return response.json();
const data = await response.json();
invalidateApiCache(["reports"]);
return data;
};
export const stopTimeEntry = async (id: string, endTime?: string) => {
@@ -111,7 +137,9 @@ export const stopTimeEntry = async (id: string, endTime?: string) => {
body: JSON.stringify(endTime ? { end_time: endTime } : {}),
});
if (!response.ok) throw new Error("Failed to stop time entry");
return response.json();
const data = await response.json();
invalidateApiCache(["reports"]);
return data;
};
export const deleteTimeEntry = async (id: string) => {
@@ -119,4 +147,5 @@ export const deleteTimeEntry = async (id: string) => {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete time entry");
invalidateApiCache(["reports"]);
};

View File

@@ -1,35 +1,180 @@
import { authFetch } from './client';
// --- Auth Endpoints ---
export const loginWithPassword = async (mobile: string, password: string) => {
const response = await authFetch('/api/users/login/', {
method: 'POST',
body: JSON.stringify({ mobile, password })
});
if (!response.ok) throw new Error('Failed to login with password');
return response.json();
};
export const sendOtp = async (mobile: string, mode: string) => {
const response = await authFetch('/api/users/otp/send/', {
method: 'POST',
body: JSON.stringify({ mobile, mode })
});
if (!response.ok) throw new Error('Failed to send OTP');
return response.json();
};
export const loginWithOtp = async (mobile: string, otp: string) => {
const response = await authFetch('/api/users/otp/login/', {
import { authFetch, buildApiError, buildApiUrl } from './client';
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
// --- Auth Endpoints ---
export const loginWithPassword = async (mobile: string, password: string) => {
const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch('/api/users/login/', {
method: 'POST',
body: JSON.stringify({ mobile, code: otp })
body: JSON.stringify({ mobile: normalizedMobile, password })
});
if (!response.ok) throw new Error('Failed to login with OTP');
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const logoutUser = async (refreshToken: string) => {
export interface SendOtpResponse {
detail: string
expires_in_seconds: number
expires_at?: string | null
}
export const sendOtp = async (mobile: string, mode: string): Promise<SendOtpResponse> => {
const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch('/api/users/otp/send/', {
method: 'POST',
body: JSON.stringify({ mobile: normalizedMobile, mode })
});
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const loginWithOtp = async (mobile: string, otp: string) => {
const normalizedMobile = normalizeDigits(mobile)
const normalizedOtp = normalizeDigits(otp)
const response = await authFetch('/api/users/otp/login/', {
method: 'POST',
body: JSON.stringify({ mobile: normalizedMobile, code: normalizedOtp })
});
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const registerWithOtp = async (
mobile: string,
code: string,
password: string,
re_password: string,
first_name = "",
last_name = "",
) => {
const normalizedMobile = normalizeDigits(mobile)
const normalizedCode = normalizeDigits(code)
const response = await authFetch("/api/users/register/", {
method: "POST",
body: JSON.stringify({
mobile: normalizedMobile,
code: normalizedCode,
password,
re_password,
first_name,
last_name,
}),
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}
export const resetPasswordWithOtp = async (
mobile: string,
code: string,
password: string,
re_password: string,
) => {
const normalizedMobile = normalizeDigits(mobile)
const normalizedCode = normalizeDigits(code)
const response = await authFetch("/api/users/password/reset/", {
method: "POST",
body: JSON.stringify({
mobile: normalizedMobile,
code: normalizedCode,
password,
re_password,
}),
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}
export const changePassword = async (
old_password: string,
new_password: string,
re_password: string,
) => {
const response = await authFetch("/api/users/password/change/", {
method: "PATCH",
body: JSON.stringify({ old_password, new_password, re_password }),
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}
export const startGoogleLogin = () => {
window.location.assign(buildApiUrl("/api/users/oauth/google/start/"));
};
export type GoogleOAuthFlowResponse =
| {
status: "authenticated";
access: string;
refresh: string;
}
| {
status: "collect_mobile";
email: string;
first_name: string;
last_name: string;
avatar_url: string;
resolution: "new_account" | "existing_email_claim";
mobile_hint?: string | null;
}
| {
status: "claim_required";
mobile: string;
detail?: string;
email: string;
resolution: "new_account" | "existing_email_claim" | "existing_mobile_claim";
mobile_hint?: string | null;
};
export const getGoogleOAuthFlow = async (flow: string): Promise<GoogleOAuthFlowResponse> => {
const response = await authFetch(`/api/users/oauth/google/flow/?flow=${encodeURIComponent(flow)}`, {
method: "GET",
});
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const completeGoogleOAuthSignup = async (
flow: string,
mobile: string,
): Promise<GoogleOAuthFlowResponse> => {
const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch("/api/users/oauth/google/complete/", {
method: "POST",
body: JSON.stringify({ flow, mobile: normalizedMobile }),
});
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const sendGoogleOAuthClaimOtp = async (flow: string) => {
const response = await authFetch("/api/users/oauth/google/claim/send-otp/", {
method: "POST",
body: JSON.stringify({ flow }),
});
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const verifyGoogleOAuthClaim = async (
flow: string,
code: string,
): Promise<GoogleOAuthFlowResponse> => {
const normalizedCode = normalizeDigits(code)
const response = await authFetch("/api/users/oauth/google/claim/verify/", {
method: "POST",
body: JSON.stringify({ flow, code: normalizedCode }),
});
if (!response.ok) throw await buildApiError(response);
return response.json();
};
export const logoutUser = async (refreshToken: string) => {
const response = await authFetch('/api/users/logout/', {
method: 'POST',
body: JSON.stringify({ refresh: refreshToken })
@@ -86,9 +231,9 @@ export interface SearchedUser {
profile_picture: string | null;
}
export const searchUserByExactMobile = async (mobile: string): Promise<SearchedUser | null> => {
try {
const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(mobile)}`);
export const searchUserByExactMobile = async (mobile: string): Promise<SearchedUser | null> => {
try {
const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(normalizeDigits(mobile))}`);
if (!response.ok) return null; // Returns null on 404 or other errors
return await response.json();
} catch (error) {

View File

@@ -1,13 +1,15 @@
import { authFetch } from "./client";
import { authFetch } from "./client";
import { cachedGetJson, invalidateApiCache } from "./cache";
export interface Workspace {
id: string;
name: string;
description?: string;
owner?: string;
my_role?: 'owner' | 'admin' | 'member' | 'guest';
[key: string]: any;
}
name: string;
description?: string;
thumbnail?: string | null;
owner?: string;
my_role?: 'owner' | 'admin' | 'member' | 'guest';
[key: string]: any;
}
export interface PaginatedResponse<T> {
count: number;
@@ -19,13 +21,15 @@ export interface PaginatedResponse<T> {
export interface WorkspaceMembership {
id: string;
workspace: string;
user: {
id: string;
email: string;
first_name?: string;
last_name?: string;
[key: string]: any;
};
user: {
id: string;
email?: string;
first_name?: string;
last_name?: string;
mobile?: string;
profile_picture?: string | null;
[key: string]: any;
};
role: 'owner' | 'admin' | 'member' | 'guest';
is_active: boolean;
joined_at?: string;
@@ -49,13 +53,10 @@ const toQueryString = (params?: Record<string, QueryValue>) => {
export const fetchWorkspaces = async (params?: Record<string, QueryValue>): Promise<PaginatedResponse<Workspace>> => {
const query = toQueryString(params);
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
const response = await authFetch(url);
if (!response.ok) {
throw new Error("Failed to fetch workspaces");
}
const data = await response.json();
const data = await cachedGetJson<any>(url, {
ttlMs: 60 * 1000,
namespaces: ["workspaces"],
});
if (Array.isArray(data)) {
return { count: data.length, next: null, previous: null, results: data };
@@ -75,49 +76,100 @@ export const getWorkspace = async (id: string): Promise<Workspace> => {
return await response.json();
};
export const createWorkspace = async (data: { name: string; description: string; members?: any[] }): Promise<Workspace> => {
const response = await authFetch('/api/workspaces/', {
method: 'POST',
body: JSON.stringify(data),
});
export const createWorkspace = async (data: {
name: string;
description: string;
members?: any[];
thumbnail?: File | null;
}): Promise<Workspace> => {
const hasFile = data.thumbnail instanceof File;
const body = hasFile
? (() => {
const formData = new FormData();
formData.append("name", data.name);
formData.append("description", data.description);
if (Array.isArray(data.members)) {
formData.append("members", JSON.stringify(data.members));
}
if (data.thumbnail) {
formData.append("thumbnail", data.thumbnail);
}
return formData;
})()
: JSON.stringify({
name: data.name,
description: data.description,
members: data.members,
});
const response = await authFetch('/api/workspaces/', {
method: 'POST',
body,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to create workspace');
}
return await response.json();
};
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to create workspace');
}
const payload = await response.json();
invalidateApiCache(["workspaces", "workspace-memberships", "reports"]);
return payload;
};
export const updateWorkspace = async (id: string, data: { name?: string; description?: string }): Promise<Workspace> => {
const response = await authFetch(`/api/workspaces/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
export const updateWorkspace = async (
id: string,
data: {
name?: string;
description?: string;
thumbnail?: File | null;
clear_thumbnail?: boolean;
},
): Promise<Workspace> => {
const hasFile = data.thumbnail instanceof File;
const shouldClear = Boolean(data.clear_thumbnail);
const useForm = hasFile || shouldClear;
const body = useForm
? (() => {
const formData = new FormData();
if (data.name !== undefined) formData.append("name", data.name);
if (data.description !== undefined) formData.append("description", data.description);
if (data.thumbnail) formData.append("thumbnail", data.thumbnail);
if (shouldClear) formData.append("clear_thumbnail", "true");
return formData;
})()
: JSON.stringify(data);
const response = await authFetch(`/api/workspaces/${id}/`, {
method: 'PATCH',
body,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update workspace');
}
return await response.json();
};
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update workspace');
}
const payload = await response.json();
invalidateApiCache(["workspaces"]);
return payload;
};
export const deleteWorkspace = async (id: string): Promise<void> => {
const response = await authFetch(`/api/workspaces/${id}/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete workspace');
}
};
if (!response.ok) {
throw new Error('Failed to delete workspace');
}
invalidateApiCache(["workspaces", "workspace-memberships", "workspace-rates", "reports"]);
};
export const fetchWorkspaceMemberships = async (params?: Record<string, QueryValue>): Promise<PaginatedResponse<WorkspaceMembership>> => {
const queryParams = toQueryString(params);
const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`);
if (!response.ok) throw new Error("Failed to fetch workspace memberships");
const data = await response.json();
const data = await cachedGetJson<any>(`/api/workspace-memberships/?${queryParams.toString()}`, {
ttlMs: 5 * 60 * 1000,
namespaces: ["workspace-memberships"],
});
if (Array.isArray(data)) {
return { count: data.length, next: null, previous: null, results: data };
@@ -137,23 +189,26 @@ export const addWorkspaceMembership = async (data: { workspace: string; user: st
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to add workspace membership');
}
return await response.json();
};
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to add workspace membership');
}
const payload = await response.json();
invalidateApiCache(["workspace-memberships", "reports"]);
return payload;
};
export const removeWorkspaceMembership = async (membershipId: string): Promise<void> => {
const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to remove workspace membership');
}
};
if (!response.ok) {
throw new Error('Failed to remove workspace membership');
}
invalidateApiCache(["workspace-memberships", "reports"]);
};
export const updateWorkspaceMembership = async (membershipId: string | number, data: { role: string }) => {
const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, {
@@ -161,10 +216,12 @@ export const updateWorkspaceMembership = async (membershipId: string | number, d
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update membership');
}
return await response.json();
};
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update membership');
}
const payload = await response.json();
invalidateApiCache(["workspace-memberships", "reports"]);
return payload;
};

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,9 +0,0 @@
import React from "react"
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-50 transition-colors">
{children}
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { createClient } from "../api/clients";
import { useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { createClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -16,40 +17,88 @@ interface CreateClientModalProps {
export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [notes, setNotes] = useState("");
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
const handleSubmit = async () => {
if (!name.trim()) return;
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!name.trim()) return;
setIsLoading(true);
try {
await createClient(workspaceId, { name, notes });
onSuccess();
setName("");
setNotes("");
onClose();
} catch (error) {
console.error(t.clients.errors.createFailed, error);
} finally {
setIsLoading(false);
}
};
try {
await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
toast.success(t.clients.createSuccess);
onSuccess();
setName("");
setNotes("");
setThumbnailFile(null);
onClose();
} catch (error) {
console.error(t.clients.errors.createFailed, error);
toast.error(t.clients.errors.createFailed);
} finally {
setIsLoading(false);
}
};
const footer = (
<>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel}
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.create}
</Button>
<Button type="submit" form="create-client-form" disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.create}
</Button>
</>
);
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
<div className="space-y-4">
<div>
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
<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"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : name.trim().charAt(0).toUpperCase() || "C"}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{thumbnailFile ? (
<Button type="button" variant="outline" onClick={() => setThumbnailFile(null)}>
{t.remove || "Remove"}
</Button>
) : null}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.clients.clientName}
</label>
@@ -69,7 +118,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
placeholder={t.clients.notesPlaceholder}
/>
</div>
</div>
</Modal>
);
}
</form>
</Modal>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { type Client } from "../types/client";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { type Client } from "../types/client";
import { deleteClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation";
import { Button } from "./ui/button";
@@ -16,30 +17,34 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const handleDelete = async () => {
if (!client) return;
const handleDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!client) return;
setIsLoading(true);
try {
await deleteClient(client.id);
onSuccess();
onClose();
} catch (error) {
console.error(t.clients.errors.deleteFailed, error);
} finally {
setIsLoading(false);
}
};
try {
await deleteClient(client.id);
toast.success(t.clients.deleteSuccess);
onSuccess();
onClose();
} catch (error) {
console.error(t.clients.errors.deleteFailed, error);
toast.error(t.clients.errors.deleteFailed);
} finally {
setIsLoading(false);
}
};
const footer = (
<>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
<Button
type="submit"
form="delete-client-form"
variant="destructive"
disabled={isLoading}
>
{isLoading ? "..." : t.clients.delete}
</Button>
</>
@@ -52,10 +57,12 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
title={t.clients.deleteConfirmTitle}
footer={footer}
maxWidth="max-w-sm"
>
<p className="text-slate-500 dark:text-slate-400">
{client ? t.clients.deleteConfirmMessage(client.name) : ""}
</p>
</Modal>
);
}
>
<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,5 +1,6 @@
import { useState, useEffect } from "react";
import { type Client } from "../types/client";
import { useState, useEffect, type FormEvent } from "react";
import { toast } from "sonner";
import { type Client } from "../types/client";
import { updateClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation";
import { Button } from "./ui/button";
@@ -16,46 +17,109 @@ interface EditClientModalProps {
export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client) {
setName(client.name);
setNotes(client.notes || "");
}
}, [client]);
setName(client.name);
setNotes(client.notes || "");
setThumbnailUrl(client.thumbnail || null);
setThumbnailFile(null);
setClearThumbnail(false);
}
}, [client]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
const handleSubmit = async () => {
if (!client || !name.trim()) return;
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!client || !name.trim()) return;
setIsLoading(true);
try {
await updateClient(client.id, { name, notes });
onSuccess();
onClose();
} catch (error) {
console.error(t.clients.errors.updateFailed, error);
} finally {
setIsLoading(false);
}
};
try {
await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
toast.success(t.clients.updateSuccess);
onSuccess();
onClose();
} catch (error) {
console.error(t.clients.errors.updateFailed, error);
toast.error(t.clients.errors.updateFailed);
} finally {
setIsLoading(false);
}
};
const footer = (
<>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t.actions?.cancel}
</Button>
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.saveChanges}
</Button>
<Button type="submit" form="edit-client-form" disabled={isLoading || !name.trim()}>
{isLoading ? "..." : t.clients.saveChanges}
</Button>
</>
);
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
<div className="space-y-4">
<div>
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
<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"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{thumbnailPreview ? (
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
) : !clearThumbnail && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
name.trim().charAt(0).toUpperCase() || "C"
)}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
<Button
type="button"
variant="outline"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
>
{t.remove || "Remove"}
</Button>
) : null}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.clients.clientName}
</label>
@@ -75,7 +139,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
placeholder={t.clients.notesPlaceholder}
/>
</div>
</div>
</Modal>
);
}
</form>
</Modal>
);
}

View File

@@ -0,0 +1,25 @@
import type { LucideIcon } from "lucide-react";
interface EmptyStateCardProps {
icon: LucideIcon;
title: string;
description: string;
className?: string;
}
export default function EmptyStateCard({
icon: Icon,
title,
description,
className = "",
}: EmptyStateCardProps) {
return (
<div
className={`flex flex-1 flex-col justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900 ${className}`}
>
<Icon className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{title}</h3>
<p className="mt-1 text-slate-500 dark:text-slate-400">{description}</p>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Search, ArrowUpDown } from 'lucide-react';
import { Select } from './ui/Select';
import { Input } from './ui/input';
import { Select } from './ui/Select';
interface FilterBarProps {
searchQuery: string;
@@ -11,38 +11,55 @@ interface FilterBarProps {
searchPlaceholder: string;
}
export default function FilterBar({
searchQuery,
setSearchQuery,
ordering,
setOrdering,
orderingOptions,
searchPlaceholder
export default function FilterBar({
searchQuery,
setSearchQuery,
ordering,
setOrdering,
orderingOptions,
searchPlaceholder
}: FilterBarProps) {
const [inputValue, setInputValue] = useState(searchQuery);
useEffect(() => {
if (inputValue === searchQuery) return;
const timeout = setTimeout(() => {
setSearchQuery(inputValue);
}, 1000);
return () => clearTimeout(timeout);
}, [inputValue, searchQuery, setSearchQuery]);
useEffect(() => {
setInputValue(searchQuery);
}, [searchQuery]);
return (
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 rtl:left-auto rtl:right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={searchPlaceholder || "Search..."}
className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow"
/>
</div>
<div className="flex w-full items-center gap-2 sm:w-auto">
<ArrowUpDown className="h-5 w-5 text-slate-400 hidden sm:block" />
<Select
value={ordering}
onChange={setOrdering}
options={orderingOptions}
className="w-full sm:w-max"
buttonClassName="w-full whitespace-nowrap sm:min-w-[150px]"
/>
</div>
<div className="flex w-full items-center gap-2 sm:w-auto">
<ArrowUpDown className="h-5 w-5 text-slate-400 hidden sm:block" />
<Select
value={ordering}
onChange={setOrdering}
options={orderingOptions}
className="w-full sm:w-max"
buttonClassName="w-full whitespace-nowrap sm:min-w-[150px]"
/>
</div>
</div>
);
}
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef } from "react";
import { useTranslation } from "../hooks/useTranslation";
interface InfiniteScrollProps {
children: React.ReactNode;
@@ -16,8 +17,9 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
isLoading,
className = "",
loader,
}) => {
const observerTarget = useRef<HTMLDivElement>(null);
}) => {
const { t } = useTranslation();
const observerTarget = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore);
const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading);
@@ -56,11 +58,11 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
{isLoading && (
loader || (
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
Loading...
</div>
)
)}
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
{t.loading || "Loading..."}
</div>
)
)}
</div>
);
};

View File

@@ -0,0 +1,85 @@
import { useMemo } from "react";
import { useTranslation } from "../hooks/useTranslation";
type ListPageSkeletonVariant = "list" | "standard-grid" | "dense-grid";
type ListPageSkeletonProps = {
variant?: ListPageSkeletonVariant;
};
export function ListPageSkeleton({
variant = "standard-grid",
}: ListPageSkeletonProps) {
const { t } = useTranslation();
const cardCount = variant === "list" ? 5 : variant === "dense-grid" ? 8 : 6;
const items = useMemo(
() => Array.from({ length: cardCount }, (_, index) => index),
[cardCount],
);
const gridClassName =
variant === "dense-grid"
? "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
: "grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3";
return (
<div>
{variant === "list" ? (
<div className="flex flex-1 flex-col gap-4 animate-pulse">
{items.map((item) => (
<div
key={item}
className="rounded-2xl border border-slate-200/80 bg-slate-50/70 p-5 dark:border-slate-800 dark:bg-slate-950/40"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="h-10 w-10 shrink-0 rounded-xl bg-slate-200 dark:bg-slate-800" />
<div className="min-w-0 space-y-2">
<div className="h-4 w-40 max-w-[60vw] rounded-full bg-slate-200 dark:bg-slate-800" />
<div className="h-3 w-64 max-w-[70vw] rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
</div>
</div>
</div>
))}
</div>
) : (
<div className={`${gridClassName} animate-pulse`}>
{items.map((item) => (
<div
key={item}
className="rounded-2xl border border-slate-200/80 bg-slate-50/70 p-5 dark:border-slate-800 dark:bg-slate-950/40"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="h-10 w-10 shrink-0 rounded-xl bg-slate-200 dark:bg-slate-800" />
<div className="min-w-0 space-y-2">
<div className="h-4 w-32 max-w-[40vw] rounded-full bg-slate-200 dark:bg-slate-800" />
<div className="h-3 w-24 rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
<div className="h-8 w-8 rounded-xl bg-slate-200 dark:bg-slate-800" />
</div>
</div>
<div className="mt-5 space-y-2">
<div className="h-3 w-full rounded-full bg-slate-200 dark:bg-slate-800" />
<div className="h-3 w-[82%] rounded-full bg-slate-200 dark:bg-slate-800" />
<div className="h-3 w-[64%] rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
<div className="mt-5 h-3 w-28 rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
))}
</div>
)}
</div>
);
}

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,16 +23,44 @@ export const Modal: React.FC<ModalProps> = ({
footer,
maxWidth = "max-w-lg",
}) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
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,12 +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
@@ -16,18 +18,27 @@ type NavbarProps = {
export function Navbar({ onOpenSidebar }: NavbarProps) {
const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme()
const navigate = useNavigate()
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)
const isFa = lang === "fa"
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem("theme")
if (savedTheme) return savedTheme === "dark"
return document.documentElement.classList.contains("dark")
})
const dropdownRef = useRef<HTMLDivElement>(null)
const isFa = lang === "fa"
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) => {
@@ -40,14 +51,6 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
return () => window.removeEventListener("profile_updated", handleProfileUpdated)
}, [])
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
}, [isDarkMode])
useEffect(() => {
const fetchUser = async () => {
const token = getAccessToken()
@@ -70,6 +73,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
setIsDropdownOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
@@ -91,14 +95,30 @@ 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 = () => {
const newThemeState = !isDarkMode
setIsDarkMode(newThemeState)
localStorage.setItem("theme", newThemeState ? "dark" : "light")
setTheme(isDarkMode ? "light" : "dark")
}
const toggleLanguage = () => {
const newLang = isFa ? "en" : "fa"
if (setLanguage) {
setLanguage(newLang)
} else {
@@ -109,7 +129,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
return (
<>
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-8 py-6 backdrop-blur-md transition-colors dark:border-slate-800/80 dark:bg-slate-900/70">
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-4 py-4 backdrop-blur-md transition-colors md:px-8 md:py-6 dark:border-slate-800/80 dark:bg-slate-900/70">
<div className="flex items-center gap-3">
<button
type="button"
@@ -119,19 +139,46 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
>
<Menu className="h-5 w-5" />
</button>
<div className="flex cursor-pointer items-center gap-2" onClick={() => navigate("/")}>
<span className="relative z-20 flex items-center gap-2 text-xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
<Command className="h-7 w-7" />
{t.title || "Qlockify"}
</span>
</div>
<div
className="flex cursor-pointer items-center gap-2"
onClick={() => navigate("/")}
>
<span className="relative z-20 flex items-center gap-2 text-lg font-bold tracking-tight text-slate-900 md:text-xl dark:text-slate-50">
<Command className="h-6 w-6 md:h-7 md:w-7" />
Qlockify.ir
</span>
</div>
</div>
<div className="flex items-center gap-4">
{/* Mobile navbar: theme toggle + notification bell */}
<div className="flex items-center gap-2 md:hidden">
<button
type="button"
onClick={toggleTheme}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
title={isDarkMode ? t.lightMode || "Light Mode" : t.darkMode || "Dark Mode"}
>
{isDarkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{user && <NotificationBell />}
</div>
{/* 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 ? (
<>
<NotificationBell />
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsDropdownOpen((current) => !current)}
@@ -152,8 +199,10 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
{isDropdownOpen && (
<div
dir="rtl"
className={`absolute ${isFa ? "left-0" : "right-0"} z-50 mt-2 w-56 overflow-hidden rounded-lg border border-slate-200 bg-white py-2 shadow-lg ring-1 ring-black ring-opacity-5 dark:border-slate-800 dark:bg-slate-900`}
dir={isFa ? "rtl" : "ltr"}
className={`absolute ${
isFa ? "left-0" : "right-0"
} z-50 mt-2 w-56 overflow-hidden rounded-lg border border-slate-200 bg-white py-2 shadow-lg ring-1 ring-black ring-opacity-5 dark:border-slate-800 dark:bg-slate-900`}
>
<div className="mb-2 border-b border-slate-100 px-4 py-2 dark:border-slate-800">
<p className="truncate text-sm font-semibold text-slate-800 dark:text-slate-400">
@@ -161,7 +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")
@@ -177,8 +243,16 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
onClick={toggleTheme}
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
>
{isDarkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span>{isDarkMode ? (t.lightMode || "Light Mode") : (t.darkMode || "Dark Mode")}</span>
{isDarkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<span>
{isDarkMode
? t.lightMode || "Light Mode"
: t.darkMode || "Dark Mode"}
</span>
</button>
<button
@@ -189,7 +263,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
<span>{isFa ? "English" : "فارسی"}</span>
</button>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800"></div>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<button
onClick={() => {
@@ -208,6 +282,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
) : (
<>
<SettingsMenu />
<Button
onClick={() => navigate("/auth")}
className="bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700"
@@ -221,7 +296,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
{showLogoutModal && (
<div
className="fixed inset-0 z-60 flex items-center justify-center bg-black/50 px-4"
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/50 px-4"
onClick={() => setShowLogoutModal(false)}
>
<div
@@ -231,9 +306,12 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
<h2 className="mb-2 text-lg font-bold text-slate-900 dark:text-white">
{t.confirmLogoutTitle || "Confirm Logout"}
</h2>
<p className="mb-6 text-slate-600 dark:text-slate-400">
{t.confirmLogoutMessage || "Are you sure you want to log out of your account?"}
{t.confirmLogoutMessage ||
"Are you sure you want to log out of your account?"}
</p>
<div className="flex justify-end gap-3">
<Button
variant="outline"
@@ -242,6 +320,7 @@ export function Navbar({ onOpenSidebar }: NavbarProps) {
>
{t.actions?.cancel || "Cancel"}
</Button>
<Button
variant="destructive"
onClick={handleLogout}

View File

@@ -1,84 +1,151 @@
import React from 'react';
import { useTranslation } from '../hooks/useTranslation';
import { Select } from './ui/Select';
import { Button } from './ui/button';
interface PaginationProps {
currentPage: number;
totalCount: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
pageSizeOptions?: number[];
}
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalCount,
limit,
onPageChange,
onLimitChange,
pageSizeOptions = [10, 20, 50],
}) => {
const { t, lang } = useTranslation();
const isFa = lang === 'fa';
const toPersianNum = (num: string | number | undefined | null) => {
if (num === null || num === undefined) return num;
if (!isFa) return num;
return num.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d as any]);
};
const totalPages = Math.ceil(totalCount / limit) || 1;
if (totalCount === 0) return null;
const startItem = ((currentPage - 1) * limit) + 1;
const endItem = Math.min(currentPage * limit, totalCount);
return (
<div className="mt-auto sticky bottom-0 bg-slate-50/60 dark:bg-slate-900/60 backdrop-blur-md left-0 right-0 z-10 flex items-center justify-between py-4 px-4 -mx-4 sm:px-0 sm:mx-0">
<div className="flex items-center gap-4">
<Select
value={String(limit)}
onChange={(val) => {
onLimitChange(Number(val));
onPageChange(1);
}}
options={pageSizeOptions.map((option) => ({
value: String(option),
label: String(toPersianNum(option)),
}))}
className="w-20 shrink-0"
buttonClassName=""
/>
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline-block">
{t.pagination?.showing || 'Showing'} <strong className="text-slate-700 dark:text-slate-300">{toPersianNum(startItem)}</strong> {t.pagination?.to || '-'} <strong className="text-slate-700 dark:text-slate-300">{toPersianNum(endItem)}</strong> {t.pagination?.of || 'of'} <strong className="text-slate-700 dark:text-slate-300">{toPersianNum(totalCount)}</strong>
</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
{t.pagination?.previous || 'Previous'}
</Button>
<span className="text-sm text-slate-600 dark:text-slate-400 font-medium hidden sm:inline-block">
{t.pagination?.page || 'Page'} {toPersianNum(currentPage)} {t.pagination?.of || 'of'} {toPersianNum(totalPages)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
{t.pagination?.next || 'Next'}
</Button>
</div>
</div>
);
};
import React, { useMemo } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation';
import { cn } from '../lib/utils';
import { Select } from './ui/Select';
import { Button } from './ui/button';
interface PaginationProps {
currentPage: number;
totalCount: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
pageSizeOptions?: number[];
}
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalCount,
limit,
onPageChange,
onLimitChange,
pageSizeOptions = [10, 20, 50],
}) => {
const { t, lang } = useTranslation();
const isFa = lang === 'fa';
const toPersianNum = (num: string | number | undefined | null) => {
if (num === null || num === undefined) return num;
if (!isFa) return num;
return num.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d as any]);
};
const totalPages = Math.ceil(totalCount / limit) || 1;
if (totalCount === 0) return null;
const startItem = ((currentPage - 1) * limit) + 1;
const endItem = Math.min(currentPage * limit, totalCount);
const pageItems = useMemo(() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const pages: Array<number | "ellipsis-left" | "ellipsis-right"> = [1];
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) {
pages.push("ellipsis-left");
}
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
if (end < totalPages - 1) {
pages.push("ellipsis-right");
}
pages.push(totalPages);
return pages;
}, [currentPage, totalPages]);
return (
<div className="sticky bottom-0 left-0 right-0 z-10 mt-auto -mx-4 border-t border-slate-200/80 bg-white/95 px-4 py-3 shadow-[0_-10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-slate-800/80 dark:bg-slate-950/95 dark:shadow-[0_-10px_30px_rgba(0,0,0,0.24)] md:-mx-6 md:px-6">
<div className="mx-auto flex w-full max-w-7xl gap-3 flex-row items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-start">
<div className="flex items-center gap-3">
<Select
value={String(limit)}
onChange={(val) => {
onLimitChange(Number(val));
onPageChange(1);
}}
options={pageSizeOptions.map((option) => ({
value: String(option),
label: String(toPersianNum(option)),
}))}
className="w-24 shrink-0"
buttonClassName="h-9 rounded-lg border-slate-200 bg-slate-50 font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
/>
<span className="inline-flex h-9 items-center rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 sm:hidden">
{toPersianNum(currentPage)} / {toPersianNum(totalPages)}
</span>
</div>
<span className="text-sm text-slate-500 dark:text-slate-400">
{t.pagination?.showing || 'Showing'} <strong className="text-slate-700 dark:text-slate-200">{toPersianNum(startItem)}</strong> {t.pagination?.to || '-'} <strong className="text-slate-700 dark:text-slate-200">{toPersianNum(endItem)}</strong> {t.pagination?.of || 'of'} <strong className="text-slate-700 dark:text-slate-200">{toPersianNum(totalCount)}</strong>
</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
<div className="hidden items-center gap-2 md:flex">
{pageItems.map((pageItem, index) =>
typeof pageItem === 'number' ? (
<button
key={pageItem}
type="button"
onClick={() => onPageChange(pageItem)}
className={cn(
'inline-flex h-9 min-w-9 items-center justify-center rounded-lg border px-3 text-sm font-semibold transition-colors',
pageItem === currentPage
? 'border-sky-500 bg-sky-500 text-white shadow-sm'
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800',
)}
>
{toPersianNum(pageItem)}
</button>
) : (
<span
key={`${pageItem}-${index}`}
className="inline-flex h-9 min-w-9 items-center justify-center rounded-lg text-slate-400 dark:text-slate-500"
>
<MoreHorizontal className="h-4 w-4" />
</span>
),
)}
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-9 rounded-lg border-slate-200 bg-slate-50 px-3 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
>
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
{t.pagination?.previous || 'Previous'}
</Button>
<div className="hidden text-sm font-medium text-slate-500 dark:text-slate-400 sm:block md:hidden">
{t.pagination?.page || 'Page'} {toPersianNum(currentPage)} {t.pagination?.of || 'of'} {toPersianNum(totalPages)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="h-9 rounded-lg border-slate-200 bg-slate-50 px-3 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
>
{t.pagination?.next || 'Next'}
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,172 +1,481 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
import { useEffect, useState } from "react"
import { NavLink, useNavigate } from "react-router-dom"
import {
Users,
LayoutDashboard,
LayoutDashboard,
X,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Briefcase,
ChartColumn,
Clock3,
History,
Tags,
} from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation';
User,
Globe,
LogOut,
LogIn,
FlaskConical,
RefreshCcw,
} from "lucide-react"
import { toast } from "sonner"
import { useOptionalWorkspace } from "../context/WorkspaceContext"
import { useTranslation } from "../hooks/useTranslation"
import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions"
import { WorkspaceSelector } from "./WorkspaceSelector"
import { SettingsMenu } from "./SettingsMenu"
import { Button } from "./ui/button"
import { getUserProfile, logoutUser } from "../api/users"
import { clearSessionTokens, getAccessToken, getRefreshToken, setDemoSessionMeta, setSessionTokens } from "../lib/session"
import { startDemo } from "../api/demo"
type SidebarProps = {
mobileOpen?: boolean;
onMobileClose?: () => void;
};
mobileOpen?: boolean
onMobileClose?: () => void
}
export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const { t, lang } = useTranslation();
const isRtl = lang === 'fa';
const ToggleIcon = isRtl
? (isCollapsed ? PanelRightOpen : PanelRightClose)
: (isCollapsed ? PanelLeftOpen : PanelLeftClose);
const [isCollapsed, setIsCollapsed] = useState(false)
const [showLogoutModal, setShowLogoutModal] = useState(false)
const [isResettingDemo, setIsResettingDemo] = useState(false)
const [user, setUser] = useState<any>(null)
const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation()
const workspaceContext = useOptionalWorkspace()
const activeWorkspace = workspaceContext?.activeWorkspace ?? null
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
? PanelRightOpen
: PanelRightClose
: isCollapsed
? PanelLeftOpen
: PanelLeftClose
useEffect(() => {
const handleProfileUpdated = ((e: CustomEvent) => {
if (e.detail) {
setUser((prev: any) => (prev ? { ...prev, ...e.detail } : e.detail))
}
}) as EventListener
window.addEventListener("profile_updated", handleProfileUpdated)
return () => window.removeEventListener("profile_updated", handleProfileUpdated)
}, [])
useEffect(() => {
const fetchUser = async () => {
const token = getAccessToken()
if (!token) return
try {
const userData = await getUserProfile()
setUser(userData)
} catch (error) {
console.error("Failed to fetch user profile:", error)
}
}
void fetchUser()
}, [])
const handleLogout = async () => {
try {
const refreshToken = getRefreshToken()
if (refreshToken) {
await logoutUser(refreshToken)
}
} catch (error) {
console.error("Logout API failed:", error)
} finally {
clearSessionTokens()
setUser(null)
setShowLogoutModal(false)
onMobileClose?.()
toast.success(t.logoutToast || "Successfully logged out!")
navigate("/auth")
}
}
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"
if (setLanguage) {
setLanguage(newLang)
} else {
localStorage.setItem("language", newLang)
window.location.reload()
}
}
const navItems = [
{
path: '/timesheet',
path: "/timesheet",
icon: Clock3,
label: t.sidebar?.timesheet || 'Timesheet'
label: t.sidebar?.timesheet || "Timesheet",
},
{
path: '/reports',
icon: ChartColumn,
label: t.sidebar?.reports || 'Reports'
},
{
path: '/tags',
path: "/tags",
icon: Tags,
label: t.sidebar?.tags || 'Tags'
label: t.sidebar?.tags || "Tags",
},
{
path: '/workspaces',
icon: LayoutDashboard,
label: t.sidebar?.workspaces || 'Workspaces'
},
{
path: '/clients',
icon: Users,
label: t.sidebar?.clients || 'Clients'
},
{
path: '/projects',
icon: Briefcase,
label: t.sidebar?.projects || 'Projects'
},
];
{
path: "/clients",
icon: Users,
label: t.sidebar?.clients || "Clients",
},
{
path: "/projects",
icon: Briefcase,
label: t.sidebar?.projects || "Projects",
},
{
path: "/workspaces",
icon: LayoutDashboard,
label: t.sidebar?.workspaces || "Workspaces",
},
{
path: "/reports",
icon: ChartColumn,
label: t.sidebar?.reports || "Reports",
},
...(canViewLogs
? [
{
path: "/logs",
icon: History,
label: t.sidebar?.logs || "Logs",
},
]
: []),
]
const renderNavItems = (mobile = false) =>
navItems.map((item) => {
const Icon = item.icon;
const Icon = item.icon
return (
<NavLink
key={`${mobile ? 'mobile' : 'desktop'}-${item.path}`}
key={`${mobile ? "mobile" : "desktop"}-${item.path}`}
to={item.path}
title={!mobile && isCollapsed ? item.label : undefined}
onClick={() => {
if (mobile) onMobileClose?.();
if (mobile) onMobileClose?.()
}}
className={({ isActive }) =>
`flex items-center rounded-lg text-sm font-medium transition-colors ${
mobile
? 'gap-3 px-4 py-3'
? "gap-3 px-4 py-3"
: isCollapsed
? 'justify-center px-0 py-2.5'
: 'gap-3 px-3 py-2.5'
? "justify-center px-0 py-2.5"
: "gap-3 px-3 py-2.5"
} ${
isActive
? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50'
? "bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50"
}`
}
>
<Icon size={mobile ? 20 : isCollapsed ? 22 : 18} className="shrink-0" />
{(mobile || !isCollapsed) && (
<span className="truncate whitespace-nowrap">{item.label}</span>
)}
</NavLink>
);
});
)
})
const renderMobileTopSection = () => {
if (!user) {
return null
}
return (
<div className="border-b border-slate-200 p-4 dark:border-slate-800">
<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>
)
}
const renderMobileFooterSection = () => {
if (!user) {
return (
<div className="border-t border-slate-200 p-4 dark:border-slate-800">
<button
onClick={toggleLanguage}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 mb-3 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50"
>
<Globe size={20} className="shrink-0" />
<span className="truncate whitespace-nowrap">
{isRtl ? "English" : "فارسی"}
</span>
</button>
<Button
onClick={() => {
onMobileClose?.()
navigate("/auth")
}}
className="w-full bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700"
>
<LogIn className="mr-2 h-4 w-4" />
{t.login?.signIn || "Login"}
</Button>
</div>
)
}
return (
<div className="space-y-1 p-4">
<button
onClick={() => {
onMobileClose?.()
navigate("/profile")
}}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50"
>
<User size={20} className="shrink-0" />
<span className="truncate whitespace-nowrap">
{t.profile?.title || "Profile"}
</span>
</button>
<button
onClick={toggleLanguage}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900/50"
>
<Globe size={20} className="shrink-0" />
<span className="truncate whitespace-nowrap">
{isRtl ? "English" : "فارسی"}
</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"
>
<LogOut size={20} className="shrink-0" />
<span className="truncate whitespace-nowrap">{t.logout || "Logout"}</span>
</button>
</div>
)
}
return (
<>
<aside
className={`border-e border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 hidden md:flex flex-col h-screen sticky top-0 shrink-0 transition-all duration-300 ease-in-out ${
isCollapsed ? 'w-20' : 'w-64'
{/* Desktop sidebar */}
<aside
className={`hidden h-screen shrink-0 flex-col border-e border-slate-200 bg-white transition-all duration-300 ease-in-out md:sticky md:top-0 md:flex dark:border-slate-800 dark:bg-slate-950 ${
isCollapsed ? "w-20" : "w-64"
}`}
>
<div className={`h-18.25 flex items-center border-b border-slate-200 dark:border-slate-800 shrink-0 transition-all ${
isCollapsed ? 'justify-center px-0' : 'justify-between px-6'
}`}>
<div
className={`flex h-[73px] shrink-0 items-center border-b border-slate-200 transition-all dark:border-slate-800 ${
isCollapsed ? "justify-center px-0" : "justify-between px-6"
}`}
>
{!isCollapsed && (
<h2 className="text-xl font-bold text-slate-800 dark:text-white truncate">
<h2 className="truncate text-xl font-bold text-slate-800 dark:text-white">
{t.title || "Qlockify"}
</h2>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
title={isCollapsed ? (t.sidebar?.expand || 'Expand') : (t.sidebar?.collapse || 'Collapse')}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
title={
isCollapsed
? t.sidebar?.expand || "Expand"
: t.sidebar?.collapse || "Collapse"
}
>
<ToggleIcon size={20} />
</button>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto overflow-x-hidden">
<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>
{/* Mobile sidebar */}
<div
className={`fixed inset-0 z-[70] md:hidden transition-all duration-200 ${
mobileOpen ? 'pointer-events-auto' : 'pointer-events-none'
className={`fixed inset-0 z-[70] transition-all duration-200 md:hidden ${
mobileOpen ? "pointer-events-auto" : "pointer-events-none"
}`}
aria-hidden={!mobileOpen}
>
<button
type="button"
className={`absolute inset-0 bg-slate-950/50 backdrop-blur-[1px] transition-opacity ${
mobileOpen ? 'opacity-100' : 'opacity-0'
mobileOpen ? "opacity-100" : "opacity-0"
}`}
onClick={onMobileClose}
/>
<aside
className={`absolute inset-y-0 flex w-[18.5rem] max-w-[86vw] flex-col bg-white shadow-2xl transition-transform dark:bg-slate-950 ${
dir={isRtl ? "rtl" : "ltr"}
className={`absolute inset-y-0 flex w-[19.5rem] max-w-[88vw] flex-col overflow-hidden bg-white shadow-2xl transition-transform dark:bg-slate-950 ${
isRtl
? `right-0 border-s border-slate-200 dark:border-slate-800 ${
mobileOpen ? 'translate-x-0' : 'translate-x-full'
mobileOpen ? "translate-x-0" : "translate-x-full"
}`
: `left-0 border-e border-slate-200 dark:border-slate-800 ${
mobileOpen ? 'translate-x-0' : '-translate-x-full'
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`
}`}
>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-5 dark:border-slate-800">
<h2 className="truncate text-lg font-bold text-slate-800 dark:text-white">
{t.title || "Qlockify"}
</h2>
<button
type="button"
onClick={onMobileClose}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
title="Close"
>
<X size={20} />
</button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="flex shrink-0 items-center justify-between border-b border-slate-200 px-5 py-5 dark:border-slate-800">
<h2 className="truncate text-lg font-bold text-slate-800 dark:text-white">
{t.title || "Qlockify"}
</h2>
<button
type="button"
onClick={onMobileClose}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
title="Close"
>
<X size={20} />
</button>
</div>
<div className="shrink-0">
{renderMobileTopSection()}
</div>
<nav className="shrink-0 space-y-1 p-4">
{renderNavItems(true)}
</nav>
<div className="mt-auto shrink-0">
{renderMobileFooterSection()}
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
{renderNavItems(true)}
</nav>
</aside>
</div>
{showLogoutModal && (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 px-4"
onClick={() => setShowLogoutModal(false)}
>
<div
className="w-full max-w-sm rounded-lg border bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-2 text-lg font-bold text-slate-900 dark:text-white">
{t.confirmLogoutTitle || "Confirm Logout"}
</h2>
<p className="mb-6 text-slate-600 dark:text-slate-400">
{t.confirmLogoutMessage ||
"Are you sure you want to log out of your account?"}
</p>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setShowLogoutModal(false)}
className="dark:text-white"
>
{t.actions?.cancel || "Cancel"}
</Button>
<Button
variant="destructive"
onClick={handleLogout}
className="bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700"
>
{t.logout || "Logout"}
</Button>
</div>
</div>
</div>
)}
</>
);
};
)
}

View File

@@ -1,17 +0,0 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "./ThemeProvider"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white transition-colors hover:bg-slate-100 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all text-slate-900 dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all text-slate-50 dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</button>
)
}

View File

@@ -6,7 +6,11 @@ import { useNavigate } from "react-router-dom";
import { fetchWorkspaces, type Workspace } from "../api/workspaces";
import { InfiniteScroll } from "./InfiniteScroll";
export const WorkspaceSelector: React.FC = () => {
type WorkspaceSelectorProps = {
className?: string
}
export const WorkspaceSelector: React.FC<WorkspaceSelectorProps> = ({ className = "" }) => {
const { workspaces, activeWorkspace, setActiveWorkspace } = useWorkspace();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -40,22 +44,21 @@ export const WorkspaceSelector: React.FC = () => {
refreshWorkspacesList();
}) as EventListener;
const handleWorkspaceCreated = ((e: CustomEvent) => {
if (e.detail?.id) {
setActiveWorkspace(e.detail);
}
refreshWorkspacesList();
}) as EventListener;
const handleWorkspaceCreated = ((e: CustomEvent) => {
if (e.detail?.id) {
setActiveWorkspace(e.detail);
}
refreshWorkspacesList();
}) as EventListener;
const handleWorkspaceEdited = ((e: CustomEvent) => {
// آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد
if (activeWorkspace?.id === e.detail?.id) {
setActiveWorkspace({
...activeWorkspace,
name: e.detail.name,
description: e.detail.description
} as Workspace);
}
if (activeWorkspace?.id === e.detail?.id) {
setActiveWorkspace({
...activeWorkspace,
...e.detail,
} as Workspace);
}
refreshWorkspacesList();
}) as EventListener;
@@ -117,19 +120,23 @@ export const WorkspaceSelector: React.FC = () => {
}, [offset, hasMore, isLoadingMore]);
return (
<div className="relative" ref={dropdownRef}>
<div className={`relative ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-base font-medium text-slate-700 dark:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-base font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
>
<div className="w-6 h-6 flex items-center justify-center bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 rounded-md">
{activeWorkspace?.name?.charAt(0) || <Briefcase className="w-4 h-4" />}
<div className="w-6 h-6 flex items-center justify-center overflow-hidden rounded-md bg-blue-100 text-blue-600 dark:bg-blue-900/50 dark:text-blue-400">
{activeWorkspace?.thumbnail ? (
<img src={activeWorkspace.thumbnail} alt={activeWorkspace?.name || "Workspace"} className="h-full w-full object-cover" />
) : (
activeWorkspace?.name?.charAt(0)?.toUpperCase() || <Briefcase className="w-4 h-4" />
)}
</div>
<span className="max-w-30 truncate">
<span className="min-w-0 flex-1 truncate text-start">
{activeWorkspace?.name || t.workspace?.title || "Workspaces"}
</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" />
</button>
{isOpen && (
@@ -159,8 +166,12 @@ export const WorkspaceSelector: React.FC = () => {
className="w-full flex items-center justify-between px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2 truncate">
<div className="w-6 h-6 flex items-center justify-center bg-slate-100 dark:bg-slate-800 rounded-md font-medium">
{ws.name.charAt(0)}
<div className="w-6 h-6 flex items-center justify-center overflow-hidden rounded-md bg-slate-100 font-medium dark:bg-slate-800">
{ws.thumbnail ? (
<img src={ws.thumbnail} alt={ws.name} className="h-full w-full object-cover" />
) : (
ws.name.charAt(0).toUpperCase()
)}
</div>
<span className="truncate">{ws.name}</span>
</div>

View File

@@ -0,0 +1,182 @@
import { Clock3, Globe2, User2, X } from "lucide-react";
import type { WorkspaceLogDetail } from "../../api/logs";
import { API_BASE_URL } from "../../config/constants";
import { useTranslation } from "../../hooks/useTranslation";
import { Button } from "../ui/button";
const resolveImageUrl = (value?: string | null) => {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
return `${API_BASE_URL.replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`;
};
export function LogDetailsPanel({
open,
log,
isLoading,
onClose,
}: {
open: boolean;
log: WorkspaceLogDetail | null;
isLoading: boolean;
onClose: () => void;
}) {
const { t, lang } = useTranslation();
if (!open) {
return null;
}
const formatTimestamp = (value?: string | null) => {
if (!value) return "-";
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
};
const actorName = log?.actor?.full_name || t.logs?.unknownActor || "Unknown actor";
const actorAvatar = resolveImageUrl(log?.actor?.profile_picture);
return (
<div className="fixed inset-0 z-[80] flex items-end bg-slate-950/40 backdrop-blur-[2px] lg:items-stretch lg:justify-end">
<button type="button" className="absolute inset-0 cursor-pointer" onClick={onClose} aria-label="Close log details" />
<aside className="relative z-10 flex max-h-[88vh] w-full flex-col rounded-t-[2rem] bg-white shadow-2xl dark:bg-slate-950 lg:h-full lg:max-h-none lg:w-[34rem] lg:rounded-none lg:border-l lg:border-slate-800">
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{t.logs?.detailsTitle || "Activity details"}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.logs?.detailsHint || "Review the exact values that changed in this action."}
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="rounded-xl">
<X className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5">
{isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
{t.logs?.loadingDetails || "Loading details..."}
</div>
) : !log ? (
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
{t.logs?.selectLogHint || "Select a log entry to see its details."}
</div>
) : (
<div className="space-y-5">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-200 text-base font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{actorAvatar ? (
<img src={actorAvatar} alt={actorName} className="h-full w-full object-cover" />
) : (
<span>{actorName.trim().charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-slate-900 dark:text-white">{actorName}</p>
<div className="mt-2 grid gap-2 text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2">
<User2 className="h-4 w-4" />
<span>{log.actor?.mobile || "-"}</span>
</div>
<div className="flex items-center gap-2">
<Clock3 className="h-4 w-4" />
<span>{formatTimestamp(log.timestamp)}</span>
</div>
{log.remote_addr ? (
<div className="flex items-center gap-2">
<Globe2 className="h-4 w-4" />
<span>{log.remote_addr}</span>
</div>
) : null}
</div>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.logs?.target || "Target"}
</p>
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">{log.target.name}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.sections?.[log.section] || log.section}
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.logs?.event || "Event"}
</p>
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">
{t.logs?.events?.[log.event] || log.event}
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{log.audit_action}</p>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{t.logs?.changesTitle || "Changes"}
</h3>
</div>
{log.changes.length ? (
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{log.changes.map((change, index) => (
<div key={`${change.field}-${index}`} className="grid gap-3 px-5 py-4 md:grid-cols-[1.2fr_1fr_1fr]">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{change.label}</p>
{/* <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{change.summary}</p> */}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.logs?.previousValue || "Previous"}
</p>
<p className="mt-1 break-words text-sm text-slate-700 dark:text-slate-300">
{change.old_value || "-"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.logs?.currentValue || "Current"}
</p>
<p className="mt-1 break-words text-sm text-slate-700 dark:text-slate-300">
{change.new_value || "-"}
</p>
</div>
</div>
))}
</div>
) : (
<div className="px-5 py-6 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.noDetails || "No field-level details are available for this activity."}
</div>
)}
</div>
{log.serialized_snapshot ? (
<details className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<summary className="cursor-pointer text-sm font-semibold text-slate-900 dark:text-white">
{t.logs?.snapshot || "Serialized snapshot"}
</summary>
<pre className="mt-3 overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs text-slate-100" dir="ltr">
{JSON.stringify(log.serialized_snapshot, null, 2)}
</pre>
</details>
) : null}
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { Clock3, History, Sparkles } from "lucide-react";
import type { WorkspaceLogItem } from "../../api/logs";
import { API_BASE_URL } from "../../config/constants";
import { useTranslation } from "../../hooks/useTranslation";
import { Button } from "../ui/button";
const eventBadgeStyles: Record<string, string> = {
create: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
update: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
restore: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
archive: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
unarchive: "bg-lime-100 text-lime-700 dark:bg-lime-900/30 dark:text-lime-300",
activate: "bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300",
deactivate: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
};
const sectionBadgeStyles: Record<string, string> = {
workspace: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
workspace_members: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
clients: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
projects: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
tags: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
time_entries: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
rates: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
report_exports: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-300",
};
const resolveImageUrl = (value?: string | null) => {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
return `${API_BASE_URL.replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`;
};
export function LogsFeed({
items,
total,
hasMore,
isLoading,
isLoadingMore,
selectedId,
onOpen,
onLoadMore,
}: {
items: WorkspaceLogItem[];
total: number;
hasMore: boolean;
isLoading: boolean;
isLoadingMore: boolean;
selectedId: number | null;
onOpen: (id: number) => void;
onLoadMore: () => void;
}) {
const { t, lang } = useTranslation();
const formatTimestamp = (value: string) =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
const buildSummary = (item: WorkspaceLogItem) => {
const actor = item.actor?.full_name || t.logs?.unknownActor || "Unknown actor";
const eventLabel = t.logs?.events?.[item.event] || item.event;
const sectionLabel = t.logs?.sections?.[item.section] || item.section;
const target = item.target?.name || "-";
if (typeof t.logs?.summary === "function") {
return t.logs.summary(actor, eventLabel, sectionLabel, target);
}
return `${actor} ${eventLabel.toLowerCase()} ${target} in ${sectionLabel.toLowerCase()}`;
};
const getInitials = (item: WorkspaceLogItem) => {
const label = item.actor?.full_name || t.logs?.unknownActor || "?";
return label.trim().charAt(0).toUpperCase();
};
if (isLoading) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
{t.logs?.loading || "Loading logs..."}
</div>
);
}
if (!items.length) {
return (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center shadow-sm dark:border-slate-700 dark:bg-slate-900">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300">
<History className="h-5 w-5" />
</div>
<p className="text-base font-semibold text-slate-900 dark:text-white">
{t.logs?.empty || "No activity logs found"}
</p>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.emptyHint || "Adjust your filters or wait for new workspace activity."}
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{t.logs?.resultsCount?.(total) || `${total} results`}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.logs?.detailsHint || "Select an activity item to inspect the exact field changes."}
</p>
</div>
<div className="hidden items-center gap-2 rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-600 dark:bg-slate-800 dark:text-slate-300 sm:flex">
<Sparkles className="h-4 w-4" />
<span>{items.length}/{total}</span>
</div>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{items.map((item) => {
const avatarUrl = resolveImageUrl(item.actor?.profile_picture);
return (
<button
key={item.id}
type="button"
onClick={() => onOpen(item.id)}
className={`flex w-full flex-col gap-3 px-5 py-4 text-left transition hover:bg-slate-50 dark:hover:bg-slate-800/70 ${
selectedId === item.id ? "bg-sky-50 dark:bg-sky-950/20" : ""
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-200 text-sm font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{avatarUrl ? (
<img src={avatarUrl} alt={item.actor?.full_name || "Actor"} className="h-full w-full object-cover" />
) : (
<span>{getInitials(item)}</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
{buildSummary(item)}
</p>
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-semibold ${sectionBadgeStyles[item.section] || sectionBadgeStyles.workspace}`}>
{t.logs?.sections?.[item.section] || item.section}
</span>
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-semibold ${eventBadgeStyles[item.event] || eventBadgeStyles.update}`}>
{t.logs?.events?.[item.event] || item.event}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
<span className="truncate">{item.target?.name}</span>
<span className="inline-flex items-center gap-1">
<Clock3 className="h-3.5 w-3.5" />
{formatTimestamp(item.timestamp)}
</span>
</div>
{item.preview_changes.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{item.preview_changes.map((change) => (
<span
key={`${item.id}-${change.field}`}
className="inline-flex max-w-full truncate rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300"
title={change.summary}
>
{change.summary}
</span>
))}
</div>
) : null}
</div>
</div>
</button>
);
})}
</div>
</div>
{hasMore ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={onLoadMore}
disabled={isLoadingMore}
className="rounded-2xl px-5"
>
{isLoadingMore ? t.loading || "Loading..." : t.logs?.loadMore || "Load more"}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,220 @@
import { useEffect, useState } from "react";
import { Filter, RotateCcw, Search } from "lucide-react";
import type { WorkspaceLogEvent, WorkspaceLogSection } from "../../api/logs";
import type { WorkspaceMembership } from "../../api/workspaces";
import JalaliDatePicker from "../ui/JalaliDatePicker";
import { SearchableSelect } from "../ui/SearchableSelect";
import { Select } from "../ui/Select";
import { Input } from "../ui/input";
import { useTranslation } from "../../hooks/useTranslation";
export interface LogsFilterDraft {
search: string;
section: "" | WorkspaceLogSection;
event: "" | WorkspaceLogEvent;
actor: string;
from: string;
to: string;
ordering: "-timestamp" | "timestamp";
}
export function LogsFilterBar({
value,
users,
isLoadingUsers,
canSelectUsers,
onApply,
}: {
value: LogsFilterDraft;
users: WorkspaceMembership[];
isLoadingUsers: boolean;
canSelectUsers: boolean;
onApply: (value: LogsFilterDraft) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<LogsFilterDraft>(value);
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
if (!canSelectUsers && draft.actor) {
setDraft((current) => ({ ...current, actor: "" }));
}
}, [canSelectUsers, draft.actor]);
return (
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="xl:col-span-2">
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.search || "Search"}
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={draft.search}
onChange={(event) => setDraft((current) => ({ ...current, search: event.target.value }))}
placeholder={t.logs?.searchPlaceholder || "Search logs..."}
className="h-10 rounded-2xl border-slate-200 bg-white pl-10 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.section || "Section"}
</label>
<Select
value={draft.section}
onChange={(section) =>
setDraft((current) => ({ ...current, section: section as "" | WorkspaceLogSection }))
}
options={[
{ value: "", label: t.logs?.allSections || "All sections" },
{ value: "workspace", label: t.logs?.sections?.workspace || "Workspace" },
{ value: "workspace_members", label: t.logs?.sections?.workspace_members || "Workspace members" },
{ value: "clients", label: t.logs?.sections?.clients || "Clients" },
{ value: "projects", label: t.logs?.sections?.projects || "Projects" },
{ value: "tags", label: t.logs?.sections?.tags || "Tags" },
{ value: "time_entries", label: t.logs?.sections?.time_entries || "Time entries" },
{ value: "rates", label: t.logs?.sections?.rates || "Rates" },
{ value: "report_exports", label: t.logs?.sections?.report_exports || "Report exports" },
]}
className="w-full"
buttonClassName="h-10 w-full rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.event || "Event"}
</label>
<Select
value={draft.event}
onChange={(event) =>
setDraft((current) => ({ ...current, event: event as "" | WorkspaceLogEvent }))
}
options={[
{ value: "", label: t.logs?.allEvents || "All events" },
{ value: "create", label: t.logs?.events?.create || "Create" },
{ value: "update", label: t.logs?.events?.update || "Update" },
{ value: "delete", label: t.logs?.events?.delete || "Delete" },
{ value: "restore", label: t.logs?.events?.restore || "Restore" },
{ value: "archive", label: t.logs?.events?.archive || "Archive" },
{ value: "unarchive", label: t.logs?.events?.unarchive || "Unarchive" },
{ value: "activate", label: t.logs?.events?.activate || "Activate" },
{ value: "deactivate", label: t.logs?.events?.deactivate || "Deactivate" },
]}
className="w-full"
buttonClassName="h-10 w-full rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{canSelectUsers || isLoadingUsers ? (
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.actor || "Actor"}
</label>
{isLoadingUsers ? (
<div className="flex h-10 items-center rounded-2xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
{t.logs?.loadingUsers || "Loading users..."}
</div>
) : (
<SearchableSelect
value={draft.actor}
onChange={(actor) => setDraft((current) => ({ ...current, actor }))}
options={[
{ value: "", label: t.logs?.allActors || "All actors" },
...users.map((membership) => ({
value: membership.user.id,
label:
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
membership.user.email ||
membership.user.id,
searchText: membership.user.mobile || "",
})),
]}
placeholder={t.logs?.allActors || "All actors"}
searchPlaceholder={t.logs?.searchActors || "Search users..."}
className="w-full"
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
)}
</div>
) : null}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.fromDate || "From date"}
</label>
<JalaliDatePicker
value={draft.from}
onChange={(nextValue) => setDraft((current) => ({ ...current, from: nextValue }))}
placeholder="YYYY/MM/DD"
inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.toDate || "To date"}
</label>
<JalaliDatePicker
value={draft.to}
onChange={(nextValue) => setDraft((current) => ({ ...current, to: nextValue }))}
placeholder="YYYY/MM/DD"
inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{t.logs?.ordering || "Ordering"}
</label>
<Select
value={draft.ordering}
onChange={(ordering) => setDraft((current) => ({ ...current, ordering: ordering as "-timestamp" | "timestamp" }))}
options={[
{ value: "-timestamp", label: t.logs?.newestFirst || "Newest first" },
{ value: "timestamp", label: t.logs?.oldestFirst || "Oldest first" },
]}
className="w-full"
buttonClassName="h-10 w-full rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() =>
setDraft({
search: "",
section: "",
event: "",
actor: "",
from: "",
to: "",
ordering: "-timestamp",
})
}
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
<RotateCcw className="h-4 w-4" />
{t.logs?.clear || "Clear"}
</button>
<button
type="button"
onClick={() => onApply(draft)}
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl bg-sky-600 px-4 text-sm font-medium text-white transition hover:bg-sky-700"
>
<Filter className="h-4 w-4" />
{t.logs?.apply || "Apply"}
</button>
</div>
</div>
);
}

View File

@@ -1,105 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react"
import { useTranslation } from "../../hooks/useTranslation"
import { cn } from "../../lib/utils"
import { useNotifications } from "../../context/NotificationsContext"
import type { NotificationItem } from "../../api/notifications"
import { Button } from "../ui/button"
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Bell, CheckCheck, Loader2 } from "lucide-react";
const formatNotificationTimestamp = (value: string, locale: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date)
}
function NotificationRow({
notification,
locale,
onClick,
onDelete,
}: {
notification: NotificationItem
locale: string
onClick: (notification: NotificationItem) => void
onDelete: (notification: NotificationItem) => void
}) {
return (
<div
className={cn(
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
notification.is_seen
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
)}
>
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onClick(notification)}
className="flex min-w-0 flex-1 items-start gap-3 text-start"
>
<span
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{notification.title || notification.type}
</p>
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
{formatNotificationTimestamp(notification.created_at, locale)}
</span>
</div>
{notification.message ? (
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{notification.message}
</p>
) : null}
</div>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void onDelete(notification)
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
aria-label="Delete notification"
title="Delete notification"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)
}
import { NotificationList } from "./NotificationList";
import { useTranslation } from "../../hooks/useTranslation";
import { useNotifications } from "../../context/NotificationsContext";
import { Button } from "../ui/button";
export function NotificationBell() {
const { t, lang } = useTranslation()
const { t } = useTranslation();
const navigate = useNavigate();
const {
notifications,
unreadCount,
totalCount,
hasMore,
isLoading,
isLoadingMore,
loadMore,
markAllAsSeen,
deleteOne,
handleNotificationClick,
} = useNotifications()
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
} = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const unreadNotifications = useMemo(
() => notifications.filter((notification) => !notification.is_seen),
[notifications],
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -107,12 +32,12 @@ export function NotificationBell() {
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className="relative" ref={containerRef}>
@@ -136,8 +61,8 @@ export function NotificationBell() {
{t.notifications?.title || "Notifications"}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{t.notifications?.summary?.(totalCount, unreadCount) ||
`${totalCount} total, ${unreadCount} unread`}
{t.notifications?.summary?.(unreadNotifications.length, unreadCount) ||
`${unreadNotifications.length} total, ${unreadCount} unread`}
</p>
</div>
<Button
@@ -159,45 +84,31 @@ export function NotificationBell() {
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loading || "Loading notifications..."}
</div>
) : notifications.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400">
{t.notifications?.empty || "No notifications yet."}
</div>
) : (
notifications.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
locale={lang}
onClick={(item) => void handleNotificationClick(item)}
onDelete={(item) => void deleteOne(item)}
/>
))
<NotificationList
notifications={unreadNotifications}
emptyLabel={t.notifications?.emptyUnread || "No unread notifications."}
onClick={(item) => void handleNotificationClick(item)}
onDelete={(item) => void deleteOne(item)}
/>
)}
</div>
{hasMore ? (
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loadingMore || "Loading more..."}
</>
) : (
t.notifications?.loadMore || "Load more"
)}
</Button>
</div>
) : null}
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setIsOpen(false);
navigate("/notifications");
}}
>
{t.notifications?.viewAll || "View all notifications"}
</Button>
</div>
</div>
) : null}
</div>
)
);
}

View File

@@ -0,0 +1,105 @@
import { Trash2 } from "lucide-react";
import type { NotificationItem } from "../../api/notifications";
import { useTranslation } from "../../hooks/useTranslation";
import { presentNotification } from "../../lib/notificationPresenter";
import { cn } from "../../lib/utils";
const formatNotificationTimestamp = (value: string, locale: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
export function NotificationList({
notifications,
emptyLabel,
onClick,
onDelete,
className = "",
}: {
notifications: NotificationItem[];
emptyLabel: string;
onClick: (notification: NotificationItem) => void;
onDelete: (notification: NotificationItem) => void;
className?: string;
}) {
const { t, lang } = useTranslation();
if (notifications.length === 0) {
return (
<div className={cn("px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400", className)}>
{emptyLabel}
</div>
);
}
return (
<div className={className}>
{notifications.map((notification) => {
const presented = presentNotification(notification, t);
return (
<div
key={notification.id}
className={cn(
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
notification.is_seen
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
)}
>
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onClick(notification)}
className="flex min-w-0 flex-1 items-start gap-3 text-start"
>
<span
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{presented.title}
</p>
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
{formatNotificationTimestamp(notification.created_at, lang)}
</span>
</div>
{presented.message ? (
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{presented.message}
</p>
) : null}
</div>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void onDelete(notification);
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
aria-label={t.notifications?.deleteLabel || "Delete notification"}
title={t.notifications?.deleteLabel || "Delete notification"}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,821 @@
import { useEffect, useMemo, useState } from "react";
import {
Briefcase,
CheckCheck,
CheckCircle2,
CheckSquare,
FolderTree,
Loader2,
Search,
ShieldAlert,
ShieldCheck,
Square,
UserRound,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
getProjectAccessState,
grantProjectAccess,
revokeProjectAccess,
saveProjectAccessRate,
type ProjectAccessItem,
type ProjectAccessRateValue,
} from "../../api/projects";
import { getPriceUnits, type PriceUnit } from "../../api/rates";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces";
import { useTranslation } from "../../hooks/useTranslation";
import { formatRateDisplay } from "../../lib/money";
import { Modal } from "../Modal";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Select } from "../ui/Select";
type Labels = {
title: string;
description: string;
close: string;
member: string;
projects: string;
loading: string;
noMembers: string;
noProjects: string;
searchPlaceholder: string;
allClients: string;
selectAllVisible: string;
clearSelection: string;
selectClientProjects: string;
grantSelected: string;
revokeSelected: string;
accessGranted: string;
accessRevoked: string;
memberRole: string;
client: string;
noClient: string;
accessOn: string;
accessOff: string;
loadError: string;
saveError: string;
workspaceRate: string;
projectOverride: string;
inheritsWorkspaceRate: string;
noRate: string;
hourlyRatePlaceholder: string;
currencyPlaceholder: string;
removeRate: string;
projectRateSaved: string;
projectRateRemoved: string;
projectRateSaveError: string;
projectRateRemoveError: string;
implicitAccessHint: string;
};
type RateDraft = {
hourlyRate: string;
currency: string;
};
function getMemberName(member: WorkspaceMembership) {
return (
member.user?.name ||
`${member.user?.first_name || ""} ${member.user?.last_name || ""}`.trim() ||
member.user?.mobile ||
member.id
);
}
function getPreferredCurrency(
item: Pick<ProjectAccessItem, "project_rate" | "workspace_rate">,
defaultCurrency: string,
) {
return item.project_rate?.currency || item.workspace_rate?.currency || defaultCurrency;
}
function getDraftFromItem(item: ProjectAccessItem, defaultCurrency: string): RateDraft {
return {
hourlyRate: item.project_rate?.hourly_rate || "",
currency: getPreferredCurrency(item, defaultCurrency),
};
}
function formatRate(rate: ProjectAccessRateValue | null, labels: Labels, lang: "en" | "fa") {
if (!rate) return labels.noRate;
return formatRateDisplay(rate, lang);
}
export function ProjectAccessModal({
isOpen,
onClose,
workspaceId,
labels,
onApplied,
}: {
isOpen: boolean;
onClose: () => void;
workspaceId: string;
labels: Labels;
onApplied: () => void;
}) {
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [projectItems, setProjectItems] = useState<ProjectAccessItem[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [memberSearchQuery, setMemberSearchQuery] = useState("");
const [selectedClientId, setSelectedClientId] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
const [selectedUserRole, setSelectedUserRole] = useState<"owner" | "admin" | "member" | "guest" | "">("");
const { lang } = useTranslation();
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const defaultCurrency = priceUnits[0]?.code || "USD";
const activeMembers = useMemo(
() => members.filter((member) => member.is_active),
[members],
);
const filteredMembers = useMemo(() => {
const normalizedSearch = memberSearchQuery.trim().toLowerCase();
const baseMembers = !normalizedSearch
? activeMembers
: activeMembers.filter((member) => {
const memberName = getMemberName(member).toLowerCase();
const memberMobile = member.user?.mobile?.toLowerCase() ?? "";
return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch);
});
return [...baseMembers].sort((a, b) => {
if (a.user.id === selectedUserId) return -1;
if (b.user.id === selectedUserId) return 1;
return 0;
});
}, [activeMembers, memberSearchQuery, selectedUserId]);
const canManageExplicitAccess = selectedUserRole === "member" || selectedUserRole === "guest";
const clientOptions = useMemo(() => {
const map = new Map<string, string>();
projectItems.forEach((item) => {
if (item.client) {
map.set(item.client.id, item.client.name);
}
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [projectItems]);
const visibleProjects = useMemo(() => {
const normalizedSearch = searchQuery.trim().toLowerCase();
return projectItems.filter((item) => {
const matchesClient = !selectedClientId || item.client?.id === selectedClientId;
const matchesSearch =
!normalizedSearch ||
item.name.toLowerCase().includes(normalizedSearch) ||
item.client?.name.toLowerCase().includes(normalizedSearch);
return matchesClient && matchesSearch;
});
}, [projectItems, searchQuery, selectedClientId]);
const visibleProjectIds = useMemo(() => visibleProjects.map((item) => item.id), [visibleProjects]);
const selectedVisibleCount = useMemo(
() => selectedProjectIds.filter((id) => visibleProjectIds.includes(id)).length,
[selectedProjectIds, visibleProjectIds],
);
const currencyOptions = useMemo(() => {
if (priceUnits.length) {
return priceUnits.map((unit) => ({
value: unit.code,
label: unit.local_name ? `${unit.local_name} (${unit.code})` : `${unit.code} (${unit.name})`,
}));
}
const fallbackCurrencies = Array.from(
new Set(
projectItems.flatMap((item) => [
item.project_rate?.currency,
item.workspace_rate?.currency,
defaultCurrency,
]).filter(Boolean) as string[],
),
);
return fallbackCurrencies.map((code) => ({ value: code, label: code }));
}, [defaultCurrency, priceUnits, projectItems]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setMemberSearchQuery("");
setSelectedClientId("");
setSelectedProjectIds([]);
setRateDrafts({});
return;
}
const loadDependencies = async () => {
setLoadingMembers(true);
const [membersResult, priceUnitsResult] = await Promise.allSettled([
fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }),
getPriceUnits(),
]);
if (membersResult.status === "fulfilled") {
setMembers(membersResult.value.results || []);
} else {
toast.error(labels.loadError);
setMembers([]);
}
if (priceUnitsResult.status === "fulfilled") {
setPriceUnits(priceUnitsResult.value.results || []);
} else {
setPriceUnits([]);
}
setLoadingMembers(false);
};
void loadDependencies();
}, [isOpen, labels.loadError, workspaceId]);
useEffect(() => {
if (!activeMembers.length) {
setSelectedUserId("");
return;
}
if (!activeMembers.some((member) => member.user.id === selectedUserId)) {
setSelectedUserId(activeMembers[0].user.id);
}
}, [activeMembers, selectedUserId]);
useEffect(() => {
if (!isOpen || !selectedUserId) {
setProjectItems([]);
return;
}
const loadAccessState = async () => {
setLoadingProjects(true);
try {
const response = await getProjectAccessState(workspaceId, selectedUserId);
setSelectedUserRole(response.user.role);
setProjectItems(response.items);
setSelectedProjectIds([]);
} catch {
toast.error(labels.loadError);
setSelectedUserRole("");
setProjectItems([]);
} finally {
setLoadingProjects(false);
}
};
void loadAccessState();
}, [isOpen, labels.loadError, selectedUserId, workspaceId]);
useEffect(() => {
if (!projectItems.length) {
setRateDrafts({});
return;
}
const nextDrafts: Record<string, RateDraft> = {};
projectItems.forEach((item) => {
nextDrafts[item.id] = getDraftFromItem(item, defaultCurrency);
});
setRateDrafts(nextDrafts);
}, [defaultCurrency, projectItems]);
const replaceProjectItem = (nextItem: ProjectAccessItem) => {
setProjectItems((current) =>
current.map((item) => (item.id === nextItem.id ? nextItem : item)),
);
};
const syncRateDraftFromItem = (item: ProjectAccessItem) => {
setRateDrafts((current) => ({
...current,
[item.id]: getDraftFromItem(item, defaultCurrency),
}));
};
const toggleProjectSelection = (projectId: string) => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) =>
current.includes(projectId)
? current.filter((id) => id !== projectId)
: [...current, projectId],
);
};
const handleSelectAllVisible = () => {
if (!canManageExplicitAccess) return;
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
};
const handleSelectClientProjects = () => {
if (!selectedClientId || !canManageExplicitAccess) return;
const clientProjectIds = visibleProjects
.filter((item) => item.client?.id === selectedClientId)
.map((item) => item.id);
setSelectedProjectIds((current) => Array.from(new Set([...current, ...clientProjectIds])));
};
const refreshState = async () => {
if (!selectedUserId) return;
const response = await getProjectAccessState(workspaceId, selectedUserId);
setProjectItems(response.items);
setSelectedProjectIds([]);
onApplied();
};
const handleMutation = async (mode: "grant" | "revoke") => {
if (!selectedUserId || !selectedProjectIds.length) return;
setIsSaving(true);
try {
if (mode === "grant") {
await grantProjectAccess(workspaceId, selectedUserId, selectedProjectIds);
toast.success(labels.accessGranted);
} else {
await revokeProjectAccess(workspaceId, selectedUserId, selectedProjectIds);
toast.success(labels.accessRevoked);
}
await refreshState();
} catch {
toast.error(labels.saveError);
} finally {
setIsSaving(false);
}
};
const handleRateDraftChange = (projectId: string, patch: Partial<RateDraft>) => {
setRateDrafts((current) => ({
...current,
[projectId]: {
hourlyRate: current[projectId]?.hourlyRate || "",
currency: current[projectId]?.currency || defaultCurrency,
...patch,
},
}));
};
const persistProjectRate = async (
item: ProjectAccessItem,
nextDraft?: Partial<RateDraft>,
) => {
if (!selectedUserId || !item.has_access || savingRateProjectId) return;
const draft = {
...(rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency)),
...nextDraft,
};
const trimmedRate = draft.hourlyRate.trim();
const normalizedCurrency = (draft.currency || getPreferredCurrency(item, defaultCurrency)).toUpperCase();
const currentRate = item.project_rate;
if (!trimmedRate) {
if (!currentRate) {
syncRateDraftFromItem(item);
return;
}
setSavingRateProjectId(item.id);
try {
const response = await saveProjectAccessRate(
workspaceId,
selectedUserId,
item.id,
null,
normalizedCurrency,
);
replaceProjectItem(response.item);
syncRateDraftFromItem(response.item);
toast.success(labels.projectRateRemoved);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.projectRateRemoveError);
syncRateDraftFromItem(item);
} finally {
setSavingRateProjectId(null);
}
return;
}
if (
currentRate?.hourly_rate === trimmedRate &&
currentRate?.currency === normalizedCurrency
) {
return;
}
setSavingRateProjectId(item.id);
try {
const response = await saveProjectAccessRate(
workspaceId,
selectedUserId,
item.id,
trimmedRate,
normalizedCurrency,
);
replaceProjectItem(response.item);
syncRateDraftFromItem(response.item);
toast.success(labels.projectRateSaved);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.projectRateSaveError);
syncRateDraftFromItem(item);
} finally {
setSavingRateProjectId(null);
}
};
const footer = (
<>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<div className="inline-flex h-8 min-w-8 items-center justify-center rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{selectedProjectIds.length}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => void handleMutation("grant")}
disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
{labels.grantSelected}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleMutation("revoke")}
disabled={!canManageExplicitAccess || !selectedProjectIds.length || isSaving}
className="gap-2"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />}
{labels.revokeSelected}
</Button>
</>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={labels.title}
maxWidth="max-w-7xl"
footer={footer}
>
<div className="space-y-4">
<p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400">
{labels.description}
</p>
{!canManageExplicitAccess && selectedUserRole ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100">
{labels.implicitAccessHint}
</div>
) : null}
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<section
dir={isRtl ? "rtl" : "ltr"}
className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-slate-50/40 dark:border-slate-800 dark:bg-slate-950/30 lg:col-span-8"
>
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Briefcase className="h-4 w-4" />
{labels.projects}
</div>
<div className="flex flex-col gap-3 xl:items-center">
<div className="relative min-w-0 flex-1 w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={labels.searchPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10"
/>
</div>
<Select
value={selectedClientId}
onChange={setSelectedClientId}
options={[
{ value: "", label: labels.allClients },
...clientOptions.map((client) => ({
value: client.id,
label: client.name,
})),
]}
className="w-full"
buttonClassName="h-11 w-full rounded-xl"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleSelectAllVisible}
disabled={!canManageExplicitAccess || !visibleProjects.length}
title={labels.selectAllVisible}
aria-label={labels.selectAllVisible}
>
<CheckCheck className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
onClick={() => setSelectedProjectIds([])}
disabled={!selectedProjectIds.length}
title={labels.clearSelection}
aria-label={labels.clearSelection}
>
<X className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleSelectClientProjects}
disabled={!canManageExplicitAccess || !selectedClientId}
title={labels.selectClientProjects}
aria-label={labels.selectClientProjects}
>
<FolderTree className="h-4 w-4" />
</Button>
<div className="ms-auto text-xs font-medium text-slate-500 dark:text-slate-400">
{selectedVisibleCount}/{visibleProjects.length}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{loadingProjects ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.loading}
</div>
) : visibleProjects.length === 0 ? (
<div className="p-5 text-sm text-slate-500 dark:text-slate-400">
{labels.noProjects}
</div>
) : (
<div className="grid gap-3">
{visibleProjects.map((item) => {
const isChecked = selectedProjectIds.includes(item.id);
const isRateSaving = savingRateProjectId === item.id;
const draft = rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency);
return (
<div
key={item.id}
className={`rounded-2xl border px-4 py-3 transition ${
isChecked
? "border-sky-200 bg-sky-50/60 shadow-sm dark:border-sky-500/30 dark:bg-sky-500/10"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
}`}
>
<button
type="button"
onClick={() => toggleProjectSelection(item.id)}
className={`flex w-full items-start gap-3 text-start ${!canManageExplicitAccess ? "cursor-default" : ""}`}
>
<div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
isChecked
? "border-sky-500 bg-sky-500 text-white"
: "border-slate-300 bg-white text-slate-400 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-500"
}`}
aria-hidden="true"
>
{isChecked ? (
<CheckSquare className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-slate-900 dark:text-slate-100">
{item.name}
</span>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${
item.has_access
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-1 dark:ring-emerald-400/25"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:ring-1 dark:ring-slate-700"
}`}
>
{item.has_access ? labels.accessOn : labels.accessOff}
</span>
</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{item.client?.name || labels.noClient}
</div>
{item.description ? (
<div className="mt-1 line-clamp-2 text-sm text-slate-500 dark:text-slate-400">
{item.description}
</div>
) : null}
</div>
</button>
<div className="mt-3 border-t border-slate-200 pt-3 dark:border-slate-800">
<div className="grid gap-2 md:grid-cols-2">
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.workspaceRate}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{formatRate(item.workspace_rate, labels, lang)}
</div>
</div>
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.projectOverride}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{item.project_rate
? formatRate(item.project_rate, labels, lang)
: labels.inheritsWorkspaceRate}
</div>
</div>
</div>
<div className="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="grid flex-1 gap-2 sm:grid-cols-[minmax(0,1fr)_200px]">
<Input
value={draft.hourlyRate}
onChange={(event) =>
handleRateDraftChange(item.id, { hourlyRate: event.target.value })
}
onBlur={() => void persistProjectRate(item)}
inputMode="decimal"
placeholder={labels.hourlyRatePlaceholder}
disabled={!item.has_access || isRateSaving}
className="h-10"
/>
<Select
value={draft.currency}
onChange={(value) => {
handleRateDraftChange(item.id, { currency: value });
if (draft.hourlyRate.trim()) {
void persistProjectRate(item, { currency: value });
}
}}
options={currencyOptions}
disabled={!item.has_access || isRateSaving}
className="w-full"
buttonClassName="h-10 w-full rounded-xl"
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="icon"
disabled={!item.has_access || !item.project_rate || isRateSaving}
title={labels.removeRate}
aria-label={labels.removeRate}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
handleRateDraftChange(item.id, { hourlyRate: "" });
void persistProjectRate(item, { hourlyRate: "" });
}}
>
{isRateSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <X className="h-4 w-4" />}
</Button>
</div>
</div>
{!item.has_access && item.project_rate ? (
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{labels.projectOverride}: {formatRate(item.project_rate, labels, lang)}
</div>
) : null}
</div>
</div>
);
})}
</div>
)}
</div>
</section>
<aside
dir={isRtl ? "rtl" : "ltr"}
className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:col-span-4"
>
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Users className="h-4 w-4" />
{labels.member}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={memberSearchQuery}
onChange={(event) => setMemberSearchQuery(event.target.value)}
placeholder={labels.member}
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2">
{loadingMembers ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.loading}
</div>
) : filteredMembers.length === 0 ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">
{labels.noMembers}
</div>
) : (
filteredMembers.map((member) => {
const isActive = member.user.id === selectedUserId;
const isImplicitUser = member.role === "owner" || member.role === "admin";
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedUserId(member.user.id)}
className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${
isActive
? "bg-sky-50/80 dark:bg-sky-500/10"
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/40"
}`}
>
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
isActive
? "bg-sky-500 text-white"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300"
}`}
>
<UserRound className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium text-slate-900 dark:text-slate-100">
{getMemberName(member)}
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{member.role}
</span>
{isImplicitUser ? (
<span className="shrink-0 rounded-full bg-sky-100 px-2 py-1 text-[11px] font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-200">
{labels.accessOn}
</span>
) : null}
</div>
{isActive ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" />
) : null}
</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{member.user.mobile}
</div>
</div>
</button>
);
})
)}
</div>
</div>
</aside>
</div>
</div>
</Modal>
);
}

View File

@@ -21,13 +21,41 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [loadingClients, setLoadingClients] = useState(false);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [loadingClients, setLoadingClients] = useState(false);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
useEffect(() => {
if (isOpen && activeWorkspace) {
@@ -44,31 +72,35 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
if (!activeWorkspace || !formData.name) return;
setLoading(true);
try {
const newProject = await createProject({
workspace: activeWorkspace.id,
name: formData.name,
description: formData.description,
color: formData.color,
client: formData.client || null,
});
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
onClose();
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
try {
const newProject = await createProject({
workspace: activeWorkspace.id,
name: formData.name,
description: formData.description,
color: formData.color,
client: formData.client || null,
thumbnail: thumbnailFile,
});
toast.success(t.projects?.createSuccess || "Project created successfully.");
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
onClose();
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
setThumbnailFile(null);
} catch (error) {
console.error(error);
toast.error(t.projects?.createError || "Failed to create project.");
} finally {
setLoading(false);
}
};
const footer = (
<>
<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>
</>
@@ -78,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">
@@ -112,7 +144,24 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
</div>
</div>
<div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : formData.name.trim().charAt(0).toUpperCase() || "P"}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{thumbnailFile ? (
<button type="button" onClick={() => setThumbnailFile(null)} className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600">
{t.remove || "Remove"}
</button>
) : null}
</div>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || 'Description'}
</label>

View File

@@ -24,13 +24,17 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE);
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [loadingClients, setLoadingClients] = useState(false);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [loadingClients, setLoadingClients] = useState(false);
useEffect(() => {
if (isOpen && activeWorkspace) {
@@ -47,47 +51,84 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
setFormData({
name: project.name || "",
description: project.description || "",
color: project.color || "#3B82F6",
client: project.client ? project.client.id : "",
});
}
}, [project]);
color: project.color || "#3B82F6",
client: project.client ? project.client.id : "",
});
setThumbnailUrl(project.thumbnail || null);
setThumbnailFile(null);
setClearThumbnail(false);
}
}, [project]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!project || !formData.name) return;
setLoading(true);
try {
const updated = await updateProject(project.id, {
name: formData.name,
description: formData.description,
color: formData.color,
client: formData.client || null,
});
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
onClose();
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
try {
const updated = await updateProject(project.id, {
name: formData.name,
description: formData.description,
color: formData.color,
client: formData.client || null,
thumbnail: thumbnailFile,
clear_thumbnail: clearThumbnail,
});
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
onClose();
} catch (error) {
console.error(error);
toast.error(t.projects?.updateError || "Failed to update project.");
} finally {
setLoading(false);
}
};
const handleArchiveToggle = async () => {
if (!project) return;
setLoading(true);
try {
const updated = await toggleArchiveProject(project.id);
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
onClose();
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
try {
const updated = await toggleArchiveProject(project.id);
toast.success(
project?.is_archived
? t.projects?.restoreSuccess || t.projects?.updateSuccess || "Project updated successfully."
: t.projects?.archiveSuccess || t.projects?.updateSuccess || "Project updated successfully.",
);
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
onClose();
} catch (error) {
console.error(error);
toast.error(t.projects?.updateError || "Failed to update project.");
} finally {
setLoading(false);
}
};
const footer = (
<div className="flex justify-between w-full">
@@ -113,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>
@@ -124,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">
@@ -156,7 +197,37 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
</div>
</div>
<div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
{thumbnailPreview ? (
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
) : !clearThumbnail && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
formData.name.trim().charAt(0).toUpperCase() || "P"
)}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
<button
type="button"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600"
>
{t.remove || "Remove"}
</button>
) : null}
</div>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || 'Description'}
</label>

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
import { Banknote, BriefcaseBusiness, FolderKanban, X } from "lucide-react";
import type { MyWorkspaceRatesResponse } from "../../api/rates";
import { useTranslation } from "../../hooks/useTranslation";
import { formatRateDisplay } from "../../lib/money";
import { Button } from "../ui/button";
export function WorkspaceRatesPanel({
open,
data,
isLoading,
onClose,
}: {
open: boolean;
data: MyWorkspaceRatesResponse | null;
isLoading: boolean;
onClose: () => void;
}) {
const { t, lang } = useTranslation();
const [shouldRender, setShouldRender] = useState(open);
const [isVisible, setIsVisible] = useState(open);
const isRtl = lang === "fa";
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let frameId: number | null = null;
if (open) {
setShouldRender(true);
frameId = window.requestAnimationFrame(() => setIsVisible(true));
} else {
setIsVisible(false);
timeoutId = setTimeout(() => setShouldRender(false), 300);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
if (frameId) window.cancelAnimationFrame(frameId);
};
}, [open]);
if (!shouldRender) {
return null;
}
return (
<div
className={`fixed inset-0 z-[80] flex items-end lg:items-stretch ${
isRtl ? "lg:justify-start" : "lg:justify-end"
}`}
>
<button
type="button"
className={`absolute inset-0 cursor-pointer bg-slate-950/40 backdrop-blur-[2px] transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
onClick={onClose}
aria-label="Close rates panel"
/>
<aside
className={`relative z-10 flex max-h-[88vh] w-full flex-col rounded-t-[2rem] bg-white shadow-2xl transition-transform duration-300 ease-out dark:bg-slate-950 lg:h-full lg:max-h-none lg:w-[34rem] lg:rounded-none ${
isRtl ? "lg:border-r lg:border-slate-800" : "lg:border-l lg:border-slate-800"
} ${
isVisible
? "translate-y-0 lg:translate-x-0"
: `translate-y-full lg:translate-y-0 ${!isRtl ? "lg:-translate-x-full" : "lg:translate-x-full"}`
}`}
>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{t.rates?.myRatesTitle || "My rates"}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.rates?.myRatesHint || "Project-specific rates override your workspace rate in this workspace."}
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="rounded-xl">
<X className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5">
{isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
{t.loading || "Loading..."}
</div>
) : !data ? (
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
{t.rates?.myRatesEmpty || "No rates are available for this workspace yet."}
</div>
) : (
<div className="space-y-5">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<Banknote className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-slate-900 dark:text-white">
{t.rates?.workspaceRate || "Workspace rate"}
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRateHint || "This is your default rate unless a project-specific rate overrides it."}
</p>
<div className="mt-3 text-lg font-bold text-slate-900 dark:text-white">
{data.workspace_rate
? formatRateDisplay(
{
hourly_rate: data.workspace_rate.hourly_rate,
currency: data.workspace_rate.currency,
price_unit: data.workspace_rate.price_unit,
},
lang,
)
: (t.rates?.noRate || "No rate")}
</div>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.rates?.accessibleProjects || "Accessible projects"}
</div>
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{data.accessible_project_count}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.rates?.projectOverrides || "Project overrides"}
</div>
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{data.project_override_count}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{t.rates?.workspaceFallbackProjects || "Using workspace rate"}
</div>
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
{data.workspace_fallback_project_count}
</div>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{t.rates?.projectSectionTitle || "Project user rates"}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.rates?.projectOverrideHint || "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate."}
</p>
</div>
{data.project_rates.length ? (
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{data.project_rates.map((projectRate) => (
<div key={projectRate.project.id} className="flex items-start gap-4 px-5 py-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300">
<FolderKanban className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-slate-900 dark:text-white">
{projectRate.project.name}
</p>
{projectRate.project.client ? (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<BriefcaseBusiness className="h-3 w-3" />
{projectRate.project.client.name}
</span>
) : null}
</div>
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">
{formatRateDisplay(
{
hourly_rate: projectRate.rate.hourly_rate,
currency: projectRate.rate.currency,
price_unit: projectRate.rate.price_unit,
},
lang,
)}
</p>
</div>
</div>
))}
</div>
) : (
<div className="px-5 py-6 text-sm text-slate-500 dark:text-slate-400">
{t.rates?.projectOverrideEmpty || "You do not have any project-specific rate overrides in this workspace."}
</div>
)}
</div>
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -2,184 +2,169 @@ import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
} from "recharts"
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports";
import { useTranslation } from "../../hooks/useTranslation";
import type { ChartReportResponse, ChartReportSeries, CurrencyTotal, ReportChartBucket } from "../../api/reports"
import { useTranslation } from "../../hooks/useTranslation"
const FA_MONTHS = [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
];
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
const SERIES_PALETTE = ["#0ea5e9", "#f97316", "#10b981", "#8b5cf6", "#ef4444", "#eab308", "#14b8a6", "#3b82f6"]
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
type ChartRow = {
bucket_key: string
bucket_label: string
tooltip_label: string
[key: string]: string | number
}
const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit);
const localizeDigits = (value: string, lang: "en" | "fa") =>
lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
const shouldTrimCurrencyDecimals = (currency?: string | null) => {
const normalized = (currency || "").toUpperCase()
return normalized === "IRR" || normalized === "IRT"
}
const formatAmount = (value: string, lang: "en" | "fa", currency?: string | null) => {
const numeric = Number(value.replace(/,/g, ""))
if (Number.isNaN(numeric)) {
return localizeDigits(value, lang)
}
const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".")
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US")
const signed = value.startsWith("-") ? `-${grouped}` : grouped
const normalizedFraction =
fractionalPart && !shouldTrimCurrencyDecimals(currency) ? fractionalPart.replace(/0+$/, "") : ""
const formatted = normalizedFraction ? `${signed}.${normalizedFraction}` : signed
return localizeDigits(formatted, lang)
}
const currencyLabel = (currency: string, lang: "en" | "fa") => {
if (lang !== "fa") {
return currency.toUpperCase()
}
return (
{
USD: "دلار",
EUR: "یورو",
GBP: "پوند",
IRR: "ریال",
IRT: "تومان",
AED: "درهم",
TRY: "لیر",
}[currency.toUpperCase()] || currency.toUpperCase()
)
}
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
if (!totals.length) return "-";
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
};
if (!totals.length) {
return localizeDigits("0", lang)
}
return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
}
const formatSecondsTick = (value: number, lang: "en" | "fa") => {
const hours = value / 3600;
const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1);
return localizeDigits(rounded, lang);
};
const hours = value / 3600
const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1)
return localizeDigits(rounded, lang)
}
const parseIsoDate = (value: string) => {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, (month || 1) - 1, day || 1);
};
const [year, month, day] = value.split("-").map(Number)
return new Date(year, (month || 1) - 1, day || 1)
}
const formatIsoDate = (value: Date) => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, "0")
const day = String(value.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`;
const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`
const getPersianDateParts = (value: Date) => {
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(value);
const getCalendarLocale = (lang: "en" | "fa") => (lang === "fa" ? "fa-IR-u-ca-persian" : "en-US")
return {
year: Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || "")),
month: Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || "")),
day: Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || "")),
};
};
const getDailyAxisLabel = (date: Date, lang: "en" | "fa") => {
if (lang === "fa") {
return toPersianDigits(String(getPersianDateParts(date).day));
const getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => {
if (period === "this_week") {
return new Intl.DateTimeFormat(getCalendarLocale(lang), { weekday: "short" }).format(date)
}
return String(date.getDate());
};
return new Intl.DateTimeFormat(getCalendarLocale(lang), { day: "numeric" }).format(date)
}
const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
new Intl.DateTimeFormat(getCalendarLocale(lang), {
weekday: "long",
month: "long",
day: "numeric",
}).format(date);
}).format(date)
const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") {
const [, month] = bucketKey.split("-").map(Number);
return FA_MONTHS[(month || 1) - 1] || bucketKey;
}
const [year, month] = bucketKey.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", { month: "short" }).format(new Date(year, (month || 1) - 1, 1));
};
const [year, month] = bucketKey.split("-").map(Number)
return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "short" }).format(
new Date(year, (month || 1) - 1, 1),
)
}
const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") {
const [year, month] = bucketKey.split("-").map(Number);
const monthName = FA_MONTHS[(month || 1) - 1] || bucketKey;
return `${monthName} ${toPersianDigits(String(year))}`;
}
const [year, month] = bucketKey.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(
const [year, month] = bucketKey.split("-").map(Number)
return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "long", year: "numeric" }).format(
new Date(year, (month || 1) - 1, 1),
);
};
)
}
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]));
const result: ReportChartBucket[] = [];
const cursor = parseIsoDate(fromDate);
const limit = parseIsoDate(toDate);
const buildDailyBuckets = (
fromDate: string,
toDate: string,
existing: ReportChartBucket[],
lang: "en" | "fa",
period: string,
) => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]))
const result: ReportChartBucket[] = []
const cursor = parseIsoDate(fromDate)
const limit = parseIsoDate(toDate)
while (cursor.getTime() <= limit.getTime()) {
const key = formatIsoDate(cursor);
const existingBucket = map.get(key);
const key = formatIsoDate(cursor)
const existingBucket = map.get(key)
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: getDailyAxisLabel(cursor, lang),
bucket_label: getDailyAxisLabel(cursor, lang, period),
total_seconds: 0,
total_duration: "00:00:00",
},
);
cursor.setDate(cursor.getDate() + 1);
)
cursor.setDate(cursor.getDate() + 1)
}
return result.map((bucket) => ({
...bucket,
bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang),
}));
};
bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period),
}))
}
const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]));
if (lang === "fa") {
const result: ReportChartBucket[] = [];
const start = getPersianDateParts(parseIsoDate(fromDate));
const end = getPersianDateParts(parseIsoDate(toDate));
let year = start.year;
let month = start.month;
while (year < end.year || (year === end.year && month <= end.month)) {
const key = `${year}-${String(month).padStart(2, "0")}`;
const existingBucket = map.get(key);
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: getMonthlyAxisLabel(key, lang),
total_seconds: 0,
total_duration: "00:00:00",
},
);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return result.map((bucket) => ({
...bucket,
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
}));
}
const result: ReportChartBucket[] = [];
const start = parseIsoDate(fromDate);
const end = parseIsoDate(toDate);
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const limit = new Date(end.getFullYear(), end.getMonth(), 1);
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]))
const result: ReportChartBucket[] = []
const start = parseIsoDate(fromDate)
const end = parseIsoDate(toDate)
const cursor = new Date(start.getFullYear(), start.getMonth(), 1)
const limit = new Date(end.getFullYear(), end.getMonth(), 1)
while (cursor.getTime() <= limit.getTime()) {
const key = monthKeyFromDate(cursor);
const existingBucket = map.get(key);
const key = monthKeyFromDate(cursor)
const existingBucket = map.get(key)
result.push(
existingBucket ?? {
bucket_key: key,
@@ -187,77 +172,153 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
total_seconds: 0,
total_duration: "00:00:00",
},
);
cursor.setMonth(cursor.getMonth() + 1);
)
cursor.setMonth(cursor.getMonth() + 1)
}
return result.map((bucket) => ({
...bucket,
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
}));
};
}))
}
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => {
if (!payload) return "";
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second";
if (useMonth) {
return getMonthlyTooltipLabel(payload.bucket_key, lang);
}
return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang);
};
const buildSeriesBuckets = (
series: ChartReportSeries,
data: ChartReportResponse,
lang: "en" | "fa",
useMonthlyBuckets: boolean,
) =>
useMonthlyBuckets
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, series.buckets, lang)
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, series.buckets, lang, data.scope.period)
const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}`
const effectiveSeries = (data: ChartReportResponse): ChartReportSeries[] =>
data.series.length
? data.series
: [
{
user: null,
buckets: [],
},
]
const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
const useMonthlyBuckets =
data.scope.period === "this_year" ||
data.scope.period === "half_year_first" ||
data.scope.period === "half_year_second"
const seriesList = effectiveSeries(data)
const normalizedSeries = seriesList.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets))
const baseBuckets = normalizedSeries[0] ?? []
const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => {
const tooltipLabel = useMonthlyBuckets
? getMonthlyTooltipLabel(bucket.bucket_key, lang)
: getDailyTooltipLabel(parseIsoDate(bucket.bucket_key), lang)
const row: ChartRow = {
bucket_key: bucket.bucket_key,
bucket_label: bucket.bucket_label,
tooltip_label: tooltipLabel,
}
seriesList.forEach((series, seriesIndex) => {
const seriesKey = createSeriesKey(series, seriesIndex)
row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0
})
return row
})
return { rows, seriesList, useMonthlyBuckets }
}
function ChartTooltip({
active,
payload,
label,
lang,
totalSecondsLabel,
totalHoursLabel,
}: {
active?: boolean;
payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>;
label: string;
lang: "en" | "fa";
totalSecondsLabel: string;
active?: boolean
payload?: ReadonlyArray<{ color?: string; dataKey?: string | number | ((obj: unknown) => unknown); name?: string | number; value?: unknown }>
label: string
lang: "en" | "fa"
totalHoursLabel: string
}) {
if (!active || !payload?.length) return null;
const point = payload[0];
const seconds = Number(point.value || 0);
const hours = seconds / 3600;
if (!active || !payload?.length) {
return null
}
return (
<div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
<div className="mt-2 space-y-1">
{payload.map((item) => {
const seconds = Number(item.value || 0)
const hours = seconds / 3600
return (
<div key={String(item.dataKey)} className="flex items-center gap-2 text-sm text-slate-900 dark:text-white">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-medium">{item.name}</span>
<span className="text-slate-500 dark:text-slate-400">
{totalHoursLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
</span>
</div>
)
})}
</div>
</div>
);
)
}
export function ReportsChartPanel({
data,
labels,
isLoading,
}: {
data: ChartReportResponse | null;
labels: Record<string, string>;
data: ChartReportResponse | null
labels: Record<string, string>
isLoading: boolean
}) {
const { lang } = useTranslation();
const { lang } = useTranslation()
if (!data) return null;
if (isLoading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{labels.loading}
</div>
<div className="h-8 animate-pulse rounded-xl bg-slate-200/80 dark:bg-slate-800/80" />
</div>
))}
</div>
const useMonthlyBuckets =
data.scope.period === "this_year" ||
data.scope.period === "half_year_first" ||
data.scope.period === "half_year_second";
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{labels.loading}</div>
</div>
<div className="h-[320px] animate-pulse rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 sm:h-[360px]" />
</div>
</div>
)
}
const buckets = useMonthlyBuckets
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang)
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang);
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 92 : 44));
const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;
if (!data) {
return null
}
const { rows, seriesList, useMonthlyBuckets } = buildChartRows(data, lang)
const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0
const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52))
const isMultiSeries = seriesList.length > 1
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
@@ -290,15 +351,13 @@ export function ReportsChartPanel({
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{localizeDigits(`${buckets.length}`, lang)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{localizeDigits(`${rows.length}`, lang)}</div>
</div>
<div className="overflow-x-auto pb-2">
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}>
<BarChart data={rows} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}>
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
<XAxis
dataKey="bucket_label"
@@ -319,29 +378,42 @@ export function ReportsChartPanel({
/>
<Tooltip
cursor={{ fill: "rgba(14,165,233,0.08)" }}
content={({ active, payload }) => (
content={({ active, payload, label }) => (
<ChartTooltip
active={active}
payload={payload}
label={formatTooltipLabel(payload?.[0]?.payload as ReportChartBucket | undefined, lang, data.scope.period)}
label={typeof label === "string" ? label : rows[0]?.tooltip_label || ""}
lang={lang}
totalSecondsLabel={labels.totalHours}
totalHoursLabel={labels.totalHours}
/>
)}
labelFormatter={(_, payload) => String(payload?.[0]?.payload?.tooltip_label || "")}
/>
<Bar dataKey="total_seconds" radius={[12, 12, 4, 4]} maxBarSize={useMonthlyBuckets ? 40 : 22}>
{buckets.map((bucket) => (
<Cell
key={bucket.bucket_key}
fill={bucket.total_seconds > 0 ? "#0ea5e9" : "#cbd5e1"}
{isMultiSeries ? (
<Legend
verticalAlign="top"
align="left"
wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }}
/>
) : null}
{seriesList.map((series, index) => {
const dataKey = createSeriesKey(series, index)
return (
<Bar
key={dataKey}
dataKey={dataKey}
name={series.user?.name || labels.totalHours}
fill={SERIES_PALETTE[index % SERIES_PALETTE.length]}
radius={[12, 12, 4, 4]}
maxBarSize={useMonthlyBuckets ? 36 : 22}
/>
))}
</Bar>
)
})}
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
)
}

View File

@@ -116,6 +116,7 @@ export function ReportsFilterBar({
tags,
users,
canSelectUsers,
isLoadingUsers,
labels,
}: {
value: ReportsFilterDraft;
@@ -125,6 +126,7 @@ export function ReportsFilterBar({
tags: Tag[];
users: WorkspaceMembership[];
canSelectUsers: boolean;
isLoadingUsers: boolean;
labels: Record<string, string>;
}) {
const [draft, setDraft] = useState(value);
@@ -200,30 +202,36 @@ export function ReportsFilterBar({
</>
) : null}
{canSelectUsers ? (
{canSelectUsers || isLoadingUsers ? (
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{labels.user}
</label>
<SearchableSelect
value={draft.user}
onChange={(user) => setDraft((current) => ({ ...current, user }))}
options={[
{ value: "", label: labels.allUsers },
...users.map((membership) => ({
value: membership.user.id,
label:
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
membership.user.email ||
membership.user.id,
searchText: membership.user.mobile || "",
})),
]}
placeholder={labels.allUsers}
searchPlaceholder={labels.searchUsers}
className="w-full"
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
{isLoadingUsers ? (
<div className="flex h-10 w-full items-center rounded-2xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
{labels.searchUsers || "Loading users..."}
</div>
) : (
<SearchableSelect
value={draft.user}
onChange={(user) => setDraft((current) => ({ ...current, user }))}
options={[
{ value: "", label: labels.allUsers },
...users.map((membership) => ({
value: membership.user.id,
label:
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
membership.user.email ||
membership.user.id,
searchText: membership.user.mobile || "",
})),
]}
placeholder={labels.allUsers}
searchPlaceholder={labels.searchUsers}
className="w-full"
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
)}
</div>
) : null}

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,8 @@ export interface TimeEntryFilters {
interface TimesheetFilterBarProps {
searchQuery: string;
filters: TimeEntryFilters;
onApply: (searchQuery: string, filters: TimeEntryFilters) => void;
onSearchChange: (value: string) => void;
onApply: (filters: TimeEntryFilters) => void;
onClearFilters: () => void;
projects: Project[];
tags: Tag[];
@@ -41,6 +42,8 @@ interface TimesheetFilterBarProps {
tagPrefix?: string;
fromPrefix?: string;
toPrefix?: string;
searchTags?: string;
noTagsFound?: string;
};
}
@@ -49,11 +52,15 @@ function FilterTagMultiSelect({
selectedTagIds,
onChange,
title,
searchPlaceholder,
emptyLabel,
}: {
tags: Tag[];
selectedTagIds: string[];
onChange: (tagIds: string[]) => void;
title: string;
searchPlaceholder: string;
emptyLabel: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -145,7 +152,7 @@ function FilterTagMultiSelect({
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search tags..."
placeholder={searchPlaceholder}
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
/>
</div>
@@ -181,7 +188,7 @@ function FilterTagMultiSelect({
})}
{filteredTags.length === 0 && (
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
No tags found.
{emptyLabel}
</div>
)}
</div>
@@ -215,6 +222,7 @@ function MiniFilterBlock({
export default function TimesheetFilterBar({
searchQuery,
filters,
onSearchChange,
onApply,
onClearFilters,
projects,
@@ -223,13 +231,8 @@ export default function TimesheetFilterBar({
labels,
}: TimesheetFilterBarProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [draftSearchQuery, setDraftSearchQuery] = useState(searchQuery);
const [draftFilters, setDraftFilters] = useState<TimeEntryFilters>(filters);
useEffect(() => {
setDraftSearchQuery(searchQuery);
}, [searchQuery]);
useEffect(() => {
setDraftFilters(filters);
}, [filters]);
@@ -268,15 +271,15 @@ export default function TimesheetFilterBar({
);
return (
<div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-slate-700 dark:bg-slate-900/95">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<input
type="text"
value={draftSearchQuery}
onChange={(event) => setDraftSearchQuery(event.target.value)}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder}
className="h-9 w-full rounded-md border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800 rtl:pl-3 rtl:pr-9"
/>
@@ -304,7 +307,7 @@ export default function TimesheetFilterBar({
<button
type="button"
onClick={() => {
setDraftSearchQuery("");
onSearchChange("");
setDraftFilters({
projectId: "",
clientId: "",
@@ -329,8 +332,8 @@ export default function TimesheetFilterBar({
</div>
{isExpanded && (
<div className="border-t border-slate-200 pt-2 dark:border-slate-800">
<div className="grid gap-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
<div className="border-t border-slate-200 pt-2 dark:border-slate-700">
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)]">
<MiniFilterBlock icon={<CalendarRange className="h-3.5 w-3.5" />} label={labels?.customFrom || "From date"}>
<JalaliDatePicker
value={draftFilters.startedAfter}
@@ -389,6 +392,8 @@ export default function TimesheetFilterBar({
selectedTagIds={draftFilters.tagIds}
onChange={(tagIds) => setDraftFilters((current) => ({ ...current, tagIds }))}
title={labels?.allTags || "All tags"}
searchPlaceholder={labels?.searchTags || "Search tags..."}
emptyLabel={labels?.noTagsFound || "No tags found."}
/>
</MiniFilterBlock>
</div>
@@ -396,7 +401,7 @@ export default function TimesheetFilterBar({
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => onApply(draftSearchQuery, draftFilters)}
onClick={() => onApply(draftFilters)}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border bg-sky-50 border-sky-200 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300 px-3 text-sm font-medium transition hover:border-sky-700 hover:bg-sky-700 hover:text-sky-100 dark:hover:border-sky-400 dark:hover:text-sky-900 dark:hover:bg-sky-400"
>
{labels?.apply || "Apply"}

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"
@@ -16,8 +16,10 @@ 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 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(() => {
@@ -28,17 +30,28 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
return () => observer.disconnect()
}, [])
const handleChange = (date: DateObject | null) => {
const handleChange = (date: DateObject | null) => {
if (!date) {
onChange("")
} else {
// Always output standard Gregorian "YYYY-MM-DD" for backend
onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD"))
}
}
return (
<div className="w-full">
}
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 ref={containerRef} className="w-full">
{label && (
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
{label}
@@ -52,13 +65,14 @@ 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"
fixMainPosition
disabled={disabled}
/>
calendarPosition={calendarPosition}
fixMainPosition
disabled={disabled}
/>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Search } from "lucide-react";
import { Input } from "./input";
export interface MultiSearchableSelectOption {
value: string;
label: string;
searchText?: string;
}
interface MultiSearchableSelectProps {
values: string[];
onChange: (values: string[]) => void;
options: MultiSearchableSelectOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean;
className?: string;
buttonClassName?: string;
renderValue?: (selectedOptions: MultiSearchableSelectOption[]) => string;
}
export function MultiSearchableSelect({
values,
onChange,
options,
placeholder = "",
searchPlaceholder,
emptyLabel,
disabled = false,
className = "",
buttonClassName = "",
renderValue,
}: MultiSearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOptions = useMemo(
() => options.filter((option) => values.includes(option.value)),
[options, values],
);
const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter((option) =>
`${option.label} ${option.searchText || ""}`.toLowerCase().includes(needle),
);
}, [options, query]);
const displayValue = useMemo(() => {
if (!selectedOptions.length) return placeholder;
if (renderValue) return renderValue(selectedOptions);
return selectedOptions.map((option) => option.label).join(", ");
}, [placeholder, renderValue, selectedOptions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setQuery("");
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
useEffect(() => {
if (!isOpen || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = 320;
const shouldOpenUp = spaceBelow < dropdownHeight && rect.top > spaceBelow;
setDropdownStyle({
position: "fixed",
top: shouldOpenUp ? `${rect.top - 4}px` : `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
transform: shouldOpenUp ? "translateY(-100%)" : "none",
zIndex: 99999,
});
}, [isOpen]);
useEffect(() => {
const handleScrollOrResize = () => setIsOpen(false);
if (isOpen) {
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("scroll", handleScrollOrResize, true);
}
return () => {
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("scroll", handleScrollOrResize, true);
};
}, [isOpen]);
const toggleValue = (value: string) => {
if (values.includes(value)) {
onChange(values.filter((item) => item !== value));
return;
}
onChange([...values, value]);
};
return (
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen((current) => !current)}
className={`flex w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 outline-none transition focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 ${buttonClassName}`}
>
<span className="truncate text-start">{displayValue}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<div className="border-b border-slate-100 p-2 dark:border-slate-700">
<div className="relative">
<Search className="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder || "Search..."}
className="h-9 pl-9 rtl:pl-3 rtl:pr-9"
autoFocus
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filteredOptions.map((option) => {
const isSelected = values.includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleValue(option.value)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition hover:bg-slate-100 dark:hover:bg-slate-700 ${
isSelected
? "bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
: "text-slate-700 dark:text-slate-300"
}`}
>
<span className="truncate">{option.label}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
</button>
);
})}
{filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">
{emptyLabel || "No results"}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}

View File

@@ -16,6 +16,7 @@ interface SearchableSelectProps {
options: SearchableSelectOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean;
className?: string;
buttonClassName?: string;
@@ -26,7 +27,8 @@ export function SearchableSelect({
onChange,
options,
placeholder = "",
searchPlaceholder = "Search...",
searchPlaceholder,
emptyLabel,
disabled = false,
className = "",
buttonClassName = "",
@@ -109,7 +111,7 @@ export function SearchableSelect({
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
placeholder={searchPlaceholder || "Search..."}
className="h-9 pl-9"
autoFocus
/>
@@ -136,7 +138,9 @@ export function SearchableSelect({
</button>
))}
{filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">No results</div>
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">
{emptyLabel || "No results"}
</div>
)}
</div>
</div>,

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

@@ -0,0 +1,245 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"
type FlowName = "login" | "signup" | "forgotPassword"
export type CooldownKey =
| "loginOtpSend"
| "signupOtpSend"
| "forgotPasswordOtpSend"
| "loginPassword"
| "loginOtpVerify"
interface FlowBranchState {
mobile: string
code: string
otpExpiresAt: number | null
pendingOtpSend: boolean
}
interface CooldownState {
loginOtpSend: number
signupOtpSend: number
forgotPasswordOtpSend: number
loginPassword: number
loginOtpVerify: number
}
interface AuthFlowState {
login: FlowBranchState
signup: FlowBranchState
forgotPassword: FlowBranchState
cooldowns: CooldownState
}
interface AuthFlowContextValue {
state: AuthFlowState
setMobile: (flow: FlowName, mobile: string) => void
setCode: (flow: FlowName, code: string) => void
markOtpSendPending: (flow: FlowName) => void
setOtpDelivery: (flow: FlowName, expiresInSeconds: number) => void
clearOtpDelivery: (flow: FlowName) => void
setCooldown: (key: CooldownKey, seconds: number) => void
clearCooldown: (key: CooldownKey) => void
resetFlow: (flow: FlowName) => void
}
const STORAGE_KEY = "auth_flow_state:v2"
const defaultState: AuthFlowState = {
login: {
mobile: "",
code: "",
otpExpiresAt: null,
pendingOtpSend: false,
},
signup: {
mobile: "",
code: "",
otpExpiresAt: null,
pendingOtpSend: false,
},
forgotPassword: {
mobile: "",
code: "",
otpExpiresAt: null,
pendingOtpSend: false,
},
cooldowns: {
loginOtpSend: 0,
signupOtpSend: 0,
forgotPasswordOtpSend: 0,
loginPassword: 0,
loginOtpVerify: 0,
},
}
const AuthFlowContext = createContext<AuthFlowContextValue | null>(null)
const parseStoredState = (): AuthFlowState => {
if (typeof window === "undefined") {
return defaultState
}
const raw = window.sessionStorage.getItem(STORAGE_KEY)
if (!raw) {
return defaultState
}
try {
const parsed = JSON.parse(raw) as Partial<AuthFlowState>
return {
login: {
mobile: parsed.login?.mobile ?? "",
code: parsed.login?.code ?? "",
otpExpiresAt: parsed.login?.otpExpiresAt ?? null,
pendingOtpSend: parsed.login?.pendingOtpSend ?? false,
},
signup: {
mobile: parsed.signup?.mobile ?? "",
code: parsed.signup?.code ?? "",
otpExpiresAt: parsed.signup?.otpExpiresAt ?? null,
pendingOtpSend: parsed.signup?.pendingOtpSend ?? false,
},
forgotPassword: {
mobile: parsed.forgotPassword?.mobile ?? "",
code: parsed.forgotPassword?.code ?? "",
otpExpiresAt: parsed.forgotPassword?.otpExpiresAt ?? null,
pendingOtpSend: parsed.forgotPassword?.pendingOtpSend ?? false,
},
cooldowns: {
loginOtpSend: parsed.cooldowns?.loginOtpSend ?? 0,
signupOtpSend: parsed.cooldowns?.signupOtpSend ?? 0,
forgotPasswordOtpSend: parsed.cooldowns?.forgotPasswordOtpSend ?? 0,
loginPassword: parsed.cooldowns?.loginPassword ?? 0,
loginOtpVerify: parsed.cooldowns?.loginOtpVerify ?? 0,
},
}
} catch {
return defaultState
}
}
export function AuthFlowProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthFlowState>(parseStoredState)
useEffect(() => {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
useEffect(() => {
if (!Object.values(state.cooldowns).some((value) => value > 0)) {
return
}
const timer = window.setInterval(() => {
setState((current) => ({
...current,
cooldowns: {
loginOtpSend: Math.max(0, current.cooldowns.loginOtpSend - 1),
signupOtpSend: Math.max(0, current.cooldowns.signupOtpSend - 1),
forgotPasswordOtpSend: Math.max(0, current.cooldowns.forgotPasswordOtpSend - 1),
loginPassword: Math.max(0, current.cooldowns.loginPassword - 1),
loginOtpVerify: Math.max(0, current.cooldowns.loginOtpVerify - 1),
},
}))
}, 1000)
return () => window.clearInterval(timer)
}, [state.cooldowns])
const value = useMemo<AuthFlowContextValue>(
() => ({
state,
setMobile: (flow, mobile) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
mobile,
},
}))
},
setCode: (flow, code) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
code,
},
}))
},
markOtpSendPending: (flow) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
code: "",
pendingOtpSend: true,
},
}))
},
setOtpDelivery: (flow, expiresInSeconds) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
pendingOtpSend: false,
otpExpiresAt: Date.now() + Math.max(0, expiresInSeconds) * 1000,
},
}))
},
clearOtpDelivery: (flow) => {
setState((current) => ({
...current,
[flow]: {
...current[flow],
pendingOtpSend: false,
otpExpiresAt: null,
},
}))
},
setCooldown: (key, seconds) => {
setState((current) => ({
...current,
cooldowns: {
...current.cooldowns,
[key]: Math.max(current.cooldowns[key], seconds),
},
}))
},
clearCooldown: (key) => {
setState((current) => ({
...current,
cooldowns: {
...current.cooldowns,
[key]: 0,
},
}))
},
resetFlow: (flow) => {
setState((current) => ({
...current,
[flow]: {
mobile: "",
code: "",
otpExpiresAt: null,
pendingOtpSend: false,
},
}))
},
}),
[state],
)
return <AuthFlowContext.Provider value={value}>{children}</AuthFlowContext.Provider>
}
export function useAuthFlow() {
const context = useContext(AuthFlowContext)
if (!context) {
throw new Error("useAuthFlow must be used within an AuthFlowProvider")
}
return context
}

View File

@@ -22,6 +22,8 @@ import {
type NotificationLevel,
} from "../api/notifications"
import { useTranslation } from "../hooks/useTranslation"
import { presentNotification } from "../lib/notificationPresenter"
import { isRateLimitActive } from "../lib/rateLimit"
import {
getAccessToken,
SESSION_CHANGED_EVENT,
@@ -153,8 +155,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
toastedNotificationIdsRef.current.add(notification.id)
const notify = getToastMethod(notification.level)
notify(notification.title || (t.notifications?.newTitle || "New notification"), {
description: notification.message || undefined,
const presented = presentNotification(notification, t)
notify(presented.title || (t.notifications?.newTitle || "New notification"), {
description: presented.message || undefined,
action: notification.action_url
? {
label: t.notifications?.openAction || "Open",
@@ -169,7 +172,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
)
const refreshNotifications = useCallback(async () => {
if (!getAccessToken()) {
if (!getAccessToken() || isRateLimitActive()) {
setNotifications([])
setUnreadCount(0)
setTotalCount(0)
@@ -277,7 +280,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
}, [markAsSeen, openNotificationTarget, t.notifications])
const connectToStream = useCallback(async () => {
if (!getAccessToken()) {
if (!getAccessToken() || isRateLimitActive()) {
closeEventSource()
setConnectionStatus("idle")
return
@@ -411,7 +414,7 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const startNotifications = async () => {
if (!getAccessToken()) {
if (!getAccessToken() || isRateLimitActive()) {
closeEventSource()
setNotifications([])
setUnreadCount(0)

View File

@@ -1,9 +1,10 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"
import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces"
import { useTranslation } from "../hooks/useTranslation"
import { toast } from "sonner"
import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
import { fetchWorkspaces, createWorkspace, type Workspace } from "../api/workspaces"
import { useTranslation } from "../hooks/useTranslation"
import { isRateLimitActive } from "../lib/rateLimit"
import { toast } from "sonner"
import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
interface WorkspaceContextType {
workspaces: Workspace[]
@@ -14,36 +15,45 @@ interface WorkspaceContextType {
isLoading: boolean
}
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(undefined)
export const useWorkspace = () => {
const context = useContext(WorkspaceContext)
if (!context) throw new Error("useWorkspace must be used within a WorkspaceProvider")
return context
}
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(undefined)
export const useWorkspace = () => {
const context = useContext(WorkspaceContext)
if (!context) throw new Error("useWorkspace must be used within a WorkspaceProvider")
return context
}
export const useOptionalWorkspace = () => useContext(WorkspaceContext)
export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation()
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspaceState] = useState<Workspace | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [newWorkspaceName, setNewWorkspaceName] = useState("")
const [isCreatingFirst, setIsCreatingFirst] = useState(false)
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)
const isAuthenticated = !!localStorage.getItem("accessToken")
const rateLimited = isRateLimitActive()
const refreshWorkspaces = async () => {
if (!isAuthenticated) {
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")
@@ -60,19 +70,23 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
}
} catch (error) {
console.error(error)
setWorkspaceLoadError(true)
setHasLoadedWorkspaces(false)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (!isAuthenticated) {
if (!isAuthenticated || rateLimited) {
setHasLoadedWorkspaces(false)
setWorkspaceLoadError(false)
setIsLoading(false)
return
}
void refreshWorkspaces()
}, [isAuthenticated])
}, [isAuthenticated, rateLimited])
const setActiveWorkspace = (workspace: Workspace | null) => {
setActiveWorkspaceState(workspace)
@@ -86,9 +100,11 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
const addWorkspace = async (name: string) => {
try {
setIsCreatingFirst(true)
const newWs = await createWorkspace({ name, description: "" })
setWorkspaces((prev) => [...prev, newWs])
setActiveWorkspace(newWs)
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) {
toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace")
@@ -97,10 +113,31 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
setIsCreatingFirst(false)
setNewWorkspaceName("")
}
}
// Force workspace creation if authenticated but none exist
if (!isLoading && isAuthenticated && workspaces.length === 0) {
}
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 && 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,26 +1,44 @@
@import "tailwindcss";
@font-face {
font-family: "Vazirmatn";
font-family: "AppSans";
src: url("/fonts/Vazirmatn[wght].woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
/* Arabic + Persian Unicode blocks */
unicode-range:
U+0600-06FF,
U+0750-077F,
U+08A0-08FF,
U+FB50-FDFF,
U+FE70-FEFF;
}
@font-face {
font-family: "AppSans";
src: local("Inter");
font-weight: 100 900;
font-style: normal;
font-display: swap;
/* Latin */
unicode-range:
U+0000-00FF,
U+0100-024F,
U+1E00-1EFF;
}
@custom-variant dark (&:is(.dark *));
@theme {
--color-quera-blue: #2563eb;
--font-sans: "Vazirmatn", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-sans: "AppSans", ui-sans-serif, system-ui, sans-serif;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
}
:lang(fa) {
font-family: "Vazirmatn", system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: "AppSans", system-ui, sans-serif;
}
@layer base {
@@ -65,6 +83,93 @@
line-height: 1.75rem !important;
}
}
@keyframes landing-rise {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes landing-float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-14px);
}
}
@keyframes landing-grid {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, 36px, 0);
}
}
@keyframes landing-aurora {
0%,
100% {
opacity: 0.8;
transform: translate3d(0, 0, 0) scale(1);
}
50% {
opacity: 1;
transform: translate3d(0, -1%, 0) scale(1.04);
}
}
@keyframes landing-shimmer {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
.animate-landing-rise {
animation: landing-rise 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.animate-landing-float {
animation: landing-float 6s ease-in-out infinite;
}
.landing-hero-grid {
background-image:
linear-gradient(to right, rgba(15, 23, 42, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(15, 23, 42, 0.06) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: radial-gradient(circle at top, rgba(0, 0, 0, 0.95), transparent 78%);
animation: landing-grid 16s linear infinite;
}
.dark .landing-hero-grid {
background-image:
linear-gradient(to right, rgba(148, 163, 184, 0.09) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.09) 1px, transparent 1px);
}
.landing-aurora {
background:
radial-gradient(circle at 10% 10%, rgba(34, 211, 238, 0.18), transparent 34%),
radial-gradient(circle at 85% 18%, rgba(245, 158, 11, 0.18), transparent 28%),
radial-gradient(circle at 58% 34%, rgba(20, 184, 166, 0.12), transparent 30%);
animation: landing-aurora 14s ease-in-out infinite;
}
.landing-shimmer {
background-size: 200% 200%;
animation: landing-shimmer 7s linear infinite;
}
}
@@ -102,6 +207,10 @@
scrollbar-color: #cbd5e1 transparent;
}
html {
scroll-behavior: smooth;
}
.dark * {
scrollbar-color: #334155 transparent;
}

76
src/lib/money.ts Normal file
View File

@@ -0,0 +1,76 @@
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
export const localizeDigits = (value: string, lang: "en" | "fa") =>
lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value;
export const currencyLabel = (currency: string, lang: "en" | "fa") => {
const normalized = currency.toUpperCase();
if (lang !== "fa") return normalized;
return (
{
USD: "دلار آمریکا",
EUR: "یورو",
GBP: "پوند",
IRR: "ریال",
IRT: "تومان",
AED: "درهم",
TRY: "لیر",
}[normalized] || normalized
);
};
export const shouldTrimCurrencyDecimals = (currency?: string | null) => {
const normalized = (currency || "").toUpperCase();
return normalized === "IRR" || normalized === "IRT";
};
export const formatAmountForCurrency = (
value: string,
currency: string | null | undefined,
lang: "en" | "fa",
) => {
const trimmed = value.trim();
if (!trimmed) return trimmed;
const normalizedValue = trimmed.replace(/,/g, "");
const numeric = Number(normalizedValue);
if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang);
const [integerPart, fractionalPart] = normalizedValue.split(".");
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US");
const signed = normalizedValue.startsWith("-") ? `-${grouped}` : grouped;
let formatted = signed;
if (fractionalPart) {
const nextFraction = shouldTrimCurrencyDecimals(currency)
? ""
: fractionalPart.replace(/0+$/, "");
if (nextFraction) {
formatted = `${formatted}.${nextFraction}`;
}
}
return localizeDigits(formatted, lang);
};
export const formatMoneyTotals = (
totals: { currency: string; amount: string }[],
lang: "en" | "fa",
) => {
if (!totals.length) return "-";
return totals
.map((item) => `${formatAmountForCurrency(item.amount, item.currency, lang)} ${currencyLabel(item.currency, lang)}`)
.join(" | ");
};
export const formatRateDisplay = (
rate: { amount?: string | null; hourly_rate?: string | null; currency: string; price_unit?: { code?: string; local_name?: string; name?: string } | null } | null,
lang: "en" | "fa",
) => {
if (!rate) return "-";
const amount = rate.amount ?? rate.hourly_rate ?? "";
const unitLabel =
lang === "fa"
? rate.price_unit?.local_name || rate.price_unit?.code || currencyLabel(rate.currency, lang)
: rate.price_unit?.code || rate.currency;
return `${formatAmountForCurrency(amount, rate.currency, lang)} ${unitLabel}`;
};

View File

@@ -0,0 +1,78 @@
import type { NotificationItem } from "../api/notifications";
const roleLabel = (role: unknown, dictionary: any) => {
if (typeof role !== "string" || !role) return "";
return dictionary?.workspace?.roles?.[role] || role;
};
export const presentNotification = (notification: NotificationItem, dictionary: any) => {
const notifications = dictionary?.notifications;
const meta = notification.meta || {};
const workspaceName = typeof meta.workspace_name === "string" ? meta.workspace_name : "";
const actorName = typeof meta.actor_name === "string" ? meta.actor_name : "";
const exportType = typeof meta.export_type === "string" ? meta.export_type : "report";
const fileName = typeof meta.file_name === "string" ? meta.file_name : null;
const previousRole = roleLabel(meta.previous_role, dictionary);
const newRole = roleLabel(meta.new_role, dictionary);
switch (notification.type) {
case "workspace_membership_added":
return {
title: notifications?.workspaceMembershipAddedTitle || notification.title || notification.type,
message:
notifications?.workspaceMembershipAddedMessage?.(actorName, workspaceName, newRole) ||
notification.message ||
"",
};
case "workspace_membership_role_changed":
return {
title: notifications?.workspaceMembershipRoleChangedTitle || notification.title || notification.type,
message:
notifications?.workspaceMembershipRoleChangedMessage?.(
actorName,
workspaceName,
previousRole,
newRole,
) ||
notification.message ||
"",
};
case "workspace_membership_deactivated":
return {
title: notifications?.workspaceMembershipDeactivatedTitle || notification.title || notification.type,
message:
notifications?.workspaceMembershipDeactivatedMessage?.(actorName, workspaceName) ||
notification.message ||
"",
};
case "workspace_membership_removed":
return {
title: notifications?.workspaceMembershipRemovedTitle || notification.title || notification.type,
message:
notifications?.workspaceMembershipRemovedMessage?.(actorName, workspaceName) ||
notification.message ||
"",
};
case "report_export_ready":
return {
title: notifications?.reportExportReadyTitle || notification.title || notification.type,
message:
notifications?.reportExportReadyMessage?.(exportType, workspaceName, fileName) ||
notification.message ||
"",
};
case "report_export_failed":
return {
title: notifications?.reportExportFailedTitle || notification.title || notification.type,
message:
notifications?.reportExportFailedMessage?.(exportType, workspaceName) ||
notification.message ||
"",
};
default:
return {
title: notification.title || notification.type,
message: notification.message || "",
};
}
};

View File

@@ -1,5 +1,4 @@
export type WorkspaceRole = "owner" | "admin" | "member" | "guest";
export type ProjectRole = "manager" | "member" | string;
export const WORKSPACE_VIEW = "workspace.view";
export const WORKSPACE_EDIT = "workspace.edit";
@@ -8,6 +7,7 @@ export const WORKSPACE_MEMBERS_VIEW = "workspace.members.view";
export const WORKSPACE_MEMBERS_ADD = "workspace.members.add";
export const WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove";
export const WORKSPACE_MEMBERS_CHANGE_ROLE = "workspace.members.change_role";
export const WORKSPACE_LOGS_VIEW = "workspace.logs.view";
export const CLIENTS_VIEW = "clients.view";
export const CLIENTS_CREATE = "clients.create";
export const CLIENTS_EDIT = "clients.edit";
@@ -21,10 +21,6 @@ export const PROJECTS_CREATE = "projects.create";
export const PROJECTS_EDIT = "projects.edit";
export const PROJECTS_DELETE = "projects.delete";
export const PROJECTS_ARCHIVE = "projects.archive";
export const PROJECT_MEMBERS_VIEW = "project_members.view";
export const PROJECT_MEMBERS_ADD = "project_members.add";
export const PROJECT_MEMBERS_REMOVE = "project_members.remove";
export const PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role";
export const TIME_ENTRIES_VIEW_OWN = "time_entries.view_own";
export const TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own";
@@ -36,6 +32,7 @@ export type WorkspaceCapability =
| typeof WORKSPACE_MEMBERS_ADD
| typeof WORKSPACE_MEMBERS_REMOVE
| typeof WORKSPACE_MEMBERS_CHANGE_ROLE
| typeof WORKSPACE_LOGS_VIEW
| typeof CLIENTS_VIEW
| typeof CLIENTS_CREATE
| typeof CLIENTS_EDIT
@@ -49,10 +46,6 @@ export type WorkspaceCapability =
| typeof PROJECTS_EDIT
| typeof PROJECTS_DELETE
| typeof PROJECTS_ARCHIVE
| typeof PROJECT_MEMBERS_VIEW
| typeof PROJECT_MEMBERS_ADD
| typeof PROJECT_MEMBERS_REMOVE
| typeof PROJECT_MEMBERS_CHANGE_ROLE
| typeof TIME_ENTRIES_VIEW_OWN
| typeof TIME_ENTRIES_MANAGE_OWN;
@@ -65,6 +58,7 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_REMOVE,
WORKSPACE_MEMBERS_CHANGE_ROLE,
WORKSPACE_LOGS_VIEW,
CLIENTS_VIEW,
CLIENTS_CREATE,
CLIENTS_EDIT,
@@ -78,10 +72,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
PROJECTS_EDIT,
PROJECTS_DELETE,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN,
]),
@@ -92,6 +82,7 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_REMOVE,
WORKSPACE_MEMBERS_CHANGE_ROLE,
WORKSPACE_LOGS_VIEW,
CLIENTS_VIEW,
CLIENTS_CREATE,
CLIENTS_EDIT,
@@ -105,10 +96,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
PROJECTS_EDIT,
PROJECTS_DELETE,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN,
]),
@@ -130,15 +117,6 @@ const CAPABILITIES_BY_ROLE: Record<WorkspaceRole, Set<WorkspaceCapability>> = {
]),
};
const PROJECT_MANAGER_CAPABILITIES = new Set<WorkspaceCapability>([
PROJECTS_EDIT,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
]);
export const canWorkspace = (
role: WorkspaceRole | null | undefined,
capability: WorkspaceCapability,
@@ -147,20 +125,19 @@ export const canWorkspace = (
return CAPABILITIES_BY_ROLE[role]?.has(capability) ?? false;
};
export const canProject = ({
export const canDeleteWorkspaceResource = ({
workspaceRole,
projectRole,
capability,
currentUserId,
createdById,
}: {
workspaceRole: WorkspaceRole | null | undefined;
projectRole?: ProjectRole | null;
capability: WorkspaceCapability;
currentUserId?: string | null;
createdById?: string | null;
}) => {
if (canWorkspace(workspaceRole, capability)) return true;
if (workspaceRole === "member" || workspaceRole === "guest" || !workspaceRole) {
return false;
}
return projectRole === "manager" && PROJECT_MANAGER_CAPABILITIES.has(capability);
if (!workspaceRole) return false;
if (workspaceRole === "owner") return true;
if (!currentUserId || !createdById) return false;
return currentUserId === createdById;
};
export const canChangeWorkspaceMember = ({

90
src/lib/queryParams.ts Normal file
View File

@@ -0,0 +1,90 @@
export type QueryParamUpdateValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number>;
type QueryParamDefaults = Record<string, string | number | boolean | undefined>;
const normalizeScalar = (value: string | number | boolean) => {
if (typeof value === "boolean") {
return value ? "1" : "0";
}
return String(value);
};
export const readStringParam = (
searchParams: URLSearchParams,
key: string,
fallback = "",
) => searchParams.get(key) ?? fallback;
export const readNumberParam = (
searchParams: URLSearchParams,
key: string,
fallback: number,
) => {
const rawValue = searchParams.get(key);
if (!rawValue) return fallback;
const parsedValue = Number(rawValue);
if (!Number.isFinite(parsedValue)) return fallback;
return parsedValue;
};
export const readBooleanParam = (
searchParams: URLSearchParams,
key: string,
fallback = false,
) => {
const rawValue = searchParams.get(key);
if (rawValue === null) return fallback;
return rawValue === "1" || rawValue === "true";
};
export const readArrayParam = (searchParams: URLSearchParams, key: string) =>
searchParams.getAll(key).filter(Boolean);
export const updateQueryParams = (
currentParams: URLSearchParams,
updates: Record<string, QueryParamUpdateValue>,
defaults: QueryParamDefaults = {},
) => {
const nextParams = new URLSearchParams(currentParams);
Object.entries(updates).forEach(([key, value]) => {
nextParams.delete(key);
if (Array.isArray(value)) {
const normalizedValues = value
.map((item) => String(item).trim())
.filter(Boolean);
normalizedValues.forEach((item) => nextParams.append(key, item));
return;
}
if (value === null || value === undefined) return;
const normalizedValue =
typeof value === "string" ? value.trim() : normalizeScalar(value);
const defaultValue = defaults[key];
if (!normalizedValue.length) return;
if (
defaultValue !== undefined &&
normalizedValue === normalizeScalar(defaultValue)
) {
return;
}
nextParams.set(key, normalizedValue);
});
return nextParams;
};

144
src/lib/rateLimit.ts Normal file
View File

@@ -0,0 +1,144 @@
const STORAGE_KEY = "qlockify:rate-limit-lock"
export interface RateLimitLock {
status: number
code: string | null
message: string
retryAfterSeconds: number
throttledUntil: string
returnTo: string
}
interface ActivateRateLimitInput {
status: number
code?: string | null
message?: string | null
retryAfterSeconds?: number | null
throttledUntil?: string | null
returnTo?: string | null
}
const DEFAULT_RETRY_SECONDS = 60
const isBrowser = typeof window !== "undefined"
const getCurrentPath = () => {
if (!isBrowser) {
return "/"
}
return `${window.location.pathname}${window.location.search}${window.location.hash}`
}
const parseIsoDate = (value: string | null | undefined) => {
if (!value) {
return null
}
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? timestamp : null
}
const readStoredLock = (): RateLimitLock | null => {
if (!isBrowser) {
return null
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw) as Partial<RateLimitLock>
if (
typeof parsed.status !== "number" ||
typeof parsed.message !== "string" ||
typeof parsed.retryAfterSeconds !== "number" ||
typeof parsed.throttledUntil !== "string" ||
typeof parsed.returnTo !== "string"
) {
window.localStorage.removeItem(STORAGE_KEY)
return null
}
if (parseIsoDate(parsed.throttledUntil) == null) {
window.localStorage.removeItem(STORAGE_KEY)
return null
}
return {
status: parsed.status,
code: typeof parsed.code === "string" ? parsed.code : null,
message: parsed.message,
retryAfterSeconds: parsed.retryAfterSeconds,
throttledUntil: parsed.throttledUntil,
returnTo: parsed.returnTo,
}
} catch {
window.localStorage.removeItem(STORAGE_KEY)
return null
}
}
export const getRateLimitRemainingSeconds = (lock: RateLimitLock | null) => {
if (!lock) {
return 0
}
const untilTimestamp = parseIsoDate(lock.throttledUntil)
if (untilTimestamp == null) {
return 0
}
return Math.max(0, Math.ceil((untilTimestamp - Date.now()) / 1000))
}
export const getStoredRateLimitLock = () => readStoredLock()
export const isRateLimitActive = () => getRateLimitRemainingSeconds(readStoredLock()) > 0
export const clearRateLimitLock = () => {
if (!isBrowser) {
return
}
window.localStorage.removeItem(STORAGE_KEY)
}
export const activateRateLimitLock = (input: ActivateRateLimitInput) => {
if (!isBrowser) {
return null
}
const parsedThrottledUntil = parseIsoDate(input.throttledUntil)
const retryFromUntil =
parsedThrottledUntil != null
? Math.ceil(Math.max(parsedThrottledUntil - Date.now(), 0) / 1000)
: 0
const retryAfterSeconds = Math.max(
input.retryAfterSeconds ?? retryFromUntil ?? DEFAULT_RETRY_SECONDS,
retryFromUntil,
0,
) || DEFAULT_RETRY_SECONDS
const throttledUntil =
input.throttledUntil && parsedThrottledUntil != null
? input.throttledUntil
: new Date(Date.now() + retryAfterSeconds * 1000).toISOString()
const returnTo =
input.returnTo && input.returnTo !== "/rate-limit" ? input.returnTo : getCurrentPath()
const lock: RateLimitLock = {
status: input.status,
code: input.code ?? "throttled",
message: input.message || "Too many requests",
retryAfterSeconds,
throttledUntil,
returnTo: returnTo === "/rate-limit" ? "/" : returnTo,
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lock))
return lock
}

54
src/lib/reportFilters.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { ReportPeriod } from "../api/reports";
import type { ReportsFilterDraft } from "../components/reports/ReportsFilterBar";
import { readArrayParam, readStringParam, updateQueryParams } from "./queryParams";
export const DEFAULT_REPORTS_FILTERS: ReportsFilterDraft = {
period: "this_month",
from_date: "",
to_date: "",
user: "",
client: "",
project: "",
tags: [],
};
export const readReportsFiltersFromParams = (
searchParams: URLSearchParams,
): ReportsFilterDraft => ({
period: readStringParam(
searchParams,
"period",
DEFAULT_REPORTS_FILTERS.period,
) as ReportPeriod,
from_date: readStringParam(searchParams, "from", ""),
to_date: readStringParam(searchParams, "to", ""),
user: readStringParam(searchParams, "user", ""),
client: readStringParam(searchParams, "client", ""),
project: readStringParam(searchParams, "project", ""),
tags: readArrayParam(searchParams, "tags"),
});
export const writeReportsFiltersToParams = (
currentParams: URLSearchParams,
filters: ReportsFilterDraft,
) =>
updateQueryParams(
currentParams,
{
period: filters.period,
from: filters.from_date,
to: filters.to_date,
user: filters.user,
client: filters.client,
project: filters.project,
tags: filters.tags,
},
{
period: DEFAULT_REPORTS_FILTERS.period,
from: "",
to: "",
user: "",
client: "",
project: "",
},
);

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

@@ -9,6 +9,7 @@ export const en = {
save: "Save",
lightMode: "Light Mode",
darkMode: "Dark Mode",
settings: "Settings",
loadingText: "Loading...",
loading: "Loading...",
add: "Add",
@@ -26,22 +27,64 @@ export const en = {
login: {
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
enterPassword: "Enter your password",
loginTitle: "Sign in to your account",
loginDescription: "Enter your mobile number and we will send a one-time code.",
loginCta: "Login",
createAccount: "Create account",
haveNoAccount: "Need an account?",
haveAccount: "Already have an account?",
loginOtpTitle: "Verify your login code",
passwordLoginTitle: "Login with password",
passwordLoginDescription: (mobile: string) => `Enter the password for ${mobile}`,
usePasswordInstead: "Use password instead",
useOtpInstead: "Use OTP instead",
backToMobile: "Back to mobile step",
backToPasswordLogin: "Back to password login",
forgotPassword: "Forgot password?",
signupTitle: "Create your account",
signupDescription: "Start with your mobile number to receive a verification code.",
sendSignupCode: "Send verification code",
signupVerifyTitle: "Verify your mobile number",
continueToPassword: "Continue to password",
signupPasswordTitle: "Set your password",
signupPasswordDescription: "Choose a password for your new account.",
createAccountPasswordCta: "Create account",
forgotPasswordTitle: "Recover your password",
forgotPasswordDescription: "Enter your mobile number and we will send a verification code for password reset.",
sendResetCode: "Send reset code",
forgotPasswordVerifyTitle: "Enter your reset code",
continueToResetPassword: "Continue to reset password",
resetPasswordTitle: "Choose a new password",
resetPasswordDescription: "Set a new password for your account and confirm it.",
resetPasswordCta: "Reset password",
newPasswordPlaceholder: "New password",
confirmPasswordPlaceholder: "Confirm password",
passwordMismatch: "The password confirmation does not match.",
passwordRequirements:
"Password must be at least 8 characters and include at least one lowercase letter, one uppercase letter, one digit, and one symbol.",
passwordReuse: "New password must be different from your previous password.",
enterPassword: "Enter your password",
verifyNumber: "Verify your number",
enterMobileDesc: "Enter your mobile number to continue",
signInDesc: "Sign in using your account password",
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
sentCodeDesc: (mobile: string) => `We sent a 5-digit code to ${mobile}`,
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
continueWithPassword: "Continue with Password",
continueWithGoogle: "Continue with Google",
orContinueWith: "Or continue with",
otpLogin: "OTP Login",
register: "Register",
passwordPlaceholder: "Password",
signIn: "Sign In",
back: "Back",
otpPlaceholder: "6-digit code",
verifyAndContinue: "Verify & Continue",
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.",
otpPlaceholder: "5-digit code",
verifyAndContinue: "Verify & Continue",
sendingOtp: "Sending code...",
verifyingOtp: "Verifying code...",
resendOtp: "Resend code",
otpExpiresIn: (time: string) => `Code expires in ${time}`,
otpExpired: "This code has expired. Request a new code to continue.",
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.",
brandingQuote: "Manage your time and workspaces efficiently with our minimal, fast, and secure platform.",
toasts: {
enterMobile: "Please enter your mobile number",
@@ -49,10 +92,52 @@ export const en = {
failedOtp: "Failed to send OTP",
fillAll: "Please fill all fields",
successLogin: "Successfully logged in!",
accountCreated: "Account created successfully!",
failedSignup: "Failed to complete sign up",
invalidCreds: "Invalid credentials",
enterOtp: "Please enter the OTP code",
invalidOtp: "Invalid OTP code"
}
invalidOtp: "Invalid OTP code",
passwordResetSuccess: "Password reset successfully.",
passwordResetFailed: "Failed to reset password.",
},
throttle: {
title: "Too many attempts",
genericMessage: (time: string) => `Too many requests. Try again in ${time}.`,
otpSendMessage: (time: string) => `Too many OTP requests. Try again in ${time}.`,
passwordLoginMessage: (time: string) => `Too many password login attempts. Try again in ${time}.`,
otpLoginMessage: (time: string) => `Too many OTP login attempts. Try again in ${time}.`,
countdownLabel: (time: string) => `Retry in ${time}`,
fallback: "Too many requests. Please wait and try again.",
},
google: {
loadingTitle: "Completing Google sign in",
loadingDescription: "We are validating your Google account and preparing the next step.",
collectMobileTitle: "Finish your account setup",
collectMobileDescription: (email: string) =>
`Google verified ${email}. Enter your mobile number to finish creating your account.`,
existingEmailClaimDescription: (email: string, mobileHint: string) =>
`Google verified ${email}. Enter the mobile number already connected to this account (${mobileHint}) to confirm ownership.`,
claimTitle: "Verify your existing account",
claimDescription: (mobile: string) =>
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
mobileClaimDescription: (mobile: string) =>
`A mobile-only account with ${mobile} already exists. Verify the code sent to that number to attach Google and keep that account.`,
errorTitle: "Google sign in could not be completed",
cancelled: "Google sign in was cancelled before it could be completed.",
missingFlow: "The Google sign-in flow is missing or has expired.",
loadFailed: "We could not load your Google sign-in state.",
callbackFailed: "We could not complete Google sign in. Please try again.",
tokenExchangeFailed: "Google sign in is temporarily unavailable. Please try again in a few minutes.",
profileLookupFailed: "We could not load your Google profile. Please try again.",
completeFailed: "We could not finish your Google account setup.",
claimOtpSent: "Verification code sent successfully.",
googleAccount: "Google account",
mobileHintLabel: (mobileHint: string) => `Expected mobile: ${mobileHint}`,
completeButton: "Continue and create account",
verifyClaimButton: "Verify and continue",
resendClaimOtp: "Resend verification code",
restartGoogle: "Start Google sign in again",
},
},
loginTerms: {
@@ -61,6 +146,18 @@ export const en = {
suffix: ""
},
rateLimit: {
eyebrow: "Request limit reached",
title: "Please wait before trying again",
message: "You have sent too many requests. Access is temporarily locked until the cooldown finishes.",
cooldownLabel: "Cooldown",
waitingMessage: (time: string) => `Requests are blocked for now.`,
finishedMessage: "The cooldown has finished. You can continue now.",
continue: "Continue",
continueCooldown: (time: string) => `Continue in ${time}`,
ready: "Ready",
},
terms: {
back: "Back",
title: "Terms of Service and Privacy Policy",
@@ -116,6 +213,20 @@ export const en = {
upload: "Upload",
remove: "Remove",
imageInput: "Click to select or drag & drop",
password: {
trigger: "Change password",
title: "Change password",
description: "Enter your current password and choose a new one.",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm new password",
submit: "Save password",
saving: "Saving...",
toasts: {
success: "Password changed successfully.",
error: "Failed to change password.",
},
},
toasts: {
successEdit: "Profile updated successfully!",
successImage: "Profile picture updated!",
@@ -131,8 +242,13 @@ export const en = {
nameLabel: "Workspace Name",
namePlaceholder: "Enter workspace name",
descriptionLabel: "Description",
descriptionPlaceholder: "Enter description (optional)",
searchMemberPlaceholder: "Search exact mobile (e.g. 09123456789)",
descriptionPlaceholder: "Enter description (optional)",
thumbnailLabel: "Thumbnail",
uploadImage: "Click to upload image",
removeImage: "Remove image",
thumbnailInvalidType: "Unsupported image type. Use JPG, PNG, or WebP.",
thumbnailMaxSizeError: "Image size must be 2MB or less.",
searchMemberPlaceholder: "Search exact mobile (e.g. 09123456789)",
addMember: "Add Member",
roleAdmin: "Admin",
roleMember: "Member",
@@ -149,15 +265,33 @@ export const en = {
subtitle: "Manage your workspaces",
noDescription: "No description",
emptyState: "You are not a member of any workspace.",
noWorkspaceSearch: "Try adjusting your search query.",
noWorkspace: "No workspaces found.",
createTitle: "Create Workspace",
editTitle: "Edit Workspace",
detailTitle: "Workspace Details",
save: "Save",
create: "Create",
noWorkspaceTitle: "Welcome!",
noWorkspaceDesc: "Please create your first workspace.",
back: "Back to Workspaces",
detailTitle: "Workspace Details",
save: "Save",
create: "Create",
noWorkspaceTitle: "Welcome!",
noWorkspaceDesc: "Please create your first workspace.",
back: "Back to Workspaces",
roleLabel: "Your Role",
openReports: "Open reports",
statsMembers: "Members",
statsRates: "Rates set",
statsOwnersAdmins: "Owners & admins",
statsGuests: "Guests",
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.",
membersLocked: "Only owners and admins can view the full member list.",
manageMembers: "Manage members",
mobileNumber: "Mobile Number",
youLabel: "You",
resourcesTitle: "Resources",
resourceOpen: "Open",
roleDistributionTitle: "Role distribution",
unknownMember: "Unknown member",
roles: {
owner: "Owner",
admin: "Admin",
@@ -166,8 +300,10 @@ export const en = {
},
createdSuccess: "Workspace created successfully",
updatedSuccess: "Workspace updated successfully",
fetchError: "Failed to load workspace data",
remove: "Remove",
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",
add: "Add",
@@ -202,6 +338,7 @@ export const en = {
cannotAddSelf: "You are automatically the owner.",
},
onlyNumbersAllowed: "Only numbers are allowed for mobile number.",
weekTotal: "Week Total"
},
clients: {
@@ -226,6 +363,9 @@ export const en = {
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
delete: "Delete",
saveChanges: "Save Changes",
createSuccess: "Client created successfully.",
updateSuccess: "Client updated successfully.",
deleteSuccess: "Client deleted successfully.",
errors: {
createFailed: "Failed to create client",
fetchFailed: "Failed to fetch clients",
@@ -244,15 +384,115 @@ export const en = {
next: "Next",
},
sidebar: {
timesheet: "Timesheet",
reports: "Reports",
workspaces: 'Workspaces',
clients: 'Clients',
projects: "Projects",
tags: "Tags",
expand: 'Expand',
collapse: 'Collapse',
sidebar: {
timesheet: "Timesheet",
reports: "Reports",
logs: "Logs",
workspaces: 'Workspaces',
clients: 'Clients',
projects: "Projects",
tags: "Tags",
expand: 'Expand',
collapse: 'Collapse',
},
landing: {
brandLabel: "Operating system for time",
eyebrow: "Built for high-discipline teams that need clean time intelligence",
nav: {
demo: "Product demo",
features: "Core capabilities",
workflow: "How it works",
about: "About us",
},
actions: {
switchToEnglish: "English",
switchToPersian: "فارسی",
signIn: "Sign in",
openApp: "Open app",
openWorkspace: "Open workspace",
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.",
titleAccent: "Qlockify makes time visible, accountable, and billable.",
description:
"A focused workspace for modern teams that need fast time capture, trustworthy project tracking, structured reports, and a log trail that management can actually use.",
},
metrics: {
capture: "cleaner billable capture",
visibility: "faster reporting visibility",
decision: "from raw entries to management context",
},
trust: {
first: "Precise timers with manual control when needed",
second: "Workspace permissions, logs, and rate-aware reporting",
third: "Built for agencies, consultancies, product teams, and operators",
},
capabilities: {
time: {
title: "Capture work without friction",
description:
"Start a timer, adjust historical entries, and keep project and tag context attached to every hour without slowing the team down.",
},
reports: {
title: "Read the business in minutes",
description:
"See daily output, billable performance, project distribution, and exportable report packs without spreadsheet cleanup.",
},
control: {
title: "Keep operations explainable",
description:
"Track who changed what, keep workspace roles explicit, and give management a cleaner operational trail than ad hoc chat or manual files.",
},
},
demo: {
timerTag: "Live timer",
timerTitle: "Current execution window",
timerText: "Design system refinement synced to the correct project, tags, and billable rate.",
panelLabel: "Interactive product preview",
panelTitle: "One surface for tracking, reporting, and operational clarity",
runningCard: "Active entry",
currentTask: "Enterprise landing page rollout",
currentTaskMeta: "Project: Qlockify Marketing · Tags: Design, Review, Delivery",
billableLabel: "Live billable rate",
reportCard: "Daily report trend",
opsCard: "Operational health",
opsLabels: ["Coverage", "Team focus", "Billing readiness"],
logCard: "Recent workspace activity",
logItems: [
{ title: "Rate updated for product design", meta: "Owner action · 3 minutes ago" },
{ title: "Client-facing project moved to archived", meta: "Admin action · 18 minutes ago" },
{ title: "Historic tag preserved on edited entry", meta: "Member action · 41 minutes ago" },
],
outcomeTag: "Management result",
outcomeText: "Less ambiguity at month end, fewer missing billable hours, and faster operational reviews.",
},
workflowTag: "Operational workflow",
workflowTitle: "A tighter loop from raw effort to usable management data.",
workflowDescription:
"Qlockify is designed to keep the path short: capture accurately, structure context once, and reuse the result everywhere from timesheets to reports to workspace-level decisions.",
workflow: {
capture: "Capture time at the source with project, tags, and billing context attached immediately.",
structure: "Keep every workspace action, membership change, and rate update visible and reviewable.",
improve: "Review daily and monthly performance with reports that are ready to export or act on.",
},
finalCtaTag: "Ready for production teams",
finalCtaTitle: "If your team sells expertise or ships client work, your time system should look this serious.",
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: {
@@ -263,13 +503,14 @@ export const en = {
nameDesc: "Name (Z-A)",
},
projects: {
projects: {
title: "Projects",
description: (workspaceName: string) => `Manage projects for ${workspaceName}`,
active: "Active Projects",
archived: "Archived Projects",
createNew: "Create New",
searchPlaceholder: "Search projects...",
selectWorkspace: "Please select a workspace first.",
titlePlaceholder: "Enter title",
descriptionPlaceholder: "Enter desription",
titleLabel: "Title",
@@ -280,6 +521,7 @@ export const en = {
client: "Client",
noClient: "No client",
emptyState: "No projects found",
noProjectsSearch: "Try adjusting your search query.",
deleteTitle: "Delete Project",
deleteWarning: "To confirm deletion, please type the project name:",
deleteSuccess: "Project deleted successfully",
@@ -290,22 +532,45 @@ export const en = {
editProject: "Edit Project",
restore: "Restore",
archive: "Archive",
archiveSuccess: "Project archived successfully.",
restoreSuccess: "Project restored successfully.",
fetchError: "Failed to fetch projects.",
clientFetchError: "Failed to load clients.",
filterClients: "Filter by client",
clearClientFilters: "Clear filters",
namePlaceholder: "Project name...",
teamMembers: "Team Members",
creator: "Creator",
teamMembers: "Team Members",
manageAccess: "Projects & Rates",
accessModalTitle: "Projects & Rates",
accessModalDescription: "Manage project access for members and guests, and set project-specific rates for any workspace user.",
accessMemberLabel: "User",
accessNoMembers: "No workspace users were found.",
accessNoProjects: "No projects found.",
accessSelectVisible: "Select all visible",
accessClearSelection: "Clear selection",
accessSelectClientProjects: "Select all projects for client",
accessGrant: "Grant selected",
accessRevoke: "Revoke selected",
accessOn: "Has access",
accessOff: "No access",
accessGrantSuccess: "Project access granted.",
accessRevokeSuccess: "Project access revoked.",
accessLoadError: "Failed to load project access state.",
accessSaveError: "Failed to update project access.",
implicitAccessHint: "Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
creator: "Creator",
addUser: "Add user by mobile",
addFromWorkspace: "Add from workspace",
searchMembers: "Search members...",
addAllWorkspaceMembers: "Add all workspace members",
confirmDeleteTitle: "Remove Member",
confirmDeleteDesc: "Are you sure you want to remove this member from the project?",
createSuccess: "Project created successfully.",
createError: "Failed to create project.",
updateSuccess: "Project updated successfully.",
updateError: "Failed to update project.",
edit: "Edit Project",
memberAlreadyAdded: "This user is already on the project team.",
createSuccess: "Project created successfully.",
createError: "Failed to create project.",
updateSuccess: "Project updated successfully.",
updateError: "Failed to update project.",
edit: "Edit Project",
memberAlreadyAdded: "This user is already on the project team.",
roles: {
member: "Member",
manager: "Manager"
@@ -316,171 +581,310 @@ export const en = {
userNotFound: "No user found with this mobile number.",
alreadyInProject: "Already Added",
addToProject: "Add to Project",
noWorkspaceMembers: "No members found.",
},
tags: {
title: "Tags",
description: (workspaceName: string) => `Manage tags for ${workspaceName}`,
create: "Create Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteTitle: "Delete Tag",
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
searchPlaceholder: "Search tags...",
nameLabel: "Tag Name",
namePlaceholder: "e.g. Design",
colorLabel: "Color",
emptyState: "No tags found",
selectWorkspace: "Please select a workspace first.",
fetchError: "Failed to load tags",
createSuccess: "Tag created successfully.",
updateSuccess: "Tag updated successfully.",
saveError: "Failed to save tag.",
deleteSuccess: "Tag deleted successfully.",
deleteError: "Failed to delete tag.",
},
noWorkspaceMembers: "No members found.",
},
tags: {
title: "Tags",
description: (workspaceName: string) => `Manage tags for ${workspaceName}`,
create: "Create Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteTitle: "Delete Tag",
deleteConfirmMessage: (name: string) => `Are you sure you want to delete ${name}?`,
searchPlaceholder: "Search tags...",
nameLabel: "Tag Name",
namePlaceholder: "e.g. Design",
colorLabel: "Color",
emptyState: "No tags found",
noTagsSearch: "Try adjusting your search query.",
selectWorkspace: "Please select a workspace first.",
fetchError: "Failed to load tags",
createSuccess: "Tag created successfully.",
updateSuccess: "Tag updated successfully.",
saveError: "Failed to save tag.",
deleteSuccess: "Tag deleted successfully.",
deleteError: "Failed to delete tag.",
},
rates: {
workspaceSectionTitle: "Workspace User Rates",
projectSectionTitle: "Project User Rates",
myRatesTitle: "My rates",
myRatesHint: "Project-specific rates override your workspace rate in the current workspace.",
workspaceRate: "Workspace rate",
workspaceRateHint: "This is your default rate unless a project-specific rate overrides it.",
projectOverride: "Project override",
projectOverrides: "Project overrides",
accessibleProjects: "Accessible projects",
workspaceFallbackProjects: "Using workspace rate",
projectOverrideHint: "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate.",
projectOverrideEmpty: "You do not have any project-specific rate overrides in this workspace.",
myRatesEmpty: "No rates are available for this workspace yet.",
inheritsWorkspaceRate: "Inherits workspace rate",
noRate: "No rate",
hourlyRatePlaceholder: "0.00",
currencyPlaceholder: "USD",
searchUnitPlaceholder: "Search unit...",
removeRate: "Remove rate",
workspaceSaveSuccess: "Workspace user rate saved.",
workspaceSaveError: "Failed to save workspace user rate.",
workspaceRemoveSuccess: "Workspace user rate removed.",
workspaceRemoveError: "Failed to remove workspace user rate.",
projectSaveSuccess: "Project user rate saved.",
projectSaveError: "Failed to save project user rate.",
projectRemoveSuccess: "Project user rate removed.",
projectRemoveError: "Failed to remove project user rate.",
hourlyRatePlaceholder: "0.00",
currencyPlaceholder: "USD",
searchUnitPlaceholder: "Search unit...",
removeRate: "Remove rate",
workspaceSaveSuccess: "Workspace user rate saved.",
workspaceSaveError: "Failed to save workspace user rate.",
workspaceRemoveSuccess: "Workspace user rate removed.",
workspaceRemoveError: "Failed to remove workspace user rate.",
projectSaveSuccess: "Project user rate saved.",
projectSaveError: "Failed to save project user rate.",
projectRemoveSuccess: "Project user rate removed.",
projectRemoveError: "Failed to remove project user rate.",
},
timesheet: {
title: "Timesheet",
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
selectWorkspace: "Please select a workspace first.",
addEntry: "Add Entry",
startTimer: "Start Timer",
stopTimer: "Stop Timer",
timerRunning: "Timer Running",
runningLabel: "Current timer",
runningBadge: "Running",
noRunningEntry: "No running entry",
searchPlaceholder: "Search time entries...",
orderingNewest: "Newest first",
orderingOldest: "Oldest first",
emptyState: "No time entries found",
emptyStateDescription: "Start the timer or add a manual entry to get started.",
noEntriesSearch: "Try adjusting your search query or filters.",
emptyDescription: "No description",
createTitle: "Add Time Entry",
startTitle: "Start Timer",
editTitle: "Edit Time Entry",
createSuccess: "Time entry created successfully.",
startSuccess: "Timer started successfully.",
updateSuccess: "Time entry updated successfully.",
saveError: "Failed to save time entry.",
stopSuccess: "Timer stopped successfully.",
stopError: "Failed to stop timer.",
deleteSuccess: "Time entry deleted successfully.",
deleteError: "Failed to delete time entry.",
fetchError: "Failed to load time entries.",
optionsError: "Failed to load projects and tags.",
descriptionLabel: "Description",
descriptionPlaceholder: "What are you working on?",
projectLabel: "Project",
noProject: "No project",
startLabel: "Start",
endLabel: "End",
timeLabel: "Time",
billable: "Billable",
noTagsHint: "Create tags first from the Tags page.",
clearFilters: "Clear filters",
customFromLabel: "From date",
customToLabel: "To date",
allClientsLabel: "All clients",
allProjectsLabel: "All projects",
allTagsLabel: "All tags",
showFiltersLabel: "Show filters",
hideFiltersLabel: "Hide filters",
applyFiltersLabel: "Apply",
clientFilterPrefix: "Client",
projectFilterPrefix: "Project",
tagFilterPrefix: "Tag",
fromFilterPrefix: "From",
toFilterPrefix: "To",
deleteTitle: "Delete Time Entry",
deleteConfirmMessage: "Are you sure you want to delete this time entry?",
restartConfirmMessage: "Start a new running timer from this entry?",
discardConfirmMessage: "Are you sure you want to discard this running timer?",
searchTagsLabel: "Search tags...",
noTagsFoundLabel: "No tags found.",
searchProjectsLabel: "Search projects...",
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.",
},
timesheet: {
title: "Timesheet",
description: (workspaceName: string) => `Track time inside ${workspaceName}`,
selectWorkspace: "Please select a workspace first.",
addEntry: "Add Entry",
startTimer: "Start Timer",
stopTimer: "Stop Timer",
timerRunning: "Timer Running",
runningLabel: "Current timer",
runningBadge: "Running",
noRunningEntry: "No running entry",
searchPlaceholder: "Search time entries...",
orderingNewest: "Newest first",
orderingOldest: "Oldest first",
emptyState: "No time entries found",
emptyDescription: "No description",
createTitle: "Add Time Entry",
startTitle: "Start Timer",
editTitle: "Edit Time Entry",
createSuccess: "Time entry created successfully.",
startSuccess: "Timer started successfully.",
updateSuccess: "Time entry updated successfully.",
saveError: "Failed to save time entry.",
stopSuccess: "Timer stopped successfully.",
stopError: "Failed to stop timer.",
deleteSuccess: "Time entry deleted successfully.",
deleteError: "Failed to delete time entry.",
fetchError: "Failed to load time entries.",
optionsError: "Failed to load projects and tags.",
descriptionLabel: "Description",
descriptionPlaceholder: "What are you working on?",
projectLabel: "Project",
noProject: "No project",
startLabel: "Start",
endLabel: "End",
billable: "Billable",
noTagsHint: "Create tags first from the Tags page.",
clearFilters: "Clear filters",
customFromLabel: "From date",
customToLabel: "To date",
allClientsLabel: "All clients",
allProjectsLabel: "All projects",
allTagsLabel: "All tags",
showFiltersLabel: "Show filters",
hideFiltersLabel: "Hide filters",
applyFiltersLabel: "Apply",
clientFilterPrefix: "Client",
projectFilterPrefix: "Project",
tagFilterPrefix: "Tag",
fromFilterPrefix: "From",
toFilterPrefix: "To",
},
reports: {
title: "Reports",
description: (workspaceName: string) => `Review activity reports for ${workspaceName}`,
selectWorkspace: "Please select a workspace first.",
chartTab: "Chart",
tableTab: "Table",
period: "Period",
periodThisWeek: "This week",
periodThisMonth: "This month",
periodThisYear: "This year",
periodFirstHalf: "First half of year",
periodSecondHalf: "Second half of year",
periodCustom: "Custom period",
fromDate: "From date",
toDate: "To date",
reports: {
title: "Reports",
description: (workspaceName: string) => `Review activity reports for ${workspaceName}`,
selectWorkspace: "Please select a workspace first.",
chartTab: "Chart",
tableTab: "Table",
period: "Period",
periodThisWeek: "This week",
periodThisMonth: "This month",
periodThisYear: "This year",
periodFirstHalf: "First half of year",
periodSecondHalf: "Second half of year",
periodCustom: "Custom period",
fromDate: "From date",
toDate: "To date",
user: "User",
mobile: "Mobile",
allUsers: "All users",
searchUsers: "Search users...",
client: "Client",
allClients: "All clients",
searchClients: "Search clients...",
project: "Project",
allProjects: "All projects",
searchProjects: "Search projects...",
tags: "Tags",
allTags: "All tags",
searchTags: "Search tags...",
name: "Name",
clear: "Clear",
apply: "Apply",
totalHours: "Total hours",
billableHours: "Billable hours",
nonBillableHours: "Non-billable hours",
searchUsers: "Search users...",
client: "Client",
allClients: "All clients",
searchClients: "Search clients...",
project: "Project",
allProjects: "All projects",
searchProjects: "Search projects...",
tags: "Tags",
allTags: "All tags",
searchTags: "Search tags...",
name: "Name",
clear: "Clear",
apply: "Apply",
totalHours: "Total hours",
billableHours: "Billable hours",
nonBillableHours: "Non-billable hours",
hourlyRate: "Hourly rate",
hourlyRates: "Hourly rates",
workingHours: "Working hours",
nonWorkingHours: "Non-working hours",
totalIncome: "Total income",
projectPercentages: "Project percentages",
clientPercentages: "Client percentages",
tagPercentages: "Tag percentages",
userSummaryTitle: "Summary by user",
userSummaryDetailsTitle: "User details: {name}",
userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.",
rateHistory: "Rate history",
percentage: "Percentage",
hourPercentage: "Hour %",
incomePercentage: "Income %",
now: "Now",
chartTitle: "Activity chart",
totalSeconds: "Total seconds",
exportExcel: "Export Excel",
exportPdf: "Export PDF",
date: "Date",
totalSeconds: "Total seconds",
exportExcel: "Export Excel",
exportPdf: "Export PDF",
date: "Date",
details: "Details",
total: "Total",
noData: "No data",
clientsTable: "Clients",
projectsTable: "Projects",
tagsTable: "Tags",
loadError: "Failed to load reports.",
loadDayDetailsError: "Failed to load day details.",
loadFiltersError: "Failed to load report filters.",
exportQueued: "Export queued. You will receive a notification with the download link.",
exportError: "Failed to queue report export.",
},
notifications: {
title: "Notifications",
open: "Open notifications",
empty: "No notifications yet.",
loading: "Loading notifications...",
loadingMore: "Loading more...",
loadMore: "Load more",
markAllRead: "Mark all as read",
markSeenError: "Failed to update notification",
markAllError: "Failed to update notifications",
deleteError: "Failed to delete notification",
loadError: "Failed to load notifications",
openError: "Failed to open notification",
newTitle: "New notification",
openAction: "Open",
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
},
}
projectsTable: "Projects",
tagsTable: "Tags",
loadError: "Failed to load reports.",
loadDayDetailsError: "Failed to load day details.",
loadFiltersError: "Failed to load report filters.",
exportQueued: "Export queued. You will receive a notification with the download link.",
exportError: "Failed to queue report export.",
},
logs: {
eyebrow: "Workspace activity",
title: "Activity logs",
description: (workspaceName: string) => `Review what has happened inside ${workspaceName}.`,
selectWorkspace: "Please select a workspace first.",
unauthorized: "Only owners and admins can access workspace activity logs.",
loading: "Loading logs...",
loadingUsers: "Loading users...",
loadingDetails: "Loading details...",
loadError: "Failed to load logs.",
loadDetailsError: "Failed to load log details.",
loadFiltersError: "Failed to load log filters.",
search: "Search",
searchPlaceholder: "Search logs...",
section: "Section",
allSections: "All sections",
event: "Event",
allEvents: "All events",
actor: "Actor",
allActors: "All actors",
searchActors: "Search users...",
ordering: "Ordering",
newestFirst: "Newest first",
oldestFirst: "Oldest first",
fromDate: "From date",
toDate: "To date",
clear: "Clear",
apply: "Apply",
loadMore: "Load more",
totalLogs: "Total logs",
activeFilters: "Active filters",
latestActivity: "Latest activity",
resultsCount: (count: number) => `${count} results`,
empty: "No activity logs found",
emptyHint: "Adjust your filters or wait for new workspace activity.",
detailsTitle: "Activity details",
detailsHint: "Select an activity item to inspect the exact field changes.",
selectLogHint: "Select a log entry to see its details.",
target: "Target",
timestamp: "Timestamp",
remoteAddress: "Remote address",
previousValue: "Previous",
currentValue: "Current",
changesTitle: "Changes",
noDetails: "No field-level details are available for this activity.",
snapshot: "Serialized snapshot",
unknownActor: "Unknown actor",
summary: (actor: string, event: string, section: string, target: string) =>
`${actor} ${event.toLowerCase()} ${target} in ${section.toLowerCase()}`,
sections: {
workspace: "Workspace",
workspace_members: "Workspace members",
clients: "Clients",
projects: "Projects",
tags: "Tags",
time_entries: "Time entries",
rates: "Rates",
report_exports: "Report exports",
},
events: {
create: "Created",
update: "Updated",
delete: "Deleted",
restore: "Restored",
archive: "Archived",
unarchive: "Unarchived",
activate: "Activated",
deactivate: "Deactivated",
},
},
notifications: {
title: "Notifications",
pageDescription: "Review all notifications and export updates.",
open: "Open notifications",
empty: "No notifications yet.",
emptyUnread: "No unread notifications.",
loading: "Loading notifications...",
loadingMore: "Loading more...",
loadMore: "Load more",
markAllRead: "Mark all as read",
viewAll: "View all notifications",
totalLabel: "Total notifications",
unreadLabel: "Unread notifications",
deleteLabel: "Delete notification",
markSeenError: "Failed to update notification",
markAllError: "Failed to update notifications",
deleteError: "Failed to delete notification",
loadError: "Failed to load notifications",
openError: "Failed to open notification",
newTitle: "New notification",
openAction: "Open",
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
workspaceMembershipAddedTitle: "Added to workspace",
workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) =>
`${actor} added you to ${workspace} as ${role}.`,
workspaceMembershipRoleChangedTitle: "Workspace role changed",
workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) =>
`${actor} changed your role in ${workspace} from ${previousRole} to ${newRole}.`,
workspaceMembershipDeactivatedTitle: "Workspace access deactivated",
workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) =>
`${actor} deactivated your access to ${workspace}.`,
workspaceMembershipRemovedTitle: "Removed from workspace",
workspaceMembershipRemovedMessage: (actor: string, workspace: string) =>
`${actor} removed you from ${workspace}.`,
reportExportReadyTitle: "Report export is ready",
reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) =>
`Your ${exportType.toUpperCase()} report for ${workspace} is ready${fileName ? `: ${fileName}` : ""}.`,
reportExportFailedTitle: "Report export failed",
reportExportFailedMessage: (exportType: string, workspace: string) =>
`Your ${exportType.toUpperCase()} report for ${workspace} could not be generated.`,
},
}

View File

@@ -12,6 +12,7 @@ export const fa = {
remove: "حذف",
lightMode: "حالت روشن",
darkMode: "حالت تاریک",
settings: "تنظیمات",
loadingText: "در حال بارگذاری...",
loading: "در حال بارگذاری...",
noMoreResults: "نتیجه دیگری نیست.",
@@ -25,33 +26,117 @@ export const fa = {
},
login: {
loginTitle: "ورود به حساب",
loginDescription: "شماره موبایل خود را وارد کنید تا کد یکبار مصرف برای شما ارسال شود.",
loginCta: "ورود",
createAccount: "ایجاد حساب",
haveNoAccount: "حساب ندارید؟",
haveAccount: "قبلا حساب دارید؟",
loginOtpTitle: "کد ورود را تایید کنید",
passwordLoginTitle: "ورود با رمز عبور",
passwordLoginDescription: (mobile: string) => `رمز عبور مربوط به ${mobile} را وارد کنید`,
usePasswordInstead: "استفاده از رمز عبور",
useOtpInstead: "استفاده از کد یکبار مصرف",
backToMobile: "بازگشت به مرحله موبایل",
backToPasswordLogin: "بازگشت به ورود با رمز عبور",
forgotPassword: "رمز عبور را فراموش کرده‌اید؟",
signupTitle: "ساخت حساب جدید",
signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.",
sendSignupCode: "ارسال کد تایید",
signupVerifyTitle: "تایید شماره موبایل",
continueToPassword: "ادامه به مرحله رمز عبور",
signupPasswordTitle: "تعیین رمز عبور",
signupPasswordDescription: "برای حساب جدید خود یک رمز عبور انتخاب کنید.",
createAccountPasswordCta: "ایجاد حساب",
forgotPasswordTitle: "بازیابی رمز عبور",
forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.",
sendResetCode: "ارسال کد بازیابی",
forgotPasswordVerifyTitle: "کد بازیابی را وارد کنید",
continueToResetPassword: "ادامه برای تعیین رمز جدید",
resetPasswordTitle: "انتخاب رمز عبور جدید",
resetPasswordDescription: "رمز عبور جدید خود را وارد کنید و آن را تایید کنید.",
resetPasswordCta: "تغییر رمز عبور",
newPasswordPlaceholder: "رمز عبور جدید",
confirmPasswordPlaceholder: "تکرار رمز عبور",
passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.",
passwordRequirements:
"رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.",
passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.",
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
enterPassword: "رمز عبور خود را وارد کنید",
verifyNumber: "تایید شماره موبایل",
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`,
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
continueWithPassword: "ادامه با رمز عبور",
continueWithGoogle: "ادامه با گوگل",
orContinueWith: "یا ادامه با",
otpLogin: "ورود با کد یکبار مصرف",
register: "ثبت نام",
passwordPlaceholder: "رمز عبور",
signIn: "ورود",
back: "بازگشت",
otpPlaceholder: "کد ۶ رقمی",
otpPlaceholder: "کد ۵ رقمی",
verifyAndContinue: "تایید و ادامه",
sendingOtp: "در حال ارسال کد...",
verifyingOtp: "در حال تأیید کد...",
resendOtp: "ارسال دوباره کد",
otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`,
otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.",
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.",
brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
toasts: {
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
verifySent: "کد تایید ارسال شد!",
failedOtp: "ارسال کد تایید با خطا مواجه شد",
fillAll: "لطفا تمام فیلدها را پر کنید",
successLogin: "با موفقیت وارد شدید!",
invalidCreds: "اطلاعات ورود نامعتبر است",
enterOtp: "لطفا کد تایید را وارد کنید",
invalidOtp: "کد تایید نامعتبر است"
verifySent: "کد تایید ارسال شد.",
failedOtp: "ارسال کد تایید انجام نشد.",
fillAll: "لطفا تمام فیلدها را پر کنید.",
successLogin: "با موفقیت وارد شدید.",
accountCreated: "حساب با موفقیت ایجاد شد.",
failedSignup: "تکمیل ثبت نام انجام نشد.",
invalidCreds: "اطلاعات ورود نامعتبر است.",
enterOtp: "لطفا کد تایید را وارد کنید.",
invalidOtp: "کد تایید نامعتبر است.",
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
passwordResetFailed: "تغییر رمز عبور انجام نشد."
},
throttle: {
title: "تعداد تلاش‌ها بیش از حد مجاز است",
genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`,
otpSendMessage: (time: string) => `ارسال کد یکبار مصرف بیش از حد مجاز انجام شده است. ${time} دیگر دوباره تلاش کنید.`,
passwordLoginMessage: (time: string) => `تلاش برای ورود با رمز عبور بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
otpLoginMessage: (time: string) => `تلاش برای ورود با کد یکبار مصرف بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
},
google: {
loadingTitle: "در حال تکمیل ورود با گوگل",
loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.",
collectMobileTitle: "ساخت حساب را کامل کنید",
collectMobileDescription: (email: string) =>
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
existingEmailClaimDescription: (email: string, mobileHint: string) =>
`حساب گوگل ${email} تایید شد. برای تایید مالکیت، شماره موبایل متصل به این حساب (${mobileHint}) را وارد کنید.`,
claimTitle: "حساب موجود خود را تایید کنید",
claimDescription: (mobile: string) =>
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
mobileClaimDescription: (mobile: string) =>
`حسابی بدون ایمیل با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
errorTitle: "ورود با گوگل کامل نشد",
cancelled: "فرآیند ورود با گوگل قبل از تکمیل لغو شد.",
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
callbackFailed: "تکمیل ورود با گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
tokenExchangeFailed: "ورود با گوگل موقتاً در دسترس نیست. چند دقیقه دیگر دوباره تلاش کنید.",
profileLookupFailed: "دریافت اطلاعات حساب گوگل انجام نشد. لطفاً دوباره تلاش کنید.",
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
googleAccount: "حساب گوگل",
mobileHintLabel: (mobileHint: string) => `شماره مورد انتظار: ${mobileHint}`,
completeButton: "ادامه و ایجاد حساب",
verifyClaimButton: "تایید و ادامه",
resendClaimOtp: "ارسال دوباره کد تایید",
restartGoogle: "شروع دوباره ورود با گوگل",
}
},
@@ -61,6 +146,18 @@ export const fa = {
suffix: " ما موافقت می‌کنید."
},
rateLimit: {
eyebrow: "محدودیت درخواست فعال شده است",
title: "لطفاً پیش از تلاش دوباره صبر کنید",
message: "درخواست‌های زیادی ارسال شده است. دسترسی شما تا پایان زمان انتظار به صورت موقت محدود شده است.",
cooldownLabel: "زمان انتظار",
waitingMessage: (time: string) => `ارسال درخواست برای مدتی مسدود است.`,
finishedMessage: "زمان انتظار به پایان رسیده است. اکنون می‌توانید ادامه دهید.",
continue: "ادامه",
continueCooldown: (time: string) => `ادامه تا ${time}`,
ready: "آماده",
},
terms: {
back: "بازگشت",
title: "شرایط خدمات و حریم خصوصی",
@@ -117,11 +214,25 @@ export const fa = {
remove: "حذف",
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
noEmail: "ایمیلی ثبت نشده",
password: {
trigger: "تغییر رمز عبور",
title: "تغییر رمز عبور",
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
currentPassword: "رمز عبور فعلی",
newPassword: "رمز عبور جدید",
confirmPassword: "تکرار رمز جدید",
submit: "ذخیره رمز عبور",
saving: "در حال ذخیره...",
toasts: {
success: "رمز عبور با موفقیت تغییر کرد.",
error: "تغییر رمز عبور انجام نشد.",
},
},
toasts: {
successEdit: "پروفایل با موفقیت بروزرسانی شد!",
successImage: "عکس پروفایل بروزرسانی شد!",
successRemoveImage: "عکس پروفایل حذف شد!",
error: "خطایی رخ داد!"
successEdit: "پروفایل با موفقیت بهروزرسانی شد.",
successImage: "عکس پروفایل بهروزرسانی شد.",
successRemoveImage: "عکس پروفایل حذف شد.",
error: "خطایی رخ داد."
}
},
@@ -132,8 +243,13 @@ export const fa = {
nameLabel: "عنوان",
namePlaceholder: "نام ورک‌اسپیس را وارد کنید",
descriptionLabel: "توضیحات",
descriptionPlaceholder: "توضیحات (اختیاری)",
searchMemberPlaceholder: "جستجو با موبایل دقیق (مثلا 09123456789)",
descriptionPlaceholder: "توضیحات (اختیاری)",
thumbnailLabel: "تصویر",
uploadImage: "برای آپلود تصویر کلیک کنید",
removeImage: "حذف تصویر",
thumbnailInvalidType: "نوع تصویر پشتیبانی نمی‌شود. از JPG، PNG یا WebP استفاده کنید.",
thumbnailMaxSizeError: "حجم تصویر باید حداکثر ۲ مگابایت باشد.",
searchMemberPlaceholder: "جستجو با موبایل دقیق (مثلا 09123456789)",
addMember: "افزودن عضو",
roleAdmin: "مدیر",
roleMember: "عضو",
@@ -150,6 +266,8 @@ export const fa = {
subtitle: "ورک‌اسپیس‌های خود را مدیریت کنید",
noDescription: "بدون توضیحات",
emptyState: "شما در هیچ ورک‌اسپیس عضو نیستید.",
noWorkspaceSearch: "لطفاً عبارت جستجو را تغییر دهید.",
noWorkspace: "ورک‌اسپیس یافت نشد.",
createTitle: "ایجاد ورک‌اسپیس",
editTitle: "ویرایش ورک‌اسپیس",
detailTitle: "جزئیات ورک‌اسپیس",
@@ -159,6 +277,22 @@ export const fa = {
noWorkspaceDesc: "لطفاً اولین ورک‌اسپیس خود را ایجاد کنید.",
back: "بازگشت به ورک‌اسپیس‌ها",
roleLabel: "نقش شما",
openReports: "مشاهده گزارش‌ها",
statsMembers: "اعضا",
statsRates: "نرخ‌های ثبت‌شده",
statsOwnersAdmins: "مالکان و ادمین‌ها",
statsGuests: "مهمان‌ها",
membersSectionTitle: "اعضا",
membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.",
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.",
manageMembers: "مدیریت اعضا",
mobileNumber: "شماره تماس",
youLabel: "شما",
resourcesTitle: "منابع",
resourceOpen: "مشاهده",
roleDistributionTitle: "توزیع نقش‌ها",
unknownMember: "عضو ناشناس",
roles: {
owner: "مالک",
admin: "ادمین",
@@ -167,8 +301,10 @@ export const fa = {
},
createdSuccess: "ورک‌اسپیس با موفقیت ایجاد شد",
updatedSuccess: "ورک‌اسپیس با موفقیت ویرایش شد",
fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس",
remove: "حذف",
fetchError: "خطا در دریافت اطلاعات ورک‌اسپیس",
loadErrorDescription: "ممکن است سرویس بک‌اند در دسترس نباشد. لطفاً چند لحظه بعد دوباره تلاش کنید.",
retry: "تلاش دوباره",
remove: "حذف",
noUsersFound: "کاربری یافت نشد",
selectRole: "انتخاب نقش",
add: "افزودن",
@@ -199,13 +335,14 @@ export const fa = {
cannotAddSelf: "شما به‌صورت خودکار مالک هستید.",
},
onlyNumbersAllowed: "برای شماره موبایل فقط مجاز به وارد کردن عدد هستید.",
weekTotal: "مجموع هفته"
},
clients: {
title: "مشتریان",
description: (workspaceName: string) => `مدیریت مشتریان برای ${workspaceName}`,
title: "مشتریها",
description: (workspaceName: string) => `مدیریت مشتریها برای ${workspaceName}`,
addClient: "افزودن مشتری",
searchPlaceholder: "جستجوی مشتریان...",
searchPlaceholder: "جستجوی مشتریها...",
noClients: "مشتری یافت نشد",
noClientsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
noClientsAdd: "برای شروع اولین مشتری خود را اضافه کنید.",
@@ -223,9 +360,12 @@ export const fa = {
deleteConfirmMessage: (name: string) => `آیا از حذف ${name} اطمینان دارید؟`,
delete: "حذف",
saveChanges: "ذخیره تغییرات",
createSuccess: "مشتری با موفقیت ایجاد شد.",
updateSuccess: "مشتری با موفقیت به‌روزرسانی شد.",
deleteSuccess: "مشتری با موفقیت حذف شد.",
errors: {
createFailed: "خطا در ایجاد مشتری",
fetchFailed: "خطا در دریافت لیست مشتریان",
fetchFailed: "خطا در دریافت لیست مشتریها",
updateFailed: "خطا در ویرایش مشتری",
deleteFailed: "خطا در حذف مشتری",
},
@@ -244,14 +384,114 @@ export const fa = {
sidebar: {
timesheet: 'تایم‌شیت',
reports: 'گزارش‌ها',
logs: "لاگ‌ها",
workspaces: 'ورک‌اسپیس‌ها',
clients: 'مشتریان',
clients: 'مشتریها',
projects: "پروژه‌ها",
tags: "تگ‌ها",
expand: 'باز کردن',
collapse: 'جمع کردن',
},
landing: {
brandLabel: "زیرساخت عملیاتی زمان",
eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند",
nav: {
demo: "دموی محصول",
features: "قابلیت‌ها",
workflow: "فرآیند کار",
about: "درباره ما",
},
actions: {
switchToEnglish: "English",
switchToPersian: "فارسی",
signIn: "ورود",
openApp: "ورود به اپ",
openWorkspace: "باز کردن ورک‌اسپیس",
startNow: "شروع با کنترل کامل",
watchDemo: "مشاهده دموی محصول",
readTerms: "مطالعه قوانین",
readAbout: "درباره Qlockify",
},
hero: {
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
titleAccent: "Qlockify زمان را شفاف، پاسخ‌گو و قابل‌صورتحساب می‌کند.",
description:
"یک محیط متمرکز برای تیم‌های مدرن که به ثبت سریع زمان، رهگیری دقیق پروژه، گزارش‌های قابل اتکا و لاگ عملیاتی واقعی برای مدیریت نیاز دارند.",
},
metrics: {
capture: "ثبت تمیزتر ساعات قابل‌صورتحساب",
visibility: "دسترسی سریع‌تر به دید گزارش‌دهی",
decision: "از ورودی خام تا تصمیم مدیریتی",
},
trust: {
first: "تایمر دقیق با امکان ویرایش دستی در زمان لازم",
second: "دسترسی‌ها، لاگ‌ها و گزارش‌های مبتنی بر نرخ",
third: "مناسب آژانس‌ها، شرکت‌های مشاوره، تیم‌های محصول و عملیات",
},
capabilities: {
time: {
title: "ثبت کار بدون اصطکاک",
description:
"تایمر را شروع کنید، ورودی‌های گذشته را اصلاح کنید و پروژه و تگ را بدون ایجاد اصطکاک برای تیم به هر ساعت متصل نگه دارید.",
},
reports: {
title: "کسب‌وکار را در چند دقیقه بخوانید",
description:
"خروجی روزانه، عملکرد قابل‌صورتحساب، توزیع پروژه‌ها و بسته‌های گزارشی قابل خروجی را بدون پاک‌سازی دستی فایل‌ها ببینید.",
},
control: {
title: "عملیات را قابل توضیح نگه دارید",
description:
"ببینید چه کسی چه چیزی را تغییر داده، نقش‌ها را شفاف نگه دارید و برای مدیریت یک رد عملیاتی تمیزتر از چت و فایل دستی بسازید.",
},
},
demo: {
timerTag: "تایمر زنده",
timerTitle: "بازه اجرای فعلی",
timerText: "بهبود دیزاین سیستم، متصل به پروژه درست، تگ‌های صحیح و نرخ قابل‌صورتحساب.",
panelLabel: "پیش‌نمایش تعاملی محصول",
panelTitle: "یک سطح واحد برای رهگیری، گزارش‌دهی و شفافیت عملیاتی",
runningCard: "ورودی فعال",
currentTask: "پیاده‌سازی لندینگ سازمانی",
currentTaskMeta: "پروژه: بازاریابی Qlockify · تگ‌ها: طراحی، بازبینی، تحویل",
billableLabel: "نرخ زنده قابل‌صورتحساب",
reportCard: "روند گزارش روزانه",
opsCard: "سلامت عملیات",
opsLabels: ["پوشش", "تمرکز تیم", "آمادگی صورتحساب"],
logCard: "آخرین فعالیت‌های ورک‌اسپیس",
logItems: [
{ title: "نرخ تیم طراحی محصول به‌روزرسانی شد", meta: "اقدام مالک · ۳ دقیقه پیش" },
{ title: "پروژه مشتری‌محور بایگانی شد", meta: "اقدام ادمین · ۱۸ دقیقه پیش" },
{ title: "تگ تاریخی روی ورودی ویرایش‌شده حفظ شد", meta: "اقدام عضو · ۴۱ دقیقه پیش" },
],
outcomeTag: "خروجی مدیریتی",
outcomeText: "ابهام کمتر در پایان ماه، ساعات قابل‌صورتحساب از‌دست‌رفته کمتر و بازبینی عملیاتی سریع‌تر.",
},
workflowTag: "فرآیند عملیاتی",
workflowTitle: "از تلاش خام تا داده مدیریتی قابل استفاده، با یک حلقه کوتاه‌تر.",
workflowDescription:
"Qlockify مسیر را کوتاه نگه می‌دارد: دقیق ثبت کنید، یک‌بار بستر را درست بسازید و همان نتیجه را در تایم‌شیت، گزارش و تصمیم‌گیری مدیریتی مصرف کنید.",
workflow: {
capture: "زمان را در مبدأ، همراه با پروژه، تگ و بستر مالی ثبت کنید.",
structure: "هر تغییر در اعضا، نرخ‌ها و تنظیمات ورک‌اسپیس را قابل مشاهده و قابل بررسی نگه دارید.",
improve: "عملکرد روزانه و ماهانه را با گزارش‌هایی بخوانید که آماده خروجی و اقدام هستند.",
},
finalCtaTag: "آماده برای تیم‌های جدی",
finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.",
finalCtaDescription:
"اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.",
},
demo: {
badge: "محیط دمو",
starting: "در حال آماده‌سازی دمو...",
started: "محیط دمو آماده شد.",
startError: "امکان ساخت محیط دمو وجود ندارد.",
expiresAt: "زمان انقضا",
resetAction: "شروع دوباره دمو",
reset: "محیط دموی تازه آماده شد.",
},
ordering: {
createdAtDesc: "جدیدترین",
createdAt: "قدیمی‌ترین",
@@ -264,9 +504,10 @@ export const fa = {
title: "پروژه‌ها",
description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`,
active: "پروژه‌های فعال",
archived: "پروژه‌های بایگانی شده",
archived: "بایگانی شده",
createNew: "ایجاد پروژه جدید",
searchPlaceholder: "جستجوی پروژه‌ها...",
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
titlePlaceholder: "عنوان پروژه",
descriptionPlaceholder: "توضیحات پروژه",
titleLabel: "عنوان",
@@ -277,6 +518,7 @@ export const fa = {
client: "مشتری",
noClient: "بدون مشتری",
emptyState: "پروژه‌ای یافت نشد",
noProjectsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
deleteTitle: "حذف پروژه",
deleteWarning: "برای تایید حذف، لطفاً نام پروژه را تایپ کنید:",
deleteSuccess: "پروژه با موفقیت حذف شد",
@@ -287,7 +529,12 @@ export const fa = {
editProject: "ویرایش پروژه",
restore: "بازیابی",
archive: "بایگانی",
clientFetchError: "خطا در دریافت لیست مشتریان.",
archiveSuccess: "پروژه با موفقیت بایگانی شد.",
restoreSuccess: "پروژه با موفقیت بازیابی شد.",
fetchError: "خطا در دریافت پروژه‌ها.",
clientFetchError: "خطا در دریافت لیست مشتری‌ها.",
filterClients: "فیلتر بر اساس مشتری",
clearClientFilters: "پاک کردن فیلترها",
memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
creator: "سازنده",
addUser: "افزودن کاربر",
@@ -302,6 +549,24 @@ export const fa = {
},
namePlaceholder: "نام پروژه...",
teamMembers: "اعضای تیم",
manageAccess: "پروژه‌ها و نرخ‌ها",
accessModalTitle: "پروژه‌ها و نرخ‌ها",
accessModalDescription: "دسترسی پروژه‌ها را برای اعضا و مهمان‌ها مدیریت کنید و برای هر کاربر ورک‌اسپیس نرخ اختصاصی پروژه ثبت کنید.",
accessMemberLabel: "کاربر",
accessNoMembers: "کاربری در این ورک‌اسپیس پیدا نشد.",
accessNoProjects: "پروژه‌ای پیدا نشد.",
accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
accessClearSelection: "پاک کردن انتخاب",
accessSelectClientProjects: "انتخاب همه پروژه‌های این مشتری",
accessGrant: "اعطای دسترسی به موارد انتخاب‌شده",
accessRevoke: "لغو دسترسی موارد انتخاب‌شده",
accessOn: "دارای دسترسی",
accessOff: "بدون دسترسی",
accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.",
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
accessLoadError: "بارگذاری وضعیت دسترسی پروژه‌ها انجام نشد.",
accessSaveError: "به‌روزرسانی دسترسی پروژه‌ها انجام نشد.",
implicitAccessHint: "مالک‌ها و ادمین‌ها همیشه به همه پروژه‌ها دسترسی دارند. از اینجا فقط می‌توانید نرخ اختصاصی پروژه برای آن‌ها تنظیم کنید.",
createSuccess: "پروژه با موفقیت ایجاد شد.",
createError: "خطا در ایجاد پروژه.",
updateSuccess: "پروژه با موفقیت به‌روزرسانی شد.",
@@ -329,6 +594,7 @@ export const fa = {
namePlaceholder: "مثلاً طراحی",
colorLabel: "رنگ",
emptyState: "تگی یافت نشد",
noTagsSearch: "لطفاً عبارت جستجو را تغییر دهید.",
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
fetchError: "دریافت تگ‌ها با خطا مواجه شد.",
createSuccess: "تگ با موفقیت ایجاد شد.",
@@ -341,8 +607,17 @@ export const fa = {
rates: {
workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس",
projectSectionTitle: "نرخ‌های کاربران پروژه",
myRatesTitle: "تعرفه‌های من",
myRatesHint: "نرخ‌های اختصاصی پروژه در این ورک‌اسپیس روی نرخ پیش‌فرض شما اولویت دارند.",
workspaceRate: "دستمزد ساعتی",
workspaceRateHint: "این نرخ پیش‌فرض شما است مگر این‌که برای یک پروژه نرخ اختصاصی ثبت شده باشد.",
projectOverride: "نرخ اختصاصی پروژه",
projectOverrides: "نرخ‌های اختصاصی پروژه",
accessibleProjects: "پروژه‌های دردسترس",
workspaceFallbackProjects: "با نرخ ورک‌اسپیس",
projectOverrideHint: "فقط پروژه‌هایی که نرخ اختصاصی دارند اینجا نمایش داده می‌شوند. بقیه پروژه‌های دردسترس از نرخ ورک‌اسپیس استفاده می‌کنند.",
projectOverrideEmpty: "برای شما در این ورک‌اسپیس هنوز نرخ اختصاصی پروژه‌ای ثبت نشده است.",
myRatesEmpty: "هنوز نرخی برای این ورک‌اسپیس ثبت نشده است.",
inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی",
noRate: "بدون نرخ",
hourlyRatePlaceholder: "0.00",
@@ -375,6 +650,8 @@ export const fa = {
orderingOldest: "قدیمی‌ترین",
emptyState: "ورودی زمانی یافت نشد",
emptyDescription: "بدون توضیح",
emptyStateDescription: "برای شروع، تایمر را اجرا کنید یا یک ورودی دستی اضافه کنید.",
noEntriesSearch: "عبارت جست‌وجو یا فیلترهای خود را تغییر دهید.",
createTitle: "افزودن ورودی زمان",
startTitle: "شروع تایمر",
editTitle: "ویرایش ورودی زمان",
@@ -394,6 +671,7 @@ export const fa = {
noProject: "بدون پروژه",
startLabel: "شروع",
endLabel: "پایان",
timeLabel: "زمان",
billable: "قابل صورتحساب",
noTagsHint: "ابتدا از صفحه تگ‌ها، تگ ایجاد کنید.",
clearFilters: "پاک کردن فیلترها",
@@ -410,7 +688,21 @@ export const fa = {
tagFilterPrefix: "تگ",
fromFilterPrefix: "از",
toFilterPrefix: "تا",
},
deleteTitle: "حذف ورودی زمان",
deleteConfirmMessage: "آیا از حذف این ورودی زمان اطمینان دارید؟",
restartConfirmMessage: "می‌خواهید یک تایمر جدید را از روی این ورودی شروع کنید؟",
discardConfirmMessage: "آیا از دور انداختن این تایمر در حال اجرا اطمینان دارید؟",
searchTagsLabel: "جست‌وجوی تگ‌ها...",
noTagsFoundLabel: "تگی پیدا نشد.",
searchProjectsLabel: "جست‌وجوی پروژه‌ها...",
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده",
startRequiredError: "تاریخ و زمان شروع الزامی است.",
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
invalidEndTimeError: "زمان پایان معتبر نیست.",
endBeforeStartError: "پایان باید بعد از شروع باشد.",
},
reports: {
title: "گزارش‌ها",
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
@@ -427,6 +719,7 @@ export const fa = {
fromDate: "از تاریخ",
toDate: "تا تاریخ",
user: "کاربر",
mobile: "موبایل",
allUsers: "همه کاربران",
searchUsers: "جست‌وجوی کاربران...",
client: "مشتری",
@@ -444,7 +737,22 @@ export const fa = {
totalHours: "مجموع ساعت",
billableHours: "ساعات کاری",
nonBillableHours: "ساعات غیر کاری",
totalIncome: "مجموع درآمد",
hourlyRate: "نرخ ساعتی",
hourlyRates: "نرخ‌های ساعتی",
workingHours: "ساعات کاری",
nonWorkingHours: "ساعات غیرکاری",
totalIncome: "مجموع کارکرد",
projectPercentages: "درصد پروژه‌ها",
clientPercentages: "درصد مشتری‌ها",
tagPercentages: "درصد تگ‌ها",
userSummaryTitle: "خلاصه کاربران",
userSummaryDetailsTitle: "جزئیات کاربر: {name}",
userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.",
rateHistory: "تاریخچه نرخ‌ها",
percentage: "درصد",
hourPercentage: "درصد ساعت",
incomePercentage: "درصد کارکرد",
now: "حال",
chartTitle: "نمودار فعالیت",
totalSeconds: "مجموع ثانیه",
exportExcel: "خروجی Excel",
@@ -452,6 +760,7 @@ export const fa = {
date: "تاریخ",
details: "جزئیات",
total: "مجموع",
noData: "داده‌ای وجود ندارد",
clientsTable: "مشتری‌ها",
projectsTable: "پروژه‌ها",
tagsTable: "تگ‌ها",
@@ -461,21 +770,115 @@ export const fa = {
exportQueued: "درخواست خروجی ثبت شد. پیوند دانلود از طریق اعلان ارسال می‌شود.",
exportError: "ثبت درخواست خروجی با خطا مواجه شد.",
},
logs: {
eyebrow: "فعالیت‌های ورک‌اسپیس",
title: "لاگ‌های فعالیت",
description: (workspaceName: string) => `مرور رویدادهای ثبت‌شده در ${workspaceName}`,
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
unauthorized: "فقط مالک و ادمین می‌توانند لاگ‌های فعالیت ورک‌اسپیس را مشاهده کنند.",
loading: "در حال بارگذاری لاگ‌ها...",
loadingUsers: "در حال بارگذاری کاربران...",
loadingDetails: "در حال بارگذاری جزئیات...",
loadError: "دریافت لاگ‌ها با خطا مواجه شد.",
loadDetailsError: "دریافت جزئیات لاگ با خطا مواجه شد.",
loadFiltersError: "دریافت فیلترهای لاگ با خطا مواجه شد.",
search: "جست‌وجو",
searchPlaceholder: "جست‌وجوی لاگ‌ها...",
section: "بخش",
allSections: "همه بخش‌ها",
event: "رویداد",
allEvents: "همه رویدادها",
actor: "انجام‌دهنده",
allActors: "همه کاربران",
searchActors: "جست‌وجوی کاربران...",
ordering: "مرتب‌سازی",
newestFirst: "جدیدترین",
oldestFirst: "قدیمی‌ترین",
fromDate: "از تاریخ",
toDate: "تا تاریخ",
clear: "پاک کردن",
apply: "اعمال",
loadMore: "بارگذاری بیشتر",
totalLogs: "کل لاگ‌ها",
activeFilters: "فیلترهای فعال",
latestActivity: "آخرین فعالیت",
resultsCount: (count: number) => `${count} نتیجه`,
empty: "لاگ فعالیتی پیدا نشد",
emptyHint: "فیلترها را تغییر دهید یا منتظر فعالیت جدید بمانید.",
detailsTitle: "جزئیات فعالیت",
detailsHint: "برای بررسی دقیق تغییرات، یک مورد را انتخاب کنید.",
selectLogHint: "یک لاگ را برای مشاهده جزئیات انتخاب کنید.",
target: "هدف",
timestamp: "زمان",
remoteAddress: "آدرس شبکه",
previousValue: "مقدار قبلی",
currentValue: "مقدار جدید",
changesTitle: "تغییرات",
noDetails: "برای این رویداد جزئیات فیلدی در دسترس نیست.",
snapshot: "نمونه ذخیره‌شده",
unknownActor: "کاربر نامشخص",
summary: (actor: string, event: string, section: string, target: string) =>
`${actor} ${target} را در بخش ${section} ${event}`,
sections: {
workspace: "ورک‌اسپیس",
workspace_members: "اعضای ورک‌اسپیس",
clients: "مشتری‌ها",
projects: "پروژه‌ها",
tags: "تگ‌ها",
time_entries: "ورودی‌های زمان",
rates: "نرخ‌ها",
report_exports: "خروجی‌های گزارش",
},
events: {
create: "ایجاد کرد",
update: "ویرایش کرد",
delete: "حذف کرد",
restore: "بازیابی کرد",
archive: "بایگانی کرد",
unarchive: "از بایگانی خارج کرد",
activate: "فعال کرد",
deactivate: "غیرفعال کرد",
},
},
notifications: {
title: "اعلان‌ها",
pageDescription: "مرور همه اعلان‌ها و وضعیت خروجی‌های گزارش.",
open: "باز کردن اعلان‌ها",
empty: "هنوز اعلانی وجود ندارد.",
emptyUnread: "اعلان خوانده‌نشده‌ای وجود ندارد.",
loading: "در حال بارگذاری اعلان‌ها...",
loadingMore: "در حال بارگذاری بیشتر...",
loadMore: "بارگذاری بیشتر",
markAllRead: "خواندن همه",
viewAll: "نمایش همه اعلان‌ها",
totalLabel: "مجموع اعلان‌ها",
unreadLabel: "اعلان‌های خوانده‌نشده",
deleteLabel: "حذف اعلان",
markSeenError: "به‌روزرسانی اعلان با خطا مواجه شد.",
markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.",
deleteError: "حذف اعلان با خطا مواجه شد.",
loadError: "دریافت اعلان‌ها با خطا مواجه شد.",
openError: "باز کردن اعلان با خطا مواجه شد.",
newTitle: "اعلان جدید",
markAllError: "به‌روزرسانی اعلان‌ها با خطا مواجه شد.",
deleteError: "حذف اعلان با خطا مواجه شد.",
loadError: "دریافت اعلان‌ها با خطا مواجه شد.",
openError: "باز کردن اعلان با خطا مواجه شد.",
newTitle: "اعلان جدید",
openAction: "باز کردن",
summary: (total: number, unread: number) => `${total} کل، ${unread} خوانده‌نشده`,
workspaceMembershipAddedTitle: "به ورک‌اسپیس اضافه شدید",
workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) =>
`${actor} شما را با نقش ${role} به ${workspace} اضافه کرد.`,
workspaceMembershipRoleChangedTitle: "نقش شما در ورک‌اسپیس تغییر کرد",
workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) =>
`${actor} نقش شما را در ${workspace} از ${previousRole} به ${newRole} تغییر داد.`,
workspaceMembershipDeactivatedTitle: "دسترسی ورک‌اسپیس غیرفعال شد",
workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) =>
`${actor} دسترسی شما به ${workspace} را غیرفعال کرد.`,
workspaceMembershipRemovedTitle: "از ورک‌اسپیس حذف شدید",
workspaceMembershipRemovedMessage: (actor: string, workspace: string) =>
`${actor} شما را از ${workspace} حذف کرد.`,
reportExportReadyTitle: "خروجی گزارش آماده است",
reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) =>
`خروجی ${exportType.toUpperCase()} گزارش ${workspace}${fileName ? ` با نام ${fileName}` : ""} آماده دانلود است.`,
reportExportFailedTitle: "خروجی گزارش ناموفق بود",
reportExportFailedMessage: (exportType: string, workspace: string) =>
`تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`,
},
}

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>
<App />
</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,233 +1,36 @@
import React, { useState } from "react"
import { useNavigate, Link } from "react-router-dom"
import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
import { SettingsMenu } from "../components/SettingsMenu"
import { ArrowLeft, ArrowRight, Command, Loader2, Eye, EyeOff } from "lucide-react"
import { toast } from "sonner"
import { Outlet } from "react-router-dom"
import { Command } from "lucide-react"
import { SettingsMenu } from "../components/SettingsMenu"
import { useTranslation } from "../hooks/useTranslation"
import { loginWithPassword, sendOtp, loginWithOtp } from "../api/users"
import { setSessionTokens } from "../lib/session"
type AuthStep = "mobile" | "password" | "otp"
type AuthMode = "login" | "register"
export default function Auth() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const isRtl = lang === "fa"
const [step, setStep] = useState<AuthStep>("mobile")
const [mode, setMode] = useState<AuthMode>("login")
const [mobile, setMobile] = useState("")
const [password, setPassword] = useState("")
const [otpCode, setOtpCode] = useState("")
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false) // Added state for password visibility
const handleTokenResponse = (access: string, refresh: string) => {
setSessionTokens(access, refresh)
toast.success(t.login.toasts.successLogin)
navigate("/profile")
}
const handleSendOtp = async (selectedMode: AuthMode) => {
if (!mobile) return toast.error(t.login.toasts.enterMobile)
setLoading(true)
try {
await sendOtp(mobile, selectedMode)
setMode(selectedMode)
setStep("otp")
toast.success(t.login.toasts.verifySent)
} catch (err: any) {
toast.error(t.login.toasts.failedOtp)
} finally {
setLoading(false)
}
}
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!mobile || !password) return toast.error(t.login.toasts.fillAll)
setLoading(true)
try {
const data = await loginWithPassword(mobile, password)
handleTokenResponse(data.access, data.refresh)
} catch (err: any) {
toast.error(t.login.toasts.invalidCreds)
} finally {
setLoading(false)
}
}
const handleOtpVerify = async (e: React.FormEvent) => {
e.preventDefault()
if (!mobile || !otpCode) return toast.error(t.login.toasts.enterOtp)
setLoading(true)
try {
const data = await loginWithOtp(mobile, otpCode)
handleTokenResponse(data.access, data.refresh)
} catch (err: any) {
toast.error(t.login.toasts.invalidOtp)
} finally {
setLoading(false)
}
}
const BackIcon = isRtl ? ArrowRight : ArrowLeft
return (
<div className="container relative min-h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0 bg-white dark:bg-slate-950 transition-colors">
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
<SettingsMenu />
</div>
<div className="relative hidden h-full flex-col bg-slate-900 dark:bg-slate-900/50 p-10 text-white lg:flex border-e border-slate-200 dark:border-slate-800">
<div className="relative z-20 flex items-center text-lg font-medium gap-2">
<Command className="h-6 w-6" />
{t.title || "Qlockify"}
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">"{t.login.brandingQuote}"</p>
</blockquote>
</div>
</div>
<div className="p-8 lg:p-8 flex h-screen items-center justify-center">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-87.5">
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
<div className="flex justify-center lg:hidden mb-4">
<Command className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{step === "mobile" && t.login.welcome(t.title)}
{step === "password" && t.login.enterPassword}
{step === "otp" && t.login.verifyNumber}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{step === "mobile" && t.login.enterMobileDesc}
{step === "password" && t.login.signInDesc}
{step === "otp" && t.login.sentCodeDesc(mobile)}
</p>
</div>
<div className="grid gap-6">
{step === "mobile" && (
<div className="grid gap-4">
<Input
id="mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
value={mobile}
onChange={(e) => setMobile(e.target.value)}
maxLength={11}
disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button onClick={() => { if (!mobile) return toast.error(t.login.toasts.enterMobile); setStep("password") }} className="w-full h-11">
{t.login.continueWithPassword}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200 dark:border-slate-800" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-slate-950 px-2 text-slate-500 dark:text-slate-400 transition-colors">
{t.login.orContinueWith}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" onClick={() => handleSendOtp("login")} disabled={loading} className="h-11">
{loading && mode === "login" && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.otpLogin}
</Button>
<Button variant="outline" onClick={() => handleSendOtp("register")} disabled={loading} className="h-11">
{loading && mode === "register" && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.register}
</Button>
</div>
</div>
)}
{step === "password" && (
<form onSubmit={handlePasswordLogin} autoComplete="off" className="grid gap-4">
<div className="relative w-full" dir="ltr">
<Input
id="password"
placeholder={t.login.passwordPlaceholder}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
dir="ltr"
name="some-random-name-to-disable-auto-complete-on-browser"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
className={`h-11 pr-10 ${isRtl ? "text-end" : "text-start"}`}
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<Button type="submit" className="w-full h-11" disabled={loading}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {t.login.signIn}
</Button>
<Button type="button" variant="ghost" onClick={() => setStep("mobile")} className="text-sm text-slate-500 dark:text-slate-400">
<BackIcon className="me-2 h-4 w-4" /> {t.login.back}
</Button>
</form>
)}
{step === "otp" && (
<form onSubmit={handleOtpVerify} className="grid gap-4">
<Input
id="otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
maxLength={6}
disabled={loading}
className="h-11 text-center tracking-widest text-lg"
/>
<Button type="submit" className="w-full h-11" disabled={loading}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {t.login.verifyAndContinue}
</Button>
<Button type="button" variant="ghost" onClick={() => setStep("mobile")} className="text-sm text-slate-500 dark:text-slate-400">
<BackIcon className="me-2 h-4 w-4" /> {t.login.back}
</Button>
</form>
)}
</div>
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
{t.loginTerms?.prefix}
<Link
to="/terms"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{t.loginTerms?.link}
</Link>
{t.loginTerms?.suffix}
</div>
</div>
</div>
</div>
)
}
export default function Auth() {
const { t } = useTranslation()
return (
<div className="container relative min-h-screen grid flex-col items-center justify-center bg-white transition-colors lg:max-w-none lg:grid-cols-2 lg:px-0 dark:bg-slate-950">
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
<SettingsMenu />
</div>
<div className="relative hidden h-full flex-col border-e border-slate-200 bg-slate-900 p-10 text-white dark:border-slate-800 dark:bg-slate-900/50 lg:flex">
<div className="relative z-20 flex items-center gap-2 text-lg font-medium">
<Command className="h-6 w-6" />
{t.title || "Qlockify"}
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">"{t.login.brandingQuote}"</p>
</blockquote>
</div>
</div>
<div className="flex h-screen items-center justify-center p-8 lg:p-8">
<div className="mx-auto flex w-full max-w-[26rem] flex-col justify-center">
<Outlet />
</div>
</div>
</div>
)
}

View File

@@ -1,221 +1,262 @@
import { useEffect, useState } from "react"
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
import { useWorkspace } from "../context/WorkspaceContext"
import { useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useWorkspace } from "../context/WorkspaceContext"
import { useAppContext } from "../context/AppContext"
import { useTranslation } from "../hooks/useTranslation"
import {
CLIENTS_CREATE,
CLIENTS_DELETE,
CLIENTS_EDIT,
canDeleteWorkspaceResource,
canWorkspace,
} from "../lib/permissions"
import { type Client } from "../types/client"
import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal"
import EditClientModal from "../components/EditClientModal"
import DeleteClientModal from "../components/DeleteClientModal"
import FilterBar from "../components/FilterBar"
import { Button } from "../components/ui/button"
import { Card } from "../components/ui/card"
import { Pagination } from "../components/Pagination"
export default function Clients() {
const { activeWorkspace } = useWorkspace()
const [clients, setClients] = useState<Client[]>([])
const [isLoading, setIsLoading] = useState(true)
// Pagination States
const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [limit, setLimit] = useState(10)
// Filter States
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [ordering, setOrdering] = useState("-created_at")
// Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [editClient, setEditClient] = useState<Client | null>(null)
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
import { type Client } from "../types/client"
import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal"
import EditClientModal from "../components/EditClientModal"
import DeleteClientModal from "../components/DeleteClientModal"
import EmptyStateCard from "../components/EmptyStateCard"
import FilterBar from "../components/FilterBar"
import { ListPageSkeleton } from "../components/ListPageSkeleton"
import { Button } from "../components/ui/button"
import { Card, CardContent, CardTitle } from "../components/ui/card"
import { Pagination } from "../components/Pagination"
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams"
export default function Clients() {
const { activeWorkspace } = useWorkspace()
const { user } = useAppContext()
const [clients, setClients] = useState<Client[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchParams, setSearchParams] = useSearchParams()
const [totalItems, setTotalItems] = useState(0)
const [debouncedSearch, setDebouncedSearch] = useState("")
const searchQuery = readStringParam(searchParams, "search", "")
const ordering = readStringParam(searchParams, "ordering", "-created_at")
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1))
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10))
// Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [editClient, setEditClient] = useState<Client | null>(null)
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
const { t, lang } = useTranslation()
const isFa = lang === "fa"
const workspaceRole = activeWorkspace?.my_role
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE)
const orderingOptions = [
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
]
useEffect(() => {
setCurrentPage(1)
}, [debouncedSearch, ordering])
// Debounce search input to avoid spamming the API
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchQuery)
}, 500)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchClientsList = async () => {
if (!activeWorkspace?.id) {
setIsLoading(false)
return
}
setIsLoading(true)
try {
const offset = (currentPage - 1) * limit
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setClients(items)
setTotalItems(count)
} catch (error) {
console.error(t.clients.errors.fetchFailed, error)
setClients([])
} finally {
setIsLoading(false)
}
}
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return "-"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "long",
timeZone: "Asia/Tehran"
}).format(date)
} catch (e) {
return dateStr
}
}
useEffect(() => {
fetchClientsList()
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
if (!activeWorkspace) {
return (
<div className="p-6 text-center text-slate-500">
{t.clients.selectWorkspace}
</div>
)
}
return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex justify-between items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{t.clients.description(activeWorkspace.name)}
</p>
const orderingOptions = [
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
]
// Debounce search input to avoid spamming the API
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchQuery)
}, 500)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchClientsList = async () => {
if (!activeWorkspace?.id) {
setIsLoading(false)
return
}
setIsLoading(true)
try {
const offset = (currentPage - 1) * limit
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setClients(items)
setTotalItems(count)
} catch (error) {
console.error(t.clients.errors.fetchFailed, error)
toast.error(t.clients.errors.fetchFailed)
setClients([])
} finally {
setIsLoading(false)
}
}
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return "-"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "long",
timeZone: "Asia/Tehran"
}).format(date)
} catch (e) {
return dateStr
}
}
useEffect(() => {
fetchClientsList()
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-created_at",
page: 1,
limit: 10,
}),
{ replace: true },
)
}
if (!activeWorkspace) {
return (
<div className="mx-auto max-w-7xl p-4 md:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.clients.selectWorkspace}
</div>
{canCreateClient && (
<Button
onClick={() => setIsCreateModalOpen(true)}
size="icon"
className="shadow-sm shrink-0"
title={t.clients.addClient}
>
<Plus className="w-4 h-4" />
</Button>
)}
</div>
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
searchPlaceholder={t.clients.searchPlaceholder}
/>
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
<div className="p-0">
{isLoading ? (
<div className="flex justify-center items-center p-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : clients.length === 0 ? (
<div className="text-center p-12">
<Building2 className="w-12 h-12 text-slate-300 dark:text-slate-700 mx-auto mb-3" />
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
<p className="text-slate-500 dark:text-slate-400 mt-1">
{searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
</p>
</div>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{clients.map((client) => (
<li key={client.id} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-slate-900 dark:text-white truncate">{client.name}</h4>
{client.notes && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
{client.notes}
</p>
)}
<div className="text-[11px] text-slate-400 mt-3 font-medium">
{t.clients.addedOn}: {formatDate(client.created_at)}
</div>
</div>
{(canEditClient || canDeleteClient) && (
<div className="flex items-center gap-1 shrink-0">
{canEditClient && (
<Button
variant="ghost"
size="icon"
onClick={() => setEditClient(client)}
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteClient && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteClient(client)}
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
)}
</li>
))}
</ul>
)}
</div>
</Card>
{!isLoading && clients.length > 0 && (
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
/>
)}
</div>
)
}
return (
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-1 flex-col gap-5">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.clients.description(activeWorkspace.name)}
</p>
</div>
{canCreateClient && (
<Button
onClick={() => setIsCreateModalOpen(true)}
size="icon"
className="shrink-0 shadow-sm"
title={t.clients.addClient}
>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.clients.searchPlaceholder}
/>
</div>
{isLoading ? (
<ListPageSkeleton variant="standard-grid" />
) : (
<div className="flex flex-1 flex-col gap-6">
{clients.length === 0 ? (
<EmptyStateCard
icon={Building2}
title={t.clients.noClients}
description={searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{clients.map((client) => {
const canDeleteClient = canDeleteWorkspaceResource({
workspaceRole,
currentUserId: user?.id,
createdById: client.created_by?.id,
})
return (
<Card key={client.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
<CardContent className="flex h-full flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{client.thumbnail ? (
<img src={client.thumbnail} alt={client.name} className="h-full w-full rounded-xl object-cover" />
) : (
client.name.trim().charAt(0).toUpperCase() || "C"
)}
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
</div>
</div>
{(canEditClient || canDeleteClient) && (
<div className="flex shrink-0 items-center gap-1">
{canEditClient && (
<Button
variant="ghost"
size="icon"
onClick={() => setEditClient(client)}
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
title={t.actions?.edit || "Edit"}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{canDeleteClient && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteClient(client)}
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title={t.actions?.delete || "Delete"}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<div className="space-y-3">
<p className="min-h-[3.75rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300">
{client.notes || t.workspace?.noDescription || "No description"}
</p>
<div className="text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.clients.addedOn}: {formatDate(client.created_at)}
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{clients.length > 0 && (
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/>
)}
</div>
)}
</div>
{canCreateClient && (
<CreateClientModal
isOpen={isCreateModalOpen}
@@ -234,7 +275,7 @@ export default function Clients() {
/>
)}
{canDeleteClient && (
{!!deleteClient && (
<DeleteClientModal
isOpen={!!deleteClient}
onClose={() => setDeleteClient(null)}
@@ -242,6 +283,6 @@ export default function Clients() {
client={deleteClient}
/>
)}
</div>
)
}
</div>
)
}

View File

@@ -0,0 +1,466 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle2, Command, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { SettingsMenu } from "../components/SettingsMenu";
import { ApiError } from "../api/client";
import {
completeGoogleOAuthSignup,
getGoogleOAuthFlow,
sendGoogleOAuthClaimOtp,
startGoogleLogin,
verifyGoogleOAuthClaim,
type GoogleOAuthFlowResponse,
} from "../api/users";
import { useTranslation } from "../hooks/useTranslation";
import { setSessionTokens } from "../lib/session";
import { AuthOtpInput } from "./auth/AuthOtpInput";
type GoogleStep = "loading" | "collect_mobile" | "claim_required" | "error";
type CooldownKey = "otpSend" | "otpVerify";
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit);
export default function GoogleAuthCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t, lang } = useTranslation();
const isRtl = lang === "fa";
const flow = searchParams.get("flow") ?? "";
const callbackErrorCode = searchParams.get("error") ?? "";
const callbackErrorDescription = searchParams.get("error_description") ?? "";
const [step, setStep] = useState<GoogleStep>("loading");
const [loading, setLoading] = useState(false);
const [mobile, setMobile] = useState("");
const [otpCode, setOtpCode] = useState("");
const [googleEmail, setGoogleEmail] = useState("");
const [flowResolution, setFlowResolution] = useState<
"new_account" | "existing_email_claim" | "existing_mobile_claim" | null
>(null);
const [mobileHint, setMobileHint] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const [cooldowns, setCooldowns] = useState<Record<CooldownKey, number>>({
otpSend: 0,
otpVerify: 0,
});
const resolveCallbackErrorMessage = () => {
if (callbackErrorCode === "access_denied") {
return t.login.google.cancelled;
}
if (callbackErrorDescription) {
if (callbackErrorDescription === "Google token exchange failed.") {
return t.login.google.tokenExchangeFailed;
}
if (callbackErrorDescription === "Google user profile lookup failed.") {
return t.login.google.profileLookupFailed;
}
return callbackErrorDescription;
}
return t.login.google.callbackFailed;
};
useEffect(() => {
if (!Object.values(cooldowns).some((value) => value > 0)) {
return;
}
const timer = window.setInterval(() => {
setCooldowns((current) => ({
otpSend: Math.max(0, current.otpSend - 1),
otpVerify: Math.max(0, current.otpVerify - 1),
}));
}, 1000);
return () => window.clearInterval(timer);
}, [cooldowns]);
const localizeDigits = (value: string) => (isRtl ? toPersianDigits(value) : value);
const formatCooldown = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const formatted =
minutes > 0
? `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
: `${remainingSeconds}s`;
return localizeDigits(formatted);
};
const setCooldown = (key: CooldownKey, seconds: number) => {
setCooldowns((current) => ({
...current,
[key]: Math.max(current[key], seconds),
}));
};
const handleAuthenticated = (payload: Extract<GoogleOAuthFlowResponse, { status: "authenticated" }>) => {
setSessionTokens(payload.access, payload.refresh);
toast.success(t.login.toasts.successLogin);
navigate("/profile", { replace: true });
};
const applyFlowPayload = (payload: GoogleOAuthFlowResponse) => {
if (payload.status === "authenticated") {
handleAuthenticated(payload);
return;
}
if (payload.status === "collect_mobile") {
setGoogleEmail(payload.email);
setFlowResolution(payload.resolution);
setMobileHint(payload.mobile_hint ?? null);
setErrorMessage("");
setStep("collect_mobile");
return;
}
if (payload.status === "claim_required") {
setMobile(payload.mobile);
setOtpCode("");
setGoogleEmail(payload.email);
setFlowResolution(payload.resolution);
setMobileHint(payload.mobile_hint ?? null);
setErrorMessage("");
setStep("claim_required");
}
};
const handleThrottleError = (error: unknown, key: CooldownKey) => {
if (!(error instanceof ApiError) || error.code !== "throttled") {
return false;
}
const seconds = Math.max(1, error.retryAfterSeconds ?? 0);
const formattedTime = formatCooldown(seconds);
setCooldown(key, seconds);
const message =
key === "otpSend"
? t.login.throttle.otpSendMessage(formattedTime)
: t.login.throttle.otpLoginMessage(formattedTime);
toast.error(message, {
description: t.login.throttle.countdownLabel(formattedTime),
});
return true;
};
useEffect(() => {
if (callbackErrorCode) {
setErrorMessage(resolveCallbackErrorMessage());
setStep("error");
return;
}
if (!flow) {
setErrorMessage(t.login.google.missingFlow);
setStep("error");
return;
}
let cancelled = false;
const loadFlow = async () => {
setLoading(true);
try {
const payload = await getGoogleOAuthFlow(flow);
if (!cancelled) {
applyFlowPayload(payload);
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error instanceof Error ? error.message : t.login.google.loadFailed);
setStep("error");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadFlow();
return () => {
cancelled = true;
};
}, [callbackErrorCode, callbackErrorDescription, flow, lang]);
const handleCompleteSignup = async (event: React.FormEvent) => {
event.preventDefault();
if (!mobile) {
toast.error(t.login.toasts.enterMobile);
return;
}
setLoading(true);
try {
const payload = await completeGoogleOAuthSignup(flow, mobile);
applyFlowPayload(payload);
if (payload.status === "claim_required") {
toast.success(payload.resolution === "new_account" ? t.login.toasts.verifySent : t.login.google.claimOtpSent);
}
} catch (error) {
const message = error instanceof Error ? error.message : t.login.google.completeFailed;
setErrorMessage(message);
if (error instanceof ApiError) {
if (error.code === "google_email_mobile_conflict" || error.code === "google_mobile_belongs_to_other_email") {
setStep("collect_mobile");
}
}
toast.error(message);
} finally {
setLoading(false);
}
};
const handleResendClaimOtp = async () => {
setLoading(true);
try {
await sendGoogleOAuthClaimOtp(flow);
setCooldowns((current) => ({ ...current, otpSend: 0 }));
toast.success(t.login.google.claimOtpSent);
} catch (error) {
if (!handleThrottleError(error, "otpSend")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.failedOtp);
}
} finally {
setLoading(false);
}
};
const handleVerifyClaim = async (event: React.FormEvent) => {
event.preventDefault();
if (!otpCode) {
toast.error(t.login.toasts.enterOtp);
return;
}
setLoading(true);
try {
const payload = await verifyGoogleOAuthClaim(flow, otpCode);
setCooldowns((current) => ({ ...current, otpVerify: 0 }));
applyFlowPayload(payload);
} catch (error) {
if (!handleThrottleError(error, "otpVerify")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.invalidOtp);
}
} finally {
setLoading(false);
}
};
const activeWarning = useMemo(() => {
if (cooldowns.otpSend > 0) {
return t.login.throttle.otpSendMessage(formatCooldown(cooldowns.otpSend));
}
if (cooldowns.otpVerify > 0) {
return t.login.throttle.otpLoginMessage(formatCooldown(cooldowns.otpVerify));
}
return null;
}, [cooldowns, isRtl, lang]);
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
return (
<div className="container relative min-h-screen grid flex-col items-center justify-center bg-white transition-colors lg:max-w-none lg:grid-cols-2 lg:px-0 dark:bg-slate-950">
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
<SettingsMenu />
</div>
<div className="relative hidden h-full flex-col border-e border-slate-200 bg-slate-900 p-10 text-white dark:border-slate-800 dark:bg-slate-900/50 lg:flex">
<div className="relative z-20 flex items-center gap-2 text-lg font-medium">
<Command className="h-6 w-6" />
{t.title || "Qlockify"}
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">"{t.login.brandingQuote}"</p>
</blockquote>
</div>
</div>
<div className="flex h-screen items-center justify-center p-8 lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[420px]">
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
<div className="mb-4 flex justify-center lg:hidden">
<Command className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{step === "loading" && t.login.google.loadingTitle}
{step === "collect_mobile" && t.login.google.collectMobileTitle}
{step === "claim_required" &&
(flowResolution === "new_account" ? t.login.signupVerifyTitle : t.login.google.claimTitle)}
{step === "error" && t.login.google.errorTitle}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{step === "loading" && t.login.google.loadingDescription}
{step === "collect_mobile" &&
(flowResolution === "existing_email_claim"
? t.login.google.existingEmailClaimDescription(googleEmail || "-", mobileHint || "-")
: t.login.google.collectMobileDescription(googleEmail || "-"))}
{step === "claim_required" &&
(flowResolution === "existing_email_claim"
? t.login.google.claimDescription(mobileHint || mobile)
: flowResolution === "existing_mobile_claim"
? t.login.google.mobileClaimDescription(mobile)
: t.login.sentCodeDesc(mobile))}
{step === "error" && (errorMessage || t.login.google.loadFailed)}
</p>
</div>
{errorMessage && step !== "error" && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-start text-red-800 shadow-sm dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
</div>
)}
{activeWarning && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{t.login.throttle.title}</p>
<p className="text-sm">{activeWarning}</p>
</div>
</div>
</div>
)}
<div className="grid gap-6">
{step === "loading" && (
<div className="rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
<Loader2 className="mx-auto mb-4 h-8 w-8 animate-spin text-slate-400" />
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.login.google.loadingDescription}
</p>
</div>
)}
{step === "collect_mobile" && (
<form key={`collect-mobile-${flowResolution ?? "default"}`} onSubmit={handleCompleteSignup} className="grid gap-4">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200">
<CheckCircle2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{t.login.google.googleAccount}
</p>
<p className="truncate text-sm text-slate-500 dark:text-slate-400">{googleEmail}</p>
</div>
</div>
{flowResolution === "existing_email_claim" && mobileHint && (
<div className="mb-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-100">
{t.login.google.mobileHintLabel(mobileHint)}
</div>
)}
<Input
id="google-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
value={mobile}
onChange={(event) => {
setMobile(event.target.value)
setErrorMessage("")
}}
maxLength={11}
disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.google.completeButton}
</Button>
<Button type="button" variant="ghost" onClick={() => startGoogleLogin()} className="text-sm text-slate-500 dark:text-slate-400">
{t.login.google.restartGoogle}
</Button>
</form>
)}
{step === "claim_required" && (
<form key={`claim-required-${flowResolution ?? "default"}`} onSubmit={handleVerifyClaim} className="grid gap-4">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
{flowResolution === "existing_email_claim"
? t.login.google.claimDescription(mobileHint || mobile)
: flowResolution === "existing_mobile_claim"
? t.login.google.mobileClaimDescription(mobile)
: t.login.sentCodeDesc(mobile)}
</p>
<AuthOtpInput
id="google-claim-otp"
value={otpCode}
disabled={loading}
onChange={(value) => {
setOtpCode(value)
setErrorMessage("")
}}
onComplete={(value) => {
setOtpCode(value)
}}
/>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading || cooldowns.otpVerify > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldowns.otpVerify > 0
? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpVerify))
: t.login.google.verifyClaimButton}
</Button>
<Button
type="button"
variant="outline"
onClick={handleResendClaimOtp}
disabled={loading || cooldowns.otpSend > 0}
className="h-11"
>
{cooldowns.otpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpSend))
: t.login.google.resendClaimOtp}
</Button>
</form>
)}
{step === "error" && (
<div className="rounded-3xl border border-red-200 bg-red-50 p-6 text-center shadow-sm dark:border-red-900/50 dark:bg-red-950/20">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-red-500" />
<p className="mb-4 text-sm text-red-700 dark:text-red-200">
{errorMessage || t.login.google.loadFailed}
</p>
<div className="grid gap-3">
<Button type="button" onClick={() => startGoogleLogin()} className="h-11">
{t.login.google.restartGoogle}
</Button>
<Button type="button" variant="ghost" asChild className="text-sm text-slate-500 dark:text-slate-400">
<Link to="/auth">
<BackIcon className="me-2 h-4 w-4" />
{t.login.back}
</Link>
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

445
src/pages/Landing.tsx Normal file
View File

@@ -0,0 +1,445 @@
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import {
ArrowRight,
BarChart3,
CheckCircle2,
Clock3,
Command,
Globe2,
Layers3,
Moon,
ShieldCheck,
Sparkles,
Sun,
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)
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 =
theme === "dark" ||
(theme === "system" && document.documentElement.classList.contains("dark"))
const metrics = useMemo(
() => [
{
value: lang === "fa" ? "۹۸٪" : "98%",
label: t.landing.metrics.capture,
tone: "from-cyan-500/20 to-cyan-500/5 text-cyan-700 dark:text-cyan-200",
},
{
value: lang === "fa" ? "۴.۶×" : "4.6x",
label: t.landing.metrics.visibility,
tone: "from-emerald-500/20 to-emerald-500/5 text-emerald-700 dark:text-emerald-200",
},
{
value: lang === "fa" ? "< ۲m" : "< 2m",
label: t.landing.metrics.decision,
tone: "from-amber-500/20 to-amber-500/5 text-amber-700 dark:text-amber-200",
},
],
[lang, t.landing.metrics],
)
const capabilityCards = useMemo(
() => [
{
icon: TimerReset,
title: t.landing.capabilities.time.title,
description: t.landing.capabilities.time.description,
},
{
icon: BarChart3,
title: t.landing.capabilities.reports.title,
description: t.landing.capabilities.reports.description,
},
{
icon: ShieldCheck,
title: t.landing.capabilities.control.title,
description: t.landing.capabilities.control.description,
},
],
[t.landing.capabilities],
)
const workflow = useMemo(
() => [
t.landing.workflow.capture,
t.landing.workflow.structure,
t.landing.workflow.improve,
],
[t.landing.workflow],
)
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" />
<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-24 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-44 h-72 w-72 rounded-full bg-amber-300/25 blur-3xl dark:bg-amber-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>
<span className="block text-lg font-semibold">{t.title}</span>
</span>
</button>
<div className="hidden items-center gap-2 md:flex">
<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">
<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>
<section className="relative grid flex-1 items-start items-center gap-10 py-12 lg:grid-cols-[1.08fr_0.92fr] lg:py-20">
<div className="space-y-8">
<div className="animate-landing-rise [animation-delay:120ms]">
<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" />
{t.landing.eyebrow}
</div>
<h1 className="max-w-4xl text-4xl font-semibold leading-[1.1] text-slate-950 sm:text-4xl lg:text-5xl 2xl:text-6xl dark:text-white">
{t.landing.hero.titleTop}
<span className="mt-4 pb-4 landing-shimmer block bg-[linear-gradient(120deg,#0f172a_15%,#0891b2_48%,#0f766e_78%,#0f172a_100%)] bg-clip-text text-transparent dark:bg-[linear-gradient(120deg,#ffffff_18%,#67e8f9_48%,#2dd4bf_78%,#ffffff_100%)]">
{t.landing.hero.titleAccent}
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-300 sm:text-xl">
{t.landing.hero.description}
</p>
</div>
<div className="animate-landing-rise flex flex-col gap-3 sm:flex-row [animation-delay:220ms]">
<Button
onClick={() => navigate(ctaTarget)}
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"
>
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
<ArrowRight className={cn("ms-2 h-4 w-4", lang === "fa" && "rtl:rotate-180")} />
</Button>
<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"
>
{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]">
{metrics.map((metric) => (
<div
key={metric.label}
className={cn(
"rounded-3xl border border-white/70 bg-gradient-to-br p-5 shadow-[0_28px_70px_-40px_rgba(15,23,42,0.55)] backdrop-blur-lg dark:border-white/10 dark:bg-slate-950/60",
metric.tone,
)}
>
<div className="text-3xl font-semibold tracking-[-0.04em]">{metric.value}</div>
<div className="mt-2 text-sm font-medium text-slate-600 dark:text-slate-300">{metric.label}</div>
</div>
))}
</div>
<div className="animate-landing-rise flex flex-wrap items-center gap-4 text-sm text-slate-500 dark:text-slate-400 [animation-delay:420ms]">
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t.landing.trust.first}
</span>
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t.landing.trust.second}
</span>
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t.landing.trust.third}
</span>
</div>
</div>
<div id="demo" className="relative animate-landing-rise [animation-delay:180ms]">
<div className="animate-landing-float absolute -left-6 top-10 hidden rounded-3xl border border-white/60 bg-white/80 p-4 shadow-[0_24px_70px_-34px_rgba(8,145,178,0.6)] backdrop-blur-xl lg:block dark:border-white/10 dark:bg-slate-950/70">
<div className="mb-3 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-cyan-500/15 text-cyan-700 dark:text-cyan-200">
<Clock3 className="h-5 w-5" />
</div>
<div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">{t.landing.demo.timerTag}</div>
<div className="text-sm font-semibold text-slate-900 dark:text-white">{t.landing.demo.timerTitle}</div>
</div>
</div>
<div className="text-3xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{lang === "fa" ? "۰۲:۴۶:۱۸" : "02:46:18"}
</div>
<div className="mt-2 text-sm text-slate-500 dark:text-slate-400">{t.landing.demo.timerText}</div>
</div>
<div className="relative overflow-hidden rounded-[2rem] border border-white/70 bg-white/80 p-4 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="mb-5 flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-slate-400 dark:text-slate-500">
{t.landing.demo.panelLabel}
</div>
<div className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{t.landing.demo.panelTitle}
</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" ? "زنده" : "Live"}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-4">
<div className="rounded-[1.5rem] border border-slate-200/80 bg-slate-950 p-5 text-white shadow-inner dark:border-slate-800">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-cyan-200/70">{t.landing.demo.runningCard}</div>
<div className="mt-2 text-xl font-semibold">{t.landing.demo.currentTask}</div>
<div className="mt-2 text-sm text-slate-300">{t.landing.demo.currentTaskMeta}</div>
</div>
<div className="rounded-3xl bg-white/10 px-4 py-3 text-right backdrop-blur">
<div className="text-xs uppercase tracking-[0.18em] text-slate-300">{t.landing.demo.billableLabel}</div>
<div className="mt-2 text-2xl font-semibold">{lang === "fa" ? "۹۵ دلار" : "$95"}</div>
</div>
</div>
<div className="mt-5 h-2 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[78%] rounded-full bg-[linear-gradient(90deg,#67e8f9,#14b8a6)]" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-[1.5rem] border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<BarChart3 className="h-4 w-4 text-cyan-500" />
{t.landing.demo.reportCard}
</div>
<div className="mt-4 flex h-32 items-end gap-2">
{[34, 56, 48, 72, 65, 88, 76].map((height, index) => (
<div
key={height}
className="flex-1 rounded-t-2xl bg-[linear-gradient(180deg,#67e8f9_0%,#0f766e_100%)] opacity-90"
style={{
height: `${height}%`,
animationDelay: `${index * 120}ms`,
}}
/>
))}
</div>
</div>
<div className="rounded-[1.5rem] border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Waypoints className="h-4 w-4 text-amber-500" />
{t.landing.demo.opsCard}
</div>
<div className="mt-4 space-y-3">
{[82, 63, 91].map((value, index) => (
<div key={value}>
<div className="mb-2 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span>{t.landing.demo.opsLabels[index]}</span>
<span>{formatNumber(value, lang)}%</span>
</div>
<div className="h-2 rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#f59e0b,#f97316)]"
style={{ width: `${value}%` }}
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="rounded-[1.5rem] border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Layers3 className="h-4 w-4 text-violet-500" />
{t.landing.demo.logCard}
</div>
<div className="mt-4 space-y-3">
{t.landing.demo.logItems.map((item: { title: string; meta: string }, index: number) => (
<div key={item.title} className="rounded-2xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-slate-900 dark:text-white">{item.title}</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">{item.meta}</div>
</div>
<div className={cn(
"mt-0.5 h-2.5 w-2.5 rounded-full",
index === 0 ? "bg-emerald-500" : index === 1 ? "bg-cyan-500" : "bg-amber-500",
)} />
</div>
</div>
))}
</div>
</div>
<div className="rounded-[1.5rem] border border-slate-200/80 bg-slate-950 p-4 text-white shadow-inner dark:border-slate-800">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">{t.landing.demo.outcomeTag}</div>
<div className="mt-3 text-4xl font-semibold tracking-[-0.05em]">{lang === "fa" ? "۳۶٪" : "36%"}</div>
<div className="mt-2 text-sm text-slate-300">{t.landing.demo.outcomeText}</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="features" className="grid gap-4 py-8 md:grid-cols-3">
{capabilityCards.map(({ icon: Icon, title, description }, index) => (
<div
key={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">{title}</h2>
<p className="mt-3 text-base leading-7 text-slate-600 dark:text-slate-300">{description}</p>
</div>
))}
</section>
<section id="workflow" className="grid gap-6 py-8 lg:grid-cols-[0.9fr_1.1fr]">
<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="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{t.landing.workflowTag}
</div>
<h2 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{t.landing.workflowTitle}
</h2>
<p className="mt-4 max-w-xl text-lg leading-8 text-slate-600 dark:text-slate-300">
{t.landing.workflowDescription}
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{workflow.map((item, index) => (
<div
key={item}
className="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"
>
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500">
{lang === "fa" ? `گام ${formatNumber(index + 1, lang)}` : `Step ${index + 1}`}
</div>
<div className="mt-12 text-xl font-semibold leading-8 tracking-[-0.03em] text-slate-950 dark:text-white">
{item}
</div>
</div>
))}
</div>
</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="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-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"
>
{isAuthenticated ? t.landing.actions.openWorkspace : t.landing.actions.startNow}
</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="/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>
</section>
</div>
</div>
)
}

353
src/pages/Logs.tsx Normal file
View File

@@ -0,0 +1,353 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { History, ShieldCheck, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import {
getWorkspaceLogDetail,
listWorkspaceLogs,
type WorkspaceLogDetail,
type WorkspaceLogItem,
} from "../api/logs";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
import { LogDetailsPanel } from "../components/logs/LogDetailsPanel";
import { LogsFeed } from "../components/logs/LogsFeed";
import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { readStringParam, updateQueryParams } from "../lib/queryParams";
import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions";
const DEFAULT_FILTERS: LogsFilterDraft = {
search: "",
section: "",
event: "",
actor: "",
from: "",
to: "",
ordering: "-timestamp",
};
const DEFAULT_QUERY_FILTERS: Record<string, string> = {
search: DEFAULT_FILTERS.search,
section: DEFAULT_FILTERS.section,
event: DEFAULT_FILTERS.event,
actor: DEFAULT_FILTERS.actor,
from: DEFAULT_FILTERS.from,
to: DEFAULT_FILTERS.to,
ordering: DEFAULT_FILTERS.ordering,
};
const PAGE_SIZE = 20;
export default function Logs() {
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [searchParams, setSearchParams] = useSearchParams();
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
const [logs, setLogs] = useState<WorkspaceLogItem[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
const [selectedLog, setSelectedLog] = useState<WorkspaceLogDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const workspaceRole = activeWorkspace?.my_role;
const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW);
const isWorkspaceRoleResolved = Boolean(workspaceRole);
const filters = useMemo<LogsFilterDraft>(
() => ({
search: readStringParam(searchParams, "search", DEFAULT_FILTERS.search),
section: readStringParam(searchParams, "section", DEFAULT_FILTERS.section) as LogsFilterDraft["section"],
event: readStringParam(searchParams, "event", DEFAULT_FILTERS.event) as LogsFilterDraft["event"],
actor: readStringParam(searchParams, "actor", DEFAULT_FILTERS.actor),
from: readStringParam(searchParams, "from", DEFAULT_FILTERS.from),
to: readStringParam(searchParams, "to", DEFAULT_FILTERS.to),
ordering: readStringParam(searchParams, "ordering", DEFAULT_FILTERS.ordering) as LogsFilterDraft["ordering"],
}),
[searchParams],
);
useEffect(() => {
setLogs([]);
setTotalLogs(0);
setSelectedLogId(null);
setSelectedLog(null);
}, [activeWorkspace?.id]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved || !canViewLogs) {
setMemberships([]);
setIsLoadingUsers(false);
return;
}
const loadUsers = async () => {
setIsLoadingUsers(true);
try {
const response = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: 200,
offset: 0,
});
setMemberships(response.results || []);
} catch {
setMemberships([]);
toast.error(t.logs?.loadFiltersError || "Failed to load log filters.");
} finally {
setIsLoadingUsers(false);
}
};
void loadUsers();
}, [activeWorkspace?.id, canViewLogs, isWorkspaceRoleResolved, t.logs?.loadFiltersError]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved || !canViewLogs) {
setLogs([]);
setTotalLogs(0);
setIsLoading(false);
return;
}
const loadLogs = async () => {
setIsLoading(true);
try {
const response = await listWorkspaceLogs(
{
workspace: activeWorkspace.id,
search: filters.search || undefined,
section: filters.section || undefined,
actor: filters.actor || undefined,
event: filters.event || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
ordering: filters.ordering,
},
{ limit: PAGE_SIZE, offset: 0 },
);
setLogs(response.results || []);
setTotalLogs(response.count || 0);
if (selectedLogId && !(response.results || []).some((item) => item.id === selectedLogId)) {
setSelectedLogId(null);
setSelectedLog(null);
}
} catch {
setLogs([]);
setTotalLogs(0);
toast.error(t.logs?.loadError || "Failed to load logs.");
} finally {
setIsLoading(false);
}
};
void loadLogs();
}, [
activeWorkspace?.id,
canViewLogs,
filters.actor,
filters.event,
filters.from,
filters.ordering,
filters.search,
filters.section,
filters.to,
isWorkspaceRoleResolved,
t.logs?.loadError,
]);
const handleLoadMore = async () => {
if (!activeWorkspace?.id || isLoadingMore || logs.length >= totalLogs) return;
setIsLoadingMore(true);
try {
const response = await listWorkspaceLogs(
{
workspace: activeWorkspace.id,
search: filters.search || undefined,
section: filters.section || undefined,
actor: filters.actor || undefined,
event: filters.event || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
ordering: filters.ordering,
},
{ limit: PAGE_SIZE, offset: logs.length },
);
setLogs((current) => [...current, ...(response.results || [])]);
setTotalLogs(response.count || 0);
} catch {
toast.error(t.logs?.loadError || "Failed to load logs.");
} finally {
setIsLoadingMore(false);
}
};
const handleOpenLog = async (id: number) => {
setSelectedLogId(id);
setIsLoadingDetail(true);
try {
const detail = await getWorkspaceLogDetail(id);
setSelectedLog(detail);
} catch {
toast.error(t.logs?.loadDetailsError || "Failed to load log details.");
setSelectedLog(null);
setSelectedLogId(null);
} finally {
setIsLoadingDetail(false);
}
};
const activeFiltersCount = useMemo(
() => [filters.search, filters.section, filters.event, filters.actor, filters.from, filters.to].filter(Boolean).length,
[filters.actor, filters.event, filters.from, filters.search, filters.section, filters.to],
);
const latestActivityLabel = useMemo(() => {
if (!logs[0]?.timestamp) return "-";
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(logs[0].timestamp));
}, [lang, logs]);
const formatNumber = (value: number) =>
new Intl.NumberFormat(lang === "fa" ? "fa-IR" : "en-US").format(value);
if (!activeWorkspace) {
return (
<div className="p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.logs?.selectWorkspace || "Please select a workspace first."}
</div>
</div>
);
}
if (!isWorkspaceRoleResolved) {
return (
<div className="p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.loading || "Loading..."}
</div>
</div>
);
}
if (!canViewLogs) {
return (
<div className="mx-auto max-w-4xl p-4 sm:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
<ShieldCheck className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t.logs?.title || "Activity logs"}
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.unauthorized || "Only owners and admins can access workspace activity logs."}
</p>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
<section className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t.logs?.title || "Activity logs"}
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.logs?.description?.(activeWorkspace.name) || `Review what has happened inside ${activeWorkspace.name}.`}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between text-slate-500 dark:text-slate-400">
<span className="text-xs font-semibold uppercase tracking-[0.16em]">
{t.logs?.totalLogs || "Total logs"}
</span>
<History className="h-4 w-4" />
</div>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{formatNumber(totalLogs)}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between text-slate-500 dark:text-slate-400">
<span className="text-xs font-semibold uppercase tracking-[0.16em]">
{t.logs?.activeFilters || "Active filters"}
</span>
<SlidersHorizontal className="h-4 w-4" />
</div>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">{formatNumber(activeFiltersCount)}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between text-slate-500 dark:text-slate-400">
<span className="text-xs font-semibold uppercase tracking-[0.16em]">
{t.logs?.latestActivity || "Latest activity"}
</span>
<ShieldCheck className="h-4 w-4" />
</div>
<p className="mt-3 text-sm font-semibold text-slate-900 dark:text-white">{latestActivityLabel}</p>
</div>
</div>
</div>
</section>
<LogsFilterBar
value={filters}
users={memberships}
isLoadingUsers={isLoadingUsers}
canSelectUsers={canViewLogs}
onApply={(nextFilters) =>
setSearchParams(
(current) =>
updateQueryParams(
current,
{
search: nextFilters.search,
section: nextFilters.section,
event: nextFilters.event,
actor: nextFilters.actor,
from: nextFilters.from,
to: nextFilters.to,
ordering: nextFilters.ordering,
},
DEFAULT_QUERY_FILTERS,
),
{ replace: true },
)
}
/>
<LogsFeed
items={logs}
total={totalLogs}
hasMore={logs.length < totalLogs}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
selectedId={selectedLogId}
onOpen={(id) => void handleOpenLog(id)}
onLoadMore={() => void handleLoadMore()}
/>
<LogDetailsPanel
open={selectedLogId !== null}
log={selectedLog}
isLoading={isLoadingDetail}
onClose={() => {
setSelectedLogId(null);
setSelectedLog(null);
}}
/>
</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>
)
}

101
src/pages/Notifications.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { CheckCheck, Loader2 } from "lucide-react";
import { NotificationList } from "../components/notifications/NotificationList";
import { Button } from "../components/ui/button";
import { useNotifications } from "../context/NotificationsContext";
import { useTranslation } from "../hooks/useTranslation";
export default function NotificationsPage() {
const { t } = useTranslation();
const {
notifications,
unreadCount,
totalCount,
hasMore,
isLoading,
isLoadingMore,
loadMore,
markAllAsSeen,
deleteOne,
handleNotificationClick,
} = useNotifications();
return (
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t.notifications?.title || "Notifications"}
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.notifications?.pageDescription || "Review all notifications and export updates."}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => void markAllAsSeen()}
disabled={unreadCount === 0}
className="gap-2"
>
<CheckCheck className="h-4 w-4" />
{t.notifications?.markAllRead || "Mark all as read"}
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.notifications?.totalLabel || "Total notifications"}
</div>
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{totalCount}</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.notifications?.unreadLabel || "Unread notifications"}
</div>
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{unreadCount}</div>
</div>
</div>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{isLoading ? (
<div className="flex items-center justify-center px-4 py-12 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loading || "Loading notifications..."}
</div>
) : (
<NotificationList
notifications={notifications}
emptyLabel={t.notifications?.empty || "No notifications yet."}
onClick={(item) => void handleNotificationClick(item)}
onDelete={(item) => void deleteOne(item)}
/>
)}
{hasMore ? (
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t.notifications?.loadingMore || "Loading more..."}
</>
) : (
t.notifications?.loadMore || "Load more"
)}
</Button>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -5,15 +5,18 @@ import {
getUserProfile,
updateUserProfile,
updateProfilePicture,
removeProfilePicture
removeProfilePicture,
changePassword,
} from "../api/users"
import { Button } from "../components/ui/button"
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "lucide-react"
import JalaliDatePicker from "../components/ui/JalaliDatePicker"
import { toast } from "sonner"
import { Modal } from "../components/Modal"
import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput"
import { Modal } from "../components/Modal"
import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput"
import { AuthPasswordField } from "./auth/AuthPasswordField"
import { getPasswordValidationMessage } from "./auth/utils"
export interface UserProfile {
id?: string;
@@ -36,6 +39,7 @@ export default function Profile() {
const { t, lang } = useTranslation()
const isFa = lang === 'fa'
const passwordCopy = t.profile.password
const toPersianNum = (num: string | number | undefined | null) => {
if (num === null || num === undefined) return num
@@ -59,6 +63,7 @@ export default function Profile() {
// Modals & Editing state
const [isEditing, setIsEditing] = useState(false)
const [isPicModalOpen, setIsPicModalOpen] = useState(false)
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Form states
@@ -66,6 +71,11 @@ export default function Profile() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
})
const fetchProfile = async () => {
try {
@@ -162,6 +172,55 @@ export default function Profile() {
}
}
const resetPasswordForm = () => {
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
})
}
const handleChangePassword = async (event: React.FormEvent) => {
event.preventDefault()
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
toast.error(t.login.toasts.fillAll)
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(t.login.passwordMismatch)
return
}
const passwordValidationMessage = getPasswordValidationMessage(passwordForm.newPassword, t.login)
if (passwordValidationMessage) {
toast.error(passwordValidationMessage)
return
}
if (passwordForm.currentPassword === passwordForm.newPassword) {
toast.error(t.login.passwordReuse)
return
}
setIsSaving(true)
try {
await changePassword(
passwordForm.currentPassword,
passwordForm.newPassword,
passwordForm.confirmPassword,
)
resetPasswordForm()
setIsPasswordModalOpen(false)
toast.success(passwordCopy.toasts.success)
} catch (error) {
toast.error(error instanceof Error ? error.message : passwordCopy.toasts.error)
} finally {
setIsSaving(false)
}
}
// Drag & Drop Handlers
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
@@ -233,10 +292,14 @@ export default function Profile() {
</h2>
{!isEditing && (
<Button onClick={handleEditClick} className="flex items-center gap-2">
<Edit2 className="h-4 w-4" />
{t.profile?.editInfo || 'Edit Profile'}
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsPasswordModalOpen(true)}>
{passwordCopy.trigger}
</Button>
<Button onClick={handleEditClick}>
<Edit2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
@@ -446,6 +509,62 @@ export default function Profile() {
</Modal>
)}
{isPasswordModalOpen && (
<Modal
isOpen={isPasswordModalOpen}
isFa={isFa}
onClose={() => {
if (isSaving) return
setIsPasswordModalOpen(false)
resetPasswordForm()
}}
title={passwordCopy.title}
description={passwordCopy.description}
maxWidth="max-w-md"
>
<form onSubmit={handleChangePassword} className="grid gap-4">
<AuthPasswordField
id="current-password"
value={passwordForm.currentPassword}
onChange={(value) => setPasswordForm((current) => ({ ...current, currentPassword: value }))}
placeholder={passwordCopy.currentPassword}
disabled={isSaving}
/>
<AuthPasswordField
id="new-password"
value={passwordForm.newPassword}
onChange={(value) => setPasswordForm((current) => ({ ...current, newPassword: value }))}
placeholder={passwordCopy.newPassword}
disabled={isSaving}
/>
<AuthPasswordField
id="confirm-password"
value={passwordForm.confirmPassword}
onChange={(value) => setPasswordForm((current) => ({ ...current, confirmPassword: value }))}
placeholder={passwordCopy.confirmPassword}
disabled={isSaving}
/>
<div className="flex flex-col-reverse gap-3 mt-3 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
setIsPasswordModalOpen(false)
resetPasswordForm()
}}
disabled={isSaving}
>
{t.actions?.cancel || "Cancel"}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? passwordCopy.saving : passwordCopy.submit}
</Button>
</div>
</form>
</Modal>
)}
</div>
</>
)

View File

@@ -1,642 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate, useBlocker } from "react-router-dom";
import {
Users,
Briefcase,
Trash2,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { createProject } from "../api/projects";
import { getClients } from "../api/clients";
import { fetchWorkspaceMemberships } from "../api/workspaces";
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_CREATE, canWorkspace } from "../lib/permissions";
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 { InfiniteScroll } from "../components/InfiniteScroll";
import { Modal } from "../components/Modal";
type ProjectRole = "manager" | "member";
interface LocalMember {
localId: string;
user: any;
role: ProjectRole;
isCreator?: boolean;
}
const COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#14B8A6",
"#64748B",
];
const toEnglishDigits = (str: string) => {
if (!str) return "";
return str
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString());
};
const LIMIT = 10;
export default function ProjectCreate() {
const navigate = useNavigate();
const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || "";
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
// Project Detail States
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [client, setClient] = useState("");
const [clientsList, setClientsList] = useState<any[]>([]);
// Workspace List & Pagination States
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
// Member Management States
const [members, setMembers] = useState<LocalMember[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [addAllMembers, setAddAllMembers] = useState(false);
const [isAddingAll, setIsAddingAll] = useState(false);
// External Search States
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const hasUnsavedChanges = name.trim() !== "" || description.trim() !== "" || members.length > 1;
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;
});
// EXACT same pagination structure as EditWorkspace.tsx
useEffect(() => {
if (activeWorkspace?.id) {
const workspaceId = activeWorkspace.id;
setName("");
setDescription("");
setColor(COLORS[0]);
setClient("");
setClientsList([]);
setWorkspaceMembers([]);
setSearchQuery("");
setSearchResult(null);
setSearchError(false);
setAddAllMembers(false);
// Reset pagination state
setOffset(0);
setHasMore(true);
setIsLoadingData(true);
if (user?.id) {
setMembers([{ localId: user.id, user: user, role: "manager", isCreator: true }]);
} else {
setMembers([]);
}
const loadInitialData = async () => {
try {
const clientsRes = await getClients(workspaceId);
setClientsList(clientsRes.results || []);
const res = await fetchWorkspaceMemberships({
workspace: workspaceId,
limit: LIMIT,
offset: 0,
});
const results = res.results || (Array.isArray(res) ? res : []);
setWorkspaceMembers(results);
setOffset(LIMIT);
setHasMore(res.next ? true : results.length >= LIMIT);
} catch (err) {
console.error("Failed to fetch initial data", err);
toast.error("Failed to load initial data.");
} finally {
setIsLoadingData(false);
}
};
loadInitialData();
}
}, [activeWorkspace?.id, user?.id]);
// EXACT same LoadMore logic and deduplication as EditWorkspace.tsx
const loadMoreMembers = useCallback(async () => {
if (isLoadingMore || !hasMore || !activeWorkspace?.id) return;
try {
setIsLoadingMore(true);
const res = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: LIMIT,
offset: offset
});
const results = res.results || (Array.isArray(res) ? res : []);
setWorkspaceMembers((prev) => {
// Safe deduplication to avoid React key warnings breaking the DOM observer
const existingIds = new Set(prev.map(m => m.id));
const newItems = results.filter((item: any) => !existingIds.has(item.id));
return [...prev, ...newItems];
});
setOffset(prev => prev + LIMIT);
setHasMore(res.next ? true : results.length >= LIMIT);
} catch (error) {
console.error("Failed to load more members", error);
} finally {
setIsLoadingMore(false);
}
}, [activeWorkspace?.id, isLoadingMore, hasMore, offset]);
// Unified Search Logic
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
const cleanQuery = toEnglishDigits(searchQuery.trim());
setSearchError(false);
if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) {
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const foundUser = await searchUserByExactMobile(cleanQuery);
if (foundUser && foundUser.id) {
if (foundUser.id === currentUserId) {
setSearchResult(null);
} else {
setSearchResult(foundUser);
setSearchError(false);
}
} else {
setSearchResult(null);
setSearchError(true);
}
} catch (error) {
setSearchResult(null);
setSearchError(true);
} finally {
setIsSearching(false);
}
}, 500);
} else {
setSearchResult(null);
}
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
};
}, [searchQuery, currentUserId]);
const handleAddMember = (userToAdd: any) => {
if (members.some((m) => m.user.id === userToAdd.id)) return;
const newMember: LocalMember = {
localId: Math.random().toString(36).substr(2, 9),
user: userToAdd,
role: "member",
};
setMembers((prev) => [newMember, ...prev]);
setSearchQuery("");
setSearchResult(null);
};
const handleToggleAddAllMembers = async () => {
if (addAllMembers) {
setMembers((prev) => prev.filter(m => m.isCreator || m.role === "manager"));
setAddAllMembers(false);
} else {
if (!activeWorkspace?.id) return;
setIsAddingAll(true);
try {
let currentOffset = 0;
let continueFetching = true;
const allWsMembers: any[] = [];
while (continueFetching) {
const res = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: 50,
offset: currentOffset,
});
const fetchedResults = res.results || (Array.isArray(res) ? res : []);
allWsMembers.push(...fetchedResults);
if (res.next) {
currentOffset += 50;
} else {
continueFetching = false;
}
}
const newMembersToAdd = allWsMembers
.map((wm) => wm.user)
.filter((u) => u && u.id !== currentUserId && !members.some((m) => m.user.id === u.id));
const localMembers: LocalMember[] = newMembersToAdd.map((u) => ({
localId: Math.random().toString(36).substr(2, 9),
user: u,
role: "member",
}));
setMembers((prev) => [...prev, ...localMembers]);
setAddAllMembers(true);
} catch (error) {
toast.error("Could not add all workspace members.");
} finally {
setIsAddingAll(false);
}
}
};
const openDeleteModal = (userId: string) => {
setMemberIdToDelete(userId);
setIsDeleteDialogOpen(true);
};
const handleDeleteMember = () => {
if (!memberIdToDelete) return;
setMembers(members.filter((m) => m.user.id !== memberIdToDelete));
setIsDeleteDialogOpen(false);
setMemberIdToDelete(null);
};
const handleChangeRole = (userId: string, newRole: string) => {
setMembers(
members.map((m) => (m.user.id === userId ? { ...m, role: newRole as ProjectRole } : m))
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace) return;
try {
setIsSaving(true);
const membersPayload = members
.filter((m) => !m.isCreator)
.map((m) => ({ user_id: m.user.id, role: m.role }));
const projectPayload: any = {
name,
description,
color,
workspace: activeWorkspace.id,
members: membersPayload,
};
if (client) projectPayload.client = client;
const newProject = await createProject(projectPayload);
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);
}
};
// Prepare unified display list
const filteredWorkspaceMembers = workspaceMembers.filter((m) => {
const q = searchQuery.trim().toLowerCase();
if (!q) return true;
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
const phone = toEnglishDigits(m.user.mobile || "");
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
});
const workspaceMemberUserIds = new Set(filteredWorkspaceMembers.map((m) => m.user.id));
const externalAddedMembers = members.filter((m) => {
if (workspaceMemberUserIds.has(m.user.id)) return false;
const q = searchQuery.trim().toLowerCase();
if (!q) return true;
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
const phone = toEnglishDigits(m.user.mobile || "");
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
});
const displayList = [
...externalAddedMembers.map((m) => ({ listId: m.localId, user: m.user })),
...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user }))
];
if (!activeWorkspace) {
return null;
}
return (
<div className="absolute inset-0 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
{t.projects?.createNew || "Create New Project"}
</h1>
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
<form id="create-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
<div className="flex-1 space-y-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
color === c
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
: "hover:scale-110 shadow-sm"
}`}
style={{ backgroundColor: c }}
aria-label={`Select color ${c}`}
/>
))}
</div>
</div>
<div>
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((c) => ({ value: c.id, label: c.name })),
]}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="block text-slate-700 dark:text-slate-300 mb-2">
{t.projects?.descriptionLabel || "Description (Optional)"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={4}
/>
</div>
</div>
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
<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" />}
{t.create || "Create"}
</Button>
</div>
</form>
</div>
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
<Users size={18} />
{t.projects?.projectMembers || "Project Members"}
</h3>
<Button
type="button"
variant={addAllMembers ? "destructive" : "outline"}
disabled={isAddingAll || isLoadingData}
onClick={handleToggleAddAllMembers}
>
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{addAllMembers
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
: (t.projects?.addAllWorkspaceMembers || "Add All")}
</Button>
</div>
<div className="relative">
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
className="ps-10"
/>
{isSearching && (
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
)}
</div>
{searchError && (
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
{t.projects?.userNotFound || "No user found with this mobile number."}
</p>
)}
{searchResult && !searchError && (
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
{searchResult.profile_picture ? (
<img
src={searchResult.profile_picture}
alt={searchResult.first_name}
className="w-10 h-10 rounded-full object-cover shadow-sm"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
{searchResult.first_name?.[0] || "U"}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{searchResult.first_name} {searchResult.last_name}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{searchResult.mobile}
</span>
</div>
</div>
<Button
type="button"
variant="default"
size="sm"
disabled={members.some((m) => m.user.id === searchResult.id)}
onClick={() => handleAddMember(searchResult)}
>
{members.some((m) => m.user.id === searchResult.id)
? (t.projects?.alreadyInProject || "Already Added")
: (t.projects?.addToProject || "Add to Project")}
</Button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoadingData ? (
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
{t.loading || "Loading..."}
</div>
) : (
<InfiniteScroll
onLoadMore={loadMoreMembers}
hasMore={hasMore && searchQuery.trim().length === 0}
isLoading={isLoadingMore}
>
{displayList.length === 0 ? (
<div className="p-4 text-sm text-slate-500 text-center">
{t.projects?.noWorkspaceMembers || "No members found."}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
{displayList.map((item) => {
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
const isAdded = !!addedMemberData;
return (
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
<div className="flex items-center gap-3">
{item.user.profile_picture ? (
<img
src={item.user.profile_picture}
alt={item.user.first_name}
className="w-9 h-9 rounded-full object-cover shadow-sm"
/>
) : (
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
{item.user.first_name?.[0] || "U"}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
{item.user.first_name} {item.user.last_name}
{addedMemberData?.isCreator && (
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
{t.projects?.creator || "Creator"}
</span>
)}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{item.user.mobile}
</span>
</div>
</div>
<div>
{isAdded ? (
<div className="flex items-center gap-2">
{!addedMemberData.isCreator && (
<Select
value={addedMemberData.role}
onChange={(val) => handleChangeRole(item.user.id, val)}
options={[
{ value: "member", label: t.projects?.roles?.member || "Member" },
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
]}
buttonClassName="text-xs h-8 w-28"
/>
)}
{!addedMemberData.isCreator && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
onClick={() => openDeleteModal(item.user.id)}
>
<Trash2 size={16} />
</Button>
)}
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={() => handleAddMember(item.user)}
>
{t.projects?.addToProject || "Add to Project"}
</Button>
)}
</div>
</li>
);
})}
</ul>
)}
</InfiniteScroll>
)}
</div>
</div>
</div>
<Modal
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
title={t.projects?.confirmDeleteTitle || "Remove Member"}
description={
t.projects?.confirmDeleteDesc || "Are you sure you want to remove this member from the project?"
}
>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
{t.cancel || "Cancel"}
</Button>
<Button variant="destructive" onClick={handleDeleteMember}>
{t.remove || "Remove"}
</Button>
</div>
</Modal>
</div>
);
}

View File

@@ -1,628 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate, useParams, useBlocker } from "react-router-dom";
import {
Users,
Briefcase,
Trash2,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { getProject, updateProject } from "../api/projects";
import { getClients } from "../api/clients";
import { fetchWorkspaceMemberships } from "../api/workspaces";
import { searchUserByExactMobile, type SearchedUser } from "../api/users";
import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { PROJECTS_EDIT, canWorkspace } from "../lib/permissions";
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 { InfiniteScroll } from "../components/InfiniteScroll";
import { Modal } from "../components/Modal";
type ProjectRole = "manager" | "member";
interface LocalMember {
localId: string;
user: any;
role: ProjectRole;
isCreator?: boolean;
}
const COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#14B8A6",
"#64748B",
];
const toEnglishDigits = (str: string) => {
if (!str) return "";
return str
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString());
};
const LIMIT = 10;
export default function ProjectEdit() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace();
const currentUserId = user?.id || "";
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<any[]>([]);
const [workspaceMembers, setWorkspaceMembers] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isProjectLoading, setIsProjectLoading] = useState(true);
const [members, setMembers] = useState<LocalMember[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [addAllMembers, setAddAllMembers] = useState(false);
const [isAddingAll, setIsAddingAll] = useState(false);
const [searchResult, setSearchResult] = useState<SearchedUser | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = 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) {
const loadInitialData = async () => {
try {
const clientsRes = await getClients(activeWorkspace.id);
setClientsList(clientsRes.results || []);
const projectRes = await getProject(id);
setName(projectRes.name || "");
setDescription(projectRes.description || "");
setColor(projectRes.color || COLORS[0]);
setClient(projectRes.client?.id || projectRes.client || "");
if (projectRes.members) {
const mappedMembers = projectRes.members.map((m: any) => ({
localId: m.id,
user: {
id: m.user_details?.id || m.user,
first_name: m.user_details?.first_name || "",
last_name: m.user_details?.last_name || "",
mobile: m.user_details?.phone_number || "",
profile_picture: m.user_details?.avatar || "",
},
role: m.role as ProjectRole,
isCreator: m.user === currentUserId && m.role === "manager",
}));
setMembers(mappedMembers);
}
const res = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: LIMIT,
offset: 0,
});
const results = res.results || (Array.isArray(res) ? res : []);
setWorkspaceMembers(results);
setOffset(LIMIT);
setHasMore(res.next ? true : results.length >= LIMIT);
} catch (err) {
toast.error("Failed to load project data.");
navigate("/projects");
} finally {
setIsLoadingData(false);
setIsProjectLoading(false);
}
};
loadInitialData();
}
}, [activeWorkspace?.id, id, currentUserId, navigate]);
const loadMoreMembers = useCallback(async () => {
if (isLoadingMore || !hasMore || !activeWorkspace?.id) return;
try {
setIsLoadingMore(true);
const res = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: LIMIT,
offset: offset
});
const results = res.results || (Array.isArray(res) ? res : []);
setWorkspaceMembers((prev) => {
const existingIds = new Set(prev.map(m => m.id));
const newItems = results.filter((item: any) => !existingIds.has(item.id));
return [...prev, ...newItems];
});
setOffset(prev => prev + LIMIT);
setHasMore(res.next ? true : results.length >= LIMIT);
} catch (error) {
console.error("Failed to load more members", error);
} finally {
setIsLoadingMore(false);
}
}, [activeWorkspace?.id, isLoadingMore, hasMore, offset]);
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
const cleanQuery = toEnglishDigits(searchQuery.trim());
setSearchError(false);
if (cleanQuery.length >= 10 && /^\d+$/.test(cleanQuery)) {
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const foundUser = await searchUserByExactMobile(cleanQuery);
if (foundUser && foundUser.id) {
setSearchResult(foundUser);
setSearchError(false);
} else {
setSearchResult(null);
setSearchError(true);
}
} catch (error) {
setSearchResult(null);
setSearchError(true);
} finally {
setIsSearching(false);
}
}, 500);
} else {
setSearchResult(null);
}
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
};
}, [searchQuery]);
const handleAddMember = (userToAdd: any) => {
if (members.some((m) => m.user.id === userToAdd.id)) return;
const newMember: LocalMember = {
localId: Math.random().toString(36).substr(2, 9),
user: userToAdd,
role: "member",
};
setMembers((prev) => [newMember, ...prev]);
setSearchQuery("");
setSearchResult(null);
};
const handleToggleAddAllMembers = async () => {
if (addAllMembers) {
setMembers((prev) => prev.filter(m => m.isCreator || m.role === "manager"));
setAddAllMembers(false);
} else {
if (!activeWorkspace?.id) return;
setIsAddingAll(true);
try {
let currentOffset = 0;
let continueFetching = true;
const allWsMembers: any[] = [];
while (continueFetching) {
const res = await fetchWorkspaceMemberships({
workspace: activeWorkspace.id,
limit: 50,
offset: currentOffset,
});
const fetchedResults = res.results || (Array.isArray(res) ? res : []);
allWsMembers.push(...fetchedResults);
if (res.next) {
currentOffset += 50;
} else {
continueFetching = false;
}
}
const newMembersToAdd = allWsMembers
.map((wm) => wm.user)
.filter((u) => u && !members.some((m) => m.user.id === u.id));
const localMembers: LocalMember[] = newMembersToAdd.map((u) => ({
localId: Math.random().toString(36).substr(2, 9),
user: u,
role: "member",
}));
setMembers((prev) => [...prev, ...localMembers]);
setAddAllMembers(true);
} catch (error) {
toast.error("Could not add all workspace members.");
} finally {
setIsAddingAll(false);
}
}
};
const openDeleteModal = (userId: string) => {
setMemberIdToDelete(userId);
setIsDeleteDialogOpen(true);
};
const handleDeleteMember = () => {
if (!memberIdToDelete) return;
setMembers(members.filter((m) => m.user.id !== memberIdToDelete));
setIsDeleteDialogOpen(false);
setMemberIdToDelete(null);
};
const handleChangeRole = (userId: string, newRole: string) => {
setMembers(
members.map((m) => (m.user.id === userId ? { ...m, role: newRole as ProjectRole } : m))
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !activeWorkspace || !id) return;
try {
setIsSaving(true);
const membersPayload = members.map((m) => ({ user_id: m.user.id, role: m.role }));
const projectPayload: any = {
name,
description,
color,
workspace: activeWorkspace.id,
members: membersPayload,
client: client || null,
};
const updatedProject = await updateProject(id, projectPayload);
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);
}
};
const filteredWorkspaceMembers = workspaceMembers.filter((m) => {
const q = searchQuery.trim().toLowerCase();
if (!q) return true;
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
const phone = toEnglishDigits(m.user.mobile || "");
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
});
const workspaceMemberUserIds = new Set(filteredWorkspaceMembers.map((m) => m.user.id));
const externalAddedMembers = members.filter((m) => {
if (workspaceMemberUserIds.has(m.user.id)) return false;
const q = searchQuery.trim().toLowerCase();
if (!q) return true;
const fullName = `${m.user.first_name || ""} ${m.user.last_name || ""}`.toLowerCase();
const phone = toEnglishDigits(m.user.mobile || "");
return fullName.includes(q) || phone.includes(toEnglishDigits(q));
});
const displayList = [
...externalAddedMembers.map((m) => ({ listId: m.localId, user: m.user })),
...filteredWorkspaceMembers.map((m) => ({ listId: m.id || m.user.id, user: m.user }))
];
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 flex flex-col p-4 sm:p-6 bg-slate-50 dark:bg-slate-900 overflow-hidden">
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-6 shrink-0">
{t.projects?.edit || "Edit Project"}
</h1>
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 flex-1 min-h-0">
<div className="w-full lg:w-1/3 lg:max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-sm border border-slate-200 dark:border-slate-800 overflow-y-auto">
<form id="edit-project-form" onSubmit={handleSubmit} className="flex flex-col h-full p-6">
<div className="flex-1 space-y-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg shrink-0 shadow-sm" style={{ backgroundColor: color }} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects?.namePlaceholder || "Project name..."}
required
/>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-5 h-5 rounded-full transition-all duration-150 shrink-0 ${
color === c
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-slate-800 ring-blue-500 scale-110 shadow-md"
: "hover:scale-110 shadow-sm"
}`}
style={{ backgroundColor: c }}
aria-label={`Select color ${c}`}
/>
))}
</div>
</div>
<div>
<label className="text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
<Briefcase size={16} />
{t.projects?.client || "Client"}
</label>
<Select
value={client}
onChange={setClient}
options={[
{ value: "", label: t.projects?.noClient || "No Client" },
...clientsList.map((c) => ({ value: c.id, label: c.name })),
]}
className="w-full"
buttonClassName="w-full"
/>
</div>
<div>
<label className="block text-slate-700 dark:text-slate-300 mb-2">
{t.projects?.descriptionLabel || "Description (Optional)"}
</label>
<TextAreaInput
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.projects?.descriptionPlaceholder || "Add more details..."}
rows={4}
/>
</div>
</div>
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 shrink-0">
<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" />}
{t.save || "Save Changes"}
</Button>
</div>
</form>
</div>
<div className="w-full lg:w-2/3 flex flex-col min-h-0 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-lg border">
<div className="p-4 border-b border-slate-200 dark:border-slate-700 shrink-0 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
<Users size={18} />
{t.projects?.projectMembers || "Project Members"}
</h3>
<Button
type="button"
variant={addAllMembers ? "destructive" : "outline"}
disabled={isAddingAll || isLoadingData}
onClick={handleToggleAddAllMembers}
>
{isAddingAll && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{addAllMembers
? (t.projects?.removeAllWorkspaceMembers || "Remove All")
: (t.projects?.addAllWorkspaceMembers || "Add All")}
</Button>
</div>
<div className="relative">
<Search className="absolute inset-s-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.projects?.searchWorkspaceMembers || "Search by name or enter mobile number..."}
className="ps-10"
/>
{isSearching && (
<Loader2 className="animate-spin absolute inset-e-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
)}
</div>
{searchError && (
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
{t.projects?.userNotFound || "No user found with this mobile number."}
</p>
)}
{searchResult && !searchError && (
<div className="p-3 border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 rounded-md flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
{searchResult.profile_picture ? (
<img
src={searchResult.profile_picture}
alt={searchResult.first_name}
className="w-10 h-10 rounded-full object-cover shadow-sm"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-200 dark:bg-blue-900/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
{searchResult.first_name?.[0] || "U"}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{searchResult.first_name} {searchResult.last_name}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{searchResult.mobile}
</span>
</div>
</div>
<Button
type="button"
variant="default"
size="sm"
disabled={members.some((m) => m.user.id === searchResult.id)}
onClick={() => handleAddMember(searchResult)}
>
{members.some((m) => m.user.id === searchResult.id)
? (t.projects?.alreadyInProject || "Already Added")
: (t.projects?.addToProject || "Add to Project")}
</Button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoadingData ? (
<div className="p-4 text-sm text-slate-500 flex justify-center items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
{t.loading || "Loading..."}
</div>
) : (
<InfiniteScroll
onLoadMore={loadMoreMembers}
hasMore={hasMore && searchQuery.trim().length === 0}
isLoading={isLoadingMore}
>
{displayList.length === 0 ? (
<div className="p-4 text-sm text-slate-500 text-center">
{t.projects?.noWorkspaceMembers || "No members found."}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-700/50">
{displayList.map((item) => {
const addedMemberData = members.find((mm) => mm.user.id === item.user.id);
const isAdded = !!addedMemberData;
return (
<li key={item.listId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 gap-3 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors rounded-lg">
<div className="flex items-center gap-3">
{item.user.profile_picture ? (
<img
src={item.user.profile_picture}
alt={item.user.first_name}
className="w-9 h-9 rounded-full object-cover shadow-sm"
/>
) : (
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm shadow-sm flex-shrink-0">
{item.user.first_name?.[0] || "U"}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2">
{item.user.first_name} {item.user.last_name}
{addedMemberData?.isCreator && (
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 px-2 py-0.5 rounded-full text-slate-600 dark:text-slate-300 font-bold">
{t.projects?.creator || "Creator"}
</span>
)}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{item.user.mobile}
</span>
</div>
</div>
<div>
{isAdded ? (
<div className="flex items-center gap-2">
<Select
value={addedMemberData.role}
onChange={(val) => handleChangeRole(item.user.id, val)}
options={[
{ value: "member", label: t.projects?.roles?.member || "Member" },
{ value: "manager", label: t.projects?.roles?.manager || "Manager" },
]}
buttonClassName="text-xs h-8 w-28"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
onClick={() => openDeleteModal(item.user.id)}
>
<Trash2 size={16} />
</Button>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={() => handleAddMember(item.user)}
>
{t.projects?.addToProject || "Add to Project"}
</Button>
)}
</div>
</li>
);
})}
</ul>
)}
</InfiniteScroll>
)}
</div>
</div>
</div>
<Modal
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
title={t.projects?.confirmDeleteTitle || "Remove Member"}
description={
t.projects?.confirmDeleteDesc || "Are you sure you want to remove this member from the project?"
}
>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
{t.cancel || "Cancel"}
</Button>
<Button variant="destructive" onClick={handleDeleteMember}>
{t.remove || "Remove"}
</Button>
</div>
</Modal>
</div>
);
}

View File

@@ -1,123 +1,173 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects";
import { useWorkspace } from "../context/WorkspaceContext";
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { Pagination } from "../components/Pagination";
import { Plus, Archive, Trash2, Pencil } from "lucide-react";
import FilterBar from "../components/FilterBar";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Modal } from "../components/Modal";
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";
import { getClients } from "../api/clients";
import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext";
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
import { Pagination } from "../components/Pagination";
import { Plus, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
import EmptyStateCard from "../components/EmptyStateCard";
import FilterBar from "../components/FilterBar";
import { ListPageSkeleton } from "../components/ListPageSkeleton";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Modal } from "../components/Modal";
import { toast } from "sonner";
import { Input } from "../components/ui/input";
import { MultiSearchableSelect } from "../components/ui/MultiSearchableSelect";
import {
PROJECTS_ARCHIVE,
PROJECTS_CREATE,
PROJECTS_DELETE,
PROJECTS_EDIT,
canDeleteWorkspaceResource,
canWorkspace,
} from "../lib/permissions";
import {
readArrayParam,
readBooleanParam,
readNumberParam,
readStringParam,
updateQueryParams,
} from "../lib/queryParams";
export const Projects: React.FC = () => {
const { t, lang } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace();
const workspaceRole = activeWorkspace?.my_role;
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE);
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<any | null>(null);
const [search, setSearch] = useState("");
const [ordering, setOrdering] = useState("-created_at");
const [isArchived, setIsArchived] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
const [deleteInput, setDeleteInput] = useState('');
const orderingOptions = [
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' },
{ value: 'name', label: t.ordering?.name || 'Name (A-Z)' },
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
];
const fetchProjectList = async () => {
if (!activeWorkspace) return;
setLoading(true);
try {
const offset = (currentPage - 1) * limit;
const data = await getProjects(activeWorkspace.id, {
limit,
offset,
search,
is_archived: isArchived,
ordering
});
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setProjects(items);
setTotalItems(count)
} catch (error) {
console.error("Failed to fetch projects", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
fetchProjectList();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
useEffect(() => {
const handleCreated = () => fetchProjectList();
const handleUpdated = () => fetchProjectList();
window.addEventListener("project_created", handleCreated);
window.addEventListener("project_updated", handleUpdated);
return () => {
window.removeEventListener("project_created", handleCreated);
window.removeEventListener("project_updated", handleUpdated);
};
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
const handleDeleteClick = (project: Project) => {
setProjectToDelete(project);
};
const confirmDelete = async () => {
if (!deleteModal.project) return;
try {
const deletedId = deleteModal.project.id;
await deleteProject(deletedId);
fetchProjectList();
window.dispatchEvent(new CustomEvent('project_deleted', {
detail: { id: deletedId }
}));
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
} catch (error) {
toast.error(t.projects?.deleteError || 'Failed to delete project');
}
const [projects, setProjects] = useState<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [isAccessModalOpen, setIsAccessModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]);
const ordering = useMemo(
() => readStringParam(searchParams, "ordering", "-created_at"),
[searchParams],
);
const isArchived = useMemo(
() => readBooleanParam(searchParams, "archived", false),
[searchParams],
);
const selectedClientIds = useMemo(
() => readArrayParam(searchParams, "clients"),
[searchParams],
);
const selectedClientIdsKey = useMemo(
() => selectedClientIds.join(","),
[selectedClientIds],
);
const currentPage = useMemo(
() => Math.max(1, readNumberParam(searchParams, "page", 1)),
[searchParams],
);
const limit = useMemo(
() => Math.max(1, readNumberParam(searchParams, "limit", 10)),
[searchParams],
);
const [totalItems, setTotalItems] = useState(0);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
const [deleteInput, setDeleteInput] = useState('');
const orderingOptions = [
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' },
{ value: 'name', label: t.ordering?.name || 'Name (A-Z)' },
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
];
const fetchProjectList = async () => {
if (!activeWorkspace) return;
setLoading(true);
try {
const offset = (currentPage - 1) * limit;
const data = await getProjects(activeWorkspace.id, {
limit,
offset,
search,
clients: selectedClientIds,
is_archived: isArchived,
ordering
});
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setProjects(items);
setTotalItems(count)
} catch (error) {
console.error("Failed to fetch projects", error);
toast.error(t.projects?.fetchError || "Failed to fetch projects.");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!activeWorkspace?.id) return;
getClients(activeWorkspace.id, "", "name", 300, 0)
.then((data: any) => {
const items = data?.results || (Array.isArray(data) ? data : []);
setClients(items.map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
})
.catch((error) => {
console.error(error);
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
setClients([]);
});
}, [activeWorkspace?.id, t.projects?.clientFetchError]);
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
void fetchProjectList();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
useEffect(() => {
const handleCreated = () => void fetchProjectList();
const handleUpdated = () => void fetchProjectList();
window.addEventListener("project_created", handleCreated);
window.addEventListener("project_updated", handleUpdated);
return () => {
window.removeEventListener("project_created", handleCreated);
window.removeEventListener("project_updated", handleUpdated);
};
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.project) return;
try {
const deletedId = deleteModal.project.id;
await deleteProject(deletedId);
fetchProjectList();
window.dispatchEvent(new CustomEvent('project_deleted', {
detail: { id: deletedId }
}));
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
} catch (error) {
toast.error(t.projects?.deleteError || 'Failed to delete project');
}
};
const formatDate = (dateStr: string | undefined) => {
@@ -132,128 +182,278 @@ export const Projects: React.FC = () => {
return dateStr
}
}
return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
{canArchiveProject && (
<Button
variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)}
className="gap-2 shadow-sm flex-1 sm:flex-none"
>
<Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</Button>
)}
{canCreateProject && (
<Button
onClick={() => setIsCreateModalOpen(true)}
size="icon"
className="shadow-sm"
title={t.projects?.createNew || 'Create New'}
>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</div>
<FilterBar
searchQuery={search}
setSearchQuery={setSearch}
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/>
{loading ? (
<div className="p-12 flex justify-center text-slate-500">
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
const sortedClients = useMemo(() => {
if (!selectedClientIds.length) return clients;
const selected = clients.filter((client) => selectedClientIds.includes(client.id));
const unselected = clients.filter((client) => !selectedClientIds.includes(client.id));
return [...selected, ...unselected];
}, [clients, selectedClientIdsKey]);
const clientOptions = useMemo(
() =>
sortedClients.map((client) => ({
value: client.id,
label: client.name,
})),
[sortedClients],
);
const hasActiveProjectFilters = selectedClientIds.length > 0 || isArchived;
const updateListParams = (
updates: Record<string, string | number | boolean | null | undefined | string[]>,
) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-created_at",
archived: false,
page: 1,
limit: 10,
}),
{ replace: true },
);
};
if (!activeWorkspace) {
return (
<div className="mx-auto max-w-7xl p-4 md:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.projects?.selectWorkspace || t.clients.selectWorkspace}
</div>
) : (
<div className="flex flex-col flex-1">
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
<div className="p-0">
{projects.length === 0 ? (
<div className="py-16 flex flex-col items-center justify-center">
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
</div>
);
}
return (
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-1 flex-col gap-5">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p>
</div>
<div className="flex w-full items-center gap-3 sm:w-auto">
{canEditProject && (
<Button
variant="secondary"
onClick={() => setIsAccessModalOpen(true)}
className="flex-1 gap-2 shadow-sm sm:flex-none"
>
<ShieldCheck className="h-4 w-4" />
{t.projects?.manageAccess || "Manage access"}
</Button>
)}
{canCreateProject && (
<Button
onClick={() => setIsCreateModalOpen(true)}
size="icon"
className="shrink-0 shadow-sm"
title={t.projects?.createNew || 'Create New'}
>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<FilterBar
searchQuery={search}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/>
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] lg:flex-1">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.projects?.filterClients || "Filter by client"}
</div>
<MultiSearchableSelect
values={selectedClientIds}
onChange={(values) => updateListParams({ clients: values, page: 1 })}
options={clientOptions}
placeholder={t.reports?.allClients || "All clients"}
searchPlaceholder={t.reports?.searchClients || "Search clients..."}
emptyLabel={t.clients?.noClients || "No clients found"}
renderValue={(selectedOptions) => {
if (selectedOptions.length === 0) {
return t.reports?.allClients || "All clients";
}
if (selectedOptions.length <= 2) {
return selectedOptions.map((option) => option.label).join(", ");
}
return `${selectedOptions[0]?.label} +${selectedOptions.length - 1}`;
}}
buttonClassName="min-h-11 w-full rounded-xl border-slate-200 bg-slate-50/80 dark:border-slate-700"
/>
</div>
{canArchiveProject ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.projects?.archived || "Archived Projects"}
</div>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{projects.map((project) => (
<li
key={project.id}
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4"
>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
</p>
{project.description && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
{project.description}
</p>
)}
<div className="text-[11px] text-slate-400 mt-3 font-medium">
{(t.projects as any)?.addedOn || "Added on"}: {formatDate(project.created_at)}
<button
type="button"
role="switch"
aria-checked={isArchived}
aria-label={t.projects?.archived || "Archived Projects"}
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
className={`inline-flex min-h-11 w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-sm font-medium transition md:w-auto ${
isArchived
? "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
: "border-slate-200 bg-slate-50/80 text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white"
}`}
>
<span>{t.projects?.archived || "Archived Projects"}</span>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition ${
isArchived ? "bg-amber-500 dark:bg-amber-400" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
isArchived ? "translate-x-5 rtl:-translate-x-5" : "translate-x-0.5 rtl:-translate-x-0.5"
}`}
/>
</span>
</button>
</div>
) : null}
</div>
<button
type="button"
onClick={() => {
updateListParams({ clients: [], archived: false, page: 1 });
}}
disabled={!hasActiveProjectFilters}
aria-label={t.projects?.clearClientFilters || "Clear filters"}
className={`inline-flex h-10 w-10 items-center justify-center self-start rounded-xl border text-sm transition lg:self-end ${
hasActiveProjectFilters
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
: "border-slate-200 bg-white text-slate-400 opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
} disabled:cursor-not-allowed`}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{loading ? (
<ListPageSkeleton variant="standard-grid" />
) : (
<div className="flex flex-1 flex-col gap-6">
{projects.length === 0 ? (
<EmptyStateCard
icon={Building2}
title={t.projects?.emptyState || "No projects found"}
description={t.projects?.noProjectsSearch || t.projects?.emptyState || "No projects found"}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{projects.map((project) => {
const canDeleteProject = canDeleteWorkspaceResource({
workspaceRole,
currentUserId: user?.id,
createdById: project.created_by?.id,
});
return (
<Card key={project.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
<CardContent className="flex h-full flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-slate-200 text-sm font-semibold text-white dark:border-slate-700"
style={{ backgroundColor: project.color || "#3B82F6" }}
>
{project.thumbnail ? (
<img src={project.thumbnail} alt={project.name} className="h-full w-full object-cover" />
) : (
project.name.trim().charAt(0).toUpperCase() || "P"
)}
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
</div>
</div>
</div>
{(canEditProject || canDeleteProject) && (
<div className="flex shrink-0 items-center gap-1">
{canEditProject && (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProject(project)}
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
title={t.actions?.edit || "Edit"}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{canDeleteProject && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, project })}
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title={t.actions?.delete || "Delete"}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
{(canEditProject || canDeleteProject) && (
<div className="flex items-center gap-1 shrink-0">
{canEditProject && (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProject(project)}
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
title={t.actions?.edit || "Edit"}
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteProject && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, project })}
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
title={t.actions?.delete || "Delete"}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
<div className="space-y-3">
<p className="min-h-[3.75rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300">
{project.description || t.workspace?.noDescription || "No description"}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
<span>{formatDate(project.created_at)}</span>
{project.is_archived ? (
<span className="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold tracking-[0.1em] text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
{t.projects?.archived || "Archived Projects"}
</span>
) : null}
</div>
)}
</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</Card>
)}
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
pageSizeOptions={[10, 20, 50]}
/>
</div>
)}
{/* Modals */}
totalCount={totalItems}
limit={limit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
pageSizeOptions={[10, 20, 50]}
/>
</div>
)}
</div>
{/* Modals */}
{canCreateProject && isCreateModalOpen && (
<ProjectCreateModal
isOpen={isCreateModalOpen}
@@ -263,60 +463,112 @@ export const Projects: React.FC = () => {
{canEditProject && editingProject && (
<ProjectEditModal
project={editingProject}
isOpen={!!editingProject}
onClose={() => setEditingProject(null)}
/>
)}
{deleteModal.project && (
<Modal
isOpen={deleteModal.isOpen}
onClose={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
title={t.projects?.deleteTitle || 'Delete Project'}
maxWidth="max-w-md"
footer={
<>
<Button
variant="secondary"
onClick={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
className="rounded-xl font-semibold"
>
{t.actions?.cancel || 'Cancel'}
</Button>
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.project.name}
onClick={confirmDelete}
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
</Button>
</>
}
>
<div 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>
<Input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name}
/>
</div>
</Modal>
)}
</div>
);
};
project={editingProject}
isOpen={!!editingProject}
onClose={() => setEditingProject(null)}
/>
)}
{canEditProject && (
<ProjectAccessModal
isOpen={isAccessModalOpen}
onClose={() => setIsAccessModalOpen(false)}
workspaceId={activeWorkspace.id}
onApplied={() => {
void fetchProjectList();
}}
labels={{
title: t.projects?.accessModalTitle || "Project access",
description: t.projects?.accessModalDescription || "Grant or revoke project access for workspace members.",
close: t.actions?.cancel || "Close",
member: t.projects?.accessMemberLabel || "Member",
projects: t.sidebar?.projects || "Projects",
loading: t.loading || "Loading...",
noMembers: t.projects?.accessNoMembers || "No eligible members were found.",
noProjects: t.projects?.accessNoProjects || "No projects found.",
searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...",
allClients: t.reports?.allClients || "All clients",
selectAllVisible: t.projects?.accessSelectVisible || "Select all visible",
clearSelection: t.projects?.accessClearSelection || "Clear selection",
selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client",
grantSelected: t.projects?.accessGrant || "Grant selected",
revokeSelected: t.projects?.accessRevoke || "Revoke selected",
accessGranted: t.projects?.accessGrantSuccess || "Project access granted.",
accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.",
memberRole: t.workspace?.roleLabel || "Role",
client: t.projects?.clientLabel || "Client",
noClient: t.projects?.noClient || "No client",
accessOn: t.projects?.accessOn || "Has access",
accessOff: t.projects?.accessOff || "No access",
loadError: t.projects?.accessLoadError || "Failed to load project access state.",
saveError: t.projects?.accessSaveError || "Failed to update project access.",
workspaceRate: t.rates?.workspaceRate || "Workspace rate",
projectOverride: t.rates?.projectOverride || "Project override",
inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate",
noRate: t.rates?.noRate || "No rate",
hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00",
currencyPlaceholder: t.rates?.currencyPlaceholder || "USD",
removeRate: t.rates?.removeRate || "Remove rate",
projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.",
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.",
implicitAccessHint:
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}}
/>
)}
{deleteModal.project && (
<Modal
isOpen={deleteModal.isOpen}
onClose={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
title={t.projects?.deleteTitle || 'Delete Project'}
maxWidth="max-w-md"
footer={
<>
<Button
variant="secondary"
onClick={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
className="rounded-xl font-semibold"
>
{t.actions?.cancel || 'Cancel'}
</Button>
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.project.name}
type="submit"
form="delete-project-form"
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
</Button>
</>
}
>
<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>
<Input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name}
/>
</form>
</Modal>
)}
</div>
);
};

115
src/pages/RateLimit.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { AlertTriangle, Clock3 } from "lucide-react"
import { Button } from "../components/ui/button"
import { useTranslation } from "../hooks/useTranslation"
import {
clearRateLimitLock,
getRateLimitRemainingSeconds,
getStoredRateLimitLock,
} from "../lib/rateLimit"
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]
const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit)
export default function RateLimitPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const isRtl = lang === "fa"
const initialLock = getStoredRateLimitLock()
const [returnTo] = useState(initialLock?.returnTo || "/")
const [status] = useState(initialLock?.status ?? 429)
const [message] = useState(initialLock?.message || t.rateLimit.message)
const [remainingSeconds, setRemainingSeconds] = useState(getRateLimitRemainingSeconds(initialLock))
useEffect(() => {
if (!initialLock) {
navigate(returnTo, { replace: true })
return
}
const timer = window.setInterval(() => {
const currentLock = getStoredRateLimitLock()
setRemainingSeconds(getRateLimitRemainingSeconds(currentLock))
}, 1000)
return () => window.clearInterval(timer)
}, [initialLock, navigate, returnTo])
const localizedDigits = (value: string) => (isRtl ? toPersianDigits(value) : value)
const countdown = useMemo(() => {
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
const base =
minutes > 0
? `${minutes}:${seconds.toString().padStart(2, "0")}`
: `${seconds}s`
return localizedDigits(base)
}, [isRtl, remainingSeconds])
const handleContinue = () => {
clearRateLimitLock()
navigate(returnTo, { replace: true })
}
const isCoolingDown = remainingSeconds > 0
return (
<div className="min-h-screen bg-slate-50 px-4 py-10 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div className="mx-auto flex min-h-[calc(100vh-5rem)] max-w-2xl items-center justify-center">
<div className="w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-10">
<div className="mb-6 flex items-center justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
<AlertTriangle className="h-8 w-8" />
</div>
</div>
<div className="space-y-4 text-center">
<p className="text-sm font-medium uppercase tracking-[0.3em] text-amber-600 dark:text-amber-300">
{t.rateLimit.eyebrow}
</p>
<h1 className="text-3xl font-semibold tracking-tight">
{t.rateLimit.title}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 sm:text-base">
{t.rateLimit.message}
</p>
</div>
<div className="mt-8 grid gap-4">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5 text-start dark:border-slate-800 dark:bg-slate-950/60">
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-400">
<Clock3 className="h-4 w-4" />
<p className="text-xs font-medium uppercase tracking-[0.2em]">
{t.rateLimit.cooldownLabel}
</p>
</div>
<p className="mt-2 text-2xl font-semibold">
{isCoolingDown ? countdown : t.rateLimit.ready}
</p>
</div>
</div>
<div className="mt-8 rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-4 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-950/50 dark:text-slate-300">
{isCoolingDown ? t.rateLimit.waitingMessage(countdown) : t.rateLimit.finishedMessage}
</div>
<div className="mt-8 flex justify-center">
<Button
onClick={handleContinue}
disabled={isCoolingDown}
className="h-11 min-w-52"
>
{isCoolingDown ? t.rateLimit.continueCooldown(countdown) : t.rateLimit.continue}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { BarChart3, Table2 } from "lucide-react";
import { toast } from "sonner";
@@ -9,10 +10,12 @@ import {
getChartReport,
getDayDetailsReport,
getTableReport,
getUserSummaryReport,
type ChartReportResponse,
type DayDetailsResponse,
type ReportFilters,
type TableReportResponse,
type UserScopedTableReport,
} from "../api/reports";
import { getTags, type Tag } from "../api/tags";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
@@ -21,6 +24,12 @@ import { ReportsFilterBar, type ReportsFilterDraft } from "../components/reports
import { ReportsTablePanel } from "../components/reports/ReportsTablePanel";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import {
DEFAULT_REPORTS_FILTERS,
readReportsFiltersFromParams,
writeReportsFiltersToParams,
} from "../lib/reportFilters";
import { readStringParam, updateQueryParams } from "../lib/queryParams";
import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions";
type Tab = "chart" | "table";
@@ -86,7 +95,8 @@ const getCurrentLanguageAwareMonthRange = (lang: "en" | "fa") => {
export default function Reports() {
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [tab, setTab] = useState<Tab>("chart");
const [searchParams, setSearchParams] = useSearchParams();
const tab = (readStringParam(searchParams, "tab", "chart") as Tab);
const [projects, setProjects] = useState<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
@@ -96,33 +106,32 @@ export default function Reports() {
const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null);
const [openDay, setOpenDay] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [exportState, setExportState] = useState({
excel: { pending: false, cooldownSeconds: 0 },
pdf: { pending: false, cooldownSeconds: 0 },
});
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
const [filters, setFilters] = useState<ReportsFilterDraft>({
period: "this_month",
from_date: "",
to_date: "",
user: "",
client: "",
project: "",
tags: [],
});
const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role);
const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers);
const filters = useMemo<ReportsFilterDraft>(
() => readReportsFiltersFromParams(searchParams),
[searchParams],
);
useEffect(() => {
if (!activeWorkspace?.id) return;
const loadOptions = async () => {
try {
const [projectsResponse, clientsResponse, tagsResponse, membersResponse] = await Promise.all([
const [projectsResponse, clientsResponse, tagsResponse] = await Promise.all([
getProjects(activeWorkspace.id, { limit: 300, offset: 0 }),
getClients(activeWorkspace.id, "", "", 300, 0),
getTags(activeWorkspace.id, { limit: 300, offset: 0 }),
fetchWorkspaceMemberships({ workspace: activeWorkspace.id }),
]);
setProjects(projectsResponse.results || []);
setClients((clientsResponse.results || []).map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
setTags(tagsResponse.results || []);
setMemberships(membersResponse.results || []);
} catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
}
@@ -130,6 +139,31 @@ export default function Reports() {
void loadOptions();
}, [activeWorkspace?.id, t.reports?.loadFiltersError]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved) return;
if (!canSelectUsers) {
setMemberships([]);
setIsLoadingUsers(false);
return;
}
const loadUsers = async () => {
setIsLoadingUsers(true);
try {
const membersResponse = await fetchWorkspaceMemberships({ workspace: activeWorkspace.id });
setMemberships(membersResponse.results || []);
} catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
setMemberships([]);
} finally {
setIsLoadingUsers(false);
}
};
void loadUsers();
}, [activeWorkspace?.id, canSelectUsers, isWorkspaceRoleResolved, t.reports?.loadFiltersError]);
const buildApiFilters = (draft: ReportsFilterDraft): ReportFilters | null => {
if (!activeWorkspace?.id) return null;
@@ -147,6 +181,7 @@ export default function Reports() {
};
const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]);
const apiFiltersKey = apiFilters ? JSON.stringify(apiFilters) : "";
const runReportLoad = async (nextFilters: ReportFilters) => {
setIsLoading(true);
@@ -169,8 +204,7 @@ export default function Reports() {
useEffect(() => {
if (!apiFilters) return;
void runReportLoad(apiFilters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiFilters?.workspace]);
}, [apiFilters, apiFiltersKey]);
const handleToggleDay = async (day: string) => {
if (!apiFilters) return;
@@ -188,16 +222,55 @@ export default function Reports() {
}
};
useEffect(() => {
const interval = window.setInterval(() => {
setExportState((current) => ({
excel: {
pending: current.excel.pending,
cooldownSeconds: Math.max(current.excel.cooldownSeconds - 1, 0),
},
pdf: {
pending: current.pdf.pending,
cooldownSeconds: Math.max(current.pdf.cooldownSeconds - 1, 0),
},
}));
}, 1000);
return () => window.clearInterval(interval);
}, []);
const handleExport = async (type: "excel" | "pdf") => {
if (!apiFilters) return;
if (exportState[type].pending || exportState[type].cooldownSeconds > 0) return;
setExportState((current) => ({
...current,
[type]: { pending: true, cooldownSeconds: 0 },
}));
try {
await createReportExport(apiFilters, type, lang);
setExportState((current) => ({
...current,
[type]: { pending: false, cooldownSeconds: 60 },
}));
toast.success(t.reports?.exportQueued || "Export queued. You will receive a notification with the download link.");
} catch {
setExportState((current) => ({
...current,
[type]: { pending: false, cooldownSeconds: 0 },
}));
toast.error(t.reports?.exportError || "Failed to queue report export.");
}
};
const handleLoadUserSummaryReport = async (userId: string): Promise<UserScopedTableReport> => {
if (!apiFilters) {
throw new Error("Missing report filters");
}
return getUserSummaryReport(apiFilters, userId);
};
if (!activeWorkspace) {
return (
<div className="p-6">
@@ -221,7 +294,12 @@ export default function Reports() {
<div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto">
<button
type="button"
onClick={() => setTab("chart")}
onClick={() =>
setSearchParams(
(current) => updateQueryParams(current, { tab: "chart" }, { tab: "chart" }),
{ replace: true },
)
}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
tab === "chart"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
@@ -233,7 +311,12 @@ export default function Reports() {
</button>
<button
type="button"
onClick={() => setTab("table")}
onClick={() =>
setSearchParams(
(current) => updateQueryParams(current, { tab: "table" }, { tab: "chart" }),
{ replace: true },
)
}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
tab === "table"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
@@ -249,17 +332,18 @@ export default function Reports() {
<ReportsFilterBar
value={filters}
onApply={(draft) => {
setFilters(draft);
const nextFilters = buildApiFilters(draft);
if (!nextFilters) return;
void runReportLoad(nextFilters);
}}
onApply={(draft) =>
setSearchParams(
(current) => writeReportsFiltersToParams(current, draft),
{ replace: true },
)
}
projects={projects}
clients={clients}
tags={tags}
users={memberships}
canSelectUsers={canSelectUsers}
isLoadingUsers={showUserFilterLoading}
labels={{
period: t.reports?.period || "Period",
thisWeek: t.reports?.periodThisWeek || "This week",
@@ -287,13 +371,10 @@ export default function Reports() {
}}
/>
{isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.loading || "Loading..."}
</div>
) : tab === "chart" ? (
{tab === "chart" ? (
<ReportsChartPanel
data={chartData}
isLoading={isLoading}
labels={{
totalHours: t.reports?.totalHours || "Total hours",
billableHours: t.reports?.billableHours || "Billable hours",
@@ -301,6 +382,7 @@ export default function Reports() {
totalIncome: t.reports?.totalIncome || "Total income",
chart: t.reports?.chartTitle || "Activity chart",
totalSeconds: t.reports?.totalSeconds || "Total seconds",
loading: t.loading || "Loading...",
}}
/>
) : (
@@ -309,7 +391,10 @@ export default function Reports() {
dayDetails={dayDetails}
openDay={openDay}
onToggleDay={(day) => void handleToggleDay(day)}
onLoadUserSummaryReport={(userId) => handleLoadUserSummaryReport(userId)}
onExport={(type) => void handleExport(type)}
exportState={exportState}
isLoading={isLoading}
labels={{
exportExcel: t.reports?.exportExcel || "Export Excel",
exportPdf: t.reports?.exportPdf || "Export PDF",
@@ -317,14 +402,35 @@ export default function Reports() {
billableHours: t.reports?.billableHours || "Billable hours",
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
totalHours: t.reports?.totalHours || "Total hours",
hourlyRate: t.reports?.hourlyRate || "Hourly rate",
totalIncome: t.reports?.totalIncome || "Total income",
details: t.reports?.details || "Details",
total: t.reports?.total || "Total",
name: t.reports?.name || "Name",
mobile: t.reports?.mobile || "Mobile",
hourlyRates: t.reports?.hourlyRates || "Hourly rates",
workingHours: t.reports?.workingHours || "Working hours",
nonWorkingHours: t.reports?.nonWorkingHours || "Non-working hours",
projectPercentages: t.reports?.projectPercentages || "Project percentages",
clientPercentages: t.reports?.clientPercentages || "Client percentages",
tagPercentages: t.reports?.tagPercentages || "Tag percentages",
userSummaryTitle: t.reports?.userSummaryTitle || "Summary by user",
userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}",
userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.",
rateHistory: t.reports?.rateHistory || "Rate history",
project: t.reports?.project || "Project",
fromDate: t.reports?.fromDate || "From",
toDate: t.reports?.toDate || "To",
now: t.reports?.now || "Now",
loadDetailsError: t.reports?.loadError || "Failed to load user report details.",
hourPercentage: t.reports?.hourPercentage || "Hour %",
incomePercentage: t.reports?.incomePercentage || "Income %",
noData: t.reports?.noData || "No data",
clientsTable: t.reports?.clientsTable || "Clients",
projectsTable: t.reports?.projectsTable || "Projects",
tagsTable: t.reports?.tagsTable || "Tags",
noDescription: t.timesheet?.emptyDescription || "No description",
loading: t.loading || "Loading...",
}}
/>
)}

View File

@@ -1,35 +1,41 @@
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";
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
import EmptyStateCard from "../components/EmptyStateCard";
import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { TAGS_CREATE, TAGS_DELETE, TAGS_EDIT, canWorkspace } from "../lib/permissions";
import { TAGS_CREATE, TAGS_EDIT, canDeleteWorkspaceResource, canWorkspace } from "../lib/permissions";
import FilterBar from "../components/FilterBar";
import { ListPageSkeleton } from "../components/ListPageSkeleton";
import { Modal } from "../components/Modal";
import { Pagination } from "../components/Pagination";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams";
const DEFAULT_COLOR = "#3B82F6";
export default function Tags() {
const { t } = useTranslation();
const { user } = useAppContext();
const { activeWorkspace } = useWorkspace();
const workspaceRole = activeWorkspace?.my_role;
const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE);
const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT);
const canDeleteTag = canWorkspace(workspaceRole, TAGS_DELETE);
const [searchParams, setSearchParams] = useSearchParams();
const [tags, setTags] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [ordering, setOrdering] = useState("-updated_at");
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [limit, setLimit] = useState(10);
const searchQuery = readStringParam(searchParams, "search", "");
const ordering = readStringParam(searchParams, "ordering", "-updated_at");
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1));
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10));
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
@@ -46,10 +52,6 @@ export default function Tags() {
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => {
if (!activeWorkspace?.id) return;
@@ -103,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 {
@@ -138,95 +141,140 @@ export default function Tags() {
}
};
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-updated_at",
page: 1,
limit: 10,
}),
{ replace: true },
);
};
if (!activeWorkspace) {
return <div className="p-6 text-center text-slate-500">{t.tags?.selectWorkspace || t.clients.selectWorkspace}</div>;
return (
<div className="mx-auto max-w-7xl p-4 md:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.tags?.selectWorkspace || t.clients.selectWorkspace}
</div>
</div>
);
}
return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex justify-between items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
</p>
</div>
{canCreateTag && (
<Button onClick={openCreateModal} size="icon" className="shadow-sm shrink-0" title={t.tags?.create || "Create Tag"}>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
/>
{isLoading ? (
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
) : (
<div className="flex flex-col flex-1">
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{tags.map((tag) => (
<Card key={tag.id} className="overflow-hidden shadow-sm dark:border-slate-700 dark:bg-slate-800">
<CardContent className="flex h-full flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className="h-9 w-9 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
style={{ backgroundColor: tag.color || DEFAULT_COLOR }}
/>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{tag.name}</CardTitle>
</div>
</div>
{(canEditTag || canDeleteTag) && (
<div className="flex shrink-0 items-center gap-1">
{canEditTag && (
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
<Edit2 className="w-4 h-4" />
</Button>
)}
{canDeleteTag && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, tag })}
title={t.actions?.delete || "Delete"}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
)}
</div>
</CardContent>
</Card>
))}
{tags.length === 0 && (
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-500 dark:text-slate-400">
<TagIcon className="w-10 h-10 mb-3" />
<p>{t.tags?.emptyState || "No tags found"}</p>
</div>
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-1 flex-col gap-5">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
</p>
</div>
{canCreateTag && (
<Button onClick={openCreateModal} size="icon" className="shrink-0 shadow-sm" title={t.tags?.create || "Create Tag"}>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</div>
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
/>
</div>
)}
{isLoading ? (
<ListPageSkeleton variant="list" />
) : (
<div className="flex flex-1 flex-col gap-6">
{tags.length === 0 ? (
<EmptyStateCard
icon={TagIcon}
title={t.tags?.emptyState || "No tags found"}
description={searchQuery ? t.tags?.noTagsSearch : t.tags?.emptyState || "No tags found"}
/>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{tags.map((tag) => {
const canDeleteTag = canDeleteWorkspaceResource({
workspaceRole,
currentUserId: user?.id,
createdById: tag.created_by?.id,
});
return (
<Card
key={tag.id}
className="flex flex-col text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
>
<CardContent className="flex h-full flex-col justify-between gap-4 px-5 py-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<div
className="h-9 w-9 shrink-0 rounded-lg border border-slate-200 dark:border-slate-700"
style={{ backgroundColor: tag.color || DEFAULT_COLOR }}
/>
<CardTitle className="text-lg line-clamp-1">{tag.name}</CardTitle>
</div>
</div>
{(canEditTag || canDeleteTag) && (
<div className="flex shrink-0 items-center gap-2">
{canDeleteTag && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, tag })}
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title={t.actions?.delete || "Delete"}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
{canEditTag && (
<Button
variant="ghost"
size="icon"
onClick={() => openEditModal(tag)}
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
title={t.actions?.edit || "Edit"}
>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/>
</div>
)}
</div>
<Modal
isOpen={isModalOpen}
@@ -237,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"}
@@ -257,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 && (
@@ -273,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>
</>
}
>
<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
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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, Fragment } from 'react';
import { useBlocker, useNavigate } from 'react-router-dom';
import { useTranslation } from '../hooks/useTranslation';
import { AlertCircle, UserPlus, Trash2, Shield, Loader2 } from 'lucide-react';
import { AlertCircle, UserPlus, Trash2, Shield, Loader2, UploadCloud } from 'lucide-react';
import { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner';
import { createWorkspace } from '../api/workspaces';
@@ -40,6 +40,8 @@ export default function WorkspaceCreate() {
// Workspace Info States
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Members States (Local)
@@ -54,8 +56,36 @@ export default function WorkspaceCreate() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0;
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUnsavedChanges = name.trim() !== '' || description.trim() !== '' || members.length > 0 || !!thumbnailFile;
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -121,7 +151,8 @@ export default function WorkspaceCreate() {
const payload = {
name,
description,
members: members.map(m => ({ user_id: m.user.id, role: m.role }))
members: members.map(m => ({ user_id: m.user.id, role: m.role })),
thumbnail: thumbnailFile,
};
const newWorkspace = await createWorkspace(payload);
@@ -178,13 +209,13 @@ export default function WorkspaceCreate() {
const isFirstOwner = true;
return (
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.createTitle || "Create Workspace"}
</h1>
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
<div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
<div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
@@ -210,6 +241,45 @@ export default function WorkspaceCreate() {
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 h-32 resize-none"
/>
</div>
<div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700">
{thumbnailPreview ? (
<img
src={thumbnailPreview}
alt={name || "Workspace"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-center">
<UploadCloud className="h-12 w-12 text-slate-500 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{t.workspace?.uploadImage || "Click to upload image"}
</span>
</div>
)}
<Input
type="file"
accept=".jpg,.jpeg,.png,.webp"
className="hidden"
onChange={(e) => handleThumbnailChange(e.target.files?.[0] || null)}
/>
</label>
{thumbnailFile && (
<button
type="button"
onClick={() => setThumbnailFile(null)}
className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400"
>
{t.workspace?.removeImage || "Remove image"}
</button>
)}
</div>
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
<Button
type="button"
@@ -228,7 +298,7 @@ export default function WorkspaceCreate() {
</form>
</div>
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" }
@@ -322,7 +392,7 @@ export default function WorkspaceCreate() {
</div>
{/* لیست اعضا (با قابلیت اسکرول) */}
<div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
<div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
{members.map((m) => {
return (
<div key={m.localId} className="flex flex-col sm:flex-row sm:items-center justify-between p-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg gap-3 shadow-sm hover:border-blue-200 dark:hover:border-blue-800 transition-colors">

View File

@@ -1,101 +1,455 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Edit2, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
ArrowRight,
Banknote,
BriefcaseBusiness,
Edit2,
FolderKanban,
Tag,
Trash2,
Users,
} from 'lucide-react';
import { getClients } from '../api/clients';
import { getProjects } from '../api/projects';
import { getWorkspaceUserRates, type WorkspaceUserRate } from '../api/rates';
import { getTags } from '../api/tags';
import {
deleteWorkspace,
fetchWorkspaceMemberships,
getWorkspace,
type Workspace,
type WorkspaceMembership,
} from '../api/workspaces';
import { useAppContext } from '../context/AppContext';
import { useWorkspace } from '../context/WorkspaceContext';
import { useTranslation } from '../hooks/useTranslation';
import { getWorkspace, deleteWorkspace, type Workspace } from '../api/workspaces';
import { WORKSPACE_DELETE, WORKSPACE_EDIT, canWorkspace } from '../lib/permissions';
export default function WorkspaceDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t, lang } = useTranslation();
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isRtl = lang === 'fa';
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
useEffect(() => {
if (id) loadWorkspace();
}, [id]);
const loadWorkspace = async () => {
try {
const data = await getWorkspace(id!);
setWorkspace(data);
} catch (error) {
console.error(error);
navigate('/workspaces');
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!window.confirm(t.workspace?.confirmDelete) || !id) return;
try {
await deleteWorkspace(id);
navigate('/workspaces');
} catch (error) {
console.error(error);
}
};
if (isLoading || !workspace) {
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
}
const canEdit = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
const canDelete = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
return (
<div className="max-w-4xl mx-auto p-6">
<button
onClick={() => navigate('/workspaces')}
className="flex items-center gap-2 text-slate-500 hover:text-slate-900 dark:hover:text-white mb-6 transition-colors"
>
<BackIcon className="h-5 w-5" />
<span>{t.workspace?.back}</span>
</button>
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm relative">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
{workspace.name}
</h1>
<span className="inline-block px-3 py-1 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-sm rounded-full font-medium">
{workspace.my_role ? t.workspace?.roles[workspace.my_role] : "-"}
</span>
</div>
{canEdit && (
<div className="flex gap-2">
<button
onClick={() => navigate(`/workspaces/${id}/edit`)}
className="p-2 text-slate-500 hover:text-emerald-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
>
<Edit2 className="h-5 w-5" />
</button>
{canDelete && (
<button
onClick={handleDelete}
className="p-2 text-slate-500 hover:text-red-600 bg-slate-50 dark:bg-slate-800 rounded-lg"
>
<Trash2 className="h-5 w-5" />
</button>
)}
</div>
)}
</div>
<div className="prose dark:prose-invert max-w-none">
<h3 className="text-lg font-semibold mb-2">{t.workspace?.descriptionLabel}</h3>
<p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
{workspace.description || t.workspace?.noDescription}
</p>
</div>
</div>
</div>
);
}
import { formatRateDisplay } from '../lib/money';
import {
CLIENTS_VIEW,
PROJECTS_VIEW,
TAGS_VIEW,
WORKSPACE_DELETE,
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_VIEW,
canWorkspace,
} from '../lib/permissions';
type ResourceCounts = {
projects: number;
clients: number;
tags: number;
};
const roleBadgeStyles: Record<string, string> = {
owner: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
admin: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
member: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
guest: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
};
export default function WorkspaceDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t, lang } = useTranslation();
const { user } = useAppContext();
const { setActiveWorkspace } = useWorkspace();
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [rates, setRates] = useState<WorkspaceUserRate[]>([]);
const [counts, setCounts] = useState<ResourceCounts>({ projects: 0, clients: 0, tags: 0 });
const [isLoading, setIsLoading] = useState(true);
const isRtl = lang === 'fa';
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
useEffect(() => {
if (!id) return;
void loadWorkspace();
}, [id]);
const loadWorkspace = async () => {
try {
const data = await getWorkspace(id!);
setWorkspace(data);
const canViewMembers = canWorkspace(data.my_role, WORKSPACE_VIEW);
const canViewClients = canWorkspace(data.my_role, CLIENTS_VIEW);
const canViewProjects = canWorkspace(data.my_role, PROJECTS_VIEW);
const canViewTags = canWorkspace(data.my_role, TAGS_VIEW);
const canViewRates = canWorkspace(data.my_role, WORKSPACE_EDIT);
const tasks: Promise<void>[] = [];
if (canViewMembers) {
tasks.push(
fetchWorkspaceMemberships({ workspace: id!, limit: 200, offset: 0 }).then((response) => {
setMembers(response.results || []);
}),
);
} else {
setMembers([]);
}
if (canViewRates) {
tasks.push(
getWorkspaceUserRates(id!).then((response) => {
setRates(response.results || []);
}),
);
} else {
setRates([]);
}
tasks.push(
Promise.all([
canViewProjects ? getProjects(id!, { limit: 1, offset: 0, is_archived: false }) : Promise.resolve({ count: 0 }),
canViewClients ? getClients(id!, '', '', 1, 0) : Promise.resolve({ count: 0 }),
canViewTags ? getTags(id!, { limit: 1, offset: 0 }) : Promise.resolve({ count: 0, results: [] }),
]).then(([projectsData, clientsData, tagsData]) => {
setCounts({
projects: projectsData.count || 0,
clients: clientsData.count || 0,
tags: tagsData.count || 0,
});
}),
);
await Promise.all(tasks);
} catch (error) {
console.error(error);
navigate('/workspaces');
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!window.confirm(t.workspace?.confirmDelete) || !id) return;
try {
await deleteWorkspace(id);
navigate('/workspaces');
} catch (error) {
console.error(error);
}
};
const activeMembers = members.filter((member) => member.is_active);
const roleCounts = useMemo(
() =>
activeMembers.reduce(
(acc, member) => {
acc[member.role] += 1;
return acc;
},
{ owner: 0, admin: 0, member: 0, guest: 0 },
),
[activeMembers],
);
const memberRateMap = useMemo(
() => new Map(rates.map((rate) => [rate.user, rate])),
[rates],
);
const displayNumber = (value: number) =>
new Intl.NumberFormat(lang === 'fa' ? 'fa-IR' : 'en-US').format(value);
const formatDate = (value?: string) => {
if (!value) return '-';
try {
return new Intl.DateTimeFormat(lang === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(value));
} catch {
return value;
}
};
const getMemberName = (member: WorkspaceMembership) => {
const firstName = member.user?.first_name?.trim() || '';
const lastName = member.user?.last_name?.trim() || '';
const fullName = `${firstName} ${lastName}`.trim();
return fullName || member.user?.email || t.workspace?.unknownMember || 'Unknown member';
};
const getMemberContact = (member: WorkspaceMembership) => {
return member.user?.mobile || member.user?.email || '-';
};
const getMemberInitials = (member: WorkspaceMembership) => {
const firstName = member.user?.first_name?.trim()?.charAt(0) || '';
const lastName = member.user?.last_name?.trim()?.charAt(0) || '';
return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase();
};
const formatRateUnit = (rate?: WorkspaceUserRate) =>
rate
? formatRateDisplay(
{
hourly_rate: rate.hourly_rate,
currency: rate.currency,
price_unit: rate.price_unit,
},
lang,
)
: (t.rates?.noRate || 'No rate');
const workspaceRole = workspace?.my_role;
const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT);
const canDelete = canWorkspace(workspaceRole, WORKSPACE_DELETE);
const canViewMembers = canWorkspace(workspaceRole, WORKSPACE_VIEW);
const canViewMemberSensitiveDetails = canWorkspace(workspaceRole, WORKSPACE_MEMBERS_VIEW);
const canViewReports = canWorkspace(workspaceRole, WORKSPACE_VIEW);
if (isLoading || !workspace) {
return <div className="p-8 text-center">{t.workspace?.loading}</div>;
}
const openWorkspaceRoute = (path: string) => {
setActiveWorkspace(workspace);
navigate(path);
};
const resourceCards = [
{
key: 'projects',
title: t.sidebar?.projects || 'Projects',
value: displayNumber(counts.projects),
icon: FolderKanban,
onClick: () => openWorkspaceRoute('/projects'),
visible: canWorkspace(workspace.my_role, PROJECTS_VIEW),
},
{
key: 'clients',
title: t.sidebar?.clients || 'Clients',
value: displayNumber(counts.clients),
icon: BriefcaseBusiness,
onClick: () => openWorkspaceRoute('/clients'),
visible: canWorkspace(workspace.my_role, CLIENTS_VIEW),
},
{
key: 'tags',
title: t.sidebar?.tags || 'Tags',
value: displayNumber(counts.tags),
icon: Tag,
onClick: () => openWorkspaceRoute('/tags'),
visible: canWorkspace(workspace.my_role, TAGS_VIEW),
},
{
key: 'reports',
title: t.sidebar?.reports || 'Reports',
value: t.workspace?.resourceOpen || 'Open',
icon: Banknote,
onClick: () => openWorkspaceRoute('/reports'),
visible: canViewReports,
},
].filter((item) => item.visible);
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-4 sm:p-6">
<button
onClick={() => navigate('/workspaces')}
className="flex items-center gap-2 text-slate-500 transition-colors hover:text-slate-900 dark:hover:text-white"
>
<BackIcon className="h-5 w-5" />
<span>{t.workspace?.back}</span>
</button>
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-8">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 w-full">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
{workspace.name}
</h1>
<span className={`inline-flex rounded-full px-3 py-1 text-sm font-semibold ${roleBadgeStyles[workspace.my_role || 'guest']}`}>
{workspace.my_role ? t.workspace?.roles[workspace.my_role] : '-'}
</span>
</div>
<p className="max-w-3xl whitespace-pre-wrap text-sm leading-7 text-slate-600 dark:text-slate-400">
{workspace.description || t.workspace?.noDescription}
</p>
</div>
</div>
</section>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsMembers || 'Members'}</span>
<Users className="h-5 w-5 text-slate-400" />
</div>
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(activeMembers.length)}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsRates || 'Rates set'}</span>
<Banknote className="h-5 w-5 text-slate-400" />
</div>
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(rates.length)}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsOwnersAdmins || 'Owners & admins'}</span>
<Users className="h-5 w-5 text-slate-400" />
</div>
<p className="text-3xl font-bold text-slate-900 dark:text-white">
{displayNumber(roleCounts.owner + roleCounts.admin)}
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">{t.workspace?.statsGuests || 'Guests'}</span>
<Users className="h-5 w-5 text-slate-400" />
</div>
<p className="text-3xl font-bold text-slate-900 dark:text-white">{displayNumber(roleCounts.guest)}</p>
</div>
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
{t.workspace?.resourcesTitle || 'Resources'}
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{resourceCards.map((card) => {
const Icon = card.icon;
return (
<button
key={card.key}
onClick={card.onClick}
className="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 p-4 text-start transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-700"
>
<div className="flex items-center gap-3">
<div className="rounded-xl bg-white p-2 text-slate-600 shadow-sm dark:bg-slate-900 dark:text-slate-300">
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{card.title}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{card.value}</p>
</div>
</div>
<BackIcon className="h-4 w-4 rtl:rotate-180 text-slate-400" />
</button>
);
})}
</div>
</section>
<section className="rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 p-6 dark:border-slate-800">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{t.workspace?.membersSectionTitle || t.workspace?.members || 'Members'}
</h2>
{canViewMembers
? <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{ t.workspace?.membersSectionSubtitle || 'People in this workspace and their current roles.' }
</p>
: ''}
</div>
{canEdit && (
<button
onClick={() => navigate(`/workspaces/${id}/edit`)}
className="inline-flex gap-2 h-10 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700"
>
{t.workspace?.manageMembers || 'Manage members'}
<Edit2 className="h-4 w-4" />
</button>
)}
</div>
</div>
{canViewMembers ? (
<div className={`divide-y divide-slate-100 dark:divide-slate-800 ${activeMembers.length > 6 ? 'max-h-[36rem] overflow-y-auto' : ''}`}>
{activeMembers.length === 0 ? (
<div className="p-6 text-sm text-slate-500 dark:text-slate-400">
{t.workspace?.noMembers || 'No members found.'}
</div>
) : (
activeMembers.map((member) => {
const rate = memberRateMap.get(member.user.id);
const isCurrentUser = member.user.id === user?.id;
return (
<div
key={member.id}
className={`flex flex-col gap-4 p-5 sm:p-6 ${
isCurrentUser
? 'bg-blue-50/80 ring-1 ring-blue-200 dark:bg-blue-950/20 dark:ring-blue-900/60'
: ''
}`}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-slate-200 text-sm font-semibold text-slate-600 shadow-sm dark:bg-slate-800 dark:text-slate-300">
{member.user?.profile_picture ? (
<img
src={member.user.profile_picture}
alt={getMemberName(member)}
className="h-full w-full object-cover"
/>
) : (
<span>{getMemberInitials(member)}</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{getMemberName(member)}
</h3>
{isCurrentUser && (
<span className="inline-flex rounded-full border border-blue-200 bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-700 dark:border-blue-900/60 dark:bg-blue-900/30 dark:text-blue-300">
{t.workspace?.youLabel || 'You'}
</span>
)}
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeStyles[member.role]}`}>
{t.workspace?.roles[member.role]}
</span>
</div>
{canViewMemberSensitiveDetails && (
<div className="space-y-1 text-sm text-slate-500 dark:text-slate-400">
<p>
{t.workspace?.mobileNumber || 'Mobile Number'}: {getMemberContact(member)}
</p>
</div>
)}
</div>
</div>
</div>
{canViewMemberSensitiveDetails && (
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800/80 sm:min-w-[210px]">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRate || 'Workspace rate'}
</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">
{formatRateUnit(rate)}
</p>
</div>
)}
</div>
</div>
);
})
)}
</div>
) : (
<div className="p-6 text-sm text-slate-500 dark:text-slate-400">
{t.workspace?.membersLocked || 'This member list is not available for your current role.'}
</div>
)}
</section>
</div>
);
}

View File

@@ -1,11 +1,11 @@
import React, { useState, useEffect, useRef, Fragment, useMemo, useCallback } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from '../hooks/useTranslation';
import { AlertCircle, UserPlus, Trash2, Shield } from 'lucide-react';
import { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner';
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
import {
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from '../hooks/useTranslation';
import { AlertCircle, ShieldCheck, UserPlus, Trash2, Shield, UploadCloud } from 'lucide-react';
import { Dialog, Transition } from '@headlessui/react';
import { toast } from 'sonner';
import { getPriceUnits, getWorkspaceUserRates, type PriceUnit, type WorkspaceUserRate } from '../api/rates';
import {
updateWorkspace,
addWorkspaceMembership,
removeWorkspaceMembership,
@@ -14,21 +14,22 @@ import {
getWorkspace
} from '../api/workspaces';
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
import { useAppContext } from '../context/AppContext';
import {
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_CHANGE_ROLE,
canChangeWorkspaceMember,
canWorkspace,
type WorkspaceRole,
} from '../lib/permissions';
import { useAppContext } from '../context/AppContext';
import {
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_CHANGE_ROLE,
canChangeWorkspaceMember,
canWorkspace,
type WorkspaceRole,
} from '../lib/permissions';
import { Button } from '../components/ui/button';
import { InfiniteScroll } from '../components/InfiniteScroll';
import { Select } from '../components/ui/Select';
import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { InfiniteScroll } from '../components/InfiniteScroll';
import { Select } from '../components/ui/Select';
import { Input } from '../components/ui/input';
import { TextAreaInput } from '../components/ui/TextAreaInput';
import WorkspaceMemberRateFields from '../components/rates/WorkspaceMemberRateFields';
import { ProjectAccessModal } from '../components/projects/ProjectAccessModal';
const toEnglishDigits = (str: string) => {
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
@@ -55,12 +56,16 @@ export default function EditWorkspace() {
// Workspace Info States
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [myRole, setMyRole] = useState<WorkspaceRole>('member');
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [myRole, setMyRole] = useState<WorkspaceRole>('member');
const [workspaceOwnerId, setWorkspaceOwnerId] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [workspaceRates, setWorkspaceRates] = useState<WorkspaceUserRate[]>([]);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [workspaceRates, setWorkspaceRates] = useState<WorkspaceUserRate[]>([]);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
// Members States
const [members, setMembers] = useState<any[]>([]);
@@ -78,8 +83,9 @@ export default function EditWorkspace() {
// Modal State
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
const [isProjectAccessModalOpen, setIsProjectAccessModalOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [initialData, setInitialData] = useState({
name: '',
@@ -91,9 +97,39 @@ export default function EditWorkspace() {
const isNameChanged = name.trim() !== (initialData.name || '').trim();
const isDescChanged = description.trim() !== (initialData.description || '').trim();
const isImageChanged = !!thumbnailFile || clearThumbnail;
return isNameChanged || isDescChanged;
}, [name, description, initialData, isLoading]);
return isNameChanged || isDescChanged || isImageChanged;
}, [name, description, initialData, isLoading, thumbnailFile, clearThumbnail]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -113,16 +149,16 @@ export default function EditWorkspace() {
return false;
});
useEffect(() => {
if (id) loadData();
}, [id]);
useEffect(() => {
if (!isLoading && id && !canWorkspace(myRole, WORKSPACE_EDIT)) {
toast.error("You do not have permission to edit this workspace.");
navigate(`/workspaces/${id}`);
}
}, [id, isLoading, myRole, navigate]);
useEffect(() => {
if (id) loadData();
}, [id]);
useEffect(() => {
if (!isLoading && id && !canWorkspace(myRole, WORKSPACE_EDIT)) {
toast.error("You do not have permission to edit this workspace.");
navigate(`/workspaces/${id}`);
}
}, [id, isLoading, myRole, navigate]);
const loadData = async () => {
try {
@@ -130,20 +166,23 @@ export default function EditWorkspace() {
const workspaceData = await getWorkspace(id!);
setName(workspaceData.name);
setDescription(workspaceData.description || '');
setThumbnailUrl(workspaceData.thumbnail || null);
setThumbnailFile(null);
setClearThumbnail(false);
setMyRole(workspaceData.my_role || 'member');
setWorkspaceOwnerId(workspaceData.owner || '');
const [membersData, ratesData, unitsData] = await Promise.all([
fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }),
getWorkspaceUserRates(id!),
getPriceUnits(),
]);
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
setMembers(results);
setWorkspaceRates(ratesData.results || []);
setPriceUnits(unitsData.results || []);
setOffset(LIMIT);
const [membersData, ratesData, unitsData] = await Promise.all([
fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 }),
getWorkspaceUserRates(id!),
getPriceUnits(),
]);
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
setMembers(results);
setWorkspaceRates(ratesData.results || []);
setPriceUnits(unitsData.results || []);
setOffset(LIMIT);
// Robust hasMore check: use `.next` if available, otherwise check if array filled the limit
setHasMore(membersData.next ? true : results.length >= LIMIT);
@@ -225,10 +264,15 @@ export default function EditWorkspace() {
if (!name.trim() || !id) return;
try {
setIsSaving(true);
await updateWorkspace(id, { name, description });
const updatedWorkspace = await updateWorkspace(id, {
name,
description,
thumbnail: thumbnailFile,
clear_thumbnail: clearThumbnail,
});
toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully.");
window.dispatchEvent(new CustomEvent('workspace_edited', {
detail: { id, name, description }
detail: updatedWorkspace
}));
navigate('/workspaces');
} catch (error) {
@@ -241,11 +285,11 @@ export default function EditWorkspace() {
const handleAddMember = async () => {
if (!searchResult || !id) return;
try {
const newMembership = await addWorkspaceMembership({
workspace: id,
user: String(searchResult.id),
role: newMemberRole
});
const newMembership = await addWorkspaceMembership({
workspace: id,
user: String(searchResult.id),
role: newMemberRole
});
setMembers([newMembership, ...members]);
toast.success(t.workspace?.toast?.successAdd || "Member added successfully.");
setSearchQuery('');
@@ -283,26 +327,28 @@ export default function EditWorkspace() {
}
};
const canManageMembers = canWorkspace(myRole, WORKSPACE_MEMBERS_CHANGE_ROLE);
const isFirstOwner = currentUserId === workspaceOwnerId;
const canManageMembers = canWorkspace(myRole, WORKSPACE_MEMBERS_CHANGE_ROLE);
const isFirstOwner = currentUserId === workspaceOwnerId;
const isOwner = myRole === "owner";
const roleOptions = (allowOwner: boolean) => [
...(allowOwner ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
{ value: "admin", label: t.workspace?.roles?.admin || "Admin" },
{ value: "member", label: t.workspace?.roles?.member || "Member" },
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
];
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
const roleOptions = (allowOwnerRole: boolean, allowAdminRole: boolean) => [
...(allowOwnerRole ? [{ value: "owner", label: t.workspace?.roles?.owner || "Owner" }] : []),
...(allowAdminRole ? [{ value: "admin", label: t.workspace?.roles?.admin || "Admin" }] : []),
{ value: "member", label: t.workspace?.roles?.member || "Member" },
{ value: "guest", label: t.workspace?.roles?.guest || "Guest" },
];
return (
if (isLoading) return <div className="p-8 text-center">{t.workspace?.loading || "Loading..."}</div>;
return (
<>
<div className="absolute inset-0 flex flex-col overflow-y-auto bg-slate-50 p-4 dark:bg-slate-900 lg:overflow-hidden sm:p-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-4 sm:mb-6 shrink-0">
{t.workspace?.editTitle || "Edit Workspace"}
</h1>
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
<div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:flex-row sm:gap-6">
<div className="w-full shrink-0 rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-1/3 lg:max-w-md lg:flex-col lg:overflow-y-auto">
<form onSubmit={handleSubmit} className="flex flex-col h-full p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
@@ -327,6 +373,55 @@ export default function EditWorkspace() {
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 h-32 resize-none"
/>
</div>
<div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<label className="mt-3 flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border border-dashed border-slate-300 bg-slate-100 text-5xl font-semibold text-slate-700 transition hover:bg-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700">
{thumbnailPreview ? (
<img
src={thumbnailPreview}
alt={name || "Workspace"}
className="h-full w-full object-cover"
/>
) : !clearThumbnail && thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={name || "Workspace"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-center">
<UploadCloud className="h-10 w-10 text-slate-500 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{t.workspace?.uploadImage || "Click to upload image"}
</span>
</div>
)}
<input
type="file"
accept=".jpg,.jpeg,.png,.webp"
className="hidden"
onChange={(e) => handleThumbnailChange(e.target.files?.[0] || null)}
/>
</label>
{(thumbnailUrl || thumbnailFile) && (
<button
type="button"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
className="mt-2 text-xs text-red-600 hover:underline dark:text-red-400"
>
{t.workspace?.removeImage || "Remove image"}
</button>
)}
</div>
<div className="mt-auto pt-6 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800 shrink-0">
<Button type="button" variant="ghost" onClick={() => navigate('/workspaces')}>
{t.actions?.cancel || "Cancel"}
@@ -338,14 +433,35 @@ export default function EditWorkspace() {
</form>
</div>
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{ t.workspace?.members || "Members" }
</h2>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{ t.workspace?.members || "Members" }
</h2>
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<Button
type="button"
variant="secondary"
onClick={() => setIsProjectAccessModalOpen(true)}
className="gap-2 self-start sm:self-auto"
>
<ShieldCheck className="h-4 w-4" />
{t.projects?.manageAccess || "Projects & Rates"}
</Button>
) : null}
</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" />
<p className="leading-6">
{t.workspace?.projectRateHint ||
"Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."}
</p>
</div>
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3">
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
<div className="space-y-3">
<Input
type="text"
placeholder={t.workspace?.searchMemberPlaceholder || "Search user by exact mobile number..."}
@@ -389,8 +505,8 @@ export default function EditWorkspace() {
value={newMemberRole}
onChange={(val) => setNewMemberRole(val as any)}
options={[
...roleOptions(isFirstOwner),
]}
...roleOptions(isFirstOwner, isOwner),
]}
className="flex-1 sm:flex-none"
buttonClassName="w-full sm:w-[110px] px-3 py-1.5 text-sm"
/>
@@ -414,7 +530,7 @@ export default function EditWorkspace() {
)}
</div>
<div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
<div className="space-y-3 bg-slate-50/30 p-6 dark:bg-slate-900/30 lg:flex-1 lg:overflow-y-auto">
<InfiniteScroll
onLoadMore={loadMoreMembers}
hasMore={hasMore}
@@ -424,21 +540,21 @@ export default function EditWorkspace() {
>
{members.map((m) => {
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
const canChangeThisUserRole = canChangeWorkspaceMember({
actorRole: myRole,
actorUserId: currentUserId,
targetRole: m.role,
targetUserId: m.user?.id,
ownerUserId: workspaceOwnerId,
});
const canChangeThisUserRole = canChangeWorkspaceMember({
actorRole: myRole,
actorUserId: currentUserId,
targetRole: m.role,
targetUserId: m.user?.id,
ownerUserId: workspaceOwnerId,
});
return (
<div key={m.id} className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 shadow-sm transition-colors hover:border-blue-200 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{m.user?.profile_picture ? (
<img src={m.user?.profile_picture} alt={m.user?.first_name} className="w-10 h-10 rounded-full object-cover shadow-sm" />
) : (
<div key={m.id} className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 shadow-sm transition-colors hover:border-blue-200 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{m.user?.profile_picture ? (
<img src={m.user?.profile_picture} alt={m.user?.first_name} className="w-10 h-10 rounded-full object-cover shadow-sm" />
) : (
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-400 font-bold text-sm shadow-sm">
{m.user?.name?.[0] || m.user?.first_name?.[0] || "U"}
</div>
@@ -447,57 +563,57 @@ export default function EditWorkspace() {
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{m.user?.name || `${m.user?.first_name || ''} ${m.user?.last_name || ''}`.trim() || 'Unknown'}
</p>
<p className="text-xs text-slate-500">{toPersianNum(m.user?.mobile)}</p>
</div>
</div>
<div className="flex items-center gap-3 self-end sm:self-auto">
{canChangeThisUserRole ? (
<Select
value={m.role}
onChange={(val) => handleChangeRole(m.id, val)}
options={roleOptions(isFirstOwner)}
buttonClassName="w-[110px] px-3 py-1.5 text-sm"
/>
) : (
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
{m.role === 'owner' && <Shield className="w-3 h-3" />}
{m.role && m.role in t.workspace.roles
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
: m.role || "-"}
</span>
)}
{canChangeThisUserRole && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => openDeleteModal(m.id)}
className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
title={t.workspace?.removeMemberTitle || "Remove member"}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-2 border-t border-slate-100 pt-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRate || "Workspace rate"}
</div>
<WorkspaceMemberRateFields
workspaceId={id!}
userId={m.user.id}
rate={workspaceRates.find((item) => item.user === m.user.id)}
priceUnits={priceUnits}
onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))}
/>
</div>
</div>
);
})}
<p className="text-xs text-slate-500">{toPersianNum(m.user?.mobile)}</p>
</div>
</div>
<div className="flex items-center gap-3 self-end sm:self-auto">
{canChangeThisUserRole ? (
<Select
value={m.role}
onChange={(val) => handleChangeRole(m.id, val)}
options={roleOptions(isFirstOwner, isOwner)}
buttonClassName="w-[110px] px-3 py-1.5 text-sm"
/>
) : (
<span className="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded-md capitalize flex items-center gap-1">
{m.role === 'owner' && <Shield className="w-3 h-3" />}
{m.role && m.role in t.workspace.roles
? t.workspace.roles[m.role as keyof typeof t.workspace.roles]
: m.role || "-"}
</span>
)}
{canChangeThisUserRole && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => openDeleteModal(m.id)}
className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
title={t.workspace?.removeMemberTitle || "Remove member"}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-2 border-t border-slate-100 pt-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{t.rates?.workspaceRate || "Workspace rate"}
</div>
<WorkspaceMemberRateFields
workspaceId={id!}
userId={m.user.id}
rate={workspaceRates.find((item) => item.user === m.user.id)}
priceUnits={priceUnits}
onRatesChanged={(updater) => setWorkspaceRates((current) => updater(current))}
/>
</div>
</div>
);
})}
</InfiniteScroll>
{members.length === 0 && !isLoadingMembers && (
<div className="flex flex-col items-center justify-center py-10 text-slate-500">
@@ -505,12 +621,12 @@ export default function EditWorkspace() {
<p className="text-sm">
{t.workspace?.noMembers || "No members found."}
</p>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<Transition appear show={isDeleteDialogOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsDeleteDialogOpen(false)}>
@@ -559,5 +675,57 @@ export default function EditWorkspace() {
</Dialog>
</Transition>
</div>
);
}
{canWorkspace(myRole, WORKSPACE_EDIT) && id ? (
<ProjectAccessModal
isOpen={isProjectAccessModalOpen}
onClose={() => setIsProjectAccessModalOpen(false)}
workspaceId={id}
onApplied={() => {}}
labels={{
title: t.projects?.accessModalTitle || "Projects & Rates",
description:
t.projects?.accessModalDescription ||
"Manage project access for members and guests, and set project-specific rates for any workspace user.",
close: t.actions?.cancel || "Close",
member: t.projects?.accessMemberLabel || "User",
projects: t.sidebar?.projects || "Projects",
loading: t.loading || "Loading...",
noMembers: t.projects?.accessNoMembers || "No workspace users were found.",
noProjects: t.projects?.accessNoProjects || "No projects found.",
searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...",
allClients: t.reports?.allClients || "All clients",
selectAllVisible: t.projects?.accessSelectVisible || "Select all visible",
clearSelection: t.projects?.accessClearSelection || "Clear selection",
selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client",
grantSelected: t.projects?.accessGrant || "Grant selected",
revokeSelected: t.projects?.accessRevoke || "Revoke selected",
accessGranted: t.projects?.accessGrantSuccess || "Project access granted.",
accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.",
memberRole: t.workspace?.roleLabel || "Role",
client: t.projects?.clientLabel || "Client",
noClient: t.projects?.noClient || "No client",
accessOn: t.projects?.accessOn || "Has access",
accessOff: t.projects?.accessOff || "No access",
loadError: t.projects?.accessLoadError || "Failed to load project access state.",
saveError: t.projects?.accessSaveError || "Failed to update project access.",
workspaceRate: t.rates?.workspaceRate || "Workspace rate",
projectOverride: t.rates?.projectOverride || "Project override",
inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate",
noRate: t.rates?.noRate || "No rate",
hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00",
currencyPlaceholder: t.rates?.currencyPlaceholder || "USD",
removeRate: t.rates?.removeRate || "Remove rate",
projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.",
projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.",
projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.",
projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.",
implicitAccessHint:
t.projects?.implicitAccessHint ||
"Owners and admins always have access to all projects. You can still set project-specific rate overrides here.",
}}
/>
) : null}
</>
);
}

View File

@@ -1,23 +1,26 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Trash2, Pencil, ChevronRight } from 'lucide-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';
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
import { useTranslation } from '../hooks/useTranslation';
import {
WORKSPACE_DELETE,
WORKSPACE_EDIT,
canWorkspace,
type WorkspaceRole,
} from '../lib/permissions';
import FilterBar from '../components/FilterBar';
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
import { useTranslation } from '../hooks/useTranslation';
import {
WORKSPACE_DELETE,
WORKSPACE_EDIT,
canWorkspace,
type WorkspaceRole,
} from '../lib/permissions';
import FilterBar from '../components/FilterBar';
import EmptyStateCard from '../components/EmptyStateCard';
import { ListPageSkeleton } from '../components/ListPageSkeleton';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Card, CardContent, CardTitle } from '../components/ui/card';
import { Pagination } from '../components/Pagination';
import { Modal } from '../components/Modal';
import { readNumberParam, readStringParam, updateQueryParams } from '../lib/queryParams';
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
const { t } = useTranslation();
if (!role) return null;
@@ -38,18 +41,18 @@ const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
export default function Workspaces() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [ordering, setOrdering] = useState('-created_at');
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [limit, setLimit] = useState(10);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
const [deleteInput, setDeleteInput] = useState('');
const navigate = useNavigate();
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const searchQuery = readStringParam(searchParams, 'search', '');
const ordering = readStringParam(searchParams, 'ordering', '-created_at');
const currentPage = Math.max(1, readNumberParam(searchParams, 'page', 1));
const limit = Math.max(1, readNumberParam(searchParams, 'limit', 10));
const orderingOptions = [
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
@@ -59,10 +62,6 @@ export default function Workspaces() {
{ value: '-updated_at', label: t.ordering?.updatedAtDesc || 'Recently Updated' },
];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => {
const timer = setTimeout(() => {
loadWorkspaces();
@@ -95,8 +94,9 @@ export default function Workspaces() {
}
};
const confirmDelete = async () => {
if (!deleteModal.workspace) return;
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.workspace) return;
try {
const deletedId = deleteModal.workspace.id;
await deleteWorkspace(deletedId);
@@ -115,52 +115,77 @@ export default function Workspaces() {
}
};
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: '',
ordering: '-created_at',
page: 1,
limit: 10,
}),
{ replace: true },
);
};
return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-1 flex-col gap-5">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
</div>
<Button
onClick={() => navigate('/workspaces/create')}
size="icon"
className="shrink-0 shadow-sm"
title={t.workspace?.createNew || 'Create New'}
>
<Plus className="h-5 w-5" />
</Button>
</div>
<Button
onClick={() => navigate('/workspaces/create')}
size="icon"
className="shadow-sm"
title={t.workspace?.createNew || 'Create New'}
>
<Plus className="h-5 w-5" />
</Button>
</div>
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
/>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
/>
</div>
{isLoading ? (
<div className="p-12 flex justify-center text-slate-500">
<div className="animate-pulse">{t.workspace?.loading || 'Loading...'}</div>
</div>
<ListPageSkeleton variant="list" />
) : (
<div className="flex flex-col flex-1">
<div className="flex flex-col gap-4 mb-6">
{workspaces.map((workspace) => {
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
return (
<div className="flex flex-1 flex-col gap-6">
<div className="flex flex-1 flex-col gap-4">
{workspaces.map((workspace) => {
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
return (
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 px-6 gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{workspace.thumbnail ? (
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-lg flex items-center justify-center text-sm font-semibold text-slate-700 dark:text-slate-200">
<img src={workspace.thumbnail} alt={workspace.name} className="h-full w-full object-cover" />
</div>
) : (
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-600 flex items-center justify-center text-sm font-semibold text-slate-700 dark:text-slate-200">
{workspace.name.trim().charAt(0).toUpperCase() || "W"}
</div>
)}
<CardTitle className="text-lg line-clamp-1">
{workspace.name}
</CardTitle>
<RoleBadge role={workspace.my_role} />
<RoleBadge role={workspace.my_role} />
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-1">
{workspace.description || t.workspace?.noDescription || 'No description'}
@@ -168,8 +193,8 @@ export default function Workspaces() {
</div>
<div className="flex items-center gap-2 shrink-0">
{canDeleteWorkspace && (
<Button
{canDeleteWorkspace && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, workspace })}
@@ -180,8 +205,8 @@ export default function Workspaces() {
</Button>
)}
{canEditWorkspace && (
<Button
{canEditWorkspace && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
@@ -198,7 +223,7 @@ export default function Workspaces() {
className="h-8 w-8 hover:bg-blue-700 dark:hover:bg-blue-500 border-none shadow-sm transition-all"
title={t.actions?.view || 'View'}
>
<ChevronRight className="h-4 w-4 rtl:-scale-x-100" />
<Eye className="h-4 w-4 rtl:-scale-x-100" />
</Button>
</div>
</CardContent>
@@ -206,23 +231,26 @@ export default function Workspaces() {
);
})}
{workspaces.length === 0 && (
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl">
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p>
</div>
)}
{workspaces.length === 0 && (
<EmptyStateCard
icon={LayoutDashboard}
title={t.workspace.noWorkspace}
description={searchQuery ? t.workspace.noWorkspaceSearch : t.workspace?.emptyState || t.workspace.noWorkspace}
/>
)}
</div>
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
pageSizeOptions={[10, 20, 50]}
/>
</div>
)}
</div>
{deleteModal.workspace && (
<Modal
@@ -248,15 +276,16 @@ export default function Workspaces() {
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.workspace.name}
onClick={confirmDelete}
className="rounded-xl font-semibold"
type="submit"
form="delete-workspace-form"
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
</Button>
</>
}
>
<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>
@@ -267,7 +296,7 @@ export default function Workspaces() {
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.workspace.name}
/>
</div>
</form>
</Modal>
)}
</div>

View File

@@ -0,0 +1,132 @@
import { useEffect, useMemo, useRef } from "react"
const OTP_LENGTH = 5
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
const sanitizeOtp = (value: string) => normalizeDigits(value).replace(/\D/g, "").slice(0, OTP_LENGTH)
export function AuthOtpInput({
id,
value,
disabled,
onChange,
onComplete,
}: {
id: string
value: string
disabled?: boolean
onChange: (value: string) => void
onComplete?: (value: string) => void
}) {
const inputRefs = useRef<Array<HTMLInputElement | null>>([])
const lastCompletedValueRef = useRef("")
const normalizedValue = useMemo(() => sanitizeOtp(value), [value])
const digits = useMemo(
() => Array.from({ length: OTP_LENGTH }, (_, index) => normalizedValue[index] ?? ""),
[normalizedValue],
)
useEffect(() => {
if (normalizedValue.length !== OTP_LENGTH) {
lastCompletedValueRef.current = ""
return
}
if (normalizedValue !== lastCompletedValueRef.current) {
lastCompletedValueRef.current = normalizedValue
onComplete?.(normalizedValue)
}
}, [normalizedValue, onComplete])
const focusIndex = (index: number) => {
inputRefs.current[index]?.focus()
inputRefs.current[index]?.select()
}
const handleSlotChange = (index: number, nextRawValue: string) => {
const nextValue = sanitizeOtp(nextRawValue)
if (!nextValue) {
const updated = digits.slice()
updated[index] = ""
onChange(updated.join(""))
return
}
if (nextValue.length > 1) {
onChange(nextValue)
const focusTarget = Math.min(nextValue.length, OTP_LENGTH - 1)
focusIndex(focusTarget)
return
}
const updated = digits.slice()
updated[index] = nextValue
const combined = updated.join("")
onChange(combined)
if (index < OTP_LENGTH - 1) {
focusIndex(index + 1)
}
}
const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && !digits[index] && index > 0) {
const updated = digits.slice()
updated[index - 1] = ""
onChange(updated.join(""))
focusIndex(index - 1)
event.preventDefault()
return
}
if (event.key === "ArrowLeft" && index > 0) {
focusIndex(index - 1)
event.preventDefault()
return
}
if (event.key === "ArrowRight" && index < OTP_LENGTH - 1) {
focusIndex(index + 1)
event.preventDefault()
}
}
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
event.preventDefault()
const pasted = sanitizeOtp(event.clipboardData.getData("text"))
if (!pasted) {
return
}
onChange(pasted)
focusIndex(Math.min(pasted.length, OTP_LENGTH - 1))
}
return (
<div className="flex items-center justify-center gap-2 sm:gap-3" dir="ltr">
{digits.map((digit, index) => (
<input
key={`${id}-${index}`}
ref={(element) => {
inputRefs.current[index] = element
}}
id={index === 0 ? id : undefined}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={OTP_LENGTH}
disabled={disabled}
value={digit}
onChange={(event) => handleSlotChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
className="h-12 w-12 rounded-2xl border border-slate-200 bg-white text-center text-lg font-semibold tracking-[0.18em] text-slate-900 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:ring-sky-500/20 sm:h-14 sm:w-14"
/>
))}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import type { ReactNode } from "react"
import { Link } from "react-router-dom"
import { Command, AlertTriangle } from "lucide-react"
import { useTranslation } from "../../hooks/useTranslation"
interface AuthPanelProps {
title: string
description: string
children: ReactNode
alert?: {
title: string
description: string
} | null
}
export function AuthPanel({ title, description, children, alert = null }: AuthPanelProps) {
const { t } = useTranslation()
return (
<div className="space-y-6">
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
<div className="mb-4 flex justify-center lg:hidden">
<Command className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
</div>
{alert && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{alert.title}</p>
<p className="text-sm">{alert.description}</p>
</div>
</div>
</div>
)}
<div className="grid gap-6">
{children}
</div>
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
{t.loginTerms?.prefix}
<Link
to="/terms"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.loginTerms?.link}
</Link>
{t.loginTerms?.suffix}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { useState } from "react"
import { Eye, EyeOff } from "lucide-react"
import { Input } from "../../components/ui/input"
interface AuthPasswordFieldProps {
id: string
value: string
onChange: (value: string) => void
placeholder: string
disabled?: boolean
}
export function AuthPasswordField({
id,
value,
onChange,
placeholder,
disabled = false,
}: AuthPasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false)
return (
<div className="relative w-full" dir="ltr">
<Input
id={id}
value={value}
type={showPassword ? "text" : "password"}
placeholder={placeholder}
autoComplete="new-password"
dir="ltr"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-11 pe-10 text-start"
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((current) => !current)}
className="absolute inset-y-0 end-0 flex items-center pe-3 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { useMemo } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { formatCooldown } from "./utils"
export function ForgotPasswordMobilePage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, markOtpSendPending, clearOtpDelivery } = useAuthFlow()
const isRtl = lang === "fa"
const alert = useMemo(() => {
if (state.cooldowns.forgotPasswordOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.forgotPasswordOtpSend, t.login.throttle])
const cooldownLabel =
state.cooldowns.forgotPasswordOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl))
: null
const handleContinue = async () => {
if (!state.forgotPassword.mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
clearOtpDelivery("forgotPassword")
markOtpSendPending("forgotPassword")
navigate("/auth/forgot-password/verify")
}
return (
<AuthPanel
title={t.login.forgotPasswordTitle}
description={t.login.forgotPasswordDescription}
alert={alert}
>
<div className="grid gap-4">
<Input
id="forgot-password-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
maxLength={11}
value={state.forgotPassword.mobile}
onChange={(event) => setMobile("forgotPassword", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={handleContinue}
disabled={state.cooldowns.forgotPasswordOtpSend > 0}
className="h-11 w-full"
>
{cooldownLabel || t.login.sendResetCode}
</Button>
<div className="text-center underline text-sm text-slate-500 dark:text-slate-400">
<Link
to="/auth/login/password"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.backToPasswordLogin}
</Link>
</div>
</div>
</AuthPanel>
)
}

View File

@@ -0,0 +1,163 @@
import { Loader2 } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, getOtpRemainingSeconds, handleThrottleError } from "./utils"
export function ForgotPasswordOtpPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const {
state,
setCode,
setCooldown,
clearCooldown,
setOtpDelivery,
clearOtpDelivery,
} = useAuthFlow()
const isRtl = lang === "fa"
const autoSendStartedRef = useRef(false)
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isContinuing, setIsContinuing] = useState(false)
const [now, setNow] = useState(Date.now())
if (!state.forgotPassword.mobile) {
return <Navigate to="/auth/forgot-password" replace />
}
useEffect(() => {
if (!state.forgotPassword.otpExpiresAt || getOtpRemainingSeconds(state.forgotPassword.otpExpiresAt) <= 0) {
return
}
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [state.forgotPassword.otpExpiresAt])
const sendForgotPasswordOtp = async () => {
setIsSendingOtp(true)
try {
const response = await sendOtp(state.forgotPassword.mobile, "forget_password")
clearCooldown("forgotPasswordOtpSend")
setCode("forgotPassword", "")
setOtpDelivery("forgotPassword", response.expires_in_seconds)
setNow(Date.now())
toast.success(t.login.toasts.verifySent)
} catch (error) {
clearOtpDelivery("forgotPassword")
if (
!handleThrottleError({
error,
cooldownKey: "forgotPasswordOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setIsSendingOtp(false)
}
}
useEffect(() => {
if (!state.forgotPassword.pendingOtpSend || autoSendStartedRef.current) {
return
}
autoSendStartedRef.current = true
void sendForgotPasswordOtp()
}, [state.forgotPassword.pendingOtpSend])
const otpRemainingSeconds = state.forgotPassword.otpExpiresAt
? Math.max(0, Math.ceil((state.forgotPassword.otpExpiresAt - now) / 1000))
: 0
const alert = useMemo(() => {
if (state.cooldowns.forgotPasswordOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.forgotPasswordOtpSend, t.login.throttle])
const expiryMessage =
otpRemainingSeconds > 0
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
: state.forgotPassword.otpExpiresAt
? t.login.otpExpired
: null
const continueToReset = async (code: string) => {
if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp)
return
}
setIsContinuing(true)
setCode("forgotPassword", code)
navigate("/auth/forgot-password/password")
}
const isBusy = isSendingOtp || isContinuing
return (
<AuthPanel
title={t.login.forgotPasswordVerifyTitle}
description={t.login.sentCodeDesc(state.forgotPassword.mobile)}
alert={alert}
>
<form
onSubmit={(event) => {
event.preventDefault()
void continueToReset(state.forgotPassword.code)
}}
className="grid gap-4"
>
<AuthOtpInput
id="forgot-password-otp"
value={state.forgotPassword.code}
disabled={isBusy}
onChange={(value) => setCode("forgotPassword", value)}
onComplete={(value) => void continueToReset(value)}
/>
{expiryMessage ? (
<p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
) : null}
<Button type="submit" className="h-11 w-full" disabled={isBusy}>
{(isSendingOtp || isContinuing) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSendingOtp ? t.login.sendingOtp : t.login.continueToResetPassword}
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full"
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.forgotPasswordOtpSend > 0}
onClick={() => void sendForgotPasswordOtp()}
>
{state.cooldowns.forgotPasswordOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl))
: t.login.resendOtp}
</Button>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,95 @@
import { Loader2 } from "lucide-react"
import { useState } from "react"
import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { resetPasswordWithOtp } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField"
import { getApiErrorMessage, getPasswordValidationMessage } from "./utils"
export function ForgotPasswordPasswordPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { state, resetFlow, setMobile } = useAuthFlow()
const [password, setPassword] = useState("")
const [confirmation, setConfirmation] = useState("")
const [loading, setLoading] = useState(false)
if (!state.forgotPassword.mobile) {
return <Navigate to="/auth/forgot-password" replace />
}
if (!state.forgotPassword.code) {
return <Navigate to="/auth/forgot-password/verify" replace />
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password || !confirmation) {
toast.error(t.login.toasts.fillAll)
return
}
if (password !== confirmation) {
toast.error(t.login.passwordMismatch)
return
}
const passwordValidationMessage = getPasswordValidationMessage(password, t.login)
if (passwordValidationMessage) {
toast.error(passwordValidationMessage)
return
}
setLoading(true)
try {
await resetPasswordWithOtp(state.forgotPassword.mobile, state.forgotPassword.code, password, confirmation)
setMobile("login", state.forgotPassword.mobile)
resetFlow("forgotPassword")
toast.success(t.login.toasts.passwordResetSuccess)
navigate("/auth/login/password", { replace: true })
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.passwordResetFailed))
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.resetPasswordTitle}
description={t.login.resetPasswordDescription}
>
<form onSubmit={handleSubmit} className="grid gap-4">
<AuthPasswordField
id="reset-password"
value={password}
onChange={setPassword}
placeholder={t.login.newPasswordPlaceholder}
disabled={loading}
/>
<AuthPasswordField
id="reset-password-confirmation"
value={confirmation}
onChange={setConfirmation}
placeholder={t.login.confirmPasswordPlaceholder}
disabled={loading}
/>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.resetPasswordCta}
</Button>
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/forgot-password/verify")}>
{t.login.back}
</Button>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,127 @@
import { useMemo } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { startGoogleLogin } from "../../api/users"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { formatCooldown } from "./utils"
const GoogleIcon = () => (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
<path
d="M21.805 10.023h-9.72v3.955h5.57c-.24 1.272-.96 2.35-2.042 3.07v2.548h3.3c1.933-1.78 3.042-4.4 3.042-7.506 0-.692-.062-1.357-.15-2.067Z"
fill="#4285F4"
/>
<path
d="M12.085 22c2.79 0 5.13-.925 6.84-2.504l-3.3-2.548c-.924.617-2.103.986-3.54.986-2.705 0-4.99-1.823-5.807-4.28H2.87v2.626A10.33 10.33 0 0 0 12.085 22Z"
fill="#34A853"
/>
<path
d="M6.278 13.654A6.214 6.214 0 0 1 5.95 11.7c0-.68.117-1.34.328-1.954V7.12H2.87A10.31 10.31 0 0 0 1.75 11.7c0 1.65.39 3.218 1.12 4.58l3.408-2.626Z"
fill="#FBBC05"
/>
<path
d="M12.085 5.466c1.52 0 2.882.522 3.955 1.55l2.966-2.966C17.21 2.387 14.874 1.4 12.085 1.4A10.33 10.33 0 0 0 2.87 7.12l3.408 2.626c.818-2.457 3.103-4.28 5.807-4.28Z"
fill="#EA4335"
/>
</svg>
)
export function LoginMobilePage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, markOtpSendPending, clearOtpDelivery, resetFlow } = useAuthFlow()
const isRtl = lang === "fa"
const alert = useMemo(() => {
if (state.cooldowns.loginOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.loginOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.loginOtpSend, t.login.throttle])
const cooldownLabel =
state.cooldowns.loginOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpSend, isRtl))
: null
const handleLogin = async () => {
if (!state.login.mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
resetFlow("forgotPassword")
clearOtpDelivery("login")
markOtpSendPending("login")
navigate("/auth/login/verify")
}
return (
<AuthPanel
title={t.login.loginTitle}
description={t.login.loginDescription}
alert={alert}
>
<div className="grid gap-4">
<Input
id="login-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
maxLength={11}
value={state.login.mobile}
onChange={(event) => setMobile("login", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={handleLogin}
disabled={state.cooldowns.loginOtpSend > 0}
className="h-11 w-full"
>
{cooldownLabel || t.login.loginCta}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200 dark:border-slate-800" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500 transition-colors dark:bg-slate-950 dark:text-slate-400">
{t.login.orContinueWith}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={startGoogleLogin}
className="h-11 w-full"
>
<GoogleIcon />
<span className="ms-3">{t.login.continueWithGoogle}</span>
</Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400">
{t.login.haveNoAccount}{" "}
<Link
to="/auth/signup"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.register}
</Link>
</div>
</div>
</AuthPanel>
)
}

View File

@@ -0,0 +1,237 @@
import { Loader2 } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { loginWithOtp, sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel"
import {
completeAuthentication,
formatCooldown,
getApiErrorMessage,
getOtpRemainingSeconds,
handleThrottleError,
} from "./utils"
export function LoginOtpPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const {
state,
setCode,
setCooldown,
clearCooldown,
setOtpDelivery,
clearOtpDelivery,
} = useAuthFlow()
const isRtl = lang === "fa"
const autoSendStartedRef = useRef(false)
const activeSubmitCodeRef = useRef("")
const lastFailedCodeRef = useRef("")
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [now, setNow] = useState(Date.now())
if (!state.login.mobile) {
return <Navigate to="/auth/login" replace />
}
useEffect(() => {
if (!state.login.otpExpiresAt || getOtpRemainingSeconds(state.login.otpExpiresAt) <= 0) {
return
}
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [state.login.otpExpiresAt])
const sendLoginOtp = async () => {
setIsSendingOtp(true)
try {
const response = await sendOtp(state.login.mobile, "login")
clearCooldown("loginOtpSend")
clearCooldown("loginOtpVerify")
lastFailedCodeRef.current = ""
setCode("login", "")
setOtpDelivery("login", response.expires_in_seconds)
setNow(Date.now())
toast.success(t.login.toasts.verifySent)
} catch (error) {
clearOtpDelivery("login")
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setIsSendingOtp(false)
}
}
useEffect(() => {
if (!state.login.pendingOtpSend || autoSendStartedRef.current) {
return
}
autoSendStartedRef.current = true
void sendLoginOtp()
}, [state.login.pendingOtpSend])
const otpRemainingSeconds = state.login.otpExpiresAt
? Math.max(0, Math.ceil((state.login.otpExpiresAt - now) / 1000))
: 0
const throttleAlert = useMemo(() => {
if (state.cooldowns.loginOtpVerify > 0) {
const formatted = formatCooldown(state.cooldowns.loginOtpVerify, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpLoginMessage(formatted),
}
}
if (state.cooldowns.loginOtpSend > 0) {
const formatted = formatCooldown(state.cooldowns.loginOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}
return null
}, [isRtl, state.cooldowns.loginOtpSend, state.cooldowns.loginOtpVerify, t.login.throttle])
const expiryMessage =
otpRemainingSeconds > 0
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
: state.login.otpExpiresAt
? t.login.otpExpired
: null
const submitCode = async (code: string) => {
if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp)
return
}
if (isSendingOtp || isSubmitting || code === activeSubmitCodeRef.current) {
return
}
if (code === lastFailedCodeRef.current) {
toast.error(t.login.toasts.invalidOtp)
return
}
activeSubmitCodeRef.current = code
setIsSubmitting(true)
try {
const data = await loginWithOtp(state.login.mobile, code)
clearCooldown("loginOtpVerify")
activeSubmitCodeRef.current = ""
lastFailedCodeRef.current = ""
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.successLogin,
redirectTo: "/profile",
navigate,
})
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpVerify",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
lastFailedCodeRef.current = code
toast.error(getApiErrorMessage(error, t.login.toasts.invalidOtp))
}
} finally {
activeSubmitCodeRef.current = ""
setIsSubmitting(false)
}
}
const handleResend = async () => {
autoSendStartedRef.current = false
await sendLoginOtp()
}
const isBusy = isSendingOtp || isSubmitting
const isRepeatedInvalidCode = state.login.code.length === 5 && state.login.code === lastFailedCodeRef.current
return (
<AuthPanel
title={t.login.loginOtpTitle}
description={t.login.sentCodeDesc(state.login.mobile)}
alert={throttleAlert}
>
<form
onSubmit={(event) => {
event.preventDefault()
void submitCode(state.login.code)
}}
className="grid gap-4"
>
<AuthOtpInput
id="login-otp"
value={state.login.code}
disabled={isBusy}
onChange={(value) => setCode("login", value)}
onComplete={(value) => void submitCode(value)}
/>
{expiryMessage ? (
<p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
) : null}
<Button
type="submit"
className="h-11 w-full"
disabled={isBusy || isRepeatedInvalidCode || state.cooldowns.loginOtpVerify > 0}
>
{(isSubmitting || isSendingOtp) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSubmitting ? t.login.verifyingOtp : isSendingOtp ? t.login.sendingOtp : t.login.verifyAndContinue}
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full"
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.loginOtpSend > 0}
onClick={() => void handleResend()}
>
{state.cooldowns.loginOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpSend, isRtl))
: t.login.resendOtp}
</Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400 underline">
<Link
to="/auth/login/password"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.usePasswordInstead}
</Link>
</div>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,119 @@
import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { loginWithPassword } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField"
import { completeAuthentication, formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils"
export function LoginPasswordPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, clearCooldown, setCooldown, setCode, resetFlow } = useAuthFlow()
const isRtl = lang === "fa"
const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false)
if (!state.login.mobile) {
return <Navigate to="/auth/login" replace />
}
const alert = useMemo(() => {
if (state.cooldowns.loginPassword <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.loginPassword, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.passwordLoginMessage(formatted),
}
}, [isRtl, state.cooldowns.loginPassword, t.login.throttle])
const cooldownLabel =
state.cooldowns.loginPassword > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginPassword, isRtl))
: null
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password) {
toast.error(t.login.toasts.fillAll)
return
}
setLoading(true)
try {
const data = await loginWithPassword(state.login.mobile, password)
clearCooldown("loginPassword")
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.successLogin,
redirectTo: "/profile",
navigate,
})
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginPassword",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.invalidCreds))
}
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.passwordLoginTitle}
description={t.login.passwordLoginDescription(state.login.mobile)}
alert={alert}
>
<form onSubmit={handleSubmit} autoComplete="off" className="grid gap-4">
<AuthPasswordField
id="login-password"
placeholder={t.login.passwordPlaceholder}
value={password}
onChange={setPassword}
disabled={loading}
/>
<Button type="submit" className="h-11 w-full" disabled={loading || state.cooldowns.loginPassword > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.signIn}
</Button>
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/login/verify")}>
{t.login.useOtpInstead}
</Button>
<div className="space-y-2 text-center text-sm text-slate-500 dark:text-slate-400">
<button
type="button"
className="font-medium underline text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
onClick={() => {
resetFlow("forgotPassword")
setMobile("forgotPassword", state.login.mobile)
setCode("forgotPassword", "")
navigate("/auth/forgot-password")
}}
>
{t.login.forgotPassword}
</button>
</div>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,84 @@
import { useMemo } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { formatCooldown } from "./utils"
export function SignupMobilePage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, markOtpSendPending, clearOtpDelivery } = useAuthFlow()
const isRtl = lang === "fa"
const alert = useMemo(() => {
if (state.cooldowns.signupOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.signupOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.signupOtpSend, t.login.throttle])
const cooldownLabel =
state.cooldowns.signupOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.signupOtpSend, isRtl))
: null
const handleContinue = async () => {
if (!state.signup.mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
clearOtpDelivery("signup")
markOtpSendPending("signup")
navigate("/auth/signup/verify")
}
return (
<AuthPanel
title={t.login.signupTitle}
description={t.login.signupDescription}
alert={alert}
>
<div className="grid gap-4">
<Input
id="signup-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
maxLength={11}
value={state.signup.mobile}
onChange={(event) => setMobile("signup", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={handleContinue}
disabled={state.cooldowns.signupOtpSend > 0}
className="h-11 w-full"
>
{cooldownLabel || t.login.sendSignupCode}
</Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400">
{t.login.haveAccount}{" "}
<Link
to="/auth/login"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.signIn}
</Link>
</div>
</div>
</AuthPanel>
)
}

Some files were not shown because too many files have changed in this diff Show More