build ok,
This commit is contained in:
225
002_source/cms/src/components/dashboard/tasks/board-view.tsx
Normal file
225
002_source/cms/src/components/dashboard/tasks/board-view.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
defaultDropAnimation,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
getFirstCollision,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
UniqueIdentifier,
|
||||
} from '@dnd-kit/core';
|
||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
import { ColumnItem } from './column-item';
|
||||
import { ColumnList } from './column-list';
|
||||
import { TaskCard } from './task-card';
|
||||
import { TasksContext } from './tasks-context';
|
||||
import type { Column, DnDData, Task } from './types';
|
||||
|
||||
const dropAnimation: DropAnimation = { ...defaultDropAnimation };
|
||||
|
||||
export function BoardView(): React.JSX.Element | null {
|
||||
const {
|
||||
columns,
|
||||
tasks,
|
||||
setCurrentColumnId,
|
||||
setCurrentTaskId,
|
||||
createColumn,
|
||||
clearColumn,
|
||||
deleteColumn,
|
||||
createTask,
|
||||
dragTask,
|
||||
} = React.useContext(TasksContext);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 10 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const [active, setActive] = React.useState<{ id: string; type: 'column' | 'task' } | null>(null);
|
||||
|
||||
const collisionDetection = useCollisionDetection(columns, active);
|
||||
|
||||
const activeTask = React.useMemo((): Task | undefined => {
|
||||
return active?.type === 'task' ? tasks.get(active.id) : undefined;
|
||||
}, [tasks, active]);
|
||||
|
||||
const handleDragStart = React.useCallback((event: DragStartEvent): void => {
|
||||
if (!canDrag(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActive({ id: event.active.id as string, type: event.active.data.current!.type as 'column' | 'task' });
|
||||
}, []);
|
||||
|
||||
const handleDragOver = React.useCallback((_: DragOverEvent): void => {
|
||||
// console.log('handleDragOver', event);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent): void => {
|
||||
if (!canDrop(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragTask(
|
||||
{ id: event.active.id as string, type: event.active.data.current!.type as 'task' },
|
||||
{ id: event.over!.id as string, type: event.over!.data.current!.type as 'column' | 'task' }
|
||||
);
|
||||
},
|
||||
[dragTask]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<ColumnList>
|
||||
{Array.from(columns.values()).map(({ taskIds, ...column }): React.JSX.Element => {
|
||||
const tasksFiltered = taskIds
|
||||
.map((taskId) => tasks.get(taskId))
|
||||
.filter((task) => typeof task !== 'undefined') as Task[];
|
||||
|
||||
return (
|
||||
<ColumnItem
|
||||
column={column}
|
||||
key={column.id}
|
||||
onColumnClear={clearColumn}
|
||||
onColumnDelete={deleteColumn}
|
||||
onColumnEdit={setCurrentColumnId}
|
||||
onTaskCreate={createTask}
|
||||
onTaskOpen={setCurrentTaskId}
|
||||
tasks={tasksFiltered}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Box sx={{ flex: '0 0 auto' }}>
|
||||
<Button color="secondary" onClick={createColumn} startIcon={<PlusIcon />}>
|
||||
Add column
|
||||
</Button>
|
||||
</Box>
|
||||
</ColumnList>
|
||||
<DragOverlay dropAnimation={dropAnimation}>
|
||||
{activeTask ? (
|
||||
<div style={{ cursor: 'grab' }}>
|
||||
<TaskCard task={activeTask} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function useCollisionDetection(
|
||||
columns: Map<string, Column>,
|
||||
active: { id: string; type: 'column' | 'task' } | null
|
||||
): CollisionDetection {
|
||||
const lastOverId = React.useRef<string | null>(null);
|
||||
|
||||
return React.useCallback(
|
||||
(args) => {
|
||||
/**
|
||||
* Custom collision detection strategy optimized for multiple containers
|
||||
*
|
||||
* - First, find any droppable containers intersecting with the pointer.
|
||||
* - If there are none, find intersecting containers with the active draggable.
|
||||
* - If there are no intersecting containers, return the last matched intersection
|
||||
*/
|
||||
|
||||
if (active?.type === 'column' && columns.has(active.id)) {
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter((container) => columns.has(container.id as string)),
|
||||
});
|
||||
}
|
||||
|
||||
// Start by finding any intersecting droppable
|
||||
const pointerIntersections = pointerWithin(args);
|
||||
const intersections =
|
||||
pointerIntersections.length > 0
|
||||
? // If there are droppables intersecting with the pointer, return those
|
||||
pointerIntersections
|
||||
: rectIntersection(args);
|
||||
let overId = getFirstCollision(intersections, 'id') as string | null;
|
||||
|
||||
if (overId !== null) {
|
||||
if (columns.has(overId)) {
|
||||
const columnTasks = columns.get(overId)?.taskIds ?? [];
|
||||
|
||||
if (columnTasks.length > 0) {
|
||||
// Return the closest droppable within that container
|
||||
overId = closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container) => container.id !== overId && columnTasks.includes(container.id as string)
|
||||
),
|
||||
})[0]?.id as string | null;
|
||||
}
|
||||
}
|
||||
|
||||
lastOverId.current = overId;
|
||||
|
||||
return [{ id: overId as UniqueIdentifier }];
|
||||
}
|
||||
|
||||
// If no droppable is matched, return the last match
|
||||
return lastOverId.current ? [{ id: lastOverId.current as UniqueIdentifier }] : [];
|
||||
},
|
||||
[active, columns]
|
||||
);
|
||||
}
|
||||
|
||||
function canDrag({ active }: DragStartEvent): boolean {
|
||||
if (active.data.current?.type !== 'task') {
|
||||
logger.warn('[DnD] onDragStart missing or invalid active type. Must be "task"');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function canDrop({ active, over }: DragOverEvent): boolean {
|
||||
if (!over) {
|
||||
// Since all draggable tasks are inside droppable columns,
|
||||
// in theory there should always be an "over".
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!active.data.current || !['task'].includes((active.data.current as DnDData).type)) {
|
||||
// You might want to be able to drag columns.
|
||||
// We do did not implement this functionality.
|
||||
logger.warn('onDragEnd missing or invalid active type. Must be "task"');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!over.data.current || !['column', 'task'].includes((over.data.current as DnDData).type)) {
|
||||
logger.warn('onDragEnd missing or invalid over type, Must be "column" or "task"');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
|
||||
import { TaskCard } from './task-card';
|
||||
import { TaskDraggable } from './task-draggable';
|
||||
import type { DnDData, Task } from './types';
|
||||
|
||||
export interface ColumnDroppableProps {
|
||||
id: string;
|
||||
onTaskCreate?: () => void;
|
||||
onTaskOpen?: (taskId: string) => void;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function ColumnDroppable({ id, onTaskCreate, onTaskOpen, tasks }: ColumnDroppableProps): React.JSX.Element {
|
||||
const { over, setNodeRef } = useDroppable({ id, data: { type: 'column' } satisfies DnDData });
|
||||
|
||||
const isOver = over ? over.id === id || tasks.find((task) => task.id === over.id) : false;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={setNodeRef}
|
||||
spacing={3}
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
borderRadius: '20px',
|
||||
flex: '0 0 auto',
|
||||
minHeight: '250px',
|
||||
p: 3,
|
||||
...(isOver && { bgcolor: 'var(--mui-palette-background-level2)' }),
|
||||
}}
|
||||
>
|
||||
<Button color="secondary" onClick={onTaskCreate} startIcon={<PlusIcon />}>
|
||||
Add task
|
||||
</Button>
|
||||
{tasks.map(
|
||||
(task): React.JSX.Element => (
|
||||
<TaskDraggable id={task.id} key={task.id}>
|
||||
<TaskCard onOpen={onTaskOpen} task={task} />
|
||||
</TaskDraggable>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
import { ColumnDroppable } from './column-droppable';
|
||||
import type { Column, Task } from './types';
|
||||
|
||||
export interface ColumnItemProps {
|
||||
column: Omit<Column, 'taskIds'>;
|
||||
onColumnEdit?: (columnId: string) => void;
|
||||
onColumnClear?: (columnId: string) => void;
|
||||
onColumnDelete?: (columnId: string) => void;
|
||||
onTaskOpen?: (taskId: string) => void;
|
||||
onTaskCreate?: (columnId: string) => void;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function ColumnItem({
|
||||
column,
|
||||
onColumnEdit,
|
||||
onColumnClear,
|
||||
onColumnDelete,
|
||||
onTaskOpen,
|
||||
onTaskCreate,
|
||||
tasks = [],
|
||||
}: ColumnItemProps): React.JSX.Element {
|
||||
const { id, name } = column;
|
||||
|
||||
const morePopover = usePopover<HTMLButtonElement>();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack spacing={3} sx={{ flex: '0 0 auto', width: '360px' }}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="h6">{name}</Typography>
|
||||
<Chip label={tasks.length} size="small" variant="soft" />
|
||||
</Stack>
|
||||
<IconButton onClick={morePopover.handleOpen} ref={morePopover.anchorRef}>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<ColumnDroppable
|
||||
id={id}
|
||||
onTaskCreate={() => {
|
||||
onTaskCreate?.(id);
|
||||
}}
|
||||
onTaskOpen={onTaskOpen}
|
||||
tasks={tasks}
|
||||
/>
|
||||
</Stack>
|
||||
<Menu
|
||||
anchorEl={morePopover.anchorRef.current}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
onClose={morePopover.handleClose}
|
||||
open={morePopover.open}
|
||||
slotProps={{ paper: { sx: { width: '200px' } } }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={(): void => {
|
||||
morePopover.handleClose();
|
||||
onColumnEdit?.(id);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(): void => {
|
||||
morePopover.handleClose();
|
||||
onColumnClear?.(id);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(): void => {
|
||||
morePopover.handleClose();
|
||||
onColumnDelete?.(id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export interface ColumnListProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ColumnList({ children }: ColumnListProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
gap: 4,
|
||||
overflowX: 'auto',
|
||||
px: 'var(--Content-paddingX)',
|
||||
mx: 'calc(-1 * var(--Content-paddingX))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
import type { Column } from './types';
|
||||
|
||||
export interface ColumnModalProps {
|
||||
column: Column;
|
||||
onClose?: () => void;
|
||||
onColumnUpdate?: (columnId: string, params: { name?: string }) => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function ColumnModal({ column, onClose, onColumnUpdate, open }: ColumnModalProps): React.JSX.Element {
|
||||
const { id, name: initialName } = column;
|
||||
const [name, setName] = React.useState<string>('');
|
||||
|
||||
React.useEffect((): void => {
|
||||
setName(initialName);
|
||||
}, [initialName]);
|
||||
|
||||
const handleSave = React.useCallback((): void => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === initialName) {
|
||||
return;
|
||||
}
|
||||
|
||||
onColumnUpdate?.(id, { name });
|
||||
onClose?.();
|
||||
}, [name, initialName, id, onClose, onColumnUpdate]);
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" onClose={onClose} open={open}>
|
||||
<DialogContent>
|
||||
<Stack spacing={3}>
|
||||
<FormControl>
|
||||
<InputLabel>Name</InputLabel>
|
||||
<OutlinedInput
|
||||
name="name"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setName(event.target.value);
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
</FormControl>
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-end' }}>
|
||||
<Button color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
68
002_source/cms/src/components/dashboard/tasks/task-card.tsx
Normal file
68
002_source/cms/src/components/dashboard/tasks/task-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||
import Card from '@mui/material/Card';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Chat as ChatIcon } from '@phosphor-icons/react/dist/ssr/Chat';
|
||||
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
|
||||
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Task } from './types';
|
||||
|
||||
export interface TaskCardProps {
|
||||
onOpen?: (taskId: string) => void;
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export function TaskCard({ onOpen, task }: TaskCardProps): React.JSX.Element {
|
||||
const { assignees = [], attachments = [], comments = [], description, dueDate, id, subtasks = [], title } = task;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack spacing={2} sx={{ p: 3 }}>
|
||||
{dueDate ? (
|
||||
<div>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Due {dayjs(dueDate).diff(dayjs(), 'day')} days
|
||||
</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
onClick={(): void => {
|
||||
onOpen?.(id);
|
||||
}}
|
||||
sx={{ cursor: 'pointer', ':hover': { color: 'var(--mui-palette-primary-main)' } }}
|
||||
variant="subtitle1"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2">{description}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
{assignees.length ? (
|
||||
<AvatarGroup sx={{ flex: '1 1 auto' }}>
|
||||
{assignees.map(
|
||||
(assignee): React.JSX.Element => (
|
||||
<Avatar key={assignee.id} src={assignee.avatar} />
|
||||
)
|
||||
)}
|
||||
</AvatarGroup>
|
||||
) : null}
|
||||
</div>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{attachments.length ? <LinkIcon fontSize="var(--icon-fontSize-md)" /> : null}
|
||||
{comments.length ? <ChatIcon fontSize="var(--icon-fontSize-md)" /> : null}
|
||||
{subtasks.length ? <ListIcon fontSize="var(--icon-fontSize-md)" /> : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DnDData } from './types';
|
||||
|
||||
export interface TaskDraggableProps {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function TaskDraggable({ children, id }: TaskDraggableProps): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
data: { type: 'task' } satisfies DnDData,
|
||||
});
|
||||
|
||||
const style = { transform: CSS.Transform.toString(transform), transition, ...(isDragging && { opacity: 0 }) };
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
474
002_source/cms/src/components/dashboard/tasks/task-modal.tsx
Normal file
474
002_source/cms/src/components/dashboard/tasks/task-modal.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { Archive as ArchiveIcon } from '@phosphor-icons/react/dist/ssr/Archive';
|
||||
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
|
||||
import { PaperPlaneTilt as PaperPlaneTiltIcon } from '@phosphor-icons/react/dist/ssr/PaperPlaneTilt';
|
||||
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Comment, Task } from './types';
|
||||
|
||||
export interface TaskModalProps {
|
||||
onClose?: () => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
onTaskUpdate?: (taskId: string, params: { title?: string; description?: string }) => void;
|
||||
onCommentAdd?: (taskId: string, content: string) => void;
|
||||
open: boolean;
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export function TaskModal({
|
||||
onClose,
|
||||
onTaskDelete,
|
||||
onTaskUpdate,
|
||||
onCommentAdd,
|
||||
open,
|
||||
task,
|
||||
}: TaskModalProps): React.JSX.Element {
|
||||
const {
|
||||
assignees = [],
|
||||
attachments = [],
|
||||
comments = [],
|
||||
labels = [],
|
||||
subtasks = [],
|
||||
description = '',
|
||||
id,
|
||||
title,
|
||||
} = task;
|
||||
|
||||
const [tab, setTab] = React.useState<string>('overview');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
maxWidth="sm"
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
sx={{
|
||||
'& .MuiDialog-container': { justifyContent: 'flex-end' },
|
||||
'& .MuiDialog-paper': { height: '100%', width: '100%' },
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, p: 0 }}>
|
||||
<Box sx={{ flex: '0 0 auto', p: 3 }}>
|
||||
<IconButton onClick={onClose}>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Tabs
|
||||
onChange={(_, value: string) => {
|
||||
setTab(value);
|
||||
}}
|
||||
sx={{ px: 3 }}
|
||||
value={tab}
|
||||
>
|
||||
<Tab label="Overview" tabIndex={0} value="overview" />
|
||||
<Tab label="Subtasks" tabIndex={0} value="subtasks" />
|
||||
<Tab label="Comments" tabIndex={0} value="comments" />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0, overflowY: 'auto', p: 3 }}>
|
||||
{tab === 'overview' ? (
|
||||
<Stack spacing={4} sx={{ flex: '1 1 auto' }}>
|
||||
<EditableDetails
|
||||
description={description}
|
||||
onUpdate={(params: { title: string; description: string }) => {
|
||||
onTaskUpdate?.(id, params);
|
||||
}}
|
||||
title={title}
|
||||
/>
|
||||
<Stack divider={<Divider />} spacing={2} sx={{ flex: '1 1 auto' }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Created by</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Avatar src={task.author.avatar} />
|
||||
<div>
|
||||
<Typography variant="subtitle2">{task.author.name}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
@{task.author.username}
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Assignees</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{assignees.map(
|
||||
(assignee): React.JSX.Element => (
|
||||
<Avatar key={assignee.id} src={assignee.avatar} />
|
||||
)
|
||||
)}
|
||||
<IconButton>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Due date</Typography>
|
||||
<DatePicker format="MMM D, YYYY" name="dueDate" sx={{ maxWidth: '250px' }} />
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Labels</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{labels.map((label) => (
|
||||
<Chip
|
||||
key={label}
|
||||
label={label}
|
||||
onDelete={() => {
|
||||
// noop
|
||||
}}
|
||||
size="small"
|
||||
variant="soft"
|
||||
/>
|
||||
))}
|
||||
<IconButton>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Attachments</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{attachments.map(
|
||||
(attachment): React.JSX.Element => (
|
||||
<Paper
|
||||
key={attachment.id}
|
||||
sx={{ borderRadius: 1, p: '4px 8px', maxWidth: '220px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<div>
|
||||
<FileIcon fontSize="var(--icon-fontSize-lg)" />
|
||||
</div>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography noWrap variant="body2">
|
||||
{attachment.name}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{attachment.size}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
)}
|
||||
<IconButton>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
color="error"
|
||||
onClick={() => {
|
||||
onTaskDelete?.(id);
|
||||
}}
|
||||
startIcon={<ArchiveIcon />}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : null}
|
||||
{tab === 'subtasks' ? (
|
||||
<Stack spacing={2}>
|
||||
{subtasks.length ? (
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={1}>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
{countDoneSubtasks(subtasks)} of 5
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level1)' }}
|
||||
value={(100 / subtasks.length) * countDoneSubtasks(subtasks)}
|
||||
variant="determinate"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
{subtasks.map(
|
||||
(subtask): React.JSX.Element => (
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={subtask.done} />}
|
||||
key={subtask.id}
|
||||
label={subtask.title}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
<div>
|
||||
<Button color="secondary" startIcon={<PlusIcon />} variant="outlined">
|
||||
Add subtask
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
) : null}
|
||||
{tab === 'comments' ? (
|
||||
<Stack spacing={5}>
|
||||
{comments.length ? (
|
||||
<Stack spacing={3}>
|
||||
{comments.map(
|
||||
(comment, index): React.JSX.Element => (
|
||||
<CommentItem comment={comment} connector={index < comments.length - 1} key={comment.id} />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic' }} variant="body2">
|
||||
No comments yet
|
||||
</Typography>
|
||||
)}
|
||||
<CommentAdd
|
||||
onAdd={(content: string): void => {
|
||||
onCommentAdd?.(id, content);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditableDetailsProps {
|
||||
description: string;
|
||||
onUpdate?: (params: { title: string; description: string }) => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function EditableDetails({
|
||||
description: initialDescription,
|
||||
onUpdate,
|
||||
title: initialTitle,
|
||||
}: EditableDetailsProps): React.JSX.Element {
|
||||
const [title, setTitle] = React.useState<string>('');
|
||||
const [description, setDescription] = React.useState<string>('');
|
||||
const [edit, setEdit] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setTitle(initialTitle);
|
||||
}, [initialTitle]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setDescription(initialDescription);
|
||||
}, [initialDescription]);
|
||||
|
||||
const handleSave = React.useCallback((): void => {
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate?.({ title, description });
|
||||
setEdit(false);
|
||||
}, [title, description, onUpdate]);
|
||||
|
||||
if (edit) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<OutlinedInput
|
||||
name="title"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setTitle(event.target.value);
|
||||
}}
|
||||
value={title}
|
||||
/>
|
||||
<OutlinedInput
|
||||
maxRows={5}
|
||||
minRows={3}
|
||||
multiline
|
||||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
if (!edit) {
|
||||
setEdit(true);
|
||||
}
|
||||
|
||||
setDescription(event.target.value);
|
||||
}}
|
||||
placeholder="No description"
|
||||
value={description}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setTitle(initialTitle);
|
||||
setEdit(false);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
handleSave();
|
||||
}}
|
||||
size="small"
|
||||
variant="contained"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Stack spacing={1} sx={{ flex: '1 1 auto' }}>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={(): void => {
|
||||
setEdit(true);
|
||||
}}
|
||||
>
|
||||
<PencilSimpleIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentItemProps {
|
||||
connector?: boolean;
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
function CommentItem({ comment, connector }: CommentItemProps): React.JSX.Element {
|
||||
const { author, content, createdAt, comments } = comment;
|
||||
const canReply = author.id !== 'USR-000'; // authenticated user
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Avatar src={author.avatar} />
|
||||
{connector ? (
|
||||
<Box sx={{ flex: '1 1 auto', pt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-divider)',
|
||||
height: '100%',
|
||||
minHeight: '24px',
|
||||
mx: 'auto',
|
||||
width: '1px',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Stack spacing={3} sx={{ flex: '1 1 auto' }}>
|
||||
<div>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', justifyContent: 'space-between' }}>
|
||||
<Tooltip arrow title={`@${author.username}`}>
|
||||
<Typography variant="subtitle2">{author.name}</Typography>
|
||||
</Tooltip>
|
||||
{createdAt ? (
|
||||
<Typography sx={{ whiteSpace: 'nowrap' }} variant="caption">
|
||||
{dayjs(createdAt).fromNow()}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Typography variant="body2">{content}</Typography>
|
||||
{canReply ? (
|
||||
<div>
|
||||
<Link sx={{ cursor: 'pointer' }} variant="body2">
|
||||
Reply
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{comments?.length ? (
|
||||
<Stack spacing={2}>
|
||||
{comments.map(
|
||||
(subComment, index): React.JSX.Element => (
|
||||
<CommentItem comment={subComment} connector={index < comments.length - 1} key={subComment.id} />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentAddProps {
|
||||
onAdd?: (content: string) => void;
|
||||
}
|
||||
|
||||
function CommentAdd({ onAdd }: CommentAddProps): React.JSX.Element {
|
||||
const [content, setContent] = React.useState<string>('');
|
||||
|
||||
const handleAdd = React.useCallback((): void => {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd?.(content);
|
||||
setContent('');
|
||||
}, [content, onAdd]);
|
||||
|
||||
return (
|
||||
<OutlinedInput
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={(): void => {
|
||||
handleAdd();
|
||||
}}
|
||||
>
|
||||
<PaperPlaneTiltIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setContent(event.target.value);
|
||||
}}
|
||||
onKeyUp={(event: React.KeyboardEvent): void => {
|
||||
if (event.key === 'Enter') {
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
placeholder="Add a comment..."
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<Avatar src="/assets/avatar.png" />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{ '--Input-paddingBlock': '12px' }}
|
||||
value={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function countDoneSubtasks(subtasks: Task['subtasks'] = []): number {
|
||||
return subtasks.reduce((acc, curr) => acc + (curr.done ? 1 : 0), 0);
|
||||
}
|
473
002_source/cms/src/components/dashboard/tasks/tasks-context.tsx
Normal file
473
002_source/cms/src/components/dashboard/tasks/tasks-context.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { Column, Comment, Task } from './types';
|
||||
|
||||
function noop(): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface TasksContextValue {
|
||||
columns: Map<string, Column>;
|
||||
tasks: Map<string, Task>;
|
||||
currentColumnId?: string;
|
||||
currentTaskId?: string;
|
||||
setCurrentColumnId: (columnId?: string) => void;
|
||||
setCurrentTaskId: (taskId?: string) => void;
|
||||
createColumn: () => void;
|
||||
updateColumn: (taskId: string, params: { name?: string }) => void;
|
||||
clearColumn: (columnId: string) => void;
|
||||
deleteColumn: (columnId: string) => void;
|
||||
dragTask: (active: { id: string; type: 'task' }, over: { id: string; type: 'column' | 'task' }) => void;
|
||||
createTask: (columnId: string) => void;
|
||||
deleteTask: (taskId: string) => void;
|
||||
updateTask: (taskId: string, params: { title?: string; description?: string }) => void;
|
||||
addComment: (taskId: string, content: string) => void;
|
||||
}
|
||||
|
||||
export const TasksContext = React.createContext<TasksContextValue>({
|
||||
columns: new Map(),
|
||||
tasks: new Map(),
|
||||
setCurrentColumnId: noop,
|
||||
setCurrentTaskId: noop,
|
||||
createColumn: noop,
|
||||
updateColumn: noop,
|
||||
clearColumn: noop,
|
||||
deleteColumn: noop,
|
||||
dragTask: noop,
|
||||
createTask: noop,
|
||||
deleteTask: noop,
|
||||
updateTask: noop,
|
||||
addComment: noop,
|
||||
});
|
||||
|
||||
export interface TasksProviderProps {
|
||||
children: React.ReactNode;
|
||||
columns: Column[];
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function TasksProvider({
|
||||
children,
|
||||
columns: initialColumns = [],
|
||||
tasks: initialTasks = [],
|
||||
}: TasksProviderProps): React.JSX.Element {
|
||||
const [columns, setColumns] = React.useState(new Map<string, Column>());
|
||||
const [tasks, setTasks] = React.useState(new Map<string, Task>());
|
||||
const [currentColumnId, setCurrentColumnId] = React.useState<string>();
|
||||
const [currentTaskId, setCurrentTaskId] = React.useState<string>();
|
||||
|
||||
React.useEffect((): void => {
|
||||
setColumns(new Map(initialColumns.map((column) => [column.id, column])));
|
||||
}, [initialColumns]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setTasks(new Map(initialTasks.map((task) => [task.id, task])));
|
||||
}, [initialTasks]);
|
||||
|
||||
const handleCreateColumn = React.useCallback((): void => {
|
||||
const column = { id: `COL-${Date.now()}`, name: 'Untitled', taskIds: [] } satisfies Column;
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
// Add column
|
||||
updatedColumns.set(column.id, column);
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
}, [columns]);
|
||||
|
||||
const handleUpdateColumn = React.useCallback(
|
||||
(columnId: string, { name }: { name?: string }): void => {
|
||||
const column = columns.get(columnId);
|
||||
|
||||
// Column might no longer exist
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
const updatedColumn = { ...column };
|
||||
|
||||
if (typeof name !== 'undefined') {
|
||||
updatedColumn.name = name;
|
||||
}
|
||||
|
||||
// Update column
|
||||
updatedColumns.set(updatedColumn.id, updatedColumn);
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
},
|
||||
[columns]
|
||||
);
|
||||
|
||||
const handleClearColumn = React.useCallback(
|
||||
(columnId: string): void => {
|
||||
const column = columns.get(columnId);
|
||||
|
||||
// Column might no longer exist
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
// Delete tasks
|
||||
column.taskIds.forEach((taskId): void => {
|
||||
updatedTasks.delete(taskId);
|
||||
});
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
const updatedColumn = { ...column, taskIds: [] };
|
||||
|
||||
// Update column
|
||||
updatedColumns.set(updatedColumn.id, updatedColumn);
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
setTasks(updatedTasks);
|
||||
},
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
const handleDeleteColumn = React.useCallback(
|
||||
(columnId: string): void => {
|
||||
const column = columns.get(columnId);
|
||||
|
||||
// Column might no longer exist
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
// Delete tasks
|
||||
column.taskIds.forEach((taskId): void => {
|
||||
updatedTasks.delete(taskId);
|
||||
});
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
// Delete column
|
||||
updatedColumns.delete(column.id);
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
setTasks(updatedTasks);
|
||||
},
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
const handleDragTask = React.useCallback(
|
||||
(active: { id: string; type: 'task' }, over: { id: string; type: 'column' | 'task' }): void => {
|
||||
const activeTask = tasks.get(active.id);
|
||||
|
||||
// Active task and might no longer exist
|
||||
if (!activeTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeColumn = columns.get(activeTask.columnId);
|
||||
|
||||
// Active column might no longer exist
|
||||
if (!activeColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropped over a column
|
||||
if (over.type === 'column') {
|
||||
// Dropped on the same column, reorder at the end
|
||||
if (activeTask.columnId === over.id) {
|
||||
const updatedActiveColumn = {
|
||||
...activeColumn,
|
||||
taskIds: [...activeColumn.taskIds.filter((taskId) => taskId !== activeTask.id), activeTask.id],
|
||||
} satisfies Column;
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
}
|
||||
// Dropped in a different column, move at the end
|
||||
else {
|
||||
const overColumn = columns.get(over.id);
|
||||
|
||||
// Over column might no longer exist
|
||||
if (!overColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Change task column
|
||||
const updatedActiveTask = { ...activeTask, columnId: overColumn.id } satisfies Task;
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
updatedTasks.set(updatedActiveTask.id, updatedActiveTask);
|
||||
|
||||
// Remove task from active column
|
||||
const updatedActiveColumn = {
|
||||
...activeColumn,
|
||||
taskIds: activeColumn.taskIds.filter((taskId) => taskId !== activeTask.id),
|
||||
} satisfies Column;
|
||||
|
||||
// Add task to over column
|
||||
const updatedOverColumn = { ...overColumn, taskIds: [...overColumn.taskIds, activeTask.id] } satisfies Column;
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
|
||||
updatedColumns.set(updatedOverColumn.id, updatedOverColumn);
|
||||
|
||||
// Dispatch update
|
||||
setTasks(updatedTasks);
|
||||
setColumns(updatedColumns);
|
||||
}
|
||||
}
|
||||
// Dropped over a task
|
||||
else {
|
||||
// Dropped over self
|
||||
if (activeTask.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overTask = tasks.get(over.id);
|
||||
|
||||
// Over task might no longer exist
|
||||
if (!overTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropped on the same column, reorder
|
||||
if (activeTask.columnId === overTask.columnId) {
|
||||
const oldTaskIndex = activeColumn.taskIds.findIndex((taskId) => taskId === activeTask.id);
|
||||
const newTaskIndex = activeColumn.taskIds.findIndex((taskId) => taskId === overTask.id);
|
||||
|
||||
const updatedActiveColumn = {
|
||||
...activeColumn,
|
||||
taskIds: arrayMove(activeColumn.taskIds, oldTaskIndex, newTaskIndex),
|
||||
} satisfies Column;
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
}
|
||||
// Dopped on a different column, move at position
|
||||
else {
|
||||
const overColumn = columns.get(overTask.columnId);
|
||||
|
||||
// Column might no longer exist
|
||||
if (!overColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Change task column
|
||||
const updatedActiveTask = { ...activeTask, columnId: overColumn.id } satisfies Task;
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
updatedTasks.set(updatedActiveTask.id, updatedActiveTask);
|
||||
|
||||
// Find new task position
|
||||
const overTaskIndex = overColumn.taskIds.findIndex((taskId) => taskId === overTask.id);
|
||||
|
||||
// Remove task from active column
|
||||
const updatedActiveColumn = {
|
||||
...activeColumn,
|
||||
taskIds: activeColumn.taskIds.filter((taskId) => taskId !== activeTask.id),
|
||||
} satisfies Column;
|
||||
|
||||
// Add task to over column at position
|
||||
const updatedOverColumn = {
|
||||
...overColumn,
|
||||
taskIds: arrayInsert(overColumn.taskIds, overTaskIndex, activeTask.id),
|
||||
} satisfies Column;
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
|
||||
updatedColumns.set(updatedOverColumn.id, updatedOverColumn);
|
||||
|
||||
// Dispatch update
|
||||
setTasks(updatedTasks);
|
||||
setColumns(updatedColumns);
|
||||
}
|
||||
}
|
||||
},
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
const handleCreateTask = React.useCallback(
|
||||
(columnId: string): void => {
|
||||
const column = columns.get(columnId);
|
||||
|
||||
// Column might no longer exist
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the new task
|
||||
const task = {
|
||||
id: `TSK-${Date.now()}`,
|
||||
author: { id: 'USR-000', name: 'Sofia Rivers', username: 'sofia.rivers', avatar: '/assets/avatar.png' },
|
||||
title: 'Untitled',
|
||||
columnId,
|
||||
createdAt: new Date(),
|
||||
} satisfies Task;
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
// Add it to the tasks
|
||||
updatedTasks.set(task.id, task);
|
||||
|
||||
// Add the task to the column
|
||||
const updatedColumn = { ...column, taskIds: [task.id, ...column.taskIds] } satisfies Column;
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
updatedColumns.set(updatedColumn.id, updatedColumn);
|
||||
|
||||
// Dispatch update
|
||||
setTasks(updatedTasks);
|
||||
setColumns(updatedColumns);
|
||||
},
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
const handleDeleteTask = React.useCallback(
|
||||
(taskId: string): void => {
|
||||
const task = tasks.get(taskId);
|
||||
|
||||
// Task might no longer exist
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
// Delete the task
|
||||
updatedTasks.delete(task.id);
|
||||
|
||||
const updatedColumns = new Map<string, Column>(columns);
|
||||
|
||||
// Delete the task ID from column
|
||||
const column = updatedColumns.get(task.columnId);
|
||||
|
||||
// Column might no longer exist
|
||||
if (column) {
|
||||
const updatedColumn = { ...column, taskId: column.taskIds.filter((id) => id !== task.id) };
|
||||
|
||||
updatedColumns.set(updatedColumn.id, updatedColumn);
|
||||
}
|
||||
|
||||
// Dispatch update
|
||||
setColumns(updatedColumns);
|
||||
setTasks(updatedTasks);
|
||||
},
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
const handleUpdateTask = React.useCallback(
|
||||
(taskId: string, { title, description }: { title?: string; description?: string }): void => {
|
||||
const task = tasks.get(taskId);
|
||||
|
||||
// Task might no longer exist
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
const updatedTask = { ...task };
|
||||
|
||||
// Title changed
|
||||
if (typeof title !== 'undefined') {
|
||||
updatedTask.title = title;
|
||||
}
|
||||
|
||||
// Description changed
|
||||
if (typeof description !== 'undefined') {
|
||||
updatedTask.description = description;
|
||||
}
|
||||
|
||||
updatedTasks.set(updatedTask.id, updatedTask);
|
||||
|
||||
// Dispatch update
|
||||
setTasks(updatedTasks);
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const handleAddComment = React.useCallback(
|
||||
(taskId: string, content: string): void => {
|
||||
const task = tasks.get(taskId);
|
||||
|
||||
// Task might no longer exist
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy existing tasks
|
||||
const updatedTasks = new Map<string, Task>(tasks);
|
||||
|
||||
// Create the comment and add it to the task
|
||||
const comment = {
|
||||
id: `MSG-${Date.now()}`,
|
||||
author: { id: 'USR-000', name: 'Sofia Rivers', username: 'sofia.rivers', avatar: '/assets/avatar.png' },
|
||||
content,
|
||||
createdAt: new Date(),
|
||||
} satisfies Comment;
|
||||
|
||||
updatedTasks.set(task.id, { ...task, comments: [...(task.comments ?? []), comment] });
|
||||
|
||||
// Dispatch update
|
||||
setTasks(updatedTasks);
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
return (
|
||||
<TasksContext.Provider
|
||||
value={{
|
||||
columns,
|
||||
tasks,
|
||||
currentColumnId,
|
||||
currentTaskId,
|
||||
setCurrentColumnId,
|
||||
setCurrentTaskId,
|
||||
createColumn: handleCreateColumn,
|
||||
updateColumn: handleUpdateColumn,
|
||||
clearColumn: handleClearColumn,
|
||||
deleteColumn: handleDeleteColumn,
|
||||
dragTask: handleDragTask,
|
||||
createTask: handleCreateTask,
|
||||
deleteTask: handleDeleteTask,
|
||||
updateTask: handleUpdateTask,
|
||||
addComment: handleAddComment,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TasksContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const TasksConsumer = TasksContext.Consumer;
|
||||
|
||||
function arrayMove<T = unknown>(arr: T[], from: number, to: number): T[] {
|
||||
const copy = [...arr];
|
||||
const [item] = copy.splice(from, 1);
|
||||
copy.splice(to, 0, item);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function arrayInsert<T = unknown>(arr: T[], index: number, item: T): T[] {
|
||||
return [...arr.slice(0, index), item, ...arr.slice(index)];
|
||||
}
|
93
002_source/cms/src/components/dashboard/tasks/tasks-view.tsx
Normal file
93
002_source/cms/src/components/dashboard/tasks/tasks-view.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
|
||||
import { BoardView } from './board-view';
|
||||
import { ColumnModal } from './column-modal';
|
||||
import { TaskModal } from './task-modal';
|
||||
import { TasksContext } from './tasks-context';
|
||||
|
||||
// You may want to also have the List instead of Board.
|
||||
// We do not handle the list at this moment.
|
||||
|
||||
export interface TasksViewProps {
|
||||
mode?: 'board' | 'list';
|
||||
}
|
||||
|
||||
export function TasksView(_: TasksViewProps): React.JSX.Element {
|
||||
const {
|
||||
columns,
|
||||
tasks,
|
||||
currentColumnId,
|
||||
currentTaskId,
|
||||
setCurrentColumnId,
|
||||
setCurrentTaskId,
|
||||
updateColumn,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
addComment,
|
||||
} = React.useContext(TasksContext);
|
||||
|
||||
const currentColumn = currentColumnId ? columns.get(currentColumnId) : undefined;
|
||||
const currentTask = currentTaskId ? tasks.get(currentTaskId) : undefined;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
px: 'var(--Content-paddingX)',
|
||||
py: 'var(--Content-paddingY)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: '1 1 auto' }}>
|
||||
<Typography variant="h4">Tasks</Typography>
|
||||
</Box>
|
||||
<div>
|
||||
<Button startIcon={<PlusIcon />} variant="contained">
|
||||
Member
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column' }}>
|
||||
<BoardView />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
{currentColumn ? (
|
||||
<ColumnModal
|
||||
column={currentColumn}
|
||||
onClose={(): void => {
|
||||
setCurrentColumnId(undefined);
|
||||
}}
|
||||
onColumnUpdate={updateColumn}
|
||||
open
|
||||
/>
|
||||
) : null}
|
||||
{currentTask ? (
|
||||
<TaskModal
|
||||
onClose={(): void => {
|
||||
setCurrentTaskId(undefined);
|
||||
}}
|
||||
onCommentAdd={addComment}
|
||||
onTaskDelete={(taskId: string): void => {
|
||||
setCurrentColumnId(undefined);
|
||||
deleteTask(taskId);
|
||||
}}
|
||||
onTaskUpdate={updateTask}
|
||||
open
|
||||
task={currentTask}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
54
002_source/cms/src/components/dashboard/tasks/types.d.ts
vendored
Normal file
54
002_source/cms/src/components/dashboard/tasks/types.d.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface DnDData {
|
||||
type: 'column' | 'task';
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
id: string;
|
||||
name: string;
|
||||
taskIds: string[];
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
author: { id: string; name: string; username: string; avatar?: string };
|
||||
title: string;
|
||||
description?: string;
|
||||
columnId: string;
|
||||
createdAt: Date;
|
||||
labels?: string[];
|
||||
dueDate?: Date;
|
||||
subscribed?: boolean;
|
||||
assignees?: Assignee[];
|
||||
attachments?: Attachment[];
|
||||
subtasks?: Subtask[];
|
||||
comments?: Comment[];
|
||||
}
|
||||
|
||||
export interface Assignee {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
extension: 'png' | 'pdf';
|
||||
url: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
title: string;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
author: { id: string; name: string; username: string; avatar?: string };
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
comments?: Comment[];
|
||||
}
|
Reference in New Issue
Block a user