Compare commits
6 Commits
e4ab9d2a12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cadb83e6 | |||
| 03c7c07a9f | |||
| 666d04ff26 | |||
| 132c8c44ef | |||
| 8abfcc9c2b | |||
| 69908887c1 |
41
src/api/contact.ts
Normal file
41
src/api/contact.ts
Normal 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()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createClient } from "../api/clients";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
@@ -48,7 +48,8 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
||||
setThumbnailFile(file);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -72,7 +73,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t.actions?.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
||||
<Button type="submit" form="create-client-form" disabled={isLoading || !name.trim()}>
|
||||
{isLoading ? "..." : t.clients.create}
|
||||
</Button>
|
||||
</>
|
||||
@@ -80,7 +81,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
||||
<div className="space-y-4">
|
||||
<form id="create-client-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||
@@ -117,7 +118,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
||||
placeholder={t.clients.notesPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type Client } from "../types/client";
|
||||
import { deleteClient } from "../api/clients";
|
||||
@@ -17,7 +17,8 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -39,8 +40,9 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
||||
{t.actions?.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="delete-client-form"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "..." : t.clients.delete}
|
||||
@@ -56,9 +58,11 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
||||
footer={footer}
|
||||
maxWidth="max-w-sm"
|
||||
>
|
||||
<form id="delete-client-form" onSubmit={handleDelete}>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{client ? t.clients.deleteConfirmMessage(client.name) : ""}
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type Client } from "../types/client";
|
||||
import { updateClient } from "../api/clients";
|
||||
@@ -59,7 +59,8 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
||||
setClearThumbnail(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!client || !name.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -80,7 +81,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t.actions?.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
||||
<Button type="submit" form="edit-client-form" disabled={isLoading || !name.trim()}>
|
||||
{isLoading ? "..." : t.clients.saveChanges}
|
||||
</Button>
|
||||
</>
|
||||
@@ -88,7 +89,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
||||
<div className="space-y-4">
|
||||
<form id="edit-client-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||
@@ -138,7 +139,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
||||
placeholder={t.clients.notesPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Card } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -23,15 +23,43 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
footer,
|
||||
maxWidth = "max-w-lg",
|
||||
}) => {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement && cardRef.current?.contains(activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTextInput = cardRef.current?.querySelector<HTMLElement>(
|
||||
'input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="tel"], input[type="password"], input[type="number"], textarea',
|
||||
);
|
||||
firstTextInput?.focus();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -42,6 +70,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
||||
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700">
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
<button form="create-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? "..." : t.projects?.create}
|
||||
</button>
|
||||
</>
|
||||
@@ -110,7 +110,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form id="create-project-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* ردیف اول: عنوان و انتخاب رنگ */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -154,7 +154,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
||||
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
<button form="edit-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? "..." : t.save || "Save"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
|
||||
<form id="edit-project-form" onSubmit={handleSubmit} className="space-y-4 mb-6">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import DatePicker, { DateObject } from "react-multi-date-picker"
|
||||
import persian from "react-date-object/calendars/persian"
|
||||
import persian_fa from "react-date-object/locales/persian_fa"
|
||||
@@ -18,6 +18,8 @@ interface JalaliDatePickerProps {
|
||||
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
|
||||
const isFa = document.documentElement.dir === 'rtl'
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [calendarPosition, setCalendarPosition] = useState("bottom-right")
|
||||
|
||||
// Listen for dark mode changes dynamically (optional but good for UX)
|
||||
useEffect(() => {
|
||||
@@ -37,8 +39,19 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
|
||||
}
|
||||
}
|
||||
|
||||
const updateCalendarPosition = () => {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const estimatedHeight = 340
|
||||
const hasMoreSpaceAbove = rect.top > window.innerHeight - rect.bottom
|
||||
const shouldOpenTop = window.innerHeight - rect.bottom < estimatedHeight && hasMoreSpaceAbove
|
||||
const horizontal = isFa ? "left" : "right"
|
||||
setCalendarPosition(`${shouldOpenTop ? "top" : "bottom"}-${horizontal}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div ref={containerRef} className="w-full">
|
||||
{label && (
|
||||
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
|
||||
{label}
|
||||
@@ -52,10 +65,11 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
|
||||
format="YYYY/MM/DD"
|
||||
placeholder={placeholder || "YYYY/MM/DD"}
|
||||
onOpenPickNewDate={false}
|
||||
onOpen={updateCalendarPosition}
|
||||
inputClass={`w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${inputClassName}`}
|
||||
containerClassName="w-full"
|
||||
className={isDark ? "bg-dark" : ""}
|
||||
calendarPosition="bottom-right"
|
||||
calendarPosition={calendarPosition}
|
||||
fixMainPosition
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"contact": {
|
||||
"eyebrow": "Contact",
|
||||
"title": "Need help or want to talk about Qlockify?",
|
||||
"description": "Send a note or reach out through the support channels below. The form opens an email draft with your message so you can review it before sending.",
|
||||
"description": "Send a note or reach out through the support channels below. Contact form submissions are stored securely so the team can review and follow up.",
|
||||
"formTitle": "Send a note",
|
||||
"fields": {
|
||||
"firstName": "First name",
|
||||
@@ -83,7 +83,10 @@
|
||||
"mobile": "09...",
|
||||
"message": "Tell us what you need help with..."
|
||||
},
|
||||
"submit": "Prepare email",
|
||||
"submit": "Send message",
|
||||
"submitting": "Sending...",
|
||||
"success": "Your message was saved. We will contact you soon.",
|
||||
"error": "Could not send your message. Please try again.",
|
||||
"channels": [
|
||||
{
|
||||
"label": "Telegram support",
|
||||
@@ -181,7 +184,7 @@
|
||||
"contact": {
|
||||
"eyebrow": "تماس",
|
||||
"title": "کمک میخواهید یا میخواهید درباره Qlockify صحبت کنیم؟",
|
||||
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. فرم یک پیشنویس ایمیل با پیام شما آماده میکند تا قبل از ارسال آن را بررسی کنید.",
|
||||
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. پیامهای فرم تماس ذخیره میشوند تا تیم بتواند آنها را بررسی و پیگیری کند.",
|
||||
"formTitle": "ارسال پیام",
|
||||
"fields": {
|
||||
"firstName": "نام",
|
||||
@@ -197,7 +200,10 @@
|
||||
"mobile": "09...",
|
||||
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
|
||||
},
|
||||
"submit": "آمادهسازی ایمیل",
|
||||
"submit": "ارسال پیام",
|
||||
"submitting": "در حال ارسال...",
|
||||
"success": "پیام شما ثبت شد. بهزودی با شما تماس میگیریم.",
|
||||
"error": "ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.",
|
||||
"channels": [
|
||||
{
|
||||
"label": "پشتیبانی تلگرام",
|
||||
|
||||
@@ -701,6 +701,10 @@ export const en = {
|
||||
noProjectsFoundLabel: "No projects found.",
|
||||
deletedProjectLabel: "Deleted project",
|
||||
deletedTagLabel: "Deleted tag",
|
||||
startRequiredError: "Start date and time are required.",
|
||||
endRequiredError: "End date and time must both be filled.",
|
||||
invalidEndTimeError: "End time is invalid.",
|
||||
endBeforeStartError: "End must be after start.",
|
||||
},
|
||||
|
||||
reports: {
|
||||
|
||||
@@ -698,6 +698,10 @@ export const fa = {
|
||||
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
||||
deletedProjectLabel: "پروژه حذفشده",
|
||||
deletedTagLabel: "تگ حذفشده",
|
||||
startRequiredError: "تاریخ و زمان شروع الزامی است.",
|
||||
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
|
||||
invalidEndTimeError: "زمان پایان معتبر نیست.",
|
||||
endBeforeStartError: "پایان باید بعد از شروع باشد.",
|
||||
},
|
||||
reports: {
|
||||
title: "گزارشها",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from "react"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -29,6 +30,7 @@ import { Input } from "../components/ui/input"
|
||||
import { TextAreaInput } from "../components/ui/TextAreaInput"
|
||||
import { useTheme } from "../components/ThemeProvider"
|
||||
import { useTranslation } from "../hooks/useTranslation"
|
||||
import { submitContactForm } from "../api/contact"
|
||||
import aboutContent from "../content/about.json"
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
@@ -50,6 +52,7 @@ export default function About() {
|
||||
mobile: "",
|
||||
message: "",
|
||||
})
|
||||
const [isContactSubmitting, setIsContactSubmitting] = useState(false)
|
||||
|
||||
const content = aboutContent[lang] as AboutContent
|
||||
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
|
||||
@@ -62,21 +65,30 @@ export default function About() {
|
||||
setContactForm((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
const submitContactForm = (event: FormEvent<HTMLFormElement>) => {
|
||||
const handleContactSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const subject = encodeURIComponent("Qlockify contact request")
|
||||
const body = encodeURIComponent(
|
||||
[
|
||||
`First name: ${contactForm.firstName}`,
|
||||
`Last name: ${contactForm.lastName}`,
|
||||
`Email: ${contactForm.email}`,
|
||||
`Mobile: ${contactForm.mobile}`,
|
||||
"",
|
||||
"Message:",
|
||||
contactForm.message,
|
||||
].join("\n"),
|
||||
)
|
||||
window.location.href = `mailto:qlockify@gmail.com?subject=${subject}&body=${body}`
|
||||
setIsContactSubmitting(true)
|
||||
try {
|
||||
await submitContactForm({
|
||||
first_name: contactForm.firstName.trim(),
|
||||
last_name: contactForm.lastName.trim(),
|
||||
email: contactForm.email.trim(),
|
||||
mobile: contactForm.mobile.trim(),
|
||||
message: contactForm.message.trim(),
|
||||
})
|
||||
setContactForm({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
mobile: "",
|
||||
message: "",
|
||||
})
|
||||
toast.success(content.contact.success)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : content.contact.error)
|
||||
} finally {
|
||||
setIsContactSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,13 +116,13 @@ export default function About() {
|
||||
<nav className="hidden items-center gap-2 md:flex">
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition 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"}
|
||||
</Link>
|
||||
<Link
|
||||
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"}
|
||||
</Link>
|
||||
@@ -377,7 +389,7 @@ export default function About() {
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={submitContactForm}
|
||||
onSubmit={handleContactSubmit}
|
||||
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/85 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
@@ -446,10 +458,11 @@ export default function About() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isContactSubmitting}
|
||||
className="mt-6 h-14 w-full rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
|
||||
>
|
||||
<Send className="me-2 h-4 w-4" />
|
||||
{content.contact.submit}
|
||||
{isContactSubmitting ? content.contact.submitting : content.contact.submit}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -131,10 +131,10 @@ export default function Landing() {
|
||||
</button>
|
||||
|
||||
<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"}
|
||||
</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}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { getProjects, deleteProject, type Project } from "../api/projects";
|
||||
@@ -149,7 +149,8 @@ export const Projects: React.FC = () => {
|
||||
};
|
||||
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!deleteModal.project) return;
|
||||
try {
|
||||
const deletedId = deleteModal.project.id;
|
||||
@@ -543,7 +544,8 @@ export const Projects: React.FC = () => {
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteInput !== deleteModal.project.name}
|
||||
onClick={confirmDelete}
|
||||
type="submit"
|
||||
form="delete-project-form"
|
||||
className="rounded-xl font-semibold"
|
||||
>
|
||||
{t.actions?.delete || 'Delete'}
|
||||
@@ -551,7 +553,7 @@ export const Projects: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form id="delete-project-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
||||
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
|
||||
</p>
|
||||
@@ -562,7 +564,7 @@ export const Projects: React.FC = () => {
|
||||
onChange={(e) => setDeleteInput(e.target.value)}
|
||||
placeholder={deleteModal.project.name}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -105,7 +105,8 @@ export default function Tags() {
|
||||
setFormColor(DEFAULT_COLOR);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!activeWorkspace?.id || !formName.trim()) return;
|
||||
|
||||
try {
|
||||
@@ -284,13 +285,13 @@ export default function Tags() {
|
||||
<Button variant="secondary" onClick={closeModal}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}>
|
||||
<Button type="submit" form="tag-form" disabled={isSaving || !formName.trim()}>
|
||||
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<form id="tag-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t.tags?.nameLabel || "Tag name"}
|
||||
@@ -304,7 +305,7 @@ export default function Tags() {
|
||||
</label>
|
||||
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{deleteModal.tag && (
|
||||
@@ -320,21 +321,28 @@ export default function Tags() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!deleteModal.tag) return;
|
||||
void handleDelete(deleteModal.tag);
|
||||
setDeleteModal({ isOpen: false, tag: null });
|
||||
}}
|
||||
type="submit"
|
||||
form="delete-tag-form"
|
||||
>
|
||||
{t.actions?.delete || "Delete"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="delete-tag-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!deleteModal.tag) return;
|
||||
void handleDelete(deleteModal.tag);
|
||||
setDeleteModal({ isOpen: false, tag: null });
|
||||
}}
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) ||
|
||||
`Are you sure you want to delete "${deleteModal.tag.name}"?`}
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -152,6 +152,18 @@ const handleFormattedTimeInputChange = (
|
||||
});
|
||||
};
|
||||
|
||||
const selectTimeSegment = (input: HTMLInputElement) => {
|
||||
const cursor = input.selectionStart ?? 0;
|
||||
const start = cursor <= 2 ? 0 : cursor <= 5 ? 3 : 6;
|
||||
const end = start + 2;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (document.activeElement === input) {
|
||||
input.setSelectionRange(start, end);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isValidTimeValue = (value: string) => {
|
||||
if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false;
|
||||
const [hours, minutes, seconds] = value.split(":").map(Number);
|
||||
@@ -447,23 +459,34 @@ const toggleTagId = (currentTags: string[], tagId: string) =>
|
||||
|
||||
const buildPayloadFromState = (
|
||||
state: EntryFormState,
|
||||
options: { includeWorkspace: boolean; workspaceId?: string },
|
||||
options: { includeWorkspace: boolean; workspaceId?: string; messages?: Partial<Record<"startRequired" | "endRequired" | "invalidEndTime" | "endBeforeStart", string>> },
|
||||
): { payload?: TimeEntryPayload; error?: string } => {
|
||||
const messages = {
|
||||
startRequired: "Start date and time are required.",
|
||||
endRequired: "End date and time must both be filled.",
|
||||
invalidEndTime: "End time is invalid.",
|
||||
endBeforeStart: "End must be after start.",
|
||||
...options.messages,
|
||||
};
|
||||
const startDateTime = combineDateAndTime(state.startDate, state.startTime);
|
||||
if (!startDateTime) {
|
||||
return { error: "Start date and time are required." };
|
||||
return { error: messages.startRequired };
|
||||
}
|
||||
|
||||
let endDateTime: string | null = null;
|
||||
const hasEndValue = Boolean(state.endDate || state.endTime);
|
||||
if (hasEndValue) {
|
||||
if (!state.endDate || !state.endTime) {
|
||||
return { error: "End date and time must both be filled." };
|
||||
return { error: messages.endRequired };
|
||||
}
|
||||
|
||||
endDateTime = combineDateAndTime(state.endDate, state.endTime);
|
||||
if (!endDateTime) {
|
||||
return { error: "End time is invalid." };
|
||||
return { error: messages.invalidEndTime };
|
||||
}
|
||||
|
||||
if (new Date(endDateTime).getTime() <= new Date(startDateTime).getTime()) {
|
||||
return { error: messages.endBeforeStart };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,12 +628,14 @@ function TimeField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholder,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
@@ -626,6 +651,9 @@ function TimeField({
|
||||
placeholder={placeholder || "HH:MM:SS"}
|
||||
className={compact ? "h-9 px-2 text-xs" : ""}
|
||||
onChange={(event) => handleFormattedTimeInputChange(event, onChange)}
|
||||
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -670,6 +698,7 @@ function TagMultiSelect({
|
||||
onToggleTag,
|
||||
emptyHint,
|
||||
title,
|
||||
onDropdownClose,
|
||||
compact = false,
|
||||
portalOwnerId,
|
||||
className = "",
|
||||
@@ -681,6 +710,7 @@ function TagMultiSelect({
|
||||
onToggleTag: (tagId: string) => void;
|
||||
emptyHint: string;
|
||||
title: string;
|
||||
onDropdownClose?: () => void;
|
||||
compact?: boolean;
|
||||
portalOwnerId?: string;
|
||||
className?: string;
|
||||
@@ -692,6 +722,7 @@ function TagMultiSelect({
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -741,6 +772,13 @@ function TagMultiSelect({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasOpenRef.current && !isOpen) {
|
||||
onDropdownClose?.();
|
||||
}
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [isOpen, onDropdownClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
@@ -1072,12 +1110,14 @@ function CompactDateTimeField({
|
||||
timeValue,
|
||||
onDateChange,
|
||||
onTimeChange,
|
||||
onTimeBlur,
|
||||
}: {
|
||||
label: string;
|
||||
dateValue: string;
|
||||
timeValue: string;
|
||||
onDateChange: (value: string) => void;
|
||||
onTimeChange: (value: string) => void;
|
||||
onTimeBlur?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-[208px]">
|
||||
@@ -1100,6 +1140,9 @@ function CompactDateTimeField({
|
||||
placeholder="HH:MM:SS"
|
||||
className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))}
|
||||
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onBlur={onTimeBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1111,11 +1154,15 @@ function InlineTimeRangeField({
|
||||
endTime,
|
||||
onStartTimeChange,
|
||||
onEndTimeChange,
|
||||
onStartTimeBlur,
|
||||
onEndTimeBlur,
|
||||
}: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
onStartTimeChange: (value: string) => void;
|
||||
onEndTimeChange: (value: string) => void;
|
||||
onStartTimeBlur?: () => void;
|
||||
onEndTimeBlur?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-12 items-center justify-center border-s border-slate-200 px-2 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-200">
|
||||
@@ -1128,6 +1175,9 @@ function InlineTimeRangeField({
|
||||
placeholder="HH:MM:SS"
|
||||
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)}
|
||||
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onBlur={onStartTimeBlur}
|
||||
/>
|
||||
<span className="px-1 text-slate-400">-</span>
|
||||
<Input
|
||||
@@ -1139,6 +1189,9 @@ function InlineTimeRangeField({
|
||||
placeholder="HH:MM:SS"
|
||||
className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)}
|
||||
onFocus={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onClick={(event) => selectTimeSegment(event.currentTarget)}
|
||||
onBlur={onEndTimeBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1149,12 +1202,14 @@ function DateRangePopover({
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onCommit,
|
||||
portalOwnerId,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onStartDateChange: (value: string) => void;
|
||||
onEndDateChange: (value: string) => void;
|
||||
onCommit?: () => void;
|
||||
portalOwnerId?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -1219,8 +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"
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<JalaliDatePicker label="Start date" value={startDate} onChange={onStartDateChange} />
|
||||
<JalaliDatePicker label="End date" value={endDate} onChange={onEndDateChange} />
|
||||
<JalaliDatePicker
|
||||
label="Start date"
|
||||
value={startDate}
|
||||
onChange={(value) => {
|
||||
onStartDateChange(value);
|
||||
window.setTimeout(() => onCommit?.(), 0);
|
||||
}}
|
||||
/>
|
||||
<JalaliDatePicker
|
||||
label="End date"
|
||||
value={endDate}
|
||||
onChange={(value) => {
|
||||
onEndDateChange(value);
|
||||
window.setTimeout(() => onCommit?.(), 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
@@ -1251,6 +1320,7 @@ function EntryEditorFields({
|
||||
onChange,
|
||||
onToggleTag,
|
||||
onProjectChange,
|
||||
onCommit,
|
||||
projects,
|
||||
tags,
|
||||
t,
|
||||
@@ -1259,9 +1329,10 @@ function EntryEditorFields({
|
||||
portalOwnerId,
|
||||
}: {
|
||||
state: EntryFormState;
|
||||
onChange: (patch: Partial<EntryFormState>) => void;
|
||||
onChange: (patch: Partial<EntryFormState>, saveMode?: "none" | "immediate" | "debounce") => void;
|
||||
onToggleTag: (tagId: string) => void;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
onCommit?: () => void;
|
||||
projects: Project[];
|
||||
tags: Tag[];
|
||||
t: any;
|
||||
@@ -1276,7 +1347,9 @@ function EntryEditorFields({
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Input
|
||||
value={state.description}
|
||||
onChange={(event) => onChange({ description: event.target.value })}
|
||||
onChange={(event) => {
|
||||
onChange({ description: event.target.value }, "debounce");
|
||||
}}
|
||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||
className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600"
|
||||
/>
|
||||
@@ -1288,7 +1361,7 @@ function EntryEditorFields({
|
||||
<ProjectInlineSelect
|
||||
projects={projects}
|
||||
value={state.projectId}
|
||||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))}
|
||||
onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }, "immediate"))}
|
||||
placeholder={t.timesheet?.projectLabel || "Project"}
|
||||
searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."}
|
||||
emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."}
|
||||
@@ -1317,13 +1390,14 @@ function EntryEditorFields({
|
||||
portalOwnerId={portalOwnerId}
|
||||
className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
||||
buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none"
|
||||
onDropdownClose={onCommit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-10">
|
||||
<BillableIconButton
|
||||
checked={state.isBillable}
|
||||
onChange={(checked) => onChange({ isBillable: checked })}
|
||||
onChange={(checked) => onChange({ isBillable: checked }, "immediate")}
|
||||
label={t.timesheet?.billable || "Billable"}
|
||||
compact
|
||||
/>
|
||||
@@ -1335,6 +1409,8 @@ function EntryEditorFields({
|
||||
endTime={state.endTime}
|
||||
onStartTimeChange={(value) => onChange({ startTime: value })}
|
||||
onEndTimeChange={(value) => onChange({ endTime: value })}
|
||||
onStartTimeBlur={onCommit}
|
||||
onEndTimeBlur={onCommit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1342,8 +1418,8 @@ function EntryEditorFields({
|
||||
<DateRangePopover
|
||||
startDate={state.startDate}
|
||||
endDate={state.endDate}
|
||||
onStartDateChange={(value) => onChange({ startDate: value })}
|
||||
onEndDateChange={(value) => onChange({ endDate: value })}
|
||||
onStartDateChange={(value) => onChange({ startDate: value }, "immediate")}
|
||||
onEndDateChange={(value) => onChange({ endDate: value }, "immediate")}
|
||||
portalOwnerId={portalOwnerId}
|
||||
/>
|
||||
</div>
|
||||
@@ -1359,7 +1435,9 @@ function EntryEditorFields({
|
||||
</label>
|
||||
<Input
|
||||
value={state.description}
|
||||
onChange={(event) => onChange({ description: event.target.value })}
|
||||
onChange={(event) => {
|
||||
onChange({ description: event.target.value }, "debounce");
|
||||
}}
|
||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||
className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"}
|
||||
/>
|
||||
@@ -1371,7 +1449,9 @@ function EntryEditorFields({
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={state.projectId}
|
||||
onChange={(value) => onChange({ projectId: String(value) })}
|
||||
onChange={(value) => {
|
||||
onChange({ projectId: String(value) }, "immediate");
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.noProject || "No project" },
|
||||
...projects.map((project) => ({
|
||||
@@ -1393,13 +1473,16 @@ function EntryEditorFields({
|
||||
<JalaliDatePicker
|
||||
label={t.timesheet?.startLabel || "Start"}
|
||||
value={state.startDate}
|
||||
onChange={(date) => onChange({ startDate: date })}
|
||||
onChange={(date) => {
|
||||
onChange({ startDate: date }, "immediate");
|
||||
}}
|
||||
inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
|
||||
/>
|
||||
<TimeField
|
||||
label={t.timesheet?.timeLabel || "Time"}
|
||||
value={state.startTime}
|
||||
onChange={(value) => onChange({ startTime: value })}
|
||||
onBlur={onCommit}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
@@ -1408,13 +1491,16 @@ function EntryEditorFields({
|
||||
<JalaliDatePicker
|
||||
label={t.timesheet?.endLabel || "End"}
|
||||
value={state.endDate}
|
||||
onChange={(date) => onChange({ endDate: date })}
|
||||
onChange={(date) => {
|
||||
onChange({ endDate: date }, "immediate");
|
||||
}}
|
||||
inputClassName={compact ? "h-9 px-2 text-xs" : undefined}
|
||||
/>
|
||||
<TimeField
|
||||
label={t.timesheet?.timeLabel || "Time"}
|
||||
value={state.endTime}
|
||||
onChange={(value) => onChange({ endTime: value })}
|
||||
onBlur={onCommit}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
@@ -1423,7 +1509,9 @@ function EntryEditorFields({
|
||||
<div>
|
||||
<BillableIconButton
|
||||
checked={state.isBillable}
|
||||
onChange={(checked) => onChange({ isBillable: checked })}
|
||||
onChange={(checked) => {
|
||||
onChange({ isBillable: checked }, "immediate");
|
||||
}}
|
||||
label={t.timesheet?.billable || "Billable"}
|
||||
/>
|
||||
</div>
|
||||
@@ -1432,6 +1520,7 @@ function EntryEditorFields({
|
||||
tags={tags}
|
||||
selectedTags={state.tags}
|
||||
onToggleTag={onToggleTag}
|
||||
onDropdownClose={onCommit}
|
||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||
title={t.tags?.title || "Tags"}
|
||||
compact={compact}
|
||||
@@ -1467,6 +1556,7 @@ function RecordedEntryCard({
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const isSavingRef = useRef(false);
|
||||
const pendingSignatureRef = useRef<string | null>(null);
|
||||
const descriptionSaveTimeoutRef = useRef<number | null>(null);
|
||||
const editorOwnerId = `time-entry-editor-${entry.id}`;
|
||||
const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {};
|
||||
const saveErrorText = timesheetCopy.saveError || "Failed to save time entry";
|
||||
@@ -1483,6 +1573,10 @@ function RecordedEntryCard({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (descriptionSaveTimeoutRef.current) {
|
||||
window.clearTimeout(descriptionSaveTimeoutRef.current);
|
||||
descriptionSaveTimeoutRef.current = null;
|
||||
}
|
||||
const nextDraft = buildEntryFormState(entry);
|
||||
const nextSignature = serializeEntryDraft(nextDraft);
|
||||
syncedSignatureRef.current = nextSignature;
|
||||
@@ -1491,14 +1585,27 @@ function RecordedEntryCard({
|
||||
setValidationMessage("");
|
||||
}, [entry]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (descriptionSaveTimeoutRef.current) {
|
||||
window.clearTimeout(descriptionSaveTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isInsideEditorContext = useCallback((target: EventTarget | null) => {
|
||||
if (!(target instanceof Node)) return false;
|
||||
if (rowRef.current?.contains(target)) return true;
|
||||
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`));
|
||||
}, [editorOwnerId]);
|
||||
|
||||
const commitDraft = useCallback(async () => {
|
||||
const currentSignature = serializeEntryDraft(draft);
|
||||
const validationMessages = useMemo(() => ({
|
||||
startRequired: t.timesheet?.startRequiredError || "Start date and time are required.",
|
||||
endRequired: t.timesheet?.endRequiredError || "End date and time must both be filled.",
|
||||
invalidEndTime: t.timesheet?.invalidEndTimeError || "End time is invalid.",
|
||||
endBeforeStart: t.timesheet?.endBeforeStartError || "End must be after start.",
|
||||
}), [t.timesheet]);
|
||||
|
||||
const commitDraftState = useCallback(async (nextDraft: EntryFormState) => {
|
||||
const currentSignature = serializeEntryDraft(nextDraft);
|
||||
if (currentSignature === syncedSignatureRef.current) {
|
||||
setValidationMessage("");
|
||||
return false;
|
||||
@@ -1508,7 +1615,7 @@ function RecordedEntryCard({
|
||||
return false;
|
||||
}
|
||||
|
||||
const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false });
|
||||
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false, messages: validationMessages });
|
||||
if (!payload) {
|
||||
setValidationMessage(error || "");
|
||||
return false;
|
||||
@@ -1530,56 +1637,40 @@ function RecordedEntryCard({
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pendingSignatureRef.current = null;
|
||||
toast.error(saveErrorText);
|
||||
toast.error(error instanceof Error ? error.message : saveErrorText);
|
||||
return false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
|
||||
}, [entry.id, onEntryUpdated, saveErrorText, saveSuccessText, validationMessages]);
|
||||
|
||||
const commitDraft = useCallback(async () => commitDraftState(draft), [commitDraftState, draft]);
|
||||
|
||||
const commitPatchedDraft = useCallback(async (patch: Partial<EntryFormState>) => {
|
||||
const nextDraft = { ...draft, ...patch };
|
||||
const nextSignature = serializeEntryDraft(nextDraft);
|
||||
setDraft(nextDraft);
|
||||
return commitDraftState(nextDraft);
|
||||
}, [commitDraftState, draft]);
|
||||
|
||||
if (nextSignature === syncedSignatureRef.current) {
|
||||
setValidationMessage("");
|
||||
return false;
|
||||
const handleDraftChange = useCallback((patch: Partial<EntryFormState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
|
||||
setDraft((current) => {
|
||||
const nextDraft = { ...current, ...patch };
|
||||
if (descriptionSaveTimeoutRef.current) {
|
||||
window.clearTimeout(descriptionSaveTimeoutRef.current);
|
||||
descriptionSaveTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (isSavingRef.current || pendingSignatureRef.current === nextSignature) {
|
||||
return false;
|
||||
if (saveMode === "immediate") {
|
||||
window.setTimeout(() => void commitDraftState(nextDraft), 0);
|
||||
} else if (saveMode === "debounce") {
|
||||
descriptionSaveTimeoutRef.current = window.setTimeout(() => {
|
||||
void commitDraftState(nextDraft);
|
||||
}, 700);
|
||||
}
|
||||
|
||||
const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false });
|
||||
if (!payload) {
|
||||
setValidationMessage(error || "");
|
||||
return false;
|
||||
}
|
||||
|
||||
setValidationMessage("");
|
||||
isSavingRef.current = true;
|
||||
pendingSignatureRef.current = nextSignature;
|
||||
|
||||
try {
|
||||
const updatedEntry = await updateTimeEntry(entry.id, payload);
|
||||
const updatedDraft = buildEntryFormState(updatedEntry);
|
||||
const updatedSignature = serializeEntryDraft(updatedDraft);
|
||||
syncedSignatureRef.current = updatedSignature;
|
||||
pendingSignatureRef.current = updatedSignature;
|
||||
setDraft(updatedDraft);
|
||||
onEntryUpdated(updatedEntry);
|
||||
toast.success(saveSuccessText);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pendingSignatureRef.current = null;
|
||||
toast.error(saveErrorText);
|
||||
return false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
}, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]);
|
||||
return nextDraft;
|
||||
});
|
||||
}, [commitDraftState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
@@ -1610,8 +1701,9 @@ function RecordedEntryCard({
|
||||
<div className="space-y-4">
|
||||
<EntryEditorFields
|
||||
state={draft}
|
||||
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||||
onChange={handleDraftChange}
|
||||
onCommit={commitDraft}
|
||||
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
|
||||
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||||
projects={editorProjects}
|
||||
tags={editorTags}
|
||||
@@ -1660,8 +1752,9 @@ function RecordedEntryCard({
|
||||
<div className="flex min-w-0 items-center">
|
||||
<EntryEditorFields
|
||||
state={draft}
|
||||
onChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
|
||||
onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))}
|
||||
onChange={handleDraftChange}
|
||||
onCommit={commitDraft}
|
||||
onToggleTag={(tagId) => handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })}
|
||||
onProjectChange={(projectId) => void commitPatchedDraft({ projectId })}
|
||||
projects={editorProjects}
|
||||
tags={editorTags}
|
||||
@@ -1725,8 +1818,11 @@ function MobileRecordedEntryCard({
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartXRef = useRef<number | null>(null);
|
||||
const touchStartYRef = useRef<number | null>(null);
|
||||
const touchGestureRef = useRef<"pending" | "swipe" | "scroll">("pending");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [touchGesture, setTouchGesture] = useState<"pending" | "swipe" | "scroll">("pending");
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1780,6 +1876,9 @@ function MobileRecordedEntryCard({
|
||||
|
||||
const closeSwipe = () => {
|
||||
touchStartXRef.current = null;
|
||||
touchStartYRef.current = null;
|
||||
touchGestureRef.current = "pending";
|
||||
setTouchGesture("pending");
|
||||
setSwipeOffset(0);
|
||||
};
|
||||
|
||||
@@ -1788,15 +1887,40 @@ function MobileRecordedEntryCard({
|
||||
setMenuOpen(false);
|
||||
}
|
||||
touchStartXRef.current = event.touches[0]?.clientX ?? null;
|
||||
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
touchGestureRef.current = "pending";
|
||||
setTouchGesture("pending");
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (touchStartXRef.current === null) return;
|
||||
const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
|
||||
setSwipeOffset(Math.max(-88, Math.min(88, delta)));
|
||||
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
|
||||
const deltaX = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current;
|
||||
const deltaY = (event.touches[0]?.clientY ?? 0) - touchStartYRef.current;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
|
||||
if (touchGestureRef.current === "pending" && (absX > 8 || absY > 8)) {
|
||||
touchGestureRef.current = absX > absY + 8 ? "swipe" : "scroll";
|
||||
setTouchGesture(touchGestureRef.current);
|
||||
}
|
||||
|
||||
if (touchGestureRef.current === "scroll") {
|
||||
setSwipeOffset(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (touchGestureRef.current === "swipe") {
|
||||
event.preventDefault();
|
||||
setSwipeOffset(Math.max(-88, Math.min(88, deltaX)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchGestureRef.current !== "swipe") {
|
||||
closeSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
if (swipeOffset <= -72) {
|
||||
closeSwipe();
|
||||
onDelete(entry);
|
||||
@@ -1823,7 +1947,7 @@ function MobileRecordedEntryCard({
|
||||
|
||||
<div
|
||||
className="relative bg-white px-4 py-5 transition-transform duration-150 ease-out dark:bg-slate-900/95"
|
||||
style={{ transform: `translateX(${swipeOffset}px)` }}
|
||||
style={{ transform: `translateX(${swipeOffset}px)`, touchAction: touchGesture === "swipe" ? "none" : "pan-y" }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -2065,6 +2189,10 @@ export default function Timesheet() {
|
||||
noTagsFoundLabel?: string;
|
||||
searchProjectsLabel?: string;
|
||||
noProjectsFoundLabel?: string;
|
||||
startRequiredError?: string;
|
||||
endRequiredError?: string;
|
||||
invalidEndTimeError?: string;
|
||||
endBeforeStartError?: string;
|
||||
}) || {};
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
@@ -2111,6 +2239,7 @@ export default function Timesheet() {
|
||||
const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||
const pendingTimerSignatureRef = useRef<string | null>(serializeTimerDraft(EMPTY_TIMER_DRAFT));
|
||||
const isTimerSavingRef = useRef(false);
|
||||
const timerDescriptionSaveTimeoutRef = useRef<number | null>(null);
|
||||
const timerEditorOwnerId = "running-timer-editor";
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({
|
||||
@@ -2149,6 +2278,17 @@ export default function Timesheet() {
|
||||
() => buildTagOptionsForEntry(tags, editingEntry, formState.tags),
|
||||
[editingEntry, formState.tags, tags],
|
||||
);
|
||||
const entryValidationMessages = useMemo(() => ({
|
||||
startRequired: extendedTimesheet.startRequiredError || "Start date and time are required.",
|
||||
endRequired: extendedTimesheet.endRequiredError || "End date and time must both be filled.",
|
||||
invalidEndTime: extendedTimesheet.invalidEndTimeError || "End time is invalid.",
|
||||
endBeforeStart: extendedTimesheet.endBeforeStartError || "End must be after start.",
|
||||
}), [
|
||||
extendedTimesheet.endBeforeStartError,
|
||||
extendedTimesheet.endRequiredError,
|
||||
extendedTimesheet.invalidEndTimeError,
|
||||
extendedTimesheet.startRequiredError,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runningEntry) return;
|
||||
@@ -2299,6 +2439,11 @@ export default function Timesheet() {
|
||||
}, [loadRunningEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerDescriptionSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
|
||||
timerDescriptionSaveTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!runningEntry) {
|
||||
setTimerClockAnchor(null);
|
||||
timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT);
|
||||
@@ -2313,19 +2458,25 @@ export default function Timesheet() {
|
||||
setTimerDraft(nextDraft);
|
||||
}, [runningEntry]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timerDescriptionSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => {
|
||||
if (!(target instanceof Node)) return false;
|
||||
if (desktopTimerRef.current?.contains(target) || mobileTimerRef.current?.contains(target)) return true;
|
||||
return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`));
|
||||
}, [timerEditorOwnerId]);
|
||||
|
||||
const commitTimerDraft = useCallback(async () => {
|
||||
const commitTimerDraftState = useCallback(async (nextDraft: TimerDraftState) => {
|
||||
const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry";
|
||||
const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved";
|
||||
|
||||
if (!runningEntry) return false;
|
||||
|
||||
const currentSignature = serializeTimerDraft(timerDraft);
|
||||
const currentSignature = serializeTimerDraft(nextDraft);
|
||||
if (currentSignature === timerDraftSignatureRef.current) {
|
||||
return false;
|
||||
}
|
||||
@@ -2339,10 +2490,10 @@ export default function Timesheet() {
|
||||
|
||||
try {
|
||||
const updatedEntry = await updateTimeEntry(runningEntry.id, {
|
||||
description: timerDraft.description.trim(),
|
||||
project_id: timerDraft.projectId || null,
|
||||
tags: timerDraft.tags,
|
||||
is_billable: timerDraft.isBillable,
|
||||
description: nextDraft.description.trim(),
|
||||
project_id: nextDraft.projectId || null,
|
||||
tags: nextDraft.tags,
|
||||
is_billable: nextDraft.isBillable,
|
||||
});
|
||||
|
||||
const syncedDraft = buildTimerDraftState(updatedEntry);
|
||||
@@ -2360,12 +2511,34 @@ export default function Timesheet() {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pendingTimerSignatureRef.current = null;
|
||||
toast.error(saveErrorText);
|
||||
toast.error(error instanceof Error ? error.message : saveErrorText);
|
||||
return false;
|
||||
} finally {
|
||||
isTimerSavingRef.current = false;
|
||||
}
|
||||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]);
|
||||
}, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry]);
|
||||
|
||||
const commitTimerDraft = useCallback(async () => commitTimerDraftState(timerDraft), [commitTimerDraftState, timerDraft]);
|
||||
|
||||
const updateTimerDraft = useCallback((patch: Partial<TimerDraftState>, saveMode: "none" | "immediate" | "debounce" = "none") => {
|
||||
setTimerDraft((current) => {
|
||||
const nextDraft = { ...current, ...patch };
|
||||
if (timerDescriptionSaveTimeoutRef.current) {
|
||||
window.clearTimeout(timerDescriptionSaveTimeoutRef.current);
|
||||
timerDescriptionSaveTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (saveMode === "immediate") {
|
||||
window.setTimeout(() => void commitTimerDraftState(nextDraft), 0);
|
||||
} else if (saveMode === "debounce") {
|
||||
timerDescriptionSaveTimeoutRef.current = window.setTimeout(() => {
|
||||
void commitTimerDraftState(nextDraft);
|
||||
}, 700);
|
||||
}
|
||||
|
||||
return nextDraft;
|
||||
});
|
||||
}, [commitTimerDraftState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runningEntry) return;
|
||||
@@ -2407,12 +2580,14 @@ export default function Timesheet() {
|
||||
setFormState(buildEntryFormState(entry));
|
||||
};
|
||||
|
||||
const handleSaveEntryModal = async () => {
|
||||
const handleSaveEntryModal = async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (modalMode === "manual" && !activeWorkspace?.id) return;
|
||||
|
||||
const { payload, error } = buildPayloadFromState(formState, {
|
||||
includeWorkspace: modalMode === "manual",
|
||||
workspaceId: activeWorkspace?.id,
|
||||
messages: entryValidationMessages,
|
||||
});
|
||||
|
||||
if (!payload) {
|
||||
@@ -2438,7 +2613,7 @@ export default function Timesheet() {
|
||||
setFormState(EMPTY_FORM);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.timesheet?.saveError || "Failed to save time entry");
|
||||
toast.error(error instanceof Error ? error.message : (t.timesheet?.saveError || "Failed to save time entry"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -2535,7 +2710,8 @@ export default function Timesheet() {
|
||||
setDiscardTimerModal({ isOpen: false, entry: null });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const confirmDelete = async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!deleteModal.entry) return;
|
||||
|
||||
try {
|
||||
@@ -2567,7 +2743,8 @@ export default function Timesheet() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRestart = async () => {
|
||||
const confirmRestart = async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!restartModal.entry) return;
|
||||
|
||||
try {
|
||||
@@ -2709,7 +2886,7 @@ export default function Timesheet() {
|
||||
<Input
|
||||
value={timerDraft.description}
|
||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
||||
onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
|
||||
disabled={isStartingTimer}
|
||||
className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600"
|
||||
/>
|
||||
@@ -2718,7 +2895,7 @@ export default function Timesheet() {
|
||||
<div className="flex shrink-0 items-center">
|
||||
<SearchableSelect
|
||||
value={timerDraft.projectId}
|
||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||
onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||
...runningTimerProjects.map((project) => ({
|
||||
@@ -2740,9 +2917,8 @@ export default function Timesheet() {
|
||||
<TagMultiSelect
|
||||
tags={runningTimerTags}
|
||||
selectedTags={timerDraft.tags}
|
||||
onToggleTag={(tagId) =>
|
||||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||||
}
|
||||
onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
|
||||
onDropdownClose={() => void commitTimerDraft()}
|
||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||
title={t.tags?.title || "Tags"}
|
||||
compact
|
||||
@@ -2755,7 +2931,7 @@ export default function Timesheet() {
|
||||
<div className="flex shrink-0 items-center">
|
||||
<BillableIconButton
|
||||
checked={timerDraft.isBillable}
|
||||
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
||||
onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
|
||||
label={t.timesheet?.billable || "Billable"}
|
||||
disabled={isStartingTimer}
|
||||
compact
|
||||
@@ -2817,7 +2993,7 @@ export default function Timesheet() {
|
||||
<Input
|
||||
value={timerDraft.description}
|
||||
placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"}
|
||||
onChange={(event) => setTimerDraft((current) => ({ ...current, description: event.target.value }))}
|
||||
onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")}
|
||||
disabled={isStartingTimer}
|
||||
className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600"
|
||||
/>
|
||||
@@ -2825,7 +3001,7 @@ export default function Timesheet() {
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<SearchableSelect
|
||||
value={timerDraft.projectId}
|
||||
onChange={(value) => setTimerDraft((current) => ({ ...current, projectId: String(value) }))}
|
||||
onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")}
|
||||
options={[
|
||||
{ value: "", label: t.timesheet?.projectLabel || "Project" },
|
||||
...runningTimerProjects.map((project) => ({
|
||||
@@ -2852,9 +3028,8 @@ export default function Timesheet() {
|
||||
<TagMultiSelect
|
||||
tags={runningTimerTags}
|
||||
selectedTags={timerDraft.tags}
|
||||
onToggleTag={(tagId) =>
|
||||
setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))
|
||||
}
|
||||
onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })}
|
||||
onDropdownClose={() => void commitTimerDraft()}
|
||||
emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."}
|
||||
title={t.tags?.title || "Tags"}
|
||||
compact
|
||||
@@ -2863,7 +3038,7 @@ export default function Timesheet() {
|
||||
|
||||
<BillableIconButton
|
||||
checked={timerDraft.isBillable}
|
||||
onChange={(checked) => setTimerDraft((current) => ({ ...current, isBillable: checked }))}
|
||||
onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")}
|
||||
label={t.timesheet?.billable || "Billable"}
|
||||
disabled={isStartingTimer}
|
||||
compact
|
||||
@@ -3045,12 +3220,13 @@ export default function Timesheet() {
|
||||
<Button variant="secondary" onClick={closeCreateModal}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</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"))}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="time-entry-modal-form" onSubmit={handleSaveEntryModal}>
|
||||
<EntryEditorFields
|
||||
state={formState}
|
||||
onChange={(patch) => setFormState((current) => ({ ...current, ...patch }))}
|
||||
@@ -3060,6 +3236,7 @@ export default function Timesheet() {
|
||||
t={t}
|
||||
isRtl={isRtl}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{deleteModal.entry && (
|
||||
@@ -3073,13 +3250,13 @@ export default function Timesheet() {
|
||||
<Button variant="secondary" onClick={closeDeleteModal}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={isDeleting}>
|
||||
<Button type="submit" form="delete-time-entry-form" variant="destructive" disabled={isDeleting}>
|
||||
{isDeleting ? "..." : (t.actions?.delete || "Delete")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<form id="delete-time-entry-form" onSubmit={confirmDelete} className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}
|
||||
</p>
|
||||
@@ -3092,7 +3269,7 @@ export default function Timesheet() {
|
||||
{deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -3107,13 +3284,13 @@ export default function Timesheet() {
|
||||
<Button variant="secondary" onClick={closeRestartModal}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button onClick={() => void confirmRestart()} disabled={isRestarting}>
|
||||
<Button type="submit" form="restart-time-entry-form" disabled={isRestarting}>
|
||||
{isRestarting ? "..." : (t.timesheet?.startTimer || "Start")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<form id="restart-time-entry-form" onSubmit={confirmRestart} className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}
|
||||
</p>
|
||||
@@ -3126,7 +3303,7 @@ export default function Timesheet() {
|
||||
{restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -3141,13 +3318,20 @@ export default function Timesheet() {
|
||||
<Button variant="secondary" onClick={closeDiscardTimerModal}>
|
||||
{t.actions?.cancel || "Cancel"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleDiscardTimerDraft()} disabled={isDiscardingTimer}>
|
||||
<Button type="submit" form="discard-timer-form" variant="destructive" disabled={isDiscardingTimer}>
|
||||
{isDiscardingTimer ? "..." : ((t.actions as { discard?: string } | undefined)?.discard || "Discard")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
id="discard-timer-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDiscardTimerDraft();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}
|
||||
</p>
|
||||
@@ -3159,7 +3343,7 @@ export default function Timesheet() {
|
||||
{formatDateTime(discardTimerModal.entry.start_time, lang)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -94,7 +94,8 @@ export default function Workspaces() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!deleteModal.workspace) return;
|
||||
try {
|
||||
const deletedId = deleteModal.workspace.id;
|
||||
@@ -275,7 +276,8 @@ export default function Workspaces() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteInput !== deleteModal.workspace.name}
|
||||
onClick={confirmDelete}
|
||||
type="submit"
|
||||
form="delete-workspace-form"
|
||||
className="rounded-xl font-semibold"
|
||||
>
|
||||
{t.actions?.delete || 'Delete'}
|
||||
@@ -283,7 +285,7 @@ export default function Workspaces() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form id="delete-workspace-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
||||
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
|
||||
</p>
|
||||
@@ -294,7 +296,7 @@ export default function Workspaces() {
|
||||
onChange={(e) => setDeleteInput(e.target.value)}
|
||||
placeholder={deleteModal.workspace.name}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user