build ok,
This commit is contained in:
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);
|
||||
}
|
Reference in New Issue
Block a user