task detail Labels
This commit is contained in:
+4
-1
@@ -997,7 +997,10 @@
|
||||
"attachment": "ضمیمه",
|
||||
"date_settings": "تنظیمات تاریخ",
|
||||
"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 { type FC } from "react";
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TaskDetailTab } from "./types";
|
||||
|
||||
@@ -20,9 +20,10 @@ const TABS: TabItem[] = [
|
||||
type Props = {
|
||||
activeTab: TaskDetailTab | null;
|
||||
onTabChange: (tab: TaskDetailTab) => void;
|
||||
labelsPopover?: ReactNode;
|
||||
};
|
||||
|
||||
const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange }) => {
|
||||
const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange, labelsPopover }) => {
|
||||
const { t } = useTranslation("global");
|
||||
|
||||
return (
|
||||
@@ -30,17 +31,31 @@ const TaskDetailActionBar: FC<Props> = ({ activeTab, onTabChange }) => {
|
||||
{TABS.map(({ id, icon: Icon, labelKey }) => {
|
||||
const isActive = activeTab === id;
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (id === "labels") {
|
||||
return (
|
||||
<div key={id} className="relative">
|
||||
{button}
|
||||
{labelsPopover}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
{button}
|
||||
</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 DefaulModal from "../../../../components/DefaulModal";
|
||||
import type { Task } from "../../types";
|
||||
import TaskDetailActionBar from "./TaskDetailActionBar";
|
||||
import TaskDetailDescription from "./TaskDetailDescription";
|
||||
import TaskDetailHeader from "./TaskDetailHeader";
|
||||
import TaskDetailToolbar from "./TaskDetailToolbar";
|
||||
import type { TaskDetailTab } from "./types";
|
||||
|
||||
type Props = {
|
||||
@@ -24,6 +24,10 @@ const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleTabChange = (tab: TaskDetailTab) => {
|
||||
setActiveTab((prev) => (prev === tab ? null : tab));
|
||||
};
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
@@ -33,7 +37,7 @@ const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
|
||||
<h2 className="mt-6 text-sm font-bold">{task.title}</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
<TaskDetailActionBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
<TaskDetailToolbar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
|
||||
<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";
|
||||
Reference in New Issue
Block a user