Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions client/src/components/DraggableTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ const DraggableTask = ({
} ${task.id && remoteDrag?.draggableId === task.id.toString() ? "hidden" : ""} `}
style={{ ...provided.draggableProps.style }}
>
{task.labels && task.labels.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{task.labels.map((labelWrapper) => (
<div
key={labelWrapper.label.id}
className="truncate rounded-md px-3 py-1 text-sm font-bold text-white shadow-sm"
style={{
backgroundColor: labelWrapper.label.color,
border: "1px solid #333333",
boxShadow: `1px 1px 2px ${labelWrapper.label.color}aa`,
minWidth: "60px",
textAlign: "center",
}}
title={labelWrapper.label.name}
></div>
))}
</div>
)}
<div className="flex w-full items-center justify-between">
<span className="text-text-heading flex-grow p-1 font-medium break-words">
{task.title}
Expand Down
143 changes: 141 additions & 2 deletions client/src/components/TaskDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import type { Task } from "../pages/boards/types";
import type { Label, Task } from "../pages/boards/types";
import type { User } from "../pages/login/types";
import { getBoardUsers } from "../pages/boards/service";
import { Avatar } from "./ui/Avatar";
import {
useAddAssigneeAction,
useAddLabelAction,
useRemoveAssigneeAction,
useRemoveLabelAction,
} from "../store/boards/hooks";
import type { Editor as TinyMCEEditor } from "tinymce";
import { Editor } from "@tinymce/tinymce-react";
Expand All @@ -16,12 +18,16 @@ import { useAI } from "../hooks/useAI";
import { CustomToast } from "./CustomToast";
import toast from "react-hot-toast";
import { SpinnerLoadingText } from "./ui/Spinner";
import { getContrastColor } from "../lib/colorUtils";
import ConfirmDelete from "./ui/modals/confirm-delete";
import { useDismiss } from "../hooks/useDismissClickAndEsc";
import { useAppDispatch } from "../../src/store";
import { addLabel } from "../store/boards/actions";

interface TaskDetailModalProps {
isOpen: boolean;
task: Task;
boardLabels?: Label[];
columnId: string;
boardId?: string;
onClose: () => void;
Expand All @@ -31,8 +37,50 @@ interface TaskDetailModalProps {
onDeleteTask: (columnId: string, taskId: string) => void;
}

const NewLabelForm: React.FC<{
boardId: string;
}> = ({ boardId }) => {
const [name, setName] = useState("");
const [color, setColor] = useState("#cccccc");
const dispatch = useAppDispatch();

const handleCreate = async () => {
if (!name.trim()) return;
try {
await dispatch(addLabel(Number(boardId), { name, color } as Label));

setName("");
setColor("#cccccc");
} catch (error) {
console.error("Error creating label:", error);
}
};

return (
<div className="flex gap-2">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-10 rounded border p-0"
/>
<input
type="text"
value={name}
placeholder="Nombre"
onChange={(e) => setName(e.target.value)}
className="flex-grow rounded border px-2"
/>
<Button onClick={handleCreate} disabled={!name.trim()}>
<Icon icon="mdi:plus" />
</Button>
</div>
);
};

const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
task,
boardLabels,
columnId,
boardId,
isOpen,
Expand Down Expand Up @@ -68,9 +116,13 @@ const TaskDetailModal: React.FC<TaskDetailModalProps> = ({

const contentInputRef = useRef<HTMLInputElement>(null);
const usersRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLDivElement>(null);
const addMenuRef = useRef<HTMLDivElement>(null);
const { t: translate } = useTranslation();
const editorRef = useRef<TinyMCEEditor | null>(null);
const [showLabels, setShowLabels] = useState(false);
const addLabelAction = useAddLabelAction();
const removeLabelAction = useRemoveLabelAction();

const {
generateDescriptionFromTitle,
Expand Down Expand Up @@ -402,10 +454,97 @@ const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
/>
</div>

<Button className="bg-background-light-grey text-text-body hover:bg-background-hover-column flex items-center gap-1 rounded-md px-3 py-2 text-sm transition-colors duration-200">
<Button
onClick={() => setShowLabels(true)}
className="bg-background-light-grey text-text-body hover:bg-background-hover-column flex items-center gap-1 rounded-md px-3 py-2 text-sm transition-colors duration-200"
>
<Icon icon="mdi:tag-outline" className="text-lg" />{" "}
{translate("board.labels")}
</Button>
{showLabels && (
<div
ref={labelsRef}
className="border-border-medium bg-background-light-grey absolute top-full left-1/2 z-50 mt-2 max-h-60 -translate-x-1/2 overflow-y-auto rounded-md border p-2 shadow-lg"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-text-heading text-sm font-semibold">
{translate("board.labels", "Etiquetas")}
</h3>
<Button
variant="primary"
onClick={() => setShowLabels(false)}
title={translate("board.close", "Cerrar")}
>
<Icon icon="mdi:close" className="text-lg" />
</Button>
</div>

{/* Lista de etiquetas */}
<div className="mb-2 space-y-1">
{boardLabels!.map((label) => {
const isAssigned = task.labels?.some(
(l) => l.label.id === label.id,
);
const contrastColor = getContrastColor(label.color);

return (
<div
key={label.id}
onClick={() => {
if (isAssigned && task.id && label.id) {
removeLabelAction(
task.id.toString(),
label.id.toString(),
);
} else if (task.id && label.id) {
addLabelAction(
task.id.toString(),
label.id.toString(),
);
}
}}
className={`flex cursor-pointer items-center justify-between rounded-md px-2 py-1 text-sm font-medium transition-colors duration-150 ${
isAssigned
? "ring-accent ring-1"
: "hover:bg-background-hover-column"
}`}
style={{
backgroundColor: isAssigned ? label.color : "",
color: isAssigned ? contrastColor : "inherit",
}}
>
<div
className="flex w-full items-center justify-center border"
style={{
backgroundColor: label.color,
}}
>
<span
className="truncate"
style={{ color: contrastColor }}
>
{label.name}
</span>
</div>

{isAssigned && (
<Icon
icon="mdi:check"
className="ml-2 shrink-0 text-xs"
style={{ color: contrastColor }}
/>
)}
</div>
);
})}
</div>

{/* Crear nueva etiqueta */}
<div className="border-border-medium border-t pt-2">
<NewLabelForm boardId={boardId!} />
</div>
</div>
)}
<Button className="bg-background-light-grey text-text-body hover:bg-background-hover-column flex items-center gap-1 rounded-md px-3 py-2 text-sm transition-colors duration-200">
<Icon icon="mdi:calendar-month-outline" className="text-lg" />{" "}
{translate("board.dates")}
Expand Down
11 changes: 11 additions & 0 deletions client/src/lib/colorUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function getContrastColor(hex: string): "black" | "white" {
const cleanHex = hex.replace("#", "");

const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);

const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;

return luminance > 0.6 ? "black" : "white";
}
1 change: 1 addition & 0 deletions client/src/pages/boards/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ const Board = () => {
<TaskDetailModal
isOpen={!!selectedTask}
task={selectedTask}
boardLabels={boardData?.labels}
boardId={boardData?.id}
columnId={selectedColumnId}
onClose={closeTaskDetail}
Expand Down
37 changes: 36 additions & 1 deletion client/src/pages/boards/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { apiClient } from "../../api/client";
import {
BOARD_ENDPOINTS,
CARD_ENDPOINT,
LABEL_ENDPOINTS,
LIST_ENDPOINT,
} from "../../utils/endpoints";
import type { User } from "../login/types";
import type { Board, BoardsResponse, Column, Task } from "./types";
import type { Board, BoardsResponse, Label, Column, Task } from "./types";

export const getBoards = async (
page: number,
Expand Down Expand Up @@ -145,3 +146,37 @@ export const removeAssignee = async (
`${CARD_ENDPOINT.CARDS}/removeAssignee/${cardId}/${assigneeId}`,
);
};

export const getBoardLabels = async (
boardId: string | number,
): Promise<Label[]> => {
const { data } = await apiClient.get<Label[]>(
LABEL_ENDPOINTS.BY_BOARD(boardId),
);
return data;
};

export const createLabel = async (
boardId: string | number,
label: Pick<Label, "name" | "color">,
): Promise<Label> => {
const { data } = await apiClient.post<Label>(
LABEL_ENDPOINTS.BY_BOARD(boardId),
label,
);
return data;
};

export const addLabelToCard = async (cardId: number, labelId: number) => {
const { data } = await apiClient.post(
LABEL_ENDPOINTS.BY_CARD(cardId, labelId),
);
return data;
};

export const removeLabelFromCard = async (
cardId: number,
labelId: number,
): Promise<void> => {
await apiClient.delete(LABEL_ENDPOINTS.BY_CARD(cardId, labelId));
};
15 changes: 15 additions & 0 deletions client/src/pages/boards/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type Task = {
position: number;
assignees: CardAssignee[];
media?: Media[];
labels: TaskLabel[];
};

export interface TaskWithMedia extends Task {
Expand All @@ -52,6 +53,7 @@ export type Board = {
lists: Column[];
members: BoardMember[];
image: string;
labels: Label[];
};

export type BoardsResponse = {
Expand All @@ -70,3 +72,16 @@ export type BoardMember = {
role: string;
user: User;
};

export interface Label {
id: number;
name: string;
color: string;
boardId: number;
}

export interface TaskLabel {
cardId: number;
labelId: number;
label: Label;
}
Loading