Compare commits

...

12 Commits

Author SHA1 Message Date
hamid zarghami 9c6aa47cf7 Setting design
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-17 16:14:18 +03:30
hamid zarghami 92033fb94c task detail attachment form 2026-06-17 15:34:47 +03:30
hamid zarghami 707908fe03 check list 2026-06-17 12:20:54 +03:30
hamid zarghami c62e958e91 Create check list 2026-06-17 10:57:52 +03:30
hamid zarghami 908f5e72ed manage users 2026-06-17 10:40:59 +03:30
hamid zarghami a03a147d12 lebal 2026-06-17 09:46:57 +03:30
hamid zarghami 021285bc09 task detail Labels 2026-06-17 09:45:04 +03:30
hamid zarghami c6559b3054 detail modal task 2026-06-16 16:31:49 +03:30
hamid zarghami 8518662eee Create project fix design select project 2026-06-16 14:39:56 +03:30
hamid zarghami 90cc91ac86 project list 2026-06-16 14:35:37 +03:30
hamid zarghami ad523d848f create project 2026-06-16 14:05:57 +03:30
hamid zarghami 82b2138027 create workspace 2026-06-16 10:20:25 +03:30
55 changed files with 2205 additions and 185 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+48 -58
View File
@@ -1,8 +1,8 @@
import { FC, InputHTMLAttributes, useEffect, useState } from 'react'
import { clx } from '../helpers/utils';
import EyeIcon from '../assets/images/eye.svg'
import { SearchNormal } from 'iconsax-react';
import Error from './Error';
import { SearchNormal } from "iconsax-react";
import { FC, InputHTMLAttributes, useEffect, useState } from "react";
import EyeIcon from "../assets/images/eye.svg";
import { clx } from "../helpers/utils";
import Error from "./Error";
type Variant = "floating_outlined" | "primary" | "search";
@@ -16,33 +16,31 @@ type Props = {
seprator?: boolean;
isNotRequired?: boolean;
onChangeSearchFinal?: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>
labelClassName?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const formatNumber = (value: string | number): string => {
if (!value) return '';
const inputValue = String(value).replace(/,/g, '');
return inputValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
if (!value) return "";
const inputValue = String(value).replace(/,/g, "");
return inputValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const Input: FC<Props> = (props: Props) => {
const [formattedValue, setFormattedValue] = useState<string>(props.value ? formatNumber(props.value as string) : "");
const [formattedValue, setFormattedValue] = useState<string>(
props.value ? formatNumber(props.value as string) : ''
);
const [showPassword, setShowPassword] = useState<boolean>(false)
const [search, setSearch] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false);
const [search, setSearch] = useState<string>("");
const inputClass = clx(
'w-full h-10 text-black block px-4 text-xs rounded-xl border border-border',
props.readOnly && 'bg-gray-100 border-0 text-description',
props.variant === 'search' && 'bg-[#EEF0F7] border-0 ps-10',
props.className
"w-full h-10 text-black block px-4 text-xs rounded-xl border border-border",
props.readOnly && "bg-gray-100 border-0 text-description",
props.variant === "search" && "bg-[#EEF0F7] border-0 ps-10",
props.className,
);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (props.seprator) {
const inputValue = event.target.value.replace(/,/g, ''); // حذف کاماها
const inputValue = event.target.value.replace(/,/g, ""); // حذف کاماها
const formatted = formatNumber(inputValue);
// به‌روزرسانی مقدار قالب‌بندی‌شده
@@ -65,51 +63,43 @@ const Input: FC<Props> = (props: Props) => {
if (props.value) {
setFormattedValue(formatNumber(props.value as string));
} else {
setFormattedValue('');
setFormattedValue("");
}
}, [props.value])
}, [props.value]);
useEffect(() => {
if (props.variant === 'search' && props.onChangeSearchFinal) {
if (props.variant === "search" && props.onChangeSearchFinal) {
const timeout = setTimeout(() => {
props.onChangeSearchFinal?.(search)
}, 1000)
return () => clearTimeout(timeout)
props.onChangeSearchFinal?.(search);
}, 1000);
return () => clearTimeout(timeout);
}
}, [search, props])
}, [search, props]);
return (
<div className='w-full'>
<label className='text-sm'>
{props.label}
</label>
<div className="w-full">
<label className={clx("text-sm", props.labelClassName)}>{props.label}</label>
<div className='w-full relative mt-1'>
<input {...props} onChange={(e) => {
setSearch(e.target.value)
handleInputChange(e)
}} value={props.seprator ? formattedValue : props.value} type={props.type === 'password' && showPassword ? 'text' : props.type === 'password' ? 'password' : undefined} className={inputClass} />
{
props.type === 'password' &&
<img onClick={() => setShowPassword((oldValue) => !oldValue)} src={EyeIcon} className='w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3' />
}
{
props.variant === 'search' &&
<SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' />
}
{
props.error_text &&
<Error
errorText={props.error_text}
<div className="w-full relative mt-1">
<input
{...props}
onChange={(e) => {
setSearch(e.target.value);
handleInputChange(e);
}}
value={props.seprator ? formattedValue : props.value}
type={props.type === "password" && showPassword ? "text" : props.type === "password" ? "password" : undefined}
className={inputClass}
/>
}
</div>
</div>
)
}
export default Input
{props.type === "password" && <img onClick={() => setShowPassword((oldValue) => !oldValue)} src={EyeIcon} className="w-5 absolute top-0 bottom-0 cursor-pointer my-auto left-3" />}
{props.variant === "search" && <SearchNormal size={20} color="#8C90A3" className="absolute top-0 w-5 bottom-0 my-auto right-3" />}
{props.error_text && <Error errorText={props.error_text} />}
</div>
</div>
);
};
export default Input;
+18
View File
@@ -0,0 +1,18 @@
import { type FC } from "react";
type Props = {
value: number;
className?: string;
};
const ProgressBar: FC<Props> = ({ value, className = "" }) => {
const progress = Math.min(100, Math.max(0, value));
return (
<div className={`flex-1 h-[5px] rounded-full bg-white/40 flex overflow-hidden ${className}`}>
<div className="h-full rounded-full bg-black" style={{ width: `${progress}%` }} />
</div>
);
};
export default ProgressBar;
+32 -49
View File
@@ -1,62 +1,45 @@
import { FC, SelectHTMLAttributes } from 'react'
import { clx } from '../helpers/utils'
import { ArrowDown2 } from 'iconsax-react'
import Error from './Error'
import { ArrowDown2 } from "iconsax-react";
import { FC, SelectHTMLAttributes } from "react";
import { clx } from "../helpers/utils";
import Error from "./Error";
export type ItemsSelectType = {
value: string,
label: string,
}
value: string;
label: string;
};
type Props = {
className?: string,
items: ItemsSelectType[],
error_text?: string,
placeholder?: string,
label?: string,
} & SelectHTMLAttributes<HTMLSelectElement>
className?: string;
items: ItemsSelectType[];
error_text?: string;
placeholder?: string;
label?: string;
labelClassName?: string;
} & SelectHTMLAttributes<HTMLSelectElement>;
const Select: FC<Props> = (props: Props) => {
return (
<div className='w-full relative'>
{
props.label &&
<label className='text-sm'>
{props.label}
</label>
}
<select {...props} className={clx(
'w-full text-black block border appearance-none border-border px-2.5 h-10 text-sm rounded-2.5 bg-gray',
props.className,
props.label && 'mt-1'
)}>
{
props.placeholder &&
<option value="" disabled selected={!props.value}>{props.placeholder}</option>
}
{
props.items?.map((item) => {
<div className="w-full relative">
{props.label && <label className={clx("text-sm", props.labelClassName)}>{props.label}</label>}
<div className={clx("relative", props.label && "mt-1")}>
<select {...props} className={clx("w-full text-black block border appearance-none border-border px-2.5 h-10 text-sm rounded-2.5 bg-gray", props.className)}>
{props.placeholder && (
<option value="" disabled selected={!props.value}>
{props.placeholder}
</option>
)}
{props.items?.map((item) => {
return (
<option key={item.value} value={item.value}>
{item.label}
</option>
)
})
}
);
})}
</select>
<ArrowDown2 size={16} color='black' className={clx(
'absolute z-0 top-3 left-2',
props.label && 'top-10'
)} />
{
props.error_text && props.error_text !== '' ?
<Error
errorText={props.error_text}
/>
: null
}
<ArrowDown2 size={16} color="black" className="absolute z-0 top-0 bottom-0 my-auto left-2" />
</div>
{props.error_text && props.error_text !== "" ? <Error errorText={props.error_text} /> : null}
</div>
);
};
)
}
export default Select
export default Select;
+14 -21
View File
@@ -1,30 +1,23 @@
import { Trash } from 'iconsax-react'
import { FC, useState } from 'react'
import ModalConfrim from './ModalConfrim'
import { Trash } from "iconsax-react";
import { FC, useState } from "react";
import ModalConfrim from "./ModalConfrim";
interface Props {
onDelete: () => void,
isLoading?: boolean
onDelete: () => void;
isLoading?: boolean;
size?: number;
color?: string;
}
const TrashWithConfrim: FC<Props> = ({ onDelete, isLoading = false }) => {
const [isConfirm, setIsConfirm] = useState(false)
const TrashWithConfrim: FC<Props> = ({ onDelete, isLoading = false, size = 20, color = "#888" }) => {
const [isConfirm, setIsConfirm] = useState(false);
return (
<div>
<Trash
onClick={() => setIsConfirm(true)}
className='size-5'
color='#888'
/>
<Trash onClick={() => setIsConfirm(true)} color={color} size={size} />
<ModalConfrim
isOpen={isConfirm}
close={() => setIsConfirm(false)}
onConfrim={() => onDelete()}
isLoading={isLoading}
/>
<ModalConfrim isOpen={isConfirm} close={() => setIsConfirm(false)} onConfrim={() => onDelete()} isLoading={isLoading} />
</div>
)
}
);
};
export default TrashWithConfrim
export default TrashWithConfrim;
+3
View File
@@ -168,5 +168,8 @@ export const Pages = {
},
taskmanager: {
workspace: "/taskmanager/workspace/",
createWorkspace: "/workspace/create",
createProject: "/project/create",
projectList: "/project/list",
},
};
+76
View File
@@ -72,6 +72,82 @@ tbody tr {
.rmdp-container {
width: 100%;
}
.task-detail-date-calendar .rmdp-wrapper {
width: 100%;
box-shadow: none;
border: none;
background: transparent;
}
.task-detail-date-calendar .rmdp-calendar {
width: 100%;
padding: 0;
}
.task-detail-date-calendar .rmdp-header {
margin-bottom: 8px;
padding: 0;
}
.task-detail-date-calendar .rmdp-header-values {
font-size: 12px;
font-weight: 700;
color: #292d32;
}
.task-detail-date-calendar .rmdp-arrow {
border-color: #292d32;
width: 8px;
height: 8px;
}
.task-detail-date-calendar .rmdp-week-day {
font-size: 11px;
color: #8c90a3;
font-weight: 500;
}
.task-detail-date-calendar .rmdp-day {
width: 32px;
height: 32px;
}
.task-detail-date-calendar .rmdp-day span {
font-size: 11px;
border-radius: 8px;
}
.task-detail-date-calendar .rmdp-day.rmdp-deactive span {
color: #c4c4c4;
}
.task-detail-date-calendar .rmdp-day.rmdp-today span {
background-color: transparent;
color: #292d32;
font-weight: 700;
}
.task-detail-date-calendar .rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover {
background-color: #f0edf5;
color: #292d32;
}
.task-detail-date-calendar .rmdp-day.rmdp-selected span:not(.highlight) {
background-color: #292d32;
color: #fff;
box-shadow: none;
}
.task-detail-date-calendar .rmdp-range {
background-color: #f0edf5;
box-shadow: none;
}
.task-detail-date-calendar .rmdp-day.rmdp-range.start,
.task-detail-date-calendar .rmdp-day.rmdp-range.end {
background-color: transparent;
}
.dltr {
direction: ltr;
}
+46 -1
View File
@@ -965,5 +965,50 @@
"days": "روز",
"id": "شناسه"
},
"cancel": "لغو"
"cancel": "لغو",
"taskmanager": {
"new_workspace": "ساخت فضای کاری جدید",
"submit_workspace": "ثبت فضای کاری",
"workspacename": "نام",
"workspacetype": "نوع",
"workspace_description": "توضیحات",
"workspace_status": "وضعیت فضای کار",
"choose_color": "انتخاب رنگ دلخواه",
"users": "کاربران",
"select_user": "انتخاب کاربر",
"no_users_added": "هنوز کاربری اضافه نکردی",
"new_project": "افزودن پروژه",
"submit_project": "ثبت پروژه",
"project_info": "اطلاعات پروژه",
"project_name": "نام پروژه",
"project_start_date": "تاریخ شروع پروژه",
"employer_name": "نام کارفرما",
"project_status": "وضعیت فضای کار",
"background": "پس زمینه",
"background_image": "تصویر پس زمینه",
"upload_background": "تصویر مورد نظر را دراپ کنید",
"select_date": "انتخاب تاریخ",
"projects": "پروژه‌ها",
"delete_project": "پاک کردن",
"task_detail": {
"labels": "برچسب ها",
"user_management": "مدیریت کاربران",
"checklist": "چک لیست",
"attachment": "ضمیمه",
"date_settings": "تنظیمات تاریخ",
"description": "توضیحات",
"description_placeholder": "توضیحات خود را اضافه کنید",
"new_label": "برچسب جدید",
"label_title": "عنوان",
"choose_custom_color": "انتخاب رنگ دلخواه",
"members": "اعضا",
"copy_from": "کپی از",
"none": "هیچ کدام",
"link": "لینک",
"or": "یا",
"insert": "درج",
"start_date": "تاریخ شروع",
"end_date": "تاریخ پایان"
}
}
}
@@ -10,6 +10,7 @@ type Props = {
onDragStart: (taskId: string) => void;
onDragEnd: () => void;
onDrop: (columnId: string, index: number) => void;
onTaskClick?: (task: TaskType) => void;
};
const Column: FC<Props> = ({
@@ -19,6 +20,7 @@ const Column: FC<Props> = ({
onDragStart,
onDragEnd,
onDrop,
onTaskClick,
}) => {
const [isAdding, setIsAdding] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
@@ -72,6 +74,7 @@ const Column: FC<Props> = ({
isDragging={draggedTaskId === task.id}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onTaskClick}
/>
</div>
))}
+21 -4
View File
@@ -1,5 +1,5 @@
import { AttachCircle, Calendar, TickSquare } from "iconsax-react";
import { type FC } from "react";
import { useRef, type FC } from "react";
import type { Task as TaskType } from "../types";
type Props = {
@@ -7,14 +7,31 @@ type Props = {
isDragging?: boolean;
onDragStart: (taskId: string) => void;
onDragEnd: () => void;
onClick?: (task: TaskType) => void;
};
const Task: FC<Props> = ({ task, isDragging, onDragStart, onDragEnd, onClick }) => {
const hasDraggedRef = useRef(false);
const handleClick = () => {
if (hasDraggedRef.current) return;
onClick?.(task);
};
const Task: FC<Props> = ({ task, isDragging, onDragStart, onDragEnd }) => {
return (
<div
draggable
onDragStart={() => onDragStart(task.id)}
onDragEnd={onDragEnd}
onDragStart={() => {
hasDraggedRef.current = true;
onDragStart(task.id);
}}
onDragEnd={() => {
onDragEnd();
requestAnimationFrame(() => {
hasDraggedRef.current = false;
});
}}
onClick={handleClick}
className={`bg-white rounded-lg p-2 cursor-grab active:cursor-grabbing transition-opacity ${
isDragging ? "opacity-40" : ""
}`}
@@ -0,0 +1,92 @@
import { Calendar, Paperclip2, Profile2User, Tag, TickSquare, type Icon } from "iconsax-react";
import { type FC, type ReactNode, useRef } from "react";
import { useTranslation } from "react-i18next";
import TaskDetailPopoverPanel from "./TaskDetailPopoverPanel";
import type { TaskDetailTab } from "./types";
type TabItem = {
id: TaskDetailTab;
icon: Icon;
labelKey: string;
};
const TABS: TabItem[] = [
{ id: "labels", icon: Tag, labelKey: "taskmanager.task_detail.labels" },
{ id: "users", icon: Profile2User, labelKey: "taskmanager.task_detail.user_management" },
{ id: "checklist", icon: TickSquare, labelKey: "taskmanager.task_detail.checklist" },
{ id: "attachment", icon: Paperclip2, labelKey: "taskmanager.task_detail.attachment" },
{ id: "date", icon: Calendar, labelKey: "taskmanager.task_detail.date_settings" },
];
type Props = {
activeTab: TaskDetailTab | null;
onTabChange: (tab: TaskDetailTab) => void;
labelsPopover?: ReactNode;
usersPopover?: ReactNode;
checklistPopover?: ReactNode;
attachmentPopover?: ReactNode;
datePopover?: ReactNode;
};
const POPOVER_TABS = new Set<TaskDetailTab>(["labels", "users", "checklist", "attachment", "date"]);
type PopoverTab = "labels" | "users" | "checklist" | "attachment" | "date";
const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover, usersPopover, checklistPopover, attachmentPopover, datePopover }) => {
const { t } = useTranslation("global");
const anchorRefs = useRef<Record<PopoverTab, { current: HTMLElement | null }>>({
labels: { current: null },
users: { current: null },
checklist: { current: null },
attachment: { current: null },
date: { current: null },
});
const popoverByTab: Record<PopoverTab, ReactNode> = {
labels: labelsPopover,
users: usersPopover,
checklist: checklistPopover,
attachment: attachmentPopover,
date: datePopover,
};
return (
<>
<div className="flex flex-wrap items-center gap-2">
{TABS.map(({ id, icon: Icon, labelKey }) => {
const isActive = activeTab === id;
const hasPopover = POPOVER_TABS.has(id);
return (
<div
key={id}
ref={(element) => {
if (!hasPopover) return;
anchorRefs.current[id as PopoverTab].current = element;
}}
>
<button
type="button"
onClick={() => onTabChange(id)}
className={`flex items-center gap-1.5 border rounded-lg px-3 py-2 text-[11px] cursor-pointer transition-colors ${isActive ? "bg-[#4A4A4A] text-white border-transparent" : "bg-white/50 text-[#292D32] border-white"}`}
>
<Icon size={16} color={isActive ? "#FFFFFF" : "#292D32"} />
<span>{t(labelKey)}</span>
</button>
</div>
);
})}
</div>
{(Object.keys(popoverByTab) as PopoverTab[]).map((tab) =>
popoverByTab[tab] ? (
<TaskDetailPopoverPanel key={tab} anchorRef={anchorRefs.current[tab]}>
{popoverByTab[tab]}
</TaskDetailPopoverPanel>
) : null,
)}
</>
);
};
export default TaskDetailActionBar;
@@ -0,0 +1,26 @@
import { type FC } from "react";
import { useTranslation } from "react-i18next";
type Props = {
value?: string;
onChange?: (value: string) => void;
};
const TaskDetailDescription: FC<Props> = ({ value = "", onChange }) => {
const { t } = useTranslation("global");
return (
<div className="mt-6">
<div className="text-sm font-medium mb-2">{t("taskmanager.task_detail.description")}</div>
<textarea
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={t("taskmanager.task_detail.description_placeholder")}
rows={5}
className="w-full bg-white/25 rounded-xl px-4 py-3 text-xs outline-none resize-none placeholder:text-description"
/>
</div>
);
};
export default TaskDetailDescription;
@@ -0,0 +1,26 @@
import { ArrowDown2, CloseCircle } from "iconsax-react";
import { type FC } from "react";
type Props = {
statusLabel: string;
onClose: () => void;
};
const TaskDetailHeader: FC<Props> = ({ statusLabel, onClose }) => {
return (
<div className="flex items-center justify-between border-b border-border pb-4">
<button type="button" className="flex items-center gap-1.5 bg-white/60 rounded-full px-4 py-2 text-xs font-medium cursor-pointer">
<span>{statusLabel}</span>
<ArrowDown2 size={14} color="#292D32" />
</button>
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className="size-8 rounded-full bg-white/60 flex items-center justify-center cursor-pointer" aria-label="بستن">
<CloseCircle size={20} color="#292D32" />
</button>
</div>
</div>
);
};
export default TaskDetailHeader;
@@ -0,0 +1,61 @@
import { Add } from "iconsax-react";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import AvatarImage from "../../../../assets/images/avatar_image.png";
import type { TaskLabel } from "./labels/types";
import type { TaskUser } from "./users/types";
type Props = {
selectedLabels: TaskLabel[];
selectedUsers: TaskUser[];
};
const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels, selectedUsers }) => {
const { t } = useTranslation("global");
return (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.labels")}</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedLabels.map((label) => (
<span
key={label.id}
className="inline-flex items-center h-7 px-3 rounded-lg text-xs font-medium"
style={{ backgroundColor: label.color }}
>
{label.title}
</span>
))}
<button
type="button"
className="size-7 rounded-lg border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer"
aria-label={t("taskmanager.task_detail.new_label")}
>
<Add size={14} color="#292D32" />
</button>
</div>
</div>
<div>
<div className="text-xs font-medium mb-2">{t("taskmanager.users")}</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedUsers.map((user) => (
<div key={user.id} className="size-8 rounded-full bg-[#D0D0D0] overflow-hidden">
<img src={user.avatar ?? AvatarImage} alt={user.name} className="size-full object-cover" />
</div>
))}
<button
type="button"
className="size-7 rounded-full border border-[#D0D0D0] bg-white/60 flex items-center justify-center cursor-pointer"
aria-label={t("taskmanager.users")}
>
<Add size={14} color="#292D32" />
</button>
</div>
</div>
</div>
);
};
export default TaskDetailMetadataPreview;
@@ -0,0 +1,51 @@
import { type FC, useEffect, useState } from "react";
import DefaulModal from "../../../../components/DefaulModal";
import type { Task } from "../../types";
import CheckLists from "./checklist/List";
import TaskDetailDescription from "./TaskDetailDescription";
import TaskDetailHeader from "./TaskDetailHeader";
import TaskDetailToolbar from "./TaskDetailToolbar";
import type { TaskDetailTab } from "./types";
type Props = {
open: boolean;
task: Task | null;
statusLabel: string;
onClose: () => void;
};
const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
const [activeTab, setActiveTab] = useState<TaskDetailTab | null>(null);
const [description, setDescription] = useState("");
useEffect(() => {
if (!open) {
setActiveTab(null);
setDescription("");
}
}, [open]);
const handleTabChange = (tab: TaskDetailTab) => {
setActiveTab((prev) => (prev === tab ? null : tab));
};
if (!task) return null;
return (
<DefaulModal open={open} close={onClose} width={680}>
<TaskDetailHeader statusLabel={statusLabel} onClose={onClose} />
<h2 className="mt-6 text-sm font-bold">{task.title}</h2>
<div className="mt-4">
<TaskDetailToolbar activeTab={activeTab} onTabChange={handleTabChange} />
</div>
<TaskDetailDescription value={description} onChange={setDescription} />
<CheckLists />
</DefaulModal>
);
};
export default TaskDetailModal;
@@ -0,0 +1,54 @@
import { type FC, type ReactNode, useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
import { clx } from "../../../../helpers/utils";
type Props = {
anchorRef: { current: HTMLElement | null };
children: ReactNode;
className?: string;
width?: number;
};
const TaskDetailPopoverPanel: FC<Props> = ({ anchorRef, children, className, width = 300 }) => {
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const updatePosition = () => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const padding = 8;
let left = rect.right - width;
left = Math.max(padding, Math.min(left, window.innerWidth - width - padding));
setPosition({
top: rect.bottom + 8,
left,
});
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [anchorRef, width]);
if (!position) return null;
return createPortal(
<div
style={{ top: position.top, left: position.left, width }}
className={clx("fixed z-[80] bg-white/90 backdrop-blur-md rounded-2xl p-4 shadow-lg border border-white/80", className)}
>
{children}
</div>,
document.body,
);
};
export default TaskDetailPopoverPanel;
@@ -0,0 +1,156 @@
import { type FC, useState } from "react";
import TaskDetailAttachmentPopover from "./attachment/TaskDetailAttachmentPopover";
import TaskDetailDatePopover from "./date/TaskDetailDatePopover";
import type DateObject from "react-date-object";
import { DEFAULT_CHECKLISTS } from "./checklist/constants";
import TaskDetailChecklistPopover from "./checklist/TaskDetailChecklistPopover";
import type { TaskChecklist } from "./checklist/types";
import { DEFAULT_LABELS } from "./labels/constants";
import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover";
import type { LabelsView, TaskLabel } from "./labels/types";
import TaskDetailActionBar from "./TaskDetailActionBar";
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
import type { TaskDetailTab } from "./types";
import { DEFAULT_USERS } from "./users/constants";
import TaskDetailUsersPopover from "./users/TaskDetailUsersPopover";
type Props = {
activeTab: TaskDetailTab | null;
onTabChange: (tab: TaskDetailTab) => void;
};
const TaskDetailToolbar: FC<Props> = ({ activeTab, onTabChange }) => {
const [labelsView, setLabelsView] = useState<LabelsView>("list");
const [labels, setLabels] = useState<TaskLabel[]>(DEFAULT_LABELS);
const [selectedLabelIds, setSelectedLabelIds] = useState<Set<string>>(new Set(["1"]));
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set(["1"]));
const [checklists, setChecklists] = useState<TaskChecklist[]>(DEFAULT_CHECKLISTS);
const selectedLabels = labels.filter((label) => selectedLabelIds.has(label.id));
const selectedUsers = DEFAULT_USERS.filter((user) => selectedUserIds.has(user.id));
const isLabelsOpen = activeTab === "labels";
const isUsersOpen = activeTab === "users";
const isChecklistOpen = activeTab === "checklist";
const isAttachmentOpen = activeTab === "attachment";
const isDateOpen = activeTab === "date";
const handleTabChange = (tab: TaskDetailTab) => {
if (tab === "labels" && activeTab !== "labels") {
setLabelsView("list");
}
onTabChange(tab);
};
const handleLabelToggle = (id: string) => {
setSelectedLabelIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleUserToggle = (id: string) => {
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleCreate = (title: string, color: string) => {
const newLabel: TaskLabel = {
id: crypto.randomUUID(),
title,
color,
};
setLabels((prev) => [...prev, newLabel]);
setSelectedLabelIds((prev) => new Set([...prev, newLabel.id]));
setLabelsView("list");
};
const handleChecklistClose = () => {
if (isChecklistOpen) onTabChange("checklist");
};
const handleChecklistCreate = (title: string, _copyFromId: string) => {
const newChecklist: TaskChecklist = {
id: crypto.randomUUID(),
title,
};
setChecklists((prev) => [...prev, newChecklist]);
handleChecklistClose();
};
const handleAttachmentClose = () => {
if (isAttachmentOpen) onTabChange("attachment");
};
const handleAttachmentCreate = (_data: { file?: File; title: string; linkId: string }) => {
handleAttachmentClose();
};
const handleDateClose = () => {
if (isDateOpen) onTabChange("date");
};
const handleDateSubmit = (_data: { startDate: DateObject | null; endDate: DateObject | null }) => {
handleDateClose();
};
return (
<div className="relative">
<TaskDetailActionBar
activeTab={activeTab}
onTabChange={handleTabChange}
labelsPopover={
isLabelsOpen ? (
<TaskDetailLabelsPopover
view={labelsView}
labels={labels}
selectedIds={selectedLabelIds}
onViewChange={setLabelsView}
onToggle={handleLabelToggle}
onCreate={handleCreate}
/>
) : null
}
usersPopover={
isUsersOpen ? (
<TaskDetailUsersPopover users={DEFAULT_USERS} selectedIds={selectedUserIds} onToggle={handleUserToggle} />
) : null
}
checklistPopover={
isChecklistOpen ? (
<TaskDetailChecklistPopover
checklists={checklists}
onClose={handleChecklistClose}
onSubmit={handleChecklistCreate}
/>
) : null
}
attachmentPopover={
isAttachmentOpen ? (
<TaskDetailAttachmentPopover onClose={handleAttachmentClose} onSubmit={handleAttachmentCreate} />
) : null
}
datePopover={
isDateOpen ? (
<TaskDetailDatePopover onClose={handleDateClose} onSubmit={handleDateSubmit} />
) : null
}
/>
<TaskDetailMetadataPreview selectedLabels={selectedLabels} selectedUsers={selectedUsers} />
</div>
);
};
export default TaskDetailToolbar;
@@ -0,0 +1,78 @@
import { type FC, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "../../../../../components/Button";
import Input from "../../../../../components/Input";
import Select from "../../../../../components/Select";
type Props = {
onClose: () => void;
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
};
const TaskDetailAttachmentForm: FC<Props> = ({ onClose, onSubmit }) => {
const { t } = useTranslation("global");
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [linkId, setLinkId] = useState("");
const linkItems = [{ value: "", label: t("taskmanager.task_detail.none") }];
const canSubmit = Boolean(file) || Boolean(title.trim());
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) setFile(selected);
};
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit({
file: file ?? undefined,
title: title.trim(),
linkId,
});
};
return (
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold text-center">{t("taskmanager.task_detail.attachment")}</h3>
<div className="w-full h-8 border border-white rounded-xl items-center px-2 flex gap-2 bg-white/63">
<button type="button" onClick={() => fileInputRef.current?.click()} className="shrink-0 h-6 rounded-lg text-[11px] px-3 bg-secondary">
{t("select_file")}
</button>
<span className="text-[11px] text-description truncate">{file ? file.name : t("no_select_file")}</span>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>
<div className="flex items-center gap-2 mt-1.5">
<div className="flex-1 border-t border-dashed border-[#D0D0D0]" />
<span className="text-[11px] text-description">{t("taskmanager.task_detail.or")}</span>
<div className="flex-1 border-t border-dashed border-[#D0D0D0]" />
</div>
<Input label={t("taskmanager.task_detail.label_title")} value={title} onChange={(e) => setTitle(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
<Select
label={t("taskmanager.task_detail.link")}
labelClassName="text-xs"
value={linkId}
onChange={(e) => setLinkId(e.target.value)}
items={linkItems}
className="h-8 bg-white/63 border-white text-xs rounded-xl"
/>
<div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
{t("cancel")}
</Button>
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
{t("taskmanager.task_detail.insert")}
</Button>
</div>
</div>
);
};
export default TaskDetailAttachmentForm;
@@ -0,0 +1,13 @@
import { type FC } from "react";
import TaskDetailAttachmentForm from "./TaskDetailAttachmentForm";
type Props = {
onClose: () => void;
onSubmit: (data: { file?: File; title: string; linkId: string }) => void;
};
const TaskDetailAttachmentPopover: FC<Props> = ({ onClose, onSubmit }) => {
return <TaskDetailAttachmentForm onClose={onClose} onSubmit={onSubmit} />;
};
export default TaskDetailAttachmentPopover;
@@ -0,0 +1,6 @@
export type TaskAttachment = {
id: string;
title: string;
linkId: string;
fileName?: string;
};
@@ -0,0 +1,42 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { More } from "iconsax-react";
import { type FC } from "react";
import TaskDetailLabelCheckbox from "../labels/TaskDetailLabelCheckbox";
type Props = {
title: string;
checked: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
};
const ChecklistItem: FC<Props> = ({ title, checked, onToggle, onEdit, onDelete }) => {
return (
<div className="flex gap-1 h-[27px] items-center">
<TaskDetailLabelCheckbox checked={checked} onToggle={onToggle} />
<div className="group flex justify-between flex-1 h-full hover:bg-white hover:bg-opacity-10 items-center px-2 relative">
<div className="text-xs">{title}</div>
<Popover className="relative">
<PopoverButton className="flex items-center opacity-0 group-hover:opacity-100 data-[open]:opacity-100 transition-opacity outline-none">
<More size={20} color="#000" />
</PopoverButton>
<PopoverPanel anchor="bottom end" className="z-[80] mt-1 rounded-[10px] bg-white shadow-md text-right p-3">
<button type="button" onClick={onEdit} className="w-full text-sm text-right">
ویرایش
</button>
<button type="button" onClick={onDelete} className="w-full mt-3 text-sm text-right text-[#FF0000]">
پاک کردن
</button>
</PopoverPanel>
</Popover>
</div>
</div>
);
};
export default ChecklistItem;
@@ -0,0 +1,42 @@
import { Edit } from "iconsax-react";
import ProgressBar from "../../../../../components/ProgressBar";
import TrashWithConfrim from "../../../../../components/TrashWithConfrim";
import ChecklistItem from "./ChecklistItem";
const CheckLists = () => {
const items = [
{ id: "1", title: "آیتم ۱", checked: true },
{ id: "2", title: "آیتم ۱", checked: true },
{ id: "3", title: "آیتم ۱", checked: true },
];
return (
<div className="mt-6">
<div className="flex gap-2 items-center">
<div className="text-sm font-bold mt-px">چک لیست</div>
<Edit size={20} color="#0047FF" />
<TrashWithConfrim onDelete={() => {}} color="#FF0000" />
</div>
<div className="mt-4 flex gap-2 items-center">
<div className="text-xs shrink-0">۲۵٪</div>
<ProgressBar value={25} />
</div>
<div className="mt-4 flex flex-col gap-2">
{items.map((item) => (
<ChecklistItem
key={item.id}
title={item.title}
checked={item.checked}
onToggle={() => {}}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</div>
);
};
export default CheckLists;
@@ -0,0 +1,60 @@
import { ArrowRight2 } from "iconsax-react";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "../../../../../components/Button";
import Input from "../../../../../components/Input";
import Select from "../../../../../components/Select";
import type { TaskChecklist } from "./types";
type Props = {
checklists: TaskChecklist[];
onClose: () => void;
onSubmit: (title: string, copyFromId: string) => void;
};
const TaskDetailChecklistForm: FC<Props> = ({ checklists, onClose, onSubmit }) => {
const { t } = useTranslation("global");
const [title, setTitle] = useState("");
const [copyFromId, setCopyFromId] = useState("");
const copyFromItems = [{ value: "", label: t("taskmanager.task_detail.none") }, ...checklists.map((checklist) => ({ value: checklist.id, label: checklist.title }))];
const handleSubmit = () => {
const trimmed = title.trim();
if (!trimmed) return;
onSubmit(trimmed, copyFromId);
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-center relative">
<Button type="button" onClick={onClose} className="absolute right-0 size-7 bg-transparent text-inherit w-auto h-auto p-0 border-0 rounded-none shadow-none" aria-label={t("back")}>
<ArrowRight2 size={16} color="#292D32" />
</Button>
<h3 className="text-xs font-bold">{t("taskmanager.task_detail.checklist")}</h3>
</div>
<Input label={t("taskmanager.task_detail.label_title")} value={title} onChange={(e) => setTitle(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
<Select
label={t("taskmanager.task_detail.copy_from")}
labelClassName="text-xs"
value={copyFromId}
onChange={(e) => setCopyFromId(e.target.value)}
items={copyFromItems}
className="h-8 bg-white/63 border-white text-xs rounded-xl"
/>
<div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
{t("cancel")}
</Button>
<Button type="button" onClick={handleSubmit} disabled={!title.trim()} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
{t("save")}
</Button>
</div>
</div>
);
};
export default TaskDetailChecklistForm;
@@ -0,0 +1,15 @@
import { type FC } from "react";
import TaskDetailChecklistForm from "./TaskDetailChecklistForm";
import type { TaskChecklist } from "./types";
type Props = {
checklists: TaskChecklist[];
onClose: () => void;
onSubmit: (title: string, copyFromId: string) => void;
};
const TaskDetailChecklistPopover: FC<Props> = ({ checklists, onClose, onSubmit }) => {
return <TaskDetailChecklistForm checklists={checklists} onClose={onClose} onSubmit={onSubmit} />;
};
export default TaskDetailChecklistPopover;
@@ -0,0 +1,6 @@
import type { TaskChecklist } from "./types";
export const DEFAULT_CHECKLISTS: TaskChecklist[] = [
{ id: "1", title: "چک لیست پروژه" },
{ id: "2", title: "چک لیست انتشار" },
];
@@ -0,0 +1,4 @@
export type TaskChecklist = {
id: string;
title: string;
};
@@ -0,0 +1,153 @@
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import { Calendar } from "react-multi-date-picker";
import Button from "../../../../../components/Button";
import TaskDetailLabelCheckbox from "../labels/TaskDetailLabelCheckbox";
import type { TaskDateField } from "./types";
type Props = {
onClose: () => void;
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
};
const formatDateLabel = (date: DateObject | null) => {
if (!date) return "";
return date.convert(persian, persian_fa).format("D MMMM");
};
const TaskDetailDateForm: FC<Props> = ({ onClose, onSubmit }) => {
const { t } = useTranslation("global");
const [startDate, setStartDate] = useState<DateObject | null>(null);
const [endDate, setEndDate] = useState<DateObject | null>(null);
const [startEnabled, setStartEnabled] = useState(true);
const [endEnabled, setEndEnabled] = useState(false);
const [activeField, setActiveField] = useState<TaskDateField>("start");
const calendarValue = (() => {
if (startEnabled && endEnabled && startDate && endDate) {
return [startDate, endDate];
}
if (activeField === "end" && endDate) return endDate;
if (startDate) return startDate;
return undefined;
})();
const handleCalendarChange = (value: DateObject | DateObject[] | null) => {
if (!value) return;
if (Array.isArray(value)) {
const [start, end] = value;
if (startEnabled) setStartDate(start);
if (endEnabled) setEndDate(end ?? null);
return;
}
if (activeField === "start" && startEnabled) {
setStartDate(value);
return;
}
if (activeField === "end" && endEnabled) {
setEndDate(value);
}
};
const handleStartToggle = () => {
const next = !startEnabled;
setStartEnabled(next);
if (next) {
setActiveField("start");
return;
}
if (!endEnabled) {
setEndEnabled(true);
setActiveField("end");
}
};
const handleEndToggle = () => {
const next = !endEnabled;
setEndEnabled(next);
if (next) {
setActiveField("end");
return;
}
if (!startEnabled) {
setStartEnabled(true);
setActiveField("start");
}
};
const canSubmit = (startEnabled && Boolean(startDate)) || (endEnabled && Boolean(endDate));
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit({
startDate: startEnabled ? startDate : null,
endDate: endEnabled ? endDate : null,
});
};
return (
<div className="flex flex-col gap-3">
<div className="task-detail-date-calendar">
<Calendar
value={calendarValue}
onChange={handleCalendarChange}
calendar={persian}
locale={persian_fa}
range={startEnabled && endEnabled}
className="w-full"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-xs shrink-0 w-[72px]">{t("taskmanager.task_detail.start_date")}</span>
<input
readOnly
value={formatDateLabel(startDate)}
placeholder={t("taskmanager.select_date")}
onFocus={() => setActiveField("start")}
className="flex-1 h-8 text-xs rounded-xl border border-white bg-white/63 px-3 text-[#292D32] outline-none"
/>
<TaskDetailLabelCheckbox
checked={startEnabled}
onToggle={handleStartToggle}
ariaLabel={t("taskmanager.task_detail.start_date")}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs shrink-0 w-[72px]">{t("taskmanager.task_detail.end_date")}</span>
<input
readOnly
value={formatDateLabel(endDate)}
placeholder={t("taskmanager.select_date")}
onFocus={() => setActiveField("end")}
className="flex-1 h-8 text-xs rounded-xl border border-white bg-white/63 px-3 text-[#292D32] outline-none"
/>
<TaskDetailLabelCheckbox
checked={endEnabled}
onToggle={handleEndToggle}
ariaLabel={t("taskmanager.task_detail.end_date")}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} className="h-8 bg-[#E8E4F0] text-[#292D32] rounded-xl text-xs w-[95px]">
{t("cancel")}
</Button>
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-8 bg-[#292D32] text-white rounded-xl text-xs disabled:opacity-50 w-[95px]">
{t("taskmanager.task_detail.insert")}
</Button>
</div>
</div>
);
};
export default TaskDetailDateForm;
@@ -0,0 +1,14 @@
import { type FC } from "react";
import type DateObject from "react-date-object";
import TaskDetailDateForm from "./TaskDetailDateForm";
type Props = {
onClose: () => void;
onSubmit: (data: { startDate: DateObject | null; endDate: DateObject | null }) => void;
};
const TaskDetailDatePopover: FC<Props> = ({ onClose, onSubmit }) => {
return <TaskDetailDateForm onClose={onClose} onSubmit={onSubmit} />;
};
export default TaskDetailDatePopover;
@@ -0,0 +1,10 @@
import type DateObject from "react-date-object";
export type TaskDateField = "start" | "end";
export type TaskDateRange = {
startDate: DateObject | null;
endDate: DateObject | null;
startEnabled: boolean;
endEnabled: boolean;
};
@@ -0,0 +1,29 @@
import { type FC } from "react";
import { clx } from "../../../../../helpers/utils";
type Props = {
checked: boolean;
onToggle: () => void;
ariaLabel?: string;
};
const TaskDetailLabelCheckbox: FC<Props> = ({ checked, onToggle, ariaLabel }) => {
return (
<button
type="button"
onClick={onToggle}
className={clx("shrink-0 size-4 rounded-md border flex items-center justify-center cursor-pointer transition-colors", checked ? "bg-black border-black" : "bg-white/60 border-[#D0D0D0]")}
aria-label={ariaLabel}
aria-checked={checked}
role="checkbox"
>
{checked && (
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden>
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
);
};
export default TaskDetailLabelCheckbox;
@@ -0,0 +1,53 @@
import { AddCircle, Edit } from "iconsax-react";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import Button from "../../../../../components/Button";
import TaskDetailLabelCheckbox from "./TaskDetailLabelCheckbox";
import type { TaskLabel } from "./types";
type Props = {
labels: TaskLabel[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
onEdit: (label: TaskLabel) => void;
onCreateClick: () => void;
};
const TaskDetailLabelsList: FC<Props> = ({ labels, selectedIds, onToggle, onEdit, onCreateClick }) => {
const { t } = useTranslation("global");
return (
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold text-center">{t("taskmanager.task_detail.labels")}</h3>
<div className="flex flex-col gap-2">
{labels.map((label) => {
const isSelected = selectedIds.has(label.id);
return (
<div key={label.id} className="flex items-center gap-2">
<TaskDetailLabelCheckbox checked={isSelected} onToggle={() => onToggle(label.id)} ariaLabel={t("taskmanager.task_detail.labels")} />
<div className="flex-1 h-5 rounded flex items-center px-3 min-w-0" style={{ backgroundColor: label.color }}>
{label.title && <span className="text-xs font-medium truncate">{label.title}</span>}
</div>
<Edit onClick={() => onEdit(label)} size={16} color="#0047FF" />
</div>
);
})}
</div>
<Button
type="button"
onClick={onCreateClick}
className="flex items-center justify-center gap-2 bg-white border border-[#D0D0D0] text-[#292D32] h-6 rounded-[6px] text-xs mt-1"
>
<AddCircle size={14} color="#292D32" />
<span className="text-xs">{t("taskmanager.task_detail.new_label")}</span>
</Button>
</div>
);
};
export default TaskDetailLabelsList;
@@ -0,0 +1,43 @@
import { type FC } from "react";
import TaskDetailLabelsList from "./TaskDetailLabelsList";
import TaskDetailNewLabelForm from "./TaskDetailNewLabelForm";
import type { LabelsView, TaskLabel } from "./types";
type Props = {
view: LabelsView;
labels: TaskLabel[];
selectedIds: Set<string>;
onViewChange: (view: LabelsView) => void;
onToggle: (id: string) => void;
onCreate: (title: string, color: string) => void;
};
const TaskDetailLabelsPopover: FC<Props> = ({
view,
labels,
selectedIds,
onViewChange,
onToggle,
onCreate,
}) => {
return (
<>
{view === "list" ? (
<TaskDetailLabelsList
labels={labels}
selectedIds={selectedIds}
onToggle={onToggle}
onEdit={() => undefined}
onCreateClick={() => onViewChange("create")}
/>
) : (
<TaskDetailNewLabelForm
onBack={() => onViewChange("list")}
onSubmit={onCreate}
/>
)}
</>
);
};
export default TaskDetailLabelsPopover;
@@ -0,0 +1,95 @@
import { AddCircle, ArrowRight2 } from "iconsax-react";
import { type FC, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ColorsImage from "../../../../../assets/images/colors.png";
import Button from "../../../../../components/Button";
import Input from "../../../../../components/Input";
import { clx } from "../../../../../helpers/utils";
import { LABEL_COLOR_OPTIONS } from "./constants";
type Props = {
onBack: () => void;
onSubmit: (title: string, color: string) => void;
};
const TaskDetailNewLabelForm: FC<Props> = ({ onBack, onSubmit }) => {
const { t } = useTranslation("global");
const colorInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [selectedColor, setSelectedColor] = useState<string>(LABEL_COLOR_OPTIONS[1]);
const handleSubmit = () => {
const trimmed = title.trim();
if (!trimmed) return;
onSubmit(trimmed, selectedColor);
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-center relative">
<Button
type="button"
onClick={onBack}
className="absolute right-0 size-7 bg-transparent text-inherit w-auto h-auto p-0 border-0 rounded-none shadow-none"
aria-label={t("back")}
>
<ArrowRight2 size={16} color="#292D32" />
</Button>
<h3 className="text-xs font-bold">{t("taskmanager.task_detail.new_label")}</h3>
</div>
<Input
label={t("taskmanager.task_detail.label_title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-8 bg-white/63 border-white"
/>
<Button
type="button"
onClick={handleSubmit}
disabled={!title.trim()}
className="flex items-center justify-center gap-2 bg-white border border-[#D0D0D0] text-[#292D32] rounded-[6px] h-7 text-xs disabled:opacity-50"
>
<AddCircle size={14} color="#292D32" />
<span className="text-xs">{t("taskmanager.task_detail.new_label")}</span>
</Button>
<div>
<div className="text-xs mb-3">{t("taskmanager.task_detail.choose_custom_color")}</div>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => colorInputRef.current?.click()}
className={clx(
"size-6 rounded-lg cursor-pointer overflow-hidden",
!LABEL_COLOR_OPTIONS.includes(selectedColor as (typeof LABEL_COLOR_OPTIONS)[number]) && "ring-1 ring-[#0047FF] rounded-full",
)}
aria-label={t("taskmanager.task_detail.choose_custom_color")}
>
<img src={ColorsImage} alt="colors" className="size-full object-cover" />
</button>
{LABEL_COLOR_OPTIONS.map((color) => (
<button
key={color}
type="button"
onClick={() => setSelectedColor(color)}
className={clx(
"size-6 rounded-lg cursor-pointer transition-transform",
color === "#FFFFFF" && "border border-[#D0D0D0]",
selectedColor === color && "ring-2 ring-[#0047FF] ring-offset-1",
)}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
<input ref={colorInputRef} type="color" value={selectedColor} onChange={(e) => setSelectedColor(e.target.value)} className="sr-only" />
</div>
</div>
</div>
);
};
export default TaskDetailNewLabelForm;
@@ -0,0 +1,19 @@
import type { TaskLabel } from "./types";
export const LABEL_COLOR_OPTIONS = [
"#A8E6CF",
"#F5A3B8",
"#F5C4A3",
"#8B9A8B",
"#292D32",
"#E86A4A",
"#FFFFFF",
] as const;
export const DEFAULT_LABELS: TaskLabel[] = [
{ id: "1", title: "عنوان", color: "#F9A8BB" },
{ id: "2", title: "", color: "#AED9F4" },
{ id: "3", title: "", color: "#B2E7CC" },
{ id: "4", title: "", color: "#F9E7A8" },
{ id: "5", title: "", color: "#F9C8A8" },
];
@@ -0,0 +1,7 @@
export type TaskLabel = {
id: string;
title: string;
color: string;
};
export type LabelsView = "list" | "create";
@@ -0,0 +1 @@
export type TaskDetailTab = "labels" | "users" | "checklist" | "attachment" | "date";
@@ -0,0 +1,48 @@
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Input from "../../../../../components/Input";
import type { TaskUser } from "./types";
type Props = {
users: TaskUser[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
};
const TaskDetailUsersList: FC<Props> = ({ users }) => {
const { t } = useTranslation("global");
const [search, setSearch] = useState("");
const filteredUsers = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return users;
return users.filter((user) => user.name.toLowerCase().includes(query));
}, [users, search]);
return (
<div className="flex flex-col gap-3">
<div>
<h3 className="text-xs font-bold text-center">{t("taskmanager.users")}</h3>
</div>
<Input label={t("search")} value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 bg-white/63 border-white" labelClassName="text-xs" />
<div>
<div className="text-xs font-medium mb-2">{t("taskmanager.task_detail.members")}</div>
<div className="flex flex-col gap-2">
{filteredUsers.map((user) => {
return (
<div key={user.id} className="flex gap-1.5 items-center">
<div className="size-6 rounded-full bg-gray-400"></div>
<div className="text-[10px]">{user.name}</div>
</div>
);
})}
</div>
</div>
</div>
);
};
export default TaskDetailUsersList;
@@ -0,0 +1,15 @@
import { type FC } from "react";
import TaskDetailUsersList from "./TaskDetailUsersList";
import type { TaskUser } from "./types";
type Props = {
users: TaskUser[];
selectedIds: Set<string>;
onToggle: (id: string) => void;
};
const TaskDetailUsersPopover: FC<Props> = ({ users, selectedIds, onToggle }) => {
return <TaskDetailUsersList users={users} selectedIds={selectedIds} onToggle={onToggle} />;
};
export default TaskDetailUsersPopover;
@@ -0,0 +1,6 @@
import type { TaskUser } from "./types";
export const DEFAULT_USERS: TaskUser[] = [
{ id: "1", name: "فروغ براتی" },
{ id: "2", name: "پارسا مهرزاد" },
];
@@ -0,0 +1,5 @@
export type TaskUser = {
id: string;
name: string;
avatar?: string;
};
+52
View File
@@ -0,0 +1,52 @@
import { TickCircle } from "iconsax-react";
import { useTranslation } from "react-i18next";
import Button from "../../../components/Button";
import DatePickerComponent from "../../../components/DatePicker";
import Input from "../../../components/Input";
import Textarea from "../../../components/Textarea";
import CreateProjectSidebar from "./components/CreateProjectSidebar";
const CreateProject = () => {
const { t } = useTranslation("global");
return (
<div className="w-full mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("taskmanager.new_project")}</div>
<div>
<Button className="px-5" isLoading={false}>
<div className="flex gap-2">
<TickCircle className="size-5" color="#fff" />
<div>{t("taskmanager.submit_project")}</div>
</div>
</Button>
</div>
</div>
<div className="flex gap-6">
<div className="flex-1">
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
<div className="text-sm font-bold">{t("taskmanager.project_info")}</div>
<div className="mt-8">
<Input label={t("taskmanager.project_name")} />
</div>
<div className="mt-8 rowTwoInput">
<DatePickerComponent label={t("taskmanager.project_start_date")} placeholder={t("taskmanager.select_date")} onChange={() => undefined} />
<Input label={t("taskmanager.employer_name")} />
</div>
<div className="mt-8">
<Textarea label={t("description")} />
</div>
</div>
</div>
<CreateProjectSidebar />
</div>
</div>
);
};
export default CreateProject;
+31
View File
@@ -0,0 +1,31 @@
import { useState } from "react";
import projectsData from "./data/projects.json";
import ProjectGrid from "./components/ProjectGrid";
import ProjectListHeader from "./components/ProjectListHeader";
import WorkspaceInfoBanner from "./components/WorkspaceInfoBanner";
import type { ProjectItem, WorkspaceInfo } from "./types/ProjectTypes";
const List = () => {
const { workspace, projects: initialProjects } = projectsData as {
workspace: WorkspaceInfo;
projects: ProjectItem[];
};
const [projects, setProjects] = useState<ProjectItem[]>(initialProjects);
const handleDelete = (id: string) => {
setProjects((prev) => prev.filter((project) => project.id !== id));
};
return (
<div className="w-full mt-4">
<ProjectListHeader title={workspace.name} />
<WorkspaceInfoBanner description={workspace.description} />
<ProjectGrid projects={projects} onDelete={handleDelete} />
</div>
);
};
export default List;
@@ -0,0 +1,132 @@
import { CloseCircle, Gallery } from "iconsax-react";
import { FC, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Select from "../../../../components/Select";
import SwitchComponent from "../../../../components/Switch";
import { useGetUsers } from "../../../users/hooks/useUserData";
import { UserItemType } from "../../../users/types/UserTypes";
import WorkspaceColorPicker from "../../workspace/components/WorkspaceColorPicker";
type SelectedUser = {
id: string;
name: string;
};
const BACKGROUND_PRESETS = [
"linear-gradient(45deg, #6a11cb, #2575fc)",
"linear-gradient(45deg, #ff00cc, #333399)",
"linear-gradient(45deg, #ff0000, #ff8c00)",
"linear-gradient(45deg, #3f2b96, #a8c0ff)",
"linear-gradient(45deg, #7f7fd5, #91eae4)",
"linear-gradient(45deg, #2c3e50, #4ca1af)",
] as const;
const CreateProjectSidebar: FC = () => {
const { t } = useTranslation("global");
const getUsers = useGetUsers("");
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [userSelectValue, setUserSelectValue] = useState("");
const [selectedBackground, setSelectedBackground] = useState<string>(BACKGROUND_PRESETS[0]);
const [backgroundImage, setBackgroundImage] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const userItems =
getUsers.data?.data?.users
?.filter((user: UserItemType) => !selectedUsers.some((selected) => selected.id === user.id))
.map((user: UserItemType) => ({
label: `${user.firstName} ${user.lastName}`,
value: user.id,
})) ?? [];
const handleUserSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (!value) return;
const user = getUsers.data?.data?.users?.find((item: UserItemType) => item.id === value);
if (user) {
setSelectedUsers((prev) => [...prev, { id: user.id, name: `${user.firstName} ${user.lastName}` }]);
}
setUserSelectValue("");
};
const handleRemoveUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((user) => user.id !== id));
};
const handleBackgroundImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
setBackgroundImage(file);
};
return (
<div className="bg-white mt-10 w-sidebar text-xs hidden 2xl:block h-fit px-5 py-7 rounded-3xl">
<div className="text-sm flex items-center justify-between">
<div>{t("taskmanager.project_status")}</div>
<div className="flex gap-2 text-xs items-center text-description">
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
<SwitchComponent active={isActive} onChange={setIsActive} />
</div>
</div>
<div className="mt-8">
<Select label={t("taskmanager.users")} placeholder={t("taskmanager.select_user")} items={userItems} value={userSelectValue} onChange={handleUserSelect} className="border" />
<div className="mt-3 border border-dashed border-border rounded-xl p-4 flex gap-2 flex-wrap min-h-[52px]">
{selectedUsers.length === 0 ? (
<div className="text-description w-full text-center">{t("taskmanager.no_users_added")}</div>
) : (
selectedUsers.map((user) => (
<div key={user.id} className="bg-[#EBEEF5] flex gap-2 text-xs items-center px-2 py-2 rounded-lg">
<div>{user.name}</div>
<CloseCircle variant="Bold" className="size-4 cursor-pointer" color="red" onClick={() => handleRemoveUser(user.id)} />
</div>
))
)}
</div>
</div>
<div className="mt-8">
<div className="text-sm mb-3">{t("taskmanager.background")}</div>
<div className="flex flex-wrap gap-2">
{BACKGROUND_PRESETS.map((background) => (
<button
key={background}
type="button"
className="w-20 h-10 border"
style={{
background,
borderColor: selectedBackground === background ? "#000" : "#E2E8F0",
}}
onClick={() => setSelectedBackground(background)}
/>
))}
</div>
</div>
<div className="mt-8">
<div className="text-sm mb-3">{t("taskmanager.choose_color")}</div>
<WorkspaceColorPicker selectedColor={selectedColor} onChange={setSelectedColor} />
</div>
<div className="mt-8">
<div className="text-sm mb-3">{t("taskmanager.background_image")}</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full min-h-[112px] border border-dashed border-border rounded-xl flex flex-col items-center justify-center text-description px-4"
>
<Gallery size={26} color="#8C90A3" />
<div className="mt-2 text-xs text-center">{backgroundImage ? backgroundImage.name : t("taskmanager.upload_background")}</div>
</button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleBackgroundImageChange} />
</div>
</div>
);
};
export default CreateProjectSidebar;
@@ -0,0 +1,48 @@
import { FC } from "react";
import { Link } from "react-router-dom";
import { Pages } from "../../../../config/Pages";
import type { ProjectItem } from "../types/ProjectTypes";
import ProjectCardMenu from "./ProjectCardMenu";
type Props = {
project: ProjectItem;
onDelete: (id: string) => void;
};
const hexToRgba = (hex: string, alpha: number) => {
const normalized = hex.replace("#", "");
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const getCardBackground = (background: string) => {
const color = /^#[0-9A-Fa-f]{6}$/i.test(background) ? background : (background.match(/#[0-9A-Fa-f]{6}/i)?.[0] ?? "#A8E6CF");
return `linear-gradient(135deg, ${color}, ${hexToRgba(color, 0.5)})`;
};
const ProjectCard: FC<Props> = ({ project, onDelete }) => {
const handleEdit = () => {
// TODO: navigate to edit page when available
};
return (
<div className="bg-white rounded-[20px] overflow-hidden shadow-sm border border-border/40 p-3">
<Link to={Pages.taskmanager.workspace + project.id}>
<div className="h-[84px] w-full rounded-[16px]" style={{ background: getCardBackground(project.background) }} />
</Link>
<div className="flex items-center justify-between mt-3.5">
<Link to={Pages.taskmanager.workspace + project.id} className="text-sm font-medium text-black hover:opacity-70 transition-opacity">
{project.name}
</Link>
<ProjectCardMenu onEdit={handleEdit} onDelete={() => onDelete(project.id)} />
</div>
</div>
);
};
export default ProjectCard;
@@ -0,0 +1,43 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { More } from "iconsax-react";
import { FC } from "react";
import { useTranslation } from "react-i18next";
type Props = {
onEdit: () => void;
onDelete: () => void;
};
const ProjectCardMenu: FC<Props> = ({ onEdit, onDelete }) => {
const { t } = useTranslation("global");
return (
<Popover className="relative">
<PopoverButton className="flex items-center justify-center size-8 rounded-lg hover:bg-[#F4F5F9] transition-colors outline-none">
<More size={20} color="#8C90A3" />
</PopoverButton>
<PopoverPanel
anchor="bottom start"
className="z-20 mt-1 min-w-[120px] rounded-xl bg-white shadow-md border border-border py-1 text-sm"
>
<button
type="button"
onClick={onEdit}
className="w-full px-4 py-2.5 text-right hover:bg-[#F4F5F9] transition-colors"
>
{t("edit")}
</button>
<button
type="button"
onClick={onDelete}
className="w-full px-4 py-2.5 text-right text-red-500 hover:bg-red-50 transition-colors"
>
{t("taskmanager.delete_project")}
</button>
</PopoverPanel>
</Popover>
);
};
export default ProjectCardMenu;
@@ -0,0 +1,27 @@
import { FC } from "react";
import { useTranslation } from "react-i18next";
import type { ProjectItem } from "../types/ProjectTypes";
import ProjectCard from "./ProjectCard";
type Props = {
projects: ProjectItem[];
onDelete: (id: string) => void;
};
const ProjectGrid: FC<Props> = ({ projects, onDelete }) => {
const { t } = useTranslation("global");
return (
<div className="mt-6">
<h2 className="text-sm font-bold mb-4">{t("taskmanager.projects")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} onDelete={onDelete} />
))}
</div>
</div>
);
};
export default ProjectGrid;
@@ -0,0 +1,37 @@
import { Add, Setting2 } from "iconsax-react";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Button from "../../../../components/Button";
import { Pages } from "../../../../config/Pages";
type Props = {
title: string;
onSettingsClick?: () => void;
};
const ProjectListHeader: FC<Props> = ({ title, onSettingsClick }) => {
const { t } = useTranslation("global");
return (
<div className="flex w-full justify-between items-center">
<h1>{title}</h1>
<div className="flex items-center gap-3">
<Button onClick={onSettingsClick} className="flex items-center gap-2 bg-[#C3C7DD] text-black whitespace-nowrap px-4">
<Setting2 size={18} color="black" />
<span>{t("sidebar.setting")}</span>
</Button>
<Link to={Pages.taskmanager.createProject}>
<Button onClick={onSettingsClick} className="flex items-center gap-2 whitespace-nowrap px-4">
<Add size={18} color="white" />
<span>{t("taskmanager.new_project")}</span>
</Button>
</Link>
</div>
</div>
);
};
export default ProjectListHeader;
@@ -0,0 +1,11 @@
import { FC } from "react";
type Props = {
description: string;
};
const WorkspaceInfoBanner: FC<Props> = ({ description }) => {
return <div className="mt-6 bg-white rounded-3xl px-6 py-5 text-sm tracking-widest text-[#6C7080] leading-7">{description}</div>;
};
export default WorkspaceInfoBanner;
@@ -0,0 +1,23 @@
{
"workspace": {
"name": "فضای کار طراحی",
"description": "در این فضا می‌توانید پروژه‌های طراحی خود را مدیریت و سازماندهی کنید. از مرحله ایده تا پیاده‌سازی نهایی، تمام پروژه‌هایتان در یک جا قابل دسترسی و پیگیری هستند."
},
"projects": [
{
"id": "1",
"name": "طراحی Dpage",
"background": "linear-gradient(135deg, #0052d4, #4364f7)"
},
{
"id": "2",
"name": "طراحی Dmenu",
"background": "linear-gradient(135deg, #2c3e50, #1a1a2e)"
},
{
"id": "3",
"name": "طراحی Dmail",
"background": "linear-gradient(135deg, #ff00cc, #c71585)"
}
]
}
@@ -0,0 +1,10 @@
export type ProjectItem = {
id: string;
name: string;
background: string;
};
export type WorkspaceInfo = {
name: string;
description: string;
};
+14
View File
@@ -2,6 +2,7 @@ import { useMemo, useState, type FC } from "react";
import AddNewColumn from "./components/AddNewColumn";
import Column from "./components/Column";
import HeaderWorkspace from "./components/HeaderWorkspace";
import TaskDetailModal from "./components/task-detail/TaskDetailModal";
import tasksData from "./data/tasks.json";
import type { Task, WorkspaceData } from "./types";
import { reorderTasks } from "./utils/reorderTasks";
@@ -10,6 +11,7 @@ const Workspace: FC = () => {
const { columns } = tasksData as WorkspaceData;
const [tasks, setTasks] = useState<Task[]>(tasksData.tasks as Task[]);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const tasksByColumn = useMemo(() => {
return columns.reduce<Record<string, Task[]>>((acc, column) => {
@@ -34,6 +36,10 @@ const Workspace: FC = () => {
setDraggedTaskId(null);
};
const selectedTaskColumn = selectedTask
? columns.find((column) => column.id === selectedTask.columnId)
: null;
return (
<div className="bg-[#01347A] h-full rounded-[32px] overflow-hidden flex flex-col">
<HeaderWorkspace />
@@ -47,11 +53,19 @@ const Workspace: FC = () => {
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onTaskClick={setSelectedTask}
/>
))}
<AddNewColumn />
</div>
<TaskDetailModal
open={selectedTask !== null}
task={selectedTask}
statusLabel={selectedTaskColumn?.title ?? ""}
onClose={() => setSelectedTask(null)}
/>
</div>
);
};
@@ -0,0 +1,48 @@
import { TickCircle } from "iconsax-react";
import { useTranslation } from "react-i18next";
import Button from "../../../components/Button";
import Input from "../../../components/Input";
import Select from "../../../components/Select";
import Textarea from "../../../components/Textarea";
import CreateWorkspaceSidebar from "./components/CreateWorkspaceSidebar";
const CreateWorkspace = () => {
const { t } = useTranslation("global");
return (
<div className="w-full mt-4">
<div className="flex w-full justify-between items-center">
<div>{t("taskmanager.new_workspace")}</div>
<div>
<Button className="px-5" isLoading={false}>
<div className="flex gap-2">
<TickCircle className="size-5" color="#fff" />
<div>{t("taskmanager.submit_workspace")}</div>
</div>
</Button>
</div>
</div>
<div className="flex gap-6">
<div className="flex-1">
<div className="flex-1 mt-10 bg-white py-8 xl:px-10 px-4 rounded-3xl">
<div className="text-sm font-bold">اطلاعات</div>
<div className="mt-8 rowTwoInput">
<Input label={t("taskmanager.workspacename")} />
<Select label={t("taskmanager.workspacetype")} items={[]} />
</div>
<div className="mt-8">
<Textarea label={t("taskmanager.workspace_description")} />
</div>
</div>
</div>
<CreateWorkspaceSidebar />
</div>
</div>
);
};
export default CreateWorkspace;
@@ -0,0 +1,114 @@
import { FC, useState } from "react";
import { CloseCircle } from "iconsax-react";
import { useTranslation } from "react-i18next";
import SwitchComponent from "../../../../components/Switch";
import Select from "../../../../components/Select";
import { useGetUsers } from "../../../users/hooks/useUserData";
import { UserItemType } from "../../../users/types/UserTypes";
import WorkspaceColorPicker from "./WorkspaceColorPicker";
type SelectedUser = {
id: string;
name: string;
};
const CreateWorkspaceSidebar: FC = () => {
const { t } = useTranslation("global");
const getUsers = useGetUsers("");
const [isActive, setIsActive] = useState(true);
const [selectedColor, setSelectedColor] = useState("#A8E6CF");
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [userSelectValue, setUserSelectValue] = useState("");
const userItems =
getUsers.data?.data?.users
?.filter(
(user: UserItemType) =>
!selectedUsers.some((selected) => selected.id === user.id),
)
.map((user: UserItemType) => ({
label: `${user.firstName} ${user.lastName}`,
value: user.id,
})) ?? [];
const handleUserSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (!value) return;
const user = getUsers.data?.data?.users?.find(
(item: UserItemType) => item.id === value,
);
if (user) {
setSelectedUsers((prev) => [
...prev,
{ id: user.id, name: `${user.firstName} ${user.lastName}` },
]);
}
setUserSelectValue("");
};
const handleRemoveUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((user) => user.id !== id));
};
return (
<div className="bg-white mt-10 w-sidebar text-xs hidden 2xl:block h-fit px-5 py-7 rounded-3xl">
<div className="text-sm flex items-center justify-between">
<div>{t("taskmanager.workspace_status")}</div>
<div className="flex gap-2 text-xs items-center text-description">
<div>{isActive ? t("slider.active") : t("slider.inactive")}</div>
<SwitchComponent active={isActive} onChange={setIsActive} />
</div>
</div>
<div className="mt-8">
<div className="text-sm">{t("taskmanager.choose_color")}</div>
<div className="mt-3">
<WorkspaceColorPicker
selectedColor={selectedColor}
onChange={setSelectedColor}
/>
</div>
</div>
<div className="mt-8">
<Select
label={t("taskmanager.users")}
placeholder={t("taskmanager.select_user")}
items={userItems}
value={userSelectValue}
onChange={handleUserSelect}
className="border"
/>
<div className="mt-3 border border-dashed border-border rounded-xl p-4 flex gap-2 flex-wrap min-h-[52px]">
{selectedUsers.length === 0 ? (
<div className="text-description w-full text-center">
{t("taskmanager.no_users_added")}
</div>
) : (
selectedUsers.map((user) => (
<div
key={user.id}
className="bg-[#EBEEF5] flex gap-2 text-xs items-center px-2 py-2 rounded-lg"
>
<div>{user.name}</div>
<CloseCircle
variant="Bold"
className="size-4 cursor-pointer"
color="red"
onClick={() => handleRemoveUser(user.id)}
/>
</div>
))
)}
</div>
</div>
</div>
);
};
export default CreateWorkspaceSidebar;
@@ -0,0 +1,72 @@
import { FC, useRef } from "react";
import ColorsImage from "../../../../assets/images/colors.png";
const WORKSPACE_COLORS = [
"#FF6B9D",
"#F5A623",
"#6B7B7E",
"#1A1A1A",
"#C94C4C",
"#F5F5FF",
"#A8E6CF",
] as const;
type Props = {
selectedColor: string;
onChange: (color: string) => void;
};
const WorkspaceColorPicker: FC<Props> = ({ selectedColor, onChange }) => {
const colorInputRef = useRef<HTMLInputElement>(null);
const isCustomColor = !WORKSPACE_COLORS.includes(
selectedColor as (typeof WORKSPACE_COLORS)[number],
);
return (
<div className="flex gap-2.5 items-center flex-wrap">
<button
type="button"
onClick={() => colorInputRef.current?.click()}
className="size-8 rounded-full shrink-0 overflow-hidden"
style={{
boxShadow: isCustomColor ? "inset 0 0 0 2px #000" : undefined,
}}
>
<img
src={ColorsImage}
alt=""
className="size-full object-cover"
draggable={false}
/>
</button>
{WORKSPACE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className="size-8 rounded-xl shrink-0"
style={{
backgroundColor: color,
boxShadow:
selectedColor === color
? "inset 0 0 0 2px #000"
: color === "#F5F5FF"
? "inset 0 0 0 1px rgba(0,0,0,0.12)"
: undefined,
}}
/>
))}
<input
ref={colorInputRef}
type="color"
value={isCustomColor ? selectedColor : "#4A90D9"}
className="hidden"
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
};
export default WorkspaceColorPicker;
+7 -7
View File
@@ -78,7 +78,10 @@ import CreatePlan from "../pages/support/Create";
import SupportList from "../pages/support/List";
import PlanUsers from "../pages/support/PlanUsers";
import UpdateSupport from "../pages/support/Update";
import CreateProject from "../pages/taskmanager/project/Create.tsx";
import ProjectList from "../pages/taskmanager/project/List.tsx";
import Workspace from "../pages/taskmanager/workspace.tsx";
import CreateWorkspace from "../pages/taskmanager/workspace/Create.tsx";
import TicketCategory from "../pages/ticket/Category";
import CreateTicket from "../pages/ticket/CreateTicket";
import TicketDetail from "../pages/ticket/Detail";
@@ -106,13 +109,7 @@ const MainRouter: FC = () => {
<div className="p-4 overflow-hidden">
{!isWorkspace && <SideBar />}
<Header />
<div
className={clx(
"flex-1 mt-[68px] xl:mt-[81px]",
!isWorkspace && "xl:ms-[269px]",
!isWorkspace && hasSubMenu && "xl:ms-[305px]",
)}
>
<div className={clx("flex-1 mt-[68px] xl:mt-[81px]", !isWorkspace && "xl:ms-[269px]", !isWorkspace && hasSubMenu && "xl:ms-[305px]")}>
<div className={clx(`overflow-auto w-[${window.innerWidth}] h-[calc(100vh-113px)] flex flex-col`)}>
<div className="flex-1 min-h-0">
<Routes>
@@ -213,6 +210,9 @@ const MainRouter: FC = () => {
<Route path={Pages.reseller.withdraw} element={<Withdraw />} />
<Route path={Pages.taskmanager.workspace + ":slug"} element={<Workspace />} />
<Route path={Pages.taskmanager.createWorkspace} element={<CreateWorkspace />} />
<Route path={Pages.taskmanager.createProject} element={<CreateProject />} />
<Route path={Pages.taskmanager.projectList} element={<ProjectList />} />
</Routes>
</div>
<div className="h-20 shrink-0 xl:hidden" />