task detail Labels

This commit is contained in:
hamid zarghami
2026-06-17 09:45:04 +03:30
parent c6559b3054
commit 021285bc09
11 changed files with 410 additions and 9 deletions
+4 -1
View File
@@ -997,7 +997,10 @@
"attachment": "ضمیمه", "attachment": "ضمیمه",
"date_settings": "تنظیمات تاریخ", "date_settings": "تنظیمات تاریخ",
"description": "توضیحات", "description": "توضیحات",
"description_placeholder": "توضیحات خود را اضافه کنید" "description_placeholder": "توضیحات خود را اضافه کنید",
"new_label": "برچسب جدید",
"label_title": "عنوان",
"choose_custom_color": "انتخاب رنگ دلخواه"
} }
} }
} }
@@ -1,5 +1,5 @@
import { Calendar, Paperclip2, Profile2User, Tag, TickSquare, type Icon } from "iconsax-react"; import { Calendar, Paperclip2, Profile2User, Tag, TickSquare, type Icon } from "iconsax-react";
import { type FC } from "react"; import { type FC, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TaskDetailTab } from "./types"; import type { TaskDetailTab } from "./types";
@@ -20,9 +20,10 @@ const TABS: TabItem[] = [
type Props = { type Props = {
activeTab: TaskDetailTab | null; activeTab: TaskDetailTab | null;
onTabChange: (tab: TaskDetailTab) => void; onTabChange: (tab: TaskDetailTab) => void;
labelsPopover?: ReactNode;
}; };
const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange }) => { const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover }) => {
const { t } = useTranslation("global"); const { t } = useTranslation("global");
return ( return (
@@ -30,17 +31,31 @@ const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange }) => {
{TABS.map(({ id, icon: Icon, labelKey }) => { {TABS.map(({ id, icon: Icon, labelKey }) => {
const isActive = activeTab === id; const isActive = activeTab === id;
return ( const button = (
<button <button
key={id}
type="button" type="button"
onClick={() => onTabChange(id)} onClick={() => onTabChange(id)}
className={`flex items-center gap-1.5 border border-white rounded-lg px-3 py-2 text-[11px] cursor-pointer transition-colors ${isActive ? "bg-white text-black" : "bg-white/50 text-[#292D32]"}`} 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="#292D32" /> <Icon size={16} color={isActive ? "#FFFFFF" : "#292D32"} />
<span>{t(labelKey)}</span> <span>{t(labelKey)}</span>
</button> </button>
); );
if (id === "labels") {
return (
<div key={id} className="relative">
{button}
{labelsPopover}
</div>
);
}
return (
<div key={id}>
{button}
</div>
);
})} })}
</div> </div>
); );
@@ -0,0 +1,57 @@
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";
type Props = {
selectedLabels: TaskLabel[];
};
const TaskDetailMetadataPreview: FC<Props> = ({ selectedLabels }) => {
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">
<div className="size-8 rounded-full bg-[#D0D0D0] overflow-hidden">
<img src={AvatarImage} alt="" 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;
@@ -1,9 +1,9 @@
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState } from "react";
import DefaulModal from "../../../../components/DefaulModal"; import DefaulModal from "../../../../components/DefaulModal";
import type { Task } from "../../types"; import type { Task } from "../../types";
import TaskDetailActionBar from "./TaskDetailActionBar";
import TaskDetailDescription from "./TaskDetailDescription"; import TaskDetailDescription from "./TaskDetailDescription";
import TaskDetailHeader from "./TaskDetailHeader"; import TaskDetailHeader from "./TaskDetailHeader";
import TaskDetailToolbar from "./TaskDetailToolbar";
import type { TaskDetailTab } from "./types"; import type { TaskDetailTab } from "./types";
type Props = { type Props = {
@@ -24,6 +24,10 @@ const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
} }
}, [open]); }, [open]);
const handleTabChange = (tab: TaskDetailTab) => {
setActiveTab((prev) => (prev === tab ? null : tab));
};
if (!task) return null; if (!task) return null;
return ( return (
@@ -33,7 +37,7 @@ const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
<h2 className="mt-6 text-sm font-bold">{task.title}</h2> <h2 className="mt-6 text-sm font-bold">{task.title}</h2>
<div className="mt-4"> <div className="mt-4">
<TaskDetailActionBar activeTab={activeTab} onTabChange={setActiveTab} /> <TaskDetailToolbar activeTab={activeTab} onTabChange={handleTabChange} />
</div> </div>
<TaskDetailDescription value={description} onChange={setDescription} /> <TaskDetailDescription value={description} onChange={setDescription} />
@@ -0,0 +1,76 @@
import { type FC, useState } from "react";
import TaskDetailActionBar from "./TaskDetailActionBar";
import TaskDetailMetadataPreview from "./TaskDetailMetadataPreview";
import { DEFAULT_LABELS } from "./labels/constants";
import TaskDetailLabelsPopover from "./labels/TaskDetailLabelsPopover";
import type { LabelsView, TaskLabel } from "./labels/types";
import type { TaskDetailTab } from "./types";
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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set(["1"]));
const selectedLabels = labels.filter((label) => selectedIds.has(label.id));
const isLabelsOpen = activeTab === "labels";
const handleTabChange = (tab: TaskDetailTab) => {
if (tab === "labels" && activeTab !== "labels") {
setLabelsView("list");
}
onTabChange(tab);
};
const handleToggle = (id: string) => {
setSelectedIds((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]);
setSelectedIds((prev) => new Set([...prev, newLabel.id]));
setLabelsView("list");
};
return (
<div className="relative">
<TaskDetailActionBar
activeTab={activeTab}
onTabChange={handleTabChange}
labelsPopover={
isLabelsOpen ? (
<TaskDetailLabelsPopover
view={labelsView}
labels={labels}
selectedIds={selectedIds}
onViewChange={setLabelsView}
onToggle={handleToggle}
onCreate={handleCreate}
/>
) : null
}
/>
<TaskDetailMetadataPreview selectedLabels={selectedLabels} />
</div>
);
};
export default TaskDetailToolbar;
@@ -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 (
<div className="absolute top-full right-0 mt-2 z-30 w-[300px] bg-white/90 backdrop-blur-md rounded-2xl p-4 shadow-lg border border-white/80">
{view === "list" ? (
<TaskDetailLabelsList
labels={labels}
selectedIds={selectedIds}
onToggle={onToggle}
onEdit={() => undefined}
onCreateClick={() => onViewChange("create")}
/>
) : (
<TaskDetailNewLabelForm
onBack={() => onViewChange("list")}
onSubmit={onCreate}
/>
)}
</div>
);
};
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";