Compare commits

..

9 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
20 changed files with 1017 additions and 477 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

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

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect, useRef } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Card } from "./ui/card"; import { Card } from "./ui/card";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -23,15 +23,43 @@ export const Modal: React.FC<ModalProps> = ({
footer, footer,
maxWidth = "max-w-lg", maxWidth = "max-w-lg",
}) => { }) => {
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) { if (isOpen) {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
document.addEventListener("keydown", handleKeyDown);
} else { } else {
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
} }
return () => { return () => {
document.body.style.overflow = "unset"; 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]); }, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -42,6 +70,7 @@ export const Modal: React.FC<ModalProps> = ({
onClick={onClose} onClick={onClose}
> >
<Card <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`} 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()} onClick={(e) => e.stopPropagation()}
> >

View File

@@ -100,7 +100,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700"> <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"} {t.actions?.cancel || "Cancel"}
</button> </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} {loading ? "..." : t.projects?.create}
</button> </button>
</> </>
@@ -110,7 +110,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}> <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 items-end gap-3">
<div className="flex-1"> <div className="flex-1">

View File

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

View File

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

View File

@@ -64,6 +64,52 @@
"Project access and user roles help limit what each person can see or use.", "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." "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": { "cta": {
"title": "Start with one workspace and make time easier to explain.", "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.", "description": "Open Qlockify, track a real workday, and compare the report with the way your team reviews work today.",
@@ -135,6 +181,52 @@
"دسترسی پروژه و نقش کاربران کمک می‌کند هر فرد فقط چیزی را ببیند یا استفاده کند که باید.", "دسترسی پروژه و نقش کاربران کمک می‌کند هر فرد فقط چیزی را ببیند یا استفاده کند که باید.",
"خروجی‌ها و لاگ‌ها برای ساده‌تر کردن بررسی طراحی شده‌اند، نه برای پنهان کردن جزئیات مهم." "خروجی‌ها و لاگ‌ها برای ساده‌تر کردن بررسی طراحی شده‌اند، نه برای پنهان کردن جزئیات مهم."
], ],
"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": { "cta": {
"title": "با یک ورک‌اسپیس شروع کنید و توضیح زمان را ساده‌تر کنید.", "title": "با یک ورک‌اسپیس شروع کنید و توضیح زمان را ساده‌تر کنید.",
"description": "Qlockify را باز کنید، یک روز کاری واقعی را ثبت کنید و گزارش آن را با روش فعلی مرور کار در تیم مقایسه کنید.", "description": "Qlockify را باز کنید، یک روز کاری واقعی را ثبت کنید و گزارش آن را با روش فعلی مرور کار در تیم مقایسه کنید.",

View File

@@ -701,6 +701,10 @@ export const en = {
noProjectsFoundLabel: "No projects found.", noProjectsFoundLabel: "No projects found.",
deletedProjectLabel: "Deleted project", deletedProjectLabel: "Deleted project",
deletedTagLabel: "Deleted tag", deletedTagLabel: "Deleted tag",
startRequiredError: "Start date and time are required.",
endRequiredError: "End date and time must both be filled.",
invalidEndTimeError: "End time is invalid.",
endBeforeStartError: "End must be after start.",
}, },
reports: { reports: {

View File

@@ -698,6 +698,10 @@ export const fa = {
noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", noProjectsFoundLabel: "پروژه‌ای پیدا نشد.",
deletedProjectLabel: "پروژه حذف‌شده", deletedProjectLabel: "پروژه حذف‌شده",
deletedTagLabel: "تگ حذف‌شده", deletedTagLabel: "تگ حذف‌شده",
startRequiredError: "تاریخ و زمان شروع الزامی است.",
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
invalidEndTimeError: "زمان پایان معتبر نیست.",
endBeforeStartError: "پایان باید بعد از شروع باشد.",
}, },
reports: { reports: {
title: "گزارش‌ها", title: "گزارش‌ها",

View File

@@ -1,7 +1,10 @@
import { useState, type FormEvent } from "react"
import { Link, useNavigate } from "react-router-dom" import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
AtSign,
BarChart3, BarChart3,
CheckCircle2, CheckCircle2,
Command, Command,
@@ -9,7 +12,11 @@ import {
Globe2, Globe2,
Layers3, Layers3,
LockKeyhole, LockKeyhole,
Mail,
MessageCircle,
Moon, Moon,
Phone,
Send,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
Sun, Sun,
@@ -19,8 +26,11 @@ import {
} from "lucide-react" } from "lucide-react"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput"
import { useTheme } from "../components/ThemeProvider" import { useTheme } from "../components/ThemeProvider"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { submitContactForm } from "../api/contact"
import aboutContent from "../content/about.json" import aboutContent from "../content/about.json"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
@@ -29,11 +39,20 @@ type AboutContent = typeof aboutContent.en
const sectionIcons = [Sparkles, ShieldCheck, Layers3] const sectionIcons = [Sparkles, ShieldCheck, Layers3]
const principleIcons = [TimerReset, Waypoints, BarChart3] const principleIcons = [TimerReset, Waypoints, BarChart3]
const capabilityIcons = [TimerReset, Users, FileText, LockKeyhole] const capabilityIcons = [TimerReset, Users, FileText, LockKeyhole]
const contactIcons = [MessageCircle, MessageCircle, Mail, Phone]
export default function About() { export default function About() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation() const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme() 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 content = aboutContent[lang] as AboutContent
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken") const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
@@ -42,6 +61,36 @@ export default function About() {
(theme === "system" && document.documentElement.classList.contains("dark")) (theme === "system" && document.documentElement.classList.contains("dark"))
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth" 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 ( 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="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-aurora pointer-events-none fixed inset-0 opacity-80" />
@@ -67,13 +116,13 @@ export default function About() {
<nav className="hidden items-center gap-2 md:flex"> <nav className="hidden items-center gap-2 md:flex">
<Link <Link
to="/" to="/"
className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white" 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"} {lang === "fa" ? "خانه" : "Home"}
</Link> </Link>
<Link <Link
to="/about" to="/about"
className="rounded-full bg-slate-950 px-4 py-2 text-sm font-medium text-white shadow-sm dark:bg-white dark:text-slate-950" className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold"
> >
{lang === "fa" ? "درباره ما" : "About us"} {lang === "fa" ? "درباره ما" : "About us"}
</Link> </Link>
@@ -291,6 +340,133 @@ export default function About() {
</div> </div>
</section> </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"> <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="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="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%)]" />

View File

@@ -131,10 +131,10 @@ export default function Landing() {
</button> </button>
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<Link to="/" className="rounded-full bg-slate-950 px-4 py-2 text-sm font-medium text-white shadow-sm dark:bg-white dark:text-slate-950"> <Link to="/" className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold">
{lang === "fa" ? "خانه" : "Home"} {lang === "fa" ? "خانه" : "Home"}
</Link> </Link>
<Link to="/about" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"> <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} {t.landing.nav.about}
</Link> </Link>
</div> </div>

View File

@@ -1,70 +1,48 @@
import { Link, useLocation } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, Command, Compass, Home } from "lucide-react" import { ArrowLeft, ArrowRight, Home } from "lucide-react"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { cn } from "../lib/utils"
export default function NotFound() { export default function NotFound() {
const location = useLocation() const navigate = useNavigate()
const { lang, t } = useTranslation() const { lang } = useTranslation()
const isFa = lang === "fa" const isFa = lang === "fa"
return ( return (
<div className="min-h-screen overflow-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"> <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">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" /> <section className="mx-auto max-w-2xl">
<div className="landing-hero-grid pointer-events-none fixed inset-0 opacity-70 dark:opacity-40" /> <div className="text-[7rem] font-semibold leading-none tracking-[-0.08em] text-slate-950 sm:text-[10rem] dark:text-white">
404
<main className="relative mx-auto flex min-h-screen max-w-5xl items-center px-4 py-12 sm:px-6 lg:px-8">
<div className="animate-landing-rise w-full overflow-hidden rounded-[2.5rem] border border-white/70 bg-white/80 p-6 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-10">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-cyan-200/70 bg-cyan-50/80 px-4 py-2 text-sm font-semibold text-cyan-900 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<Compass className="h-4 w-4" />
{isFa ? "مسیر پیدا نشد" : "Route not found"}
</div>
<h1 className="text-5xl font-semibold tracking-[-0.06em] text-slate-950 sm:text-7xl dark:text-white">
404
</h1>
<p className="mt-5 text-xl leading-9 text-slate-600 dark:text-slate-300">
{isFa
? "این آدرس در رابط کاربری Qlockify تعریف نشده است."
: "This endpoint is not defined in the Qlockify interface."}
</p>
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200">
{location.pathname}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
<Button
asChild
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"
>
<Link to="/">
<Home className="me-2 h-4 w-4" />
{isFa ? "بازگشت به خانه" : "Back home"}
</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="/about">
<Command className="me-2 h-4 w-4" />
{isFa ? "درباره Qlockify" : "About Qlockify"}
{isFa ? (
<ArrowLeft className="ms-2 h-4 w-4" />
) : (
<ArrowRight className={cn("ms-2 h-4 w-4")} />
)}
</Link>
</Button>
</div>
</div>
</div> </div>
</main> <h1 className="mt-5 text-3xl font-semibold tracking-[-0.04em] sm:text-5xl">
</div> {isFa ? "صفحه پیدا نشد" : "Page not found"}
</h1>
<p className="mx-auto mt-5 max-w-xl text-base leading-8 text-slate-600 sm:text-lg dark:text-slate-300">
{isFa
? "این صفحه وجود ندارد یا آدرس آن تغییر کرده است."
: "This page does not exist or its address has changed."}
</p>
<div className="mt-9 flex flex-col justify-center gap-3 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="h-14 rounded-full border-slate-200 bg-white/80 px-7 text-base text-slate-800 backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
{isFa ? <ArrowRight className="me-2 h-4 w-4" /> : <ArrowLeft className="me-2 h-4 w-4" />}
{isFa ? "بازگشت" : "Go back"}
</Button>
<Button
type="button"
onClick={() => navigate("/")}
className="h-14 rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
>
<Home className="me-2 h-4 w-4" />
{isFa ? "صفحه اصلی" : "Home page"}
</Button>
</div>
</section>
</main>
) )
} }

View File

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

View File

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

View File

@@ -152,6 +152,18 @@ const handleFormattedTimeInputChange = (
}); });
}; };
const selectTimeSegment = (input: HTMLInputElement) => {
const cursor = input.selectionStart ?? 0;
const start = cursor <= 2 ? 0 : cursor <= 5 ? 3 : 6;
const end = start + 2;
window.requestAnimationFrame(() => {
if (document.activeElement === input) {
input.setSelectionRange(start, end);
}
});
};
const isValidTimeValue = (value: string) => { const isValidTimeValue = (value: string) => {
if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false; if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false;
const [hours, minutes, seconds] = value.split(":").map(Number); const [hours, minutes, seconds] = value.split(":").map(Number);
@@ -447,23 +459,34 @@ const toggleTagId = (currentTags: string[], tagId: string) =>
const buildPayloadFromState = ( const buildPayloadFromState = (
state: EntryFormState, state: EntryFormState,
options: { includeWorkspace: boolean; workspaceId?: string }, options: { includeWorkspace: boolean; workspaceId?: string; messages?: Partial<Record<"startRequired" | "endRequired" | "invalidEndTime" | "endBeforeStart", string>> },
): { payload?: TimeEntryPayload; error?: string } => { ): { payload?: TimeEntryPayload; error?: string } => {
const messages = {
startRequired: "Start date and time are required.",
endRequired: "End date and time must both be filled.",
invalidEndTime: "End time is invalid.",
endBeforeStart: "End must be after start.",
...options.messages,
};
const startDateTime = combineDateAndTime(state.startDate, state.startTime); const startDateTime = combineDateAndTime(state.startDate, state.startTime);
if (!startDateTime) { if (!startDateTime) {
return { error: "Start date and time are required." }; return { error: messages.startRequired };
} }
let endDateTime: string | null = null; let endDateTime: string | null = null;
const hasEndValue = Boolean(state.endDate || state.endTime); const hasEndValue = Boolean(state.endDate || state.endTime);
if (hasEndValue) { if (hasEndValue) {
if (!state.endDate || !state.endTime) { if (!state.endDate || !state.endTime) {
return { error: "End date and time must both be filled." }; return { error: messages.endRequired };
} }
endDateTime = combineDateAndTime(state.endDate, state.endTime); endDateTime = combineDateAndTime(state.endDate, state.endTime);
if (!endDateTime) { if (!endDateTime) {
return { error: "End time is invalid." }; return { error: messages.invalidEndTime };
}
if (new Date(endDateTime).getTime() <= new Date(startDateTime).getTime()) {
return { error: messages.endBeforeStart };
} }
} }
@@ -605,12 +628,14 @@ function TimeField({
label, label,
value, value,
onChange, onChange,
onBlur,
placeholder, placeholder,
compact = false, compact = false,
}: { }: {
label: string; label: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onBlur?: () => void;
placeholder?: string; placeholder?: string;
compact?: boolean; compact?: boolean;
}) { }) {
@@ -626,6 +651,9 @@ function TimeField({
placeholder={placeholder || "HH:MM:SS"} placeholder={placeholder || "HH:MM:SS"}
className={compact ? "h-9 px-2 text-xs" : ""} className={compact ? "h-9 px-2 text-xs" : ""}
onChange={(event) => handleFormattedTimeInputChange(event, onChange)} onChange={(event) => handleFormattedTimeInputChange(event, onChange)}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onBlur}
/> />
</div> </div>
); );
@@ -670,6 +698,7 @@ function TagMultiSelect({
onToggleTag, onToggleTag,
emptyHint, emptyHint,
title, title,
onDropdownClose,
compact = false, compact = false,
portalOwnerId, portalOwnerId,
className = "", className = "",
@@ -681,6 +710,7 @@ function TagMultiSelect({
onToggleTag: (tagId: string) => void; onToggleTag: (tagId: string) => void;
emptyHint: string; emptyHint: string;
title: string; title: string;
onDropdownClose?: () => void;
compact?: boolean; compact?: boolean;
portalOwnerId?: string; portalOwnerId?: string;
className?: string; className?: string;
@@ -692,6 +722,7 @@ function TagMultiSelect({
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({}); const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
useEffect(() => { useEffect(() => {
@@ -741,6 +772,13 @@ function TagMultiSelect({
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => {
if (wasOpenRef.current && !isOpen) {
onDropdownClose?.();
}
wasOpenRef.current = isOpen;
}, [isOpen, onDropdownClose]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setSearchQuery(""); setSearchQuery("");
@@ -1072,12 +1110,14 @@ function CompactDateTimeField({
timeValue, timeValue,
onDateChange, onDateChange,
onTimeChange, onTimeChange,
onTimeBlur,
}: { }: {
label: string; label: string;
dateValue: string; dateValue: string;
timeValue: string; timeValue: string;
onDateChange: (value: string) => void; onDateChange: (value: string) => void;
onTimeChange: (value: string) => void; onTimeChange: (value: string) => void;
onTimeBlur?: () => void;
}) { }) {
return ( return (
<div className="min-w-[208px]"> <div className="min-w-[208px]">
@@ -1100,6 +1140,9 @@ function CompactDateTimeField({
placeholder="HH:MM:SS" placeholder="HH:MM:SS"
className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))} onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onTimeBlur}
/> />
</div> </div>
</div> </div>
@@ -1111,11 +1154,15 @@ function InlineTimeRangeField({
endTime, endTime,
onStartTimeChange, onStartTimeChange,
onEndTimeChange, onEndTimeChange,
onStartTimeBlur,
onEndTimeBlur,
}: { }: {
startTime: string; startTime: string;
endTime: string; endTime: string;
onStartTimeChange: (value: string) => void; onStartTimeChange: (value: string) => void;
onEndTimeChange: (value: string) => void; onEndTimeChange: (value: string) => void;
onStartTimeBlur?: () => void;
onEndTimeBlur?: () => void;
}) { }) {
return ( return (
<div className="flex h-12 items-center justify-center border-s border-slate-200 px-2 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-200"> <div className="flex h-12 items-center justify-center border-s border-slate-200 px-2 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-200">
@@ -1128,6 +1175,9 @@ function InlineTimeRangeField({
placeholder="HH:MM:SS" placeholder="HH:MM:SS"
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)} onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onStartTimeBlur}
/> />
<span className="px-1 text-slate-400">-</span> <span className="px-1 text-slate-400">-</span>
<Input <Input
@@ -1139,6 +1189,9 @@ function InlineTimeRangeField({
placeholder="HH:MM:SS" placeholder="HH:MM:SS"
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)} onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)}
onFocus={(event) => selectTimeSegment(event.currentTarget)}
onClick={(event) => selectTimeSegment(event.currentTarget)}
onBlur={onEndTimeBlur}
/> />
</div> </div>
); );
@@ -1149,12 +1202,14 @@ function DateRangePopover({
endDate, endDate,
onStartDateChange, onStartDateChange,
onEndDateChange, onEndDateChange,
onCommit,
portalOwnerId, portalOwnerId,
}: { }: {
startDate: string; startDate: string;
endDate: string; endDate: string;
onStartDateChange: (value: string) => void; onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void; onEndDateChange: (value: string) => void;
onCommit?: () => void;
portalOwnerId?: string; portalOwnerId?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -1219,8 +1274,22 @@ function DateRangePopover({
className="rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-700 dark:bg-slate-950" className="rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-700 dark:bg-slate-950"
> >
<div className="grid gap-3"> <div className="grid gap-3">
<JalaliDatePicker label="Start date" value={startDate} onChange={onStartDateChange} /> <JalaliDatePicker
<JalaliDatePicker label="End date" value={endDate} onChange={onEndDateChange} /> label="Start date"
value={startDate}
onChange={(value) => {
onStartDateChange(value);
window.setTimeout(() => onCommit?.(), 0);
}}
/>
<JalaliDatePicker
label="End date"
value={endDate}
onChange={(value) => {
onEndDateChange(value);
window.setTimeout(() => onCommit?.(), 0);
}}
/>
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -1251,6 +1320,7 @@ function EntryEditorFields({
onChange, onChange,
onToggleTag, onToggleTag,
onProjectChange, onProjectChange,
onCommit,
projects, projects,
tags, tags,
t, t,
@@ -1259,9 +1329,10 @@ function EntryEditorFields({
portalOwnerId, portalOwnerId,
}: { }: {
state: EntryFormState; state: EntryFormState;
onChange: (patch: Partial<EntryFormState>) => void; onChange: (patch: Partial<EntryFormState>, saveMode?: "none" | "immediate" | "debounce") => void;
onToggleTag: (tagId: string) => void; onToggleTag: (tagId: string) => void;
onProjectChange?: (projectId: string) => void; onProjectChange?: (projectId: string) => void;
onCommit?: () => void;
projects: Project[]; projects: Project[];
tags: Tag[]; tags: Tag[];
t: any; t: any;
@@ -1276,7 +1347,9 @@ function EntryEditorFields({
<div className="flex min-w-0 items-center"> <div className="flex min-w-0 items-center">
<Input <Input
value={state.description} value={state.description}
onChange={(event) => onChange({ description: event.target.value })} onChange={(event) => {
onChange({ description: event.target.value }, "debounce");
}}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600" className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600"
/> />
@@ -1288,7 +1361,7 @@ function EntryEditorFields({
<ProjectInlineSelect <ProjectInlineSelect
projects={projects} projects={projects}
value={state.projectId} value={state.projectId}
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }, "immediate"))}
placeholder={t.timesheet?.projectLabel || "Project"} placeholder={t.timesheet?.projectLabel || "Project"}
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."} emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
@@ -1317,13 +1390,14 @@ function EntryEditorFields({
portalOwnerId={portalOwnerId} portalOwnerId={portalOwnerId}
className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none" className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none"
buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none" buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none"
onDropdownClose={onCommit}
/> />
</div> </div>
<div className="w-10"> <div className="w-10">
<BillableIconButton <BillableIconButton
checked={state.isBillable} checked={state.isBillable}
onChange={(checked) => onChange({ isBillable: checked })} onChange={(checked) => onChange({ isBillable: checked }, "immediate")}
label={t.timesheet?.billable || "Billable"} label={t.timesheet?.billable || "Billable"}
compact compact
/> />
@@ -1335,6 +1409,8 @@ function EntryEditorFields({
endTime={state.endTime} endTime={state.endTime}
onStartTimeChange={(value) => onChange({ startTime: value })} onStartTimeChange={(value) => onChange({ startTime: value })}
onEndTimeChange={(value) => onChange({ endTime: value })} onEndTimeChange={(value) => onChange({ endTime: value })}
onStartTimeBlur={onCommit}
onEndTimeBlur={onCommit}
/> />
</div> </div>
@@ -1342,8 +1418,8 @@ function EntryEditorFields({
<DateRangePopover <DateRangePopover
startDate={state.startDate} startDate={state.startDate}
endDate={state.endDate} endDate={state.endDate}
onStartDateChange={(value) => onChange({ startDate: value })} onStartDateChange={(value) => onChange({ startDate: value }, "immediate")}
onEndDateChange={(value) => onChange({ endDate: value })} onEndDateChange={(value) => onChange({ endDate: value }, "immediate")}
portalOwnerId={portalOwnerId} portalOwnerId={portalOwnerId}
/> />
</div> </div>
@@ -1358,8 +1434,10 @@ function EntryEditorFields({
{t.timesheet?.descriptionLabel || "Description"} {t.timesheet?.descriptionLabel || "Description"}
</label> </label>
<Input <Input
value={state.description} value={state.description}
onChange={(event) => onChange({ description: event.target.value })} onChange={(event) => {
onChange({ description: event.target.value }, "debounce");
}}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"} className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"}
/> />
@@ -1370,8 +1448,10 @@ function EntryEditorFields({
{t.timesheet?.projectLabel || "Project"} {t.timesheet?.projectLabel || "Project"}
</label> </label>
<SearchableSelect <SearchableSelect
value={state.projectId} value={state.projectId}
onChange={(value) => onChange({ projectId: String(value) })} onChange={(value) => {
onChange({ projectId: String(value) }, "immediate");
}}
options={[ options={[
{ value: "", label: t.timesheet?.noProject || "No project" }, { value: "", label: t.timesheet?.noProject || "No project" },
...projects.map((project) => ({ ...projects.map((project) => ({
@@ -1393,13 +1473,16 @@ function EntryEditorFields({
<JalaliDatePicker <JalaliDatePicker
label={t.timesheet?.startLabel || "Start"} label={t.timesheet?.startLabel || "Start"}
value={state.startDate} value={state.startDate}
onChange={(date) => onChange({ startDate: date })} onChange={(date) => {
onChange({ startDate: date }, "immediate");
}}
inputClassName={compact ? "h-9 px-2 text-xs" : undefined} inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
/> />
<TimeField <TimeField
label={t.timesheet?.timeLabel || "Time"} label={t.timesheet?.timeLabel || "Time"}
value={state.startTime} value={state.startTime}
onChange={(value) => onChange({ startTime: value })} onChange={(value) => onChange({ startTime: value })}
onBlur={onCommit}
compact={compact} compact={compact}
/> />
</div> </div>
@@ -1408,13 +1491,16 @@ function EntryEditorFields({
<JalaliDatePicker <JalaliDatePicker
label={t.timesheet?.endLabel || "End"} label={t.timesheet?.endLabel || "End"}
value={state.endDate} value={state.endDate}
onChange={(date) => onChange({ endDate: date })} onChange={(date) => {
onChange({ endDate: date }, "immediate");
}}
inputClassName={compact ? "h-9 px-2 text-xs" : undefined} inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
/> />
<TimeField <TimeField
label={t.timesheet?.timeLabel || "Time"} label={t.timesheet?.timeLabel || "Time"}
value={state.endTime} value={state.endTime}
onChange={(value) => onChange({ endTime: value })} onChange={(value) => onChange({ endTime: value })}
onBlur={onCommit}
compact={compact} compact={compact}
/> />
</div> </div>
@@ -1423,7 +1509,9 @@ function EntryEditorFields({
<div> <div>
<BillableIconButton <BillableIconButton
checked={state.isBillable} checked={state.isBillable}
onChange={(checked) => onChange({ isBillable: checked })} onChange={(checked) => {
onChange({ isBillable: checked }, "immediate");
}}
label={t.timesheet?.billable || "Billable"} label={t.timesheet?.billable || "Billable"}
/> />
</div> </div>
@@ -1432,6 +1520,7 @@ function EntryEditorFields({
tags={tags} tags={tags}
selectedTags={state.tags} selectedTags={state.tags}
onToggleTag={onToggleTag} onToggleTag={onToggleTag}
onDropdownClose={onCommit}
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
title={t.tags?.title || "Tags"} title={t.tags?.title || "Tags"}
compact={compact} compact={compact}
@@ -1467,6 +1556,7 @@ function RecordedEntryCard({
const rowRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement>(null);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const pendingSignatureRef = useRef<string | null>(null); const pendingSignatureRef = useRef<string | null>(null);
const descriptionSaveTimeoutRef = useRef<number | null>(null);
const editorOwnerId = `time-entry-editor-${entry.id}`; const editorOwnerId = `time-entry-editor-${entry.id}`;
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {}; const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry"; const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
@@ -1483,6 +1573,10 @@ function RecordedEntryCard({
); );
useEffect(() => { useEffect(() => {
if (descriptionSaveTimeoutRef.current) {
window.clearTimeout(descriptionSaveTimeoutRef.current);
descriptionSaveTimeoutRef.current = null;
}
const nextDraft = buildEntryFormState(entry); const nextDraft = buildEntryFormState(entry);
const nextSignature = serializeEntryDraft(nextDraft); const nextSignature = serializeEntryDraft(nextDraft);
syncedSignatureRef.current = nextSignature; syncedSignatureRef.current = nextSignature;
@@ -1491,14 +1585,27 @@ function RecordedEntryCard({
setValidationMessage(""); setValidationMessage("");
}, [entry]); }, [entry]);
useEffect(() => () => {
if (descriptionSaveTimeoutRef.current) {
window.clearTimeout(descriptionSaveTimeoutRef.current);
}
}, []);
const isInsideEditorContext = useCallback((target: EventTarget | null) => { const isInsideEditorContext = useCallback((target: EventTarget | null) => {
if (!(target instanceof Node)) return false; if (!(target instanceof Node)) return false;
if (rowRef.current?.contains(target)) return true; if (rowRef.current?.contains(target)) return true;
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`)); return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`));
}, [editorOwnerId]); }, [editorOwnerId]);
const commitDraft = useCallback(async () => { const validationMessages = useMemo(() => ({
const currentSignature = serializeEntryDraft(draft); startRequired: t.timesheet?.startRequiredError || "Start date and time are required.",
endRequired: t.timesheet?.endRequiredError || "End date and time must both be filled.",
invalidEndTime: t.timesheet?.invalidEndTimeError || "End time is invalid.",
endBeforeStart: t.timesheet?.endBeforeStartError || "End must be after start.",
}), [t.timesheet]);
const commitDraftState = useCallback(async (nextDraft: EntryFormState) => {
const currentSignature = serializeEntryDraft(nextDraft);
if (currentSignature === syncedSignatureRef.current) { if (currentSignature === syncedSignatureRef.current) {
setValidationMessage(""); setValidationMessage("");
return false; return false;
@@ -1508,7 +1615,7 @@ function RecordedEntryCard({
return false; return false;
} }
const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false }); const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false, messages: validationMessages });
if (!payload) { if (!payload) {
setValidationMessage(error || ""); setValidationMessage(error || "");
return false; return false;
@@ -1530,56 +1637,40 @@ function RecordedEntryCard({
} catch (error) { } catch (error) {
console.error(error); console.error(error);
pendingSignatureRef.current = null; pendingSignatureRef.current = null;
toast.error(saveErrorText); toast.error(error instanceof Error ? error.message : saveErrorText);
return false; return false;
} finally { } finally {
isSavingRef.current = false; isSavingRef.current = false;
} }
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]); }, [entry.id, onEntryUpdated, saveErrorText, saveSuccessText, validationMessages]);
const commitDraft = useCallback(async () => commitDraftState(draft), [commitDraftState, draft]);
const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => { const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => {
const nextDraft = { ...draft, ...patch }; const nextDraft = { ...draft, ...patch };
const nextSignature = serializeEntryDraft(nextDraft);
setDraft(nextDraft); setDraft(nextDraft);
return commitDraftState(nextDraft);
}, [commitDraftState, draft]);
if (nextSignature === syncedSignatureRef.current) { const handleDraftChange = useCallback((patch: Partial<EntryFormState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
setValidationMessage(""); setDraft((current) => {
return false; const nextDraft = { ...current, ...patch };
} if (descriptionSaveTimeoutRef.current) {
window.clearTimeout(descriptionSaveTimeoutRef.current);
descriptionSaveTimeoutRef.current = null;
}
if (isSavingRef.current || pendingSignatureRef.current === nextSignature) { if (saveMode === "immediate") {
return false; window.setTimeout(() => void commitDraftState(nextDraft), 0);
} } else if (saveMode === "debounce") {
descriptionSaveTimeoutRef.current = window.setTimeout(() => {
void commitDraftState(nextDraft);
}, 700);
}
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false }); return nextDraft;
if (!payload) { });
setValidationMessage(error || ""); }, [commitDraftState]);
return false;
}
setValidationMessage("");
isSavingRef.current = true;
pendingSignatureRef.current = nextSignature;
try {
const updatedEntry = await updateTimeEntry(entry.id, payload);
const updatedDraft = buildEntryFormState(updatedEntry);
const updatedSignature = serializeEntryDraft(updatedDraft);
syncedSignatureRef.current = updatedSignature;
pendingSignatureRef.current = updatedSignature;
setDraft(updatedDraft);
onEntryUpdated(updatedEntry);
toast.success(saveSuccessText);
return true;
} catch (error) {
console.error(error);
pendingSignatureRef.current = null;
toast.error(saveErrorText);
return false;
} finally {
isSavingRef.current = false;
}
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
useEffect(() => { useEffect(() => {
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
@@ -1610,8 +1701,9 @@ function RecordedEntryCard({
<div className="space-y-4"> <div className="space-y-4">
<EntryEditorFields <EntryEditorFields
state={draft} state={draft}
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))} onChange={handleDraftChange}
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} onCommit={commitDraft}
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
projects={editorProjects} projects={editorProjects}
tags={editorTags} tags={editorTags}
@@ -1660,8 +1752,9 @@ function RecordedEntryCard({
<div className="flex min-w-0 items-center"> <div className="flex min-w-0 items-center">
<EntryEditorFields <EntryEditorFields
state={draft} state={draft}
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))} onChange={handleDraftChange}
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} onCommit={commitDraft}
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
projects={editorProjects} projects={editorProjects}
tags={editorTags} tags={editorTags}
@@ -1725,8 +1818,11 @@ function MobileRecordedEntryCard({
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const touchStartXRef = useRef<number | null>(null); const touchStartXRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const touchGestureRef = useRef<"pending" | "swipe" | "scroll">("pending");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0); const [swipeOffset, setSwipeOffset] = useState(0);
const [touchGesture, setTouchGesture] = useState<"pending" | "swipe" | "scroll">("pending");
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({}); const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
useEffect(() => { useEffect(() => {
@@ -1780,6 +1876,9 @@ function MobileRecordedEntryCard({
const closeSwipe = () => { const closeSwipe = () => {
touchStartXRef.current = null; touchStartXRef.current = null;
touchStartYRef.current = null;
touchGestureRef.current = "pending";
setTouchGesture("pending");
setSwipeOffset(0); setSwipeOffset(0);
}; };
@@ -1788,15 +1887,40 @@ function MobileRecordedEntryCard({
setMenuOpen(false); setMenuOpen(false);
} }
touchStartXRef.current = event.touches[0]?.clientX ?? null; touchStartXRef.current = event.touches[0]?.clientX ?? null;
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchGestureRef.current = "pending";
setTouchGesture("pending");
}; };
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => { const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
if (touchStartXRef.current === null) return; if (touchStartXRef.current === null || touchStartYRef.current === null) return;
const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current; const deltaX = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
setSwipeOffset(Math.max(-88, Math.min(88, delta))); const deltaY = (event.touches[0]?.clientY ?? 0) - touchStartYRef.current;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (touchGestureRef.current === "pending" && (absX > 8 || absY > 8)) {
touchGestureRef.current = absX > absY + 8 ? "swipe" : "scroll";
setTouchGesture(touchGestureRef.current);
}
if (touchGestureRef.current === "scroll") {
setSwipeOffset(0);
return;
}
if (touchGestureRef.current === "swipe") {
event.preventDefault();
setSwipeOffset(Math.max(-88, Math.min(88, deltaX)));
}
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
if (touchGestureRef.current !== "swipe") {
closeSwipe();
return;
}
if (swipeOffset <= -72) { if (swipeOffset <= -72) {
closeSwipe(); closeSwipe();
onDelete(entry); onDelete(entry);
@@ -1823,7 +1947,7 @@ function MobileRecordedEntryCard({
<div <div
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95" className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95"
style={{ transform: `translateX(${swipeOffset}px)` }} style={{ transform: `translateX(${swipeOffset}px)`, touchAction: touchGesture === "swipe" ? "none" : "pan-y" }}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
@@ -2065,6 +2189,10 @@ export default function Timesheet() {
noTagsFoundLabel?: string; noTagsFoundLabel?: string;
searchProjectsLabel?: string; searchProjectsLabel?: string;
noProjectsFoundLabel?: string; noProjectsFoundLabel?: string;
startRequiredError?: string;
endRequiredError?: string;
invalidEndTimeError?: string;
endBeforeStartError?: string;
}) || {}; }) || {};
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@@ -2111,6 +2239,7 @@ export default function Timesheet() {
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT)); const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
const isTimerSavingRef = useRef(false); const isTimerSavingRef = useRef(false);
const timerDescriptionSaveTimeoutRef = useRef<number | null>(null);
const timerEditorOwnerId = "running-timer-editor"; const timerEditorOwnerId = "running-timer-editor";
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
@@ -2149,6 +2278,17 @@ export default function Timesheet() {
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags), () => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
[editingEntry, formState.tags, tags], [editingEntry, formState.tags, tags],
); );
const entryValidationMessages = useMemo(() => ({
startRequired: extendedTimesheet.startRequiredError || "Start date and time are required.",
endRequired: extendedTimesheet.endRequiredError || "End date and time must both be filled.",
invalidEndTime: extendedTimesheet.invalidEndTimeError || "End time is invalid.",
endBeforeStart: extendedTimesheet.endBeforeStartError || "End must be after start.",
}), [
extendedTimesheet.endBeforeStartError,
extendedTimesheet.endRequiredError,
extendedTimesheet.invalidEndTimeError,
extendedTimesheet.startRequiredError,
]);
useEffect(() => { useEffect(() => {
if (!runningEntry) return; if (!runningEntry) return;
@@ -2299,6 +2439,11 @@ export default function Timesheet() {
}, [loadRunningEntry]); }, [loadRunningEntry]);
useEffect(() => { useEffect(() => {
if (timerDescriptionSaveTimeoutRef.current) {
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
timerDescriptionSaveTimeoutRef.current = null;
}
if (!runningEntry) { if (!runningEntry) {
setTimerClockAnchor(null); setTimerClockAnchor(null);
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
@@ -2313,19 +2458,25 @@ export default function Timesheet() {
setTimerDraft(nextDraft); setTimerDraft(nextDraft);
}, [runningEntry]); }, [runningEntry]);
useEffect(() => () => {
if (timerDescriptionSaveTimeoutRef.current) {
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
}
}, []);
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => { const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
if (!(target instanceof Node)) return false; if (!(target instanceof Node)) return false;
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true; if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`)); return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
}, [timerEditorOwnerId]); }, [timerEditorOwnerId]);
const commitTimerDraft = useCallback(async () => { const commitTimerDraftState = useCallback(async (nextDraft: TimerDraftState) => {
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry"; const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved"; const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
if (!runningEntry) return false; if (!runningEntry) return false;
const currentSignature = serializeTimerDraft(timerDraft); const currentSignature = serializeTimerDraft(nextDraft);
if (currentSignature === timerDraftSignatureRef.current) { if (currentSignature === timerDraftSignatureRef.current) {
return false; return false;
} }
@@ -2339,10 +2490,10 @@ export default function Timesheet() {
try { try {
const updatedEntry = await updateTimeEntry(runningEntry.id, { const updatedEntry = await updateTimeEntry(runningEntry.id, {
description: timerDraft.description.trim(), description: nextDraft.description.trim(),
project_id: timerDraft.projectId || null, project_id: nextDraft.projectId || null,
tags: timerDraft.tags, tags: nextDraft.tags,
is_billable: timerDraft.isBillable, is_billable: nextDraft.isBillable,
}); });
const syncedDraft = buildTimerDraftState(updatedEntry); const syncedDraft = buildTimerDraftState(updatedEntry);
@@ -2360,12 +2511,34 @@ export default function Timesheet() {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
pendingTimerSignatureRef.current = null; pendingTimerSignatureRef.current = null;
toast.error(saveErrorText); toast.error(error instanceof Error ? error.message : saveErrorText);
return false; return false;
} finally { } finally {
isTimerSavingRef.current = false; isTimerSavingRef.current = false;
} }
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]); }, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry]);
const commitTimerDraft = useCallback(async () => commitTimerDraftState(timerDraft), [commitTimerDraftState, timerDraft]);
const updateTimerDraft = useCallback((patch: Partial<TimerDraftState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
setTimerDraft((current) => {
const nextDraft = { ...current, ...patch };
if (timerDescriptionSaveTimeoutRef.current) {
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
timerDescriptionSaveTimeoutRef.current = null;
}
if (saveMode === "immediate") {
window.setTimeout(() => void commitTimerDraftState(nextDraft), 0);
} else if (saveMode === "debounce") {
timerDescriptionSaveTimeoutRef.current = window.setTimeout(() => {
void commitTimerDraftState(nextDraft);
}, 700);
}
return nextDraft;
});
}, [commitTimerDraftState]);
useEffect(() => { useEffect(() => {
if (!runningEntry) return; if (!runningEntry) return;
@@ -2407,12 +2580,14 @@ export default function Timesheet() {
setFormState(buildEntryFormState(entry)); setFormState(buildEntryFormState(entry));
}; };
const handleSaveEntryModal = async () => { const handleSaveEntryModal = async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (modalMode === "manual" && !activeWorkspace?.id) return; if (modalMode === "manual" && !activeWorkspace?.id) return;
const { payload, error } = buildPayloadFromState(formState, { const { payload, error } = buildPayloadFromState(formState, {
includeWorkspace: modalMode === "manual", includeWorkspace: modalMode === "manual",
workspaceId: activeWorkspace?.id, workspaceId: activeWorkspace?.id,
messages: entryValidationMessages,
}); });
if (!payload) { if (!payload) {
@@ -2438,7 +2613,7 @@ export default function Timesheet() {
setFormState(EMPTY_FORM); setFormState(EMPTY_FORM);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t.timesheet?.saveError || "Failed to save time entry"); toast.error(error instanceof Error ? error.message : (t.timesheet?.saveError || "Failed to save time entry"));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -2535,7 +2710,8 @@ export default function Timesheet() {
setDiscardTimerModal({ isOpen: false, entry: null }); setDiscardTimerModal({ isOpen: false, entry: null });
}; };
const confirmDelete = async () => { const confirmDelete = async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!deleteModal.entry) return; if (!deleteModal.entry) return;
try { try {
@@ -2567,7 +2743,8 @@ export default function Timesheet() {
} }
}; };
const confirmRestart = async () => { const confirmRestart = async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!restartModal.entry) return; if (!restartModal.entry) return;
try { try {
@@ -2709,7 +2886,7 @@ export default function Timesheet() {
<Input <Input
value={timerDraft.description} value={timerDraft.description}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))} onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
disabled={isStartingTimer} disabled={isStartingTimer}
className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600" className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600"
/> />
@@ -2718,7 +2895,7 @@ export default function Timesheet() {
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<SearchableSelect <SearchableSelect
value={timerDraft.projectId} value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))} onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
options={[ options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" }, { value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({ ...runningTimerProjects.map((project) => ({
@@ -2740,9 +2917,8 @@ export default function Timesheet() {
<TagMultiSelect <TagMultiSelect
tags={runningTimerTags} tags={runningTimerTags}
selectedTags={timerDraft.tags} selectedTags={timerDraft.tags}
onToggleTag={(tagId) => onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) onDropdownClose={() => void commitTimerDraft()}
}
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
title={t.tags?.title || "Tags"} title={t.tags?.title || "Tags"}
compact compact
@@ -2755,7 +2931,7 @@ export default function Timesheet() {
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<BillableIconButton <BillableIconButton
checked={timerDraft.isBillable} checked={timerDraft.isBillable}
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))} onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
label={t.timesheet?.billable || "Billable"} label={t.timesheet?.billable || "Billable"}
disabled={isStartingTimer} disabled={isStartingTimer}
compact compact
@@ -2817,7 +2993,7 @@ export default function Timesheet() {
<Input <Input
value={timerDraft.description} value={timerDraft.description}
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))} onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
disabled={isStartingTimer} disabled={isStartingTimer}
className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600" className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600"
/> />
@@ -2825,7 +3001,7 @@ export default function Timesheet() {
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<SearchableSelect <SearchableSelect
value={timerDraft.projectId} value={timerDraft.projectId}
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))} onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
options={[ options={[
{ value: "", label: t.timesheet?.projectLabel || "Project" }, { value: "", label: t.timesheet?.projectLabel || "Project" },
...runningTimerProjects.map((project) => ({ ...runningTimerProjects.map((project) => ({
@@ -2852,9 +3028,8 @@ export default function Timesheet() {
<TagMultiSelect <TagMultiSelect
tags={runningTimerTags} tags={runningTimerTags}
selectedTags={timerDraft.tags} selectedTags={timerDraft.tags}
onToggleTag={(tagId) => onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) onDropdownClose={() => void commitTimerDraft()}
}
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
title={t.tags?.title || "Tags"} title={t.tags?.title || "Tags"}
compact compact
@@ -2863,7 +3038,7 @@ export default function Timesheet() {
<BillableIconButton <BillableIconButton
checked={timerDraft.isBillable} checked={timerDraft.isBillable}
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))} onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
label={t.timesheet?.billable || "Billable"} label={t.timesheet?.billable || "Billable"}
disabled={isStartingTimer} disabled={isStartingTimer}
compact compact
@@ -3045,21 +3220,23 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeCreateModal}> <Button variant="secondary" onClick={closeCreateModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button onClick={() => void handleSaveEntryModal()} disabled={isSaving}> <Button type="submit" form="time-entry-modal-form" disabled={isSaving}>
{isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.create || "Create"))} {isSaving ? "..." : (modalMode === "edit" ? (t.save || "Save") : (t.create || "Create"))}
</Button> </Button>
</> </>
} }
> >
<EntryEditorFields <form id="time-entry-modal-form" onSubmit={handleSaveEntryModal}>
state={formState} <EntryEditorFields
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))} state={formState}
onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
projects={modalProjects} onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
tags={modalTags} projects={modalProjects}
t={t} tags={modalTags}
isRtl={isRtl} t={t}
/> isRtl={isRtl}
/>
</form>
</Modal> </Modal>
{deleteModal.entry && ( {deleteModal.entry && (
@@ -3073,13 +3250,13 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeDeleteModal}> <Button variant="secondary" onClick={closeDeleteModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button variant="destructive" onClick={confirmDelete} disabled={isDeleting}> <Button type="submit" form="delete-time-entry-form" variant="destructive" disabled={isDeleting}>
{isDeleting ? "..." : (t.actions?.delete || "Delete")} {isDeleting ? "..." : (t.actions?.delete || "Delete")}
</Button> </Button>
</> </>
} }
> >
<div className="space-y-3"> <form id="delete-time-entry-form" onSubmit={confirmDelete} className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"} {extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
</p> </p>
@@ -3092,7 +3269,7 @@ export default function Timesheet() {
{deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""} {deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}
</p> </p>
</div> </div>
</div> </form>
</Modal> </Modal>
)} )}
@@ -3107,13 +3284,13 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeRestartModal}> <Button variant="secondary" onClick={closeRestartModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button onClick={() => void confirmRestart()} disabled={isRestarting}> <Button type="submit" form="restart-time-entry-form" disabled={isRestarting}>
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")} {isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
</Button> </Button>
</> </>
} }
> >
<div className="space-y-3"> <form id="restart-time-entry-form" onSubmit={confirmRestart} className="space-y-3">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"} {extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
</p> </p>
@@ -3126,7 +3303,7 @@ export default function Timesheet() {
{restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""} {restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""}
</p> </p>
</div> </div>
</div> </form>
</Modal> </Modal>
)} )}
@@ -3141,13 +3318,20 @@ export default function Timesheet() {
<Button variant="secondary" onClick={closeDiscardTimerModal}> <Button variant="secondary" onClick={closeDiscardTimerModal}>
{t.actions?.cancel || "Cancel"} {t.actions?.cancel || "Cancel"}
</Button> </Button>
<Button variant="destructive" onClick={() => void handleDiscardTimerDraft()} disabled={isDiscardingTimer}> <Button type="submit" form="discard-timer-form" variant="destructive" disabled={isDiscardingTimer}>
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")} {isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
</Button> </Button>
</> </>
} }
> >
<div className="space-y-3"> <form
id="discard-timer-form"
onSubmit={(event) => {
event.preventDefault();
void handleDiscardTimerDraft();
}}
className="space-y-3"
>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"} {extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
</p> </p>
@@ -3159,7 +3343,7 @@ export default function Timesheet() {
{formatDateTime(discardTimerModal.entry.start_time, lang)} {formatDateTime(discardTimerModal.entry.start_time, lang)}
</p> </p>
</div> </div>
</div> </form>
</Modal> </Modal>
)} )}

View File

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