feat: status creating

This commit is contained in:
2025-08-08 11:31:27 +04:00
parent e29664ecc5
commit f52fde0097
9 changed files with 308 additions and 281 deletions

View File

@ -1,7 +1,8 @@
import React, { FC, useEffect, useRef, useState } from "react"; import React, { FC, useState } from "react";
import { Group, Text, TextInput } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import BoardMenu from "@/app/deals/components/Board/BoardMenu"; import BoardMenu from "@/app/deals/components/Board/BoardMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { BoardSchema } from "@/lib/client"; import { BoardSchema } from "@/lib/client";
type Props = { type Props = {
@ -10,48 +11,7 @@ type Props = {
const Board: FC<Props> = ({ board }) => { const Board: FC<Props> = ({ board }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(board.name);
const { onUpdateBoard } = useBoardsContext(); const { onUpdateBoard } = useBoardsContext();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isEditing &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
handleSave();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isEditing, editValue]);
const handleSave = () => {
const newValue = editValue.trim();
if (newValue && newValue !== board.name) {
onUpdateBoard(board.id, { name: newValue });
}
setIsEditing(false);
};
const handleEdit = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setEditValue(board.name);
setIsEditing(true);
};
return ( return (
<Group <Group
@ -63,33 +23,27 @@ const Board: FC<Props> = ({ board }) => {
style={{ borderWidth: 1 }} style={{ borderWidth: 1 }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}> onMouseLeave={() => setIsHovered(false)}>
{isEditing ? ( <InPlaceInput
<TextInput defaultValue={board.name}
ref={inputRef} onComplete={value => onUpdateBoard(board.id, { name: value })}
value={editValue} inputStyles={{
onChange={e => setEditValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setEditValue(board.name);
setIsEditing(false);
}
}}
variant="unstyled"
styles={{
input: { input: {
height: 25, height: 25,
minHeight: 25, minHeight: 25,
}, },
}} }}
/> getChildren={startEditing => (
) : ( <>
<Box>
<Text>{board.name}</Text> <Text>{board.name}</Text>
)} </Box>
<BoardMenu <BoardMenu
isHovered={isHovered} isHovered={isHovered}
board={board} board={board}
handleEdit={handleEdit} startEditing={startEditing}
/>
</>
)}
/> />
</Group> </Group>
); );

View File

@ -7,10 +7,10 @@ import { BoardSchema } from "@/lib/client";
type Props = { type Props = {
isHovered: boolean; isHovered: boolean;
board: BoardSchema; board: BoardSchema;
handleEdit: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; startEditing: () => void;
}; };
const BoardMenu: FC<Props> = ({ isHovered, board, handleEdit }) => { const BoardMenu: FC<Props> = ({ isHovered, board, startEditing }) => {
const { selectedBoard, onDeleteBoard } = useBoardsContext(); const { selectedBoard, onDeleteBoard } = useBoardsContext();
return ( return (
@ -30,7 +30,12 @@ const BoardMenu: FC<Props> = ({ isHovered, board, handleEdit }) => {
</Box> </Box>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={handleEdit}> <Menu.Item
onClick={e => {
e.preventDefault();
e.stopPropagation();
startEditing();
}}>
<Group wrap={"nowrap"}> <Group wrap={"nowrap"}>
<IconEdit /> <IconEdit />
<Text>Переименовать</Text> <Text>Переименовать</Text>

View File

@ -1,52 +1,22 @@
import { useState } from "react"; import { IconPlus } from "@tabler/icons-react";
import { IconCheck, IconPlus, IconX } from "@tabler/icons-react"; import { Box } from "@mantine/core";
import { Box, Group, TextInput } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
const CreateBoardButton = () => { const CreateBoardButton = () => {
const { onCreateBoard } = useBoardsContext(); const { onCreateBoard } = useBoardsContext();
const [isWriting, setIsWriting] = useState<boolean>(false);
const [name, setName] = useState<string>("");
const onStartCreating = () => {
setName("");
setIsWriting(true);
};
const onCancelCreating = () => setIsWriting(false);
const onCompleteCreating = () => {
if (name) {
onCreateBoard(name);
}
setIsWriting(false);
};
return ( return (
<Box style={{ cursor: "pointer" }}> <Box style={{ cursor: "pointer" }}>
{isWriting ? ( <InPlaceInput
<Group>
<TextInput
placeholder={"Название доски"} placeholder={"Название доски"}
variant={"unstyled"} onComplete={onCreateBoard}
value={name} getChildren={startEditing => (
onChange={e => setName(e.target.value)} <Box onClick={startEditing}>
onKeyDown={e =>
e.key === "Enter" && onCompleteCreating()
}
/>
<Box onClick={onCompleteCreating}>
<IconCheck />
</Box>
<Box onClick={onCancelCreating}>
<IconX />
</Box>
</Group>
) : (
<Box onClick={onStartCreating}>
<IconPlus /> <IconPlus />
</Box> </Box>
)} )}
/>
</Box> </Box>
); );
}; };

View File

@ -0,0 +1,34 @@
import React from "react";
import { Box, Text } from "@mantine/core";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
const CreateStatusButton = () => {
const { onCreateStatus } = useStatusesContext();
return (
<Box
style={{
borderWidth: 1,
borderStyle: "dashed",
borderColor: "gray",
width: "15vw",
minWidth: 150,
cursor: "pointer",
}}>
<InPlaceInput
placeholder={"Название доски"}
onComplete={onCreateStatus}
getChildren={startEditing => (
<Box
p={9}
onClick={() => startEditing()}>
<Text>Создать колонку</Text>
</Box>
)}
/>
</Box>
);
};
export default CreateStatusButton;

View File

@ -1,6 +1,8 @@
"use client"; "use client";
import React, { FC, ReactNode } from "react"; import React, { FC, ReactNode } from "react";
import { Group, ScrollArea } from "@mantine/core";
import CreateStatusButton from "@/app/deals/components/CreateStatusButton/CreateStatusButton";
import DealCard from "@/app/deals/components/DealCard/DealCard"; import DealCard from "@/app/deals/components/DealCard/DealCard";
import DealContainer from "@/app/deals/components/DealContainer/DealContainer"; import DealContainer from "@/app/deals/components/DealContainer/DealContainer";
import StatusColumnWrapper from "@/app/deals/components/StatusColumnWrapper/StatusColumnWrapper"; import StatusColumnWrapper from "@/app/deals/components/StatusColumnWrapper/StatusColumnWrapper";
@ -23,14 +25,23 @@ const Funnel: FC = () => {
} = useDealsAndStatusesDnd(); } = useDealsAndStatusesDnd();
return ( return (
<ScrollArea
offsetScrollbars={"x"}
scrollbarSize={"0.5rem"}>
<Group align={"start"}>
<FunnelDnd <FunnelDnd
containers={sortedStatuses} containers={sortedStatuses}
items={deals} items={deals}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
getContainerId={(status: StatusSchema) => `${status.id}-status`} getContainerId={(status: StatusSchema) =>
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) => `${status.id}-status`
}
getItemsByContainer={(
status: StatusSchema,
items: DealSchema[]
) =>
sortByLexorank( sortByLexorank(
items.filter(deal => deal.statusId === status.id) items.filter(deal => deal.statusId === status.id)
) )
@ -53,8 +64,13 @@ const Funnel: FC = () => {
)} )}
activeContainer={activeStatus} activeContainer={activeStatus}
activeItem={activeDeal} activeItem={activeDeal}
renderItemOverlay={(deal: DealSchema) => <DealCard deal={deal} />} renderItemOverlay={(deal: DealSchema) => (
renderContainerOverlay={(status: StatusSchema, children) => ( <DealCard deal={deal} />
)}
renderContainerOverlay={(
status: StatusSchema,
children
) => (
<StatusColumnWrapper <StatusColumnWrapper
status={status} status={status}
isDragging> isDragging>
@ -62,6 +78,9 @@ const Funnel: FC = () => {
</StatusColumnWrapper> </StatusColumnWrapper>
)} )}
/> />
<CreateStatusButton />
</Group>
</ScrollArea>
); );
}; };

View File

@ -1,7 +1,8 @@
import React, { ReactNode, useEffect, useRef, useState } from "react"; import React, { ReactNode } from "react";
import { Box, Group, Text, TextInput } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import StatusMenu from "@/app/deals/components/StatusColumnWrapper/StatusMenu"; import StatusMenu from "@/app/deals/components/StatusColumnWrapper/StatusMenu";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { StatusSchema } from "@/lib/client"; import { StatusSchema } from "@/lib/client";
type Props = { type Props = {
@ -16,46 +17,12 @@ const StatusColumnWrapper = ({
isDragging = false, isDragging = false,
}: Props) => { }: Props) => {
const { onUpdateStatus } = useStatusesContext(); const { onUpdateStatus } = useStatusesContext();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(status.name);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { const handleSave = (value: string) => {
if (isEditing && inputRef.current) { const newValue = value.trim();
inputRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isEditing &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
handleSave();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isEditing, editValue]);
const handleEdit = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setEditValue(status.name);
setIsEditing(true);
};
const handleSave = () => {
const newValue = editValue.trim();
if (newValue && newValue !== status.name) { if (newValue && newValue !== status.name) {
onUpdateStatus(status.id, { name: newValue }); onUpdateStatus(status.id, { name: newValue });
} }
setIsEditing(false);
}; };
return ( return (
@ -72,27 +39,17 @@ const StatusColumnWrapper = ({
justify={"space-between"} justify={"space-between"}
ml={"xs"} ml={"xs"}
mb={"xs"}> mb={"xs"}>
{isEditing ? ( <InPlaceInput
<TextInput defaultValue={status.name}
ref={inputRef} onComplete={value => handleSave(value)}
value={editValue} inputStyles={{
onChange={e => setEditValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setEditValue(status.name);
setIsEditing(false);
}
}}
variant="unstyled"
styles={{
input: { input: {
height: 25, height: 25,
minHeight: 25, minHeight: 25,
}, },
}} }}
/> getChildren={startEditing => (
) : ( <>
<Text <Text
style={{ style={{
cursor: "grab", cursor: "grab",
@ -101,10 +58,12 @@ const StatusColumnWrapper = ({
}}> }}>
{status.name} {status.name}
</Text> </Text>
)}
<StatusMenu <StatusMenu
status={status} status={status}
handleEdit={handleEdit} handleEdit={startEditing}
/>
</>
)}
/> />
</Group> </Group>
{children} {children}

View File

@ -6,7 +6,7 @@ import { StatusSchema } from "@/lib/client";
type Props = { type Props = {
status: StatusSchema; status: StatusSchema;
handleEdit: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; handleEdit: () => void;
}; };
const StatusMenu: FC<Props> = ({ status, handleEdit }) => { const StatusMenu: FC<Props> = ({ status, handleEdit }) => {
@ -28,7 +28,12 @@ const StatusMenu: FC<Props> = ({ status, handleEdit }) => {
</Box> </Box>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={handleEdit}> <Menu.Item
onClick={e => {
e.preventDefault();
e.stopPropagation();
handleEdit();
}}>
<Group wrap={"nowrap"}> <Group wrap={"nowrap"}>
<IconEdit /> <IconEdit />
<Text>Переименовать</Text> <Text>Переименовать</Text>

View File

@ -12,7 +12,7 @@ import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
SortableContext, SortableContext,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { Group, ScrollArea } from "@mantine/core"; import { Group } from "@mantine/core";
import useDndSensors from "@/app/deals/hooks/useSensors"; import useDndSensors from "@/app/deals/hooks/useSensors";
import SortableItem from "@/components/dnd/SortableItem"; import SortableItem from "@/components/dnd/SortableItem";
import { BaseDraggable } from "@/components/dnd/types/types"; import { BaseDraggable } from "@/components/dnd/types/types";
@ -59,9 +59,6 @@ const FunnelDnd = <
const sensors = useDndSensors(); const sensors = useDndSensors();
return ( return (
<ScrollArea
offsetScrollbars="x"
scrollbarSize="0.5rem">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCorners}
@ -119,7 +116,6 @@ const FunnelDnd = <
</Group> </Group>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</ScrollArea>
); );
}; };

View File

@ -0,0 +1,85 @@
import React, { FC, ReactNode, useEffect, useRef, useState } from "react";
import { TextInput } from "@mantine/core";
import { Styles } from "@mantine/core/lib/core/styles-api/styles-api.types";
type Props = {
defaultValue?: string;
onComplete: (value: string) => void;
placeholder?: string;
getChildren: (startEditing: () => void) => ReactNode;
inputStyles?: Styles<any>;
};
const InPlaceInput: FC<Props> = ({
onComplete,
placeholder,
inputStyles,
getChildren,
defaultValue = "",
}) => {
const [isWriting, setIsWriting] = useState<boolean>(false);
const [value, setValue] = useState<string>(defaultValue);
console.log(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isWriting && inputRef.current) {
inputRef.current.focus();
}
}, [isWriting]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isWriting &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
onCompleteCreating();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isWriting, value]);
const onStartCreating = () => {
setValue(defaultValue);
setIsWriting(true);
};
const onCancelCreating = () => {
setValue(defaultValue);
setIsWriting(false);
};
const onCompleteCreating = () => {
const localValue = value.trim();
if (localValue) {
onComplete(localValue);
}
setIsWriting(false);
};
if (isWriting) {
return (
<TextInput
ref={inputRef}
placeholder={placeholder}
variant={"unstyled"}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") onCompleteCreating();
if (e.key === "Escape") onCancelCreating();
}}
styles={inputStyles}
/>
);
}
return getChildren(onStartCreating);
};
export default InPlaceInput;