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)} input: {
onKeyDown={e => { height: 25,
if (e.key === "Enter") handleSave(); minHeight: 25,
if (e.key === "Escape") { },
setEditValue(board.name); }}
setIsEditing(false); getChildren={startEditing => (
} <>
}} <Box>
variant="unstyled" <Text>{board.name}</Text>
styles={{ </Box>
input: { <BoardMenu
height: 25, isHovered={isHovered}
minHeight: 25, board={board}
}, startEditing={startEditing}
}} />
/> </>
) : ( )}
<Text>{board.name}</Text>
)}
<BoardMenu
isHovered={isHovered}
board={board}
handleEdit={handleEdit}
/> />
</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> placeholder={"Название доски"}
<TextInput onComplete={onCreateBoard}
placeholder={"Название доски"} getChildren={startEditing => (
variant={"unstyled"} <Box onClick={startEditing}>
value={name} <IconPlus />
onChange={e => setName(e.target.value)}
onKeyDown={e =>
e.key === "Enter" && onCompleteCreating()
}
/>
<Box onClick={onCompleteCreating}>
<IconCheck />
</Box> </Box>
<Box onClick={onCancelCreating}> )}
<IconX /> />
</Box>
</Group>
) : (
<Box onClick={onStartCreating}>
<IconPlus />
</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,45 +25,62 @@ const Funnel: FC = () => {
} = useDealsAndStatusesDnd(); } = useDealsAndStatusesDnd();
return ( return (
<FunnelDnd <ScrollArea
containers={sortedStatuses} offsetScrollbars={"x"}
items={deals} scrollbarSize={"0.5rem"}>
onDragStart={handleDragStart} <Group align={"start"}>
onDragOver={handleDragOver} <FunnelDnd
onDragEnd={handleDragEnd} containers={sortedStatuses}
getContainerId={(status: StatusSchema) => `${status.id}-status`} items={deals}
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) => onDragStart={handleDragStart}
sortByLexorank( onDragOver={handleDragOver}
items.filter(deal => deal.statusId === status.id) onDragEnd={handleDragEnd}
) getContainerId={(status: StatusSchema) =>
} `${status.id}-status`
renderContainer={( }
status: StatusSchema, getItemsByContainer={(
funnelColumnComponent: ReactNode status: StatusSchema,
) => ( items: DealSchema[]
<StatusColumnWrapper ) =>
status={status} sortByLexorank(
isDragging={activeStatus?.id === status.id}> items.filter(deal => deal.statusId === status.id)
{funnelColumnComponent} )
</StatusColumnWrapper> }
)} renderContainer={(
renderItem={(deal: DealSchema) => ( status: StatusSchema,
<DealContainer funnelColumnComponent: ReactNode
key={deal.id} ) => (
deal={deal} <StatusColumnWrapper
status={status}
isDragging={activeStatus?.id === status.id}>
{funnelColumnComponent}
</StatusColumnWrapper>
)}
renderItem={(deal: DealSchema) => (
<DealContainer
key={deal.id}
deal={deal}
/>
)}
activeContainer={activeStatus}
activeItem={activeDeal}
renderItemOverlay={(deal: DealSchema) => (
<DealCard deal={deal} />
)}
renderContainerOverlay={(
status: StatusSchema,
children
) => (
<StatusColumnWrapper
status={status}
isDragging>
{children}
</StatusColumnWrapper>
)}
/> />
)} <CreateStatusButton />
activeContainer={activeStatus} </Group>
activeItem={activeDeal} </ScrollArea>
renderItemOverlay={(deal: DealSchema) => <DealCard deal={deal} />}
renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper
status={status}
isDragging>
{children}
</StatusColumnWrapper>
)}
/>
); );
}; };

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,39 +39,31 @@ 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)} input: {
onKeyDown={e => { height: 25,
if (e.key === "Enter") handleSave(); minHeight: 25,
if (e.key === "Escape") { },
setEditValue(status.name); }}
setIsEditing(false); getChildren={startEditing => (
} <>
}} <Text
variant="unstyled" style={{
styles={{ cursor: "grab",
input: { userSelect: "none",
height: 25, opacity: isDragging ? 0.5 : 1,
minHeight: 25, }}>
}, {status.name}
}} </Text>
/> <StatusMenu
) : ( status={status}
<Text handleEdit={startEditing}
style={{ />
cursor: "grab", </>
userSelect: "none", )}
opacity: isDragging ? 0.5 : 1,
}}>
{status.name}
</Text>
)}
<StatusMenu
status={status}
handleEdit={handleEdit}
/> />
</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,67 +59,63 @@ const FunnelDnd = <
const sensors = useDndSensors(); const sensors = useDndSensors();
return ( return (
<ScrollArea <DndContext
offsetScrollbars="x" sensors={sensors}
scrollbarSize="0.5rem"> collisionDetection={closestCorners}
<DndContext onDragStart={onDragStart}
sensors={sensors} onDragOver={onDragOver}
collisionDetection={closestCorners} onDragEnd={onDragEnd}>
onDragStart={onDragStart} <SortableContext
onDragOver={onDragOver} items={containers.map(getContainerId)}
onDragEnd={onDragEnd}> strategy={horizontalListSortingStrategy}>
<SortableContext <Group
items={containers.map(getContainerId)} gap="xs"
strategy={horizontalListSortingStrategy}> wrap="nowrap"
<Group align="start">
gap="xs" {containers.map(container => {
wrap="nowrap" const containerItems = getItemsByContainer(
align="start"> container,
{containers.map(container => { items
const containerItems = getItemsByContainer( );
container, const containerId = getContainerId(container);
items return (
); <SortableItem
const containerId = getContainerId(container); key={containerId}
return ( id={containerId}>
<SortableItem {renderContainer(
key={containerId}
id={containerId}>
{renderContainer(
container,
<FunnelColumn
id={containerId}
items={containerItems}
renderItem={renderItem}
/>
)}
</SortableItem>
);
})}
<FunnelOverlay
activeContainer={activeContainer}
activeItem={activeItem}
renderContainer={container => {
const containerItems = getItemsByContainer(
container,
items
);
const containerId = getContainerId(container);
return renderContainerOverlay(
container, container,
<FunnelColumn <FunnelColumn
id={containerId} id={containerId}
items={containerItems} items={containerItems}
renderItem={renderItem} renderItem={renderItem}
/> />
); )}
}} </SortableItem>
renderItem={renderItemOverlay} );
/> })}
</Group> <FunnelOverlay
</SortableContext> activeContainer={activeContainer}
</DndContext> activeItem={activeItem}
</ScrollArea> renderContainer={container => {
const containerItems = getItemsByContainer(
container,
items
);
const containerId = getContainerId(container);
return renderContainerOverlay(
container,
<FunnelColumn
id={containerId}
items={containerItems}
renderItem={renderItem}
/>
);
}}
renderItem={renderItemOverlay}
/>
</Group>
</SortableContext>
</DndContext>
); );
}; };

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;