Skip to content
GitLab
Projects
Groups
Topics
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Register
Sign in
Toggle navigation
Menu
Paulo Avila
petruz-tasks
Commits
8db9e2b0
Commit
8db9e2b0
authored
Jul 17, 2026
by
Paulo Avila
Browse files
Criação de fluxo para modificação de prazo de tarefa
parent
24d624aa
Changes
7
Hide whitespace changes
Inline
Side-by-side
src/components/tasks/postponed-badge.tsx
0 → 100644
View file @
8db9e2b0
import
{
History
}
from
"
lucide-react
"
;
import
{
formatDateShort
}
from
"
@/lib/dates
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
/** Marca a tarefa que teve a previsão adiada após vencer. */
export
function
PostponedBadge
({
postponed
,
originalDueDate
,
className
,
}:
{
postponed
:
boolean
;
originalDueDate
:
Date
|
string
|
null
;
className
?:
string
;
})
{
if
(
!
postponed
)
return
null
;
const
original
=
originalDueDate
?
formatDateShort
(
originalDueDate
)
:
null
;
return
(
<
Badge
title
=
{
original
?
`Adiada — previsão original:
${
original
}
`
:
"
Adiada após o vencimento
"
}
className
=
{
cn
(
"
gap-0.5 border-transparent bg-amber-500 px-1.5 py-0 text-[10px] font-medium leading-4 text-white hover:bg-amber-500
"
,
className
)
}
>
<
History
className
=
"h-2.5 w-2.5 shrink-0"
/>
Adiada
</
Badge
>
);
}
src/components/tasks/task-board.tsx
View file @
8db9e2b0
...
@@ -6,10 +6,13 @@ import { canDeleteTask, canEditTask } from "@/lib/permissions";
...
@@ -6,10 +6,13 @@ import { canDeleteTask, canEditTask } from "@/lib/permissions";
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Card
,
CardContent
}
from
"
@/components/ui/card
"
;
import
{
Card
,
CardContent
}
from
"
@/components/ui/card
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
Progress
}
from
"
@/components/ui/progress
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
MetaBadge
}
from
"
./meta-badge
"
;
import
{
MetaBadge
}
from
"
./meta-badge
"
;
import
{
PostponedBadge
}
from
"
./postponed-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
TaskPostponeButton
}
from
"
./task-postpone-button
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDialog
}
from
"
./task-dialog
"
;
import
{
TaskDialog
}
from
"
./task-dialog
"
;
...
@@ -63,6 +66,11 @@ export function TaskBoard({
...
@@ -63,6 +66,11 @@ export function TaskBoard({
</
p
>
</
p
>
{
editable
&&
(
{
editable
&&
(
<
div
className
=
"flex shrink-0 items-center"
>
<
div
className
=
"flex shrink-0 items-center"
>
<
TaskPostponeButton
taskId
=
{
task
.
Id
}
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
<
TaskDialog
<
TaskDialog
workspaceId
=
{
workspaceId
}
workspaceId
=
{
workspaceId
}
members
=
{
members
}
members
=
{
members
}
...
@@ -103,6 +111,10 @@ export function TaskBoard({
...
@@ -103,6 +111,10 @@ export function TaskBoard({
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
compact
compact
/>
/>
<
PostponedBadge
postponed
=
{
task
.
Postponed
}
originalDueDate
=
{
task
.
OriginalDueDate
}
/>
</
div
>
</
div
>
<
div
className
=
"flex items-center gap-2"
>
<
div
className
=
"flex items-center gap-2"
>
<
Progress
value
=
{
percent
}
className
=
"h-1.5"
/>
<
Progress
value
=
{
percent
}
className
=
"h-1.5"
/>
...
@@ -159,10 +171,20 @@ export function TaskBoard({
...
@@ -159,10 +171,20 @@ export function TaskBoard({
)
}
)
}
{
task
.
CompletedDate
&&
(
{
task
.
CompletedDate
&&
(
<
span
<
span
className
=
"flex items-center gap-1 text-green-600 dark:text-green-400"
className
=
{
cn
(
title
=
"Concluída em"
"
flex items-center gap-1
"
,
task
.
Postponed
?
"
font-medium text-amber-600 dark:text-amber-400
"
:
"
text-green-600 dark:text-green-400
"
)
}
title
=
{
task
.
Postponed
?
"
Concluída após adiamento
"
:
"
Concluída no prazo
"
}
>
>
<
CalendarCheck
className
=
"h-3 w-3"
/>
<
CalendarCheck
className
=
"h-3 w-3"
/>
{
task
.
Postponed
&&
"
⤴
"
}
{
formatDateShort
(
task
.
CompletedDate
)
}
{
formatDateShort
(
task
.
CompletedDate
)
}
</
span
>
</
span
>
)
}
)
}
...
...
src/components/tasks/task-postpone-button.tsx
0 → 100644
View file @
8db9e2b0
"
use client
"
;
import
{
useState
,
useTransition
}
from
"
react
"
;
import
{
CalendarClock
}
from
"
lucide-react
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
postponeTaskAction
}
from
"
@/lib/actions
"
;
import
{
formatDate
,
getDueInfo
,
toISODate
}
from
"
@/lib/dates
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogFooter
,
DialogHeader
,
DialogTitle
,
DialogTrigger
,
}
from
"
@/components/ui/dialog
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
/** "Hoje" no fuso do navegador (toISOString deslocaria o dia em fusos negativos). */
function
todayISO
():
string
{
const
now
=
new
Date
();
const
month
=
String
(
now
.
getMonth
()
+
1
).
padStart
(
2
,
"
0
"
);
const
day
=
String
(
now
.
getDate
()).
padStart
(
2
,
"
0
"
);
return
`
${
now
.
getFullYear
()}
-
${
month
}
-
${
day
}
`
;
}
/**
* Só aparece em tarefa vencida: move a previsão de término e marca a flag
* "Adiada". Fora do vencimento, adiar não faz sentido e o botão some.
*/
export
function
TaskPostponeButton
({
taskId
,
dueDate
,
status
,
}:
{
taskId
:
number
;
dueDate
:
Date
|
string
|
null
;
status
:
string
;
})
{
const
[
open
,
setOpen
]
=
useState
(
false
);
const
[
newDate
,
setNewDate
]
=
useState
(
""
);
const
[
pending
,
startTransition
]
=
useTransition
();
const
info
=
getDueInfo
(
dueDate
,
status
);
if
(
info
?.
state
!==
"
overdue
"
)
return
null
;
const
currentDue
=
toISODate
(
dueDate
);
function
handlePostpone
()
{
startTransition
(
async
()
=>
{
const
result
=
await
postponeTaskAction
(
taskId
,
newDate
);
if
(
result
.
error
)
toast
.
error
(
result
.
error
);
else
{
toast
.
success
(
"
Tarefa adiada.
"
);
setOpen
(
false
);
}
});
}
return
(
<
Dialog
open
=
{
open
}
onOpenChange
=
{
setOpen
}
>
<
DialogTrigger
asChild
>
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-8 w-8 text-muted-foreground hover:text-amber-600"
title
=
"Adiar tarefa vencida"
>
<
CalendarClock
className
=
"h-4 w-4"
/>
</
Button
>
</
DialogTrigger
>
<
DialogContent
className
=
"sm:max-w-sm"
>
<
DialogHeader
>
<
DialogTitle
>
Adiar tarefa
</
DialogTitle
>
<
DialogDescription
>
{
currentDue
?
`Previsão atual:
${
formatDate
(
currentDue
)}
— vencida. A tarefa ficará marcada como adiada, e a previsão original é preservada.`
:
"
Escolha a nova previsão de término.
"
}
</
DialogDescription
>
</
DialogHeader
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
{
`postpone-
${
taskId
}
`
}
>
Nova previsão de término
</
Label
>
<
Input
id
=
{
`postpone-
${
taskId
}
`
}
type
=
"date"
value
=
{
newDate
}
min
=
{
todayISO
()
}
onChange
=
{
(
e
)
=>
setNewDate
(
e
.
target
.
value
)
}
/>
</
div
>
<
DialogFooter
>
<
Button
onClick
=
{
handlePostpone
}
disabled
=
{
pending
||
!
newDate
}
className
=
"w-full"
>
{
pending
?
"
Adiando...
"
:
"
Adiar tarefa
"
}
</
Button
>
</
DialogFooter
>
</
DialogContent
>
</
Dialog
>
);
}
src/components/tasks/task-table.tsx
View file @
8db9e2b0
...
@@ -21,8 +21,10 @@ import {
...
@@ -21,8 +21,10 @@ import {
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
MetaBadge
}
from
"
./meta-badge
"
;
import
{
MetaBadge
}
from
"
./meta-badge
"
;
import
{
PostponedBadge
}
from
"
./postponed-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
TaskPostponeButton
}
from
"
./task-postpone-button
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
StatusBadge
}
from
"
./status-badge
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDeleteButton
}
from
"
./task-delete-button
"
;
import
{
TaskDialog
}
from
"
./task-dialog
"
;
import
{
TaskDialog
}
from
"
./task-dialog
"
;
...
@@ -250,6 +252,10 @@ export function TaskTable({
...
@@ -250,6 +252,10 @@ export function TaskTable({
subtaskDone
=
{
task
.
SubtaskDone
}
subtaskDone
=
{
task
.
SubtaskDone
}
/>
/>
<
SubtaskOverdueBadge
subtasks
=
{
subtasks
}
/>
<
SubtaskOverdueBadge
subtasks
=
{
subtasks
}
/>
<
PostponedBadge
postponed
=
{
task
.
Postponed
}
originalDueDate
=
{
task
.
OriginalDueDate
}
/>
</
div
>
</
div
>
{
task
.
Description
&&
(
{
task
.
Description
&&
(
<
p
className
=
"mt-0.5 text-xs text-muted-foreground"
>
<
p
className
=
"mt-0.5 text-xs text-muted-foreground"
>
...
@@ -346,7 +352,19 @@ export function TaskTable({
...
@@ -346,7 +352,19 @@ export function TaskTable({
<
TableCell
className
=
"py-3 align-top"
>
<
TableCell
className
=
"py-3 align-top"
>
<
span
className
=
"block whitespace-nowrap text-sm leading-6 text-muted-foreground"
>
<
span
className
=
"block whitespace-nowrap text-sm leading-6 text-muted-foreground"
>
{
task
.
CompletedDate
?
(
{
task
.
CompletedDate
?
(
<
span
className
=
"text-green-600 dark:text-green-400"
>
<
span
className
=
{
cn
(
task
.
Postponed
?
"
font-medium text-amber-600 dark:text-amber-400
"
:
"
text-green-600 dark:text-green-400
"
)
}
title
=
{
task
.
Postponed
?
"
Concluída após adiamento
"
:
"
Concluída no prazo
"
}
>
{
task
.
Postponed
&&
"
⤴
"
}
{
formatDateShort
(
task
.
CompletedDate
)
}
{
formatDateShort
(
task
.
CompletedDate
)
}
</
span
>
</
span
>
)
:
(
)
:
(
...
@@ -357,6 +375,11 @@ export function TaskTable({
...
@@ -357,6 +375,11 @@ export function TaskTable({
<
TableCell
className
=
"py-3"
>
<
TableCell
className
=
"py-3"
>
{
editable
&&
(
{
editable
&&
(
<
div
className
=
"flex items-center justify-end transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<
div
className
=
"flex items-center justify-end transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<
TaskPostponeButton
taskId
=
{
task
.
Id
}
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
<
TaskDialog
<
TaskDialog
workspaceId
=
{
workspaceId
}
workspaceId
=
{
workspaceId
}
members
=
{
members
}
members
=
{
members
}
...
@@ -414,6 +437,10 @@ export function TaskTable({
...
@@ -414,6 +437,10 @@ export function TaskTable({
{
sub
.
Title
}
{
sub
.
Title
}
</
span
>
</
span
>
<
PriorityBadge
priority
=
{
sub
.
Priority
}
/>
<
PriorityBadge
priority
=
{
sub
.
Priority
}
/>
<
PostponedBadge
postponed
=
{
sub
.
Postponed
}
originalDueDate
=
{
sub
.
OriginalDueDate
}
/>
</
div
>
</
div
>
{
sub
.
Description
&&
(
{
sub
.
Description
&&
(
<
p
className
=
"mt-0.5 text-xs text-muted-foreground"
>
<
p
className
=
"mt-0.5 text-xs text-muted-foreground"
>
...
@@ -474,6 +501,11 @@ export function TaskTable({
...
@@ -474,6 +501,11 @@ export function TaskTable({
</
div
>
</
div
>
{
subEditable
&&
(
{
subEditable
&&
(
<
div
className
=
"flex items-center"
>
<
div
className
=
"flex items-center"
>
<
TaskPostponeButton
taskId
=
{
sub
.
Id
}
dueDate
=
{
sub
.
DueDate
}
status
=
{
sub
.
Status
}
/>
<
TaskDialog
<
TaskDialog
workspaceId
=
{
workspaceId
}
workspaceId
=
{
workspaceId
}
members
=
{
members
}
members
=
{
members
}
...
...
src/lib/actions.ts
View file @
8db9e2b0
...
@@ -19,6 +19,7 @@ import {
...
@@ -19,6 +19,7 @@ import {
}
from
"
./authz
"
;
}
from
"
./authz
"
;
import
*
as
data
from
"
./data
"
;
import
*
as
data
from
"
./data
"
;
import
{
TASK_PRIORITIES
,
TASK_STATUSES
}
from
"
./constants
"
;
import
{
TASK_PRIORITIES
,
TASK_STATUSES
}
from
"
./constants
"
;
import
{
getDueInfo
,
toISODate
}
from
"
./dates
"
;
export
interface
ActionState
{
export
interface
ActionState
{
error
?:
string
;
error
?:
string
;
...
@@ -533,6 +534,52 @@ export async function updateTaskProgressAction(
...
@@ -533,6 +534,52 @@ export async function updateTaskProgressAction(
return
{
success
:
true
};
return
{
success
:
true
};
}
}
/**
* Adia uma tarefa vencida para uma nova previsão de término e marca a flag
* "adiada" — é a única forma de mexer na previsão depois da criação.
*/
export
async
function
postponeTaskAction
(
taskId
:
number
,
newDueDate
:
string
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
if
(
!
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
newDueDate
))
{
return
{
error
:
"
Data inválida.
"
};
}
try
{
const
task
=
await
data
.
getTask
(
taskId
);
if
(
!
task
)
return
{
error
:
"
Tarefa não encontrada.
"
};
await
assertCanEditTask
(
task
,
user
);
if
(
task
.
Status
===
"
done
"
)
{
return
{
error
:
"
Uma tarefa concluída não pode ser adiada.
"
};
}
const
currentDue
=
toISODate
(
task
.
DueDate
);
if
(
!
currentDue
)
{
return
{
error
:
"
Tarefa sem previsão de término não pode ser adiada.
"
};
}
if
(
getDueInfo
(
task
.
DueDate
,
task
.
Status
)?.
state
!==
"
overdue
"
)
{
return
{
error
:
"
Só é possível adiar uma tarefa já vencida.
"
};
}
if
(
newDueDate
<=
currentDue
)
{
return
{
error
:
"
A nova previsão deve ser posterior à atual.
"
};
}
// Reaproveita a classificação de vencimento p/ barrar data no passado.
if
(
getDueInfo
(
newDueDate
,
"
todo
"
)?.
state
===
"
overdue
"
)
{
return
{
error
:
"
A nova previsão não pode estar no passado.
"
};
}
await
data
.
postponeTask
(
taskId
,
newDueDate
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao adiar a tarefa.
"
,
};
}
revalidatePath
(
"
/dashboard
"
);
return
{
success
:
true
};
}
export
async
function
deleteTaskAction
(
taskId
:
number
):
Promise
<
ActionState
>
{
export
async
function
deleteTaskAction
(
taskId
:
number
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
const
user
=
await
requireUser
();
try
{
try
{
...
...
src/lib/data.ts
View file @
8db9e2b0
...
@@ -42,6 +42,10 @@ export interface Task {
...
@@ -42,6 +42,10 @@ export interface Task {
StartDate
:
Date
|
null
;
StartDate
:
Date
|
null
;
DueDate
:
Date
|
null
;
DueDate
:
Date
|
null
;
CompletedDate
:
Date
|
null
;
CompletedDate
:
Date
|
null
;
/** True quando a tarefa foi adiada após vencer. */
Postponed
:
boolean
;
/** Previsão de término antes do primeiro adiamento. */
OriginalDueDate
:
Date
|
null
;
Progress
:
number
;
Progress
:
number
;
MetaType
:
string
|
null
;
MetaType
:
string
|
null
;
MetaValue
:
number
|
null
;
MetaValue
:
number
|
null
;
...
@@ -272,7 +276,8 @@ const TASK_SELECT = `
...
@@ -272,7 +276,8 @@ const TASK_SELECT = `
SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority,
SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority,
t.Entry,
t.Entry,
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById,
t.AssigneeId, a.Name AS AssigneeName, t.CreatedById,
t.StartDate, t.DueDate, t.CompletedDate, t.Progress, t.MetaType, t.MetaValue, t.CreatedAt, w.Name AS WorkspaceName,
t.StartDate, t.DueDate, t.CompletedDate, t.Postponed, t.OriginalDueDate,
t.Progress, t.MetaType, t.MetaValue, t.CreatedAt, w.Name AS WorkspaceName,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id) AS SubtaskCount,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone,
(SELECT COUNT(*) FROM dbo.Tasks s WHERE s.Entry = t.Id AND s.Status = 'done') AS SubtaskDone,
(SELECT u.Id AS UserId, u.Name
(SELECT u.Id AS UserId, u.Name
...
@@ -429,6 +434,30 @@ export async function updateTaskStatus(
...
@@ -429,6 +434,30 @@ export async function updateTaskStatus(
);
);
}
}
/**
* Adia uma tarefa vencida: move a previsão de término e marca a flag.
* COALESCE preserva a previsão original mesmo em adiamentos seguintes —
* é a única exceção à imutabilidade de DueDate.
*/
export
async
function
postponeTask
(
id
:
number
,
newDueDate
:
string
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
input
(
"
dueDate
"
,
sql
.
Date
,
newDueDate
)
.
query
(
`UPDATE dbo.Tasks
SET OriginalDueDate = COALESCE(OriginalDueDate, DueDate),
DueDate = @dueDate,
Postponed = 1,
UpdatedAt = SYSUTCDATETIME()
WHERE Id = @id`
);
}
export
async
function
updateTaskProgress
(
export
async
function
updateTaskProgress
(
id
:
number
,
id
:
number
,
progress
:
number
progress
:
number
...
...
src/lib/db-init.ts
View file @
8db9e2b0
...
@@ -48,6 +48,8 @@ const DDL_STATEMENTS: string[] = [
...
@@ -48,6 +48,8 @@ const DDL_STATEMENTS: string[] = [
StartDate DATE NULL,
StartDate DATE NULL,
DueDate DATE NULL,
DueDate DATE NULL,
CompletedDate DATE NULL,
CompletedDate DATE NULL,
Postponed BIT NOT NULL DEFAULT 0,
OriginalDueDate DATE NULL,
Progress INT NOT NULL DEFAULT 0,
Progress INT NOT NULL DEFAULT 0,
MetaType NVARCHAR(10) NULL,
MetaType NVARCHAR(10) NULL,
MetaValue INT NULL,
MetaValue INT NULL,
...
@@ -75,6 +77,13 @@ const DDL_STATEMENTS: string[] = [
...
@@ -75,6 +77,13 @@ const DDL_STATEMENTS: string[] = [
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`
,
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`
,
// Adiamento de tarefa vencida: flag + previsão original preservada
`IF COL_LENGTH('dbo.Tasks', 'Postponed') IS NULL
ALTER TABLE dbo.Tasks ADD Postponed BIT NOT NULL DEFAULT 0`
,
`IF COL_LENGTH('dbo.Tasks', 'OriginalDueDate') IS NULL
ALTER TABLE dbo.Tasks ADD OriginalDueDate DATE NULL`
,
// Tarefas compartilhadas: usuários vinculados além do responsável
// Tarefas compartilhadas: usuários vinculados além do responsável
`IF OBJECT_ID('dbo.TaskCollaborators', 'U') IS NULL
`IF OBJECT_ID('dbo.TaskCollaborators', 'U') IS NULL
CREATE TABLE dbo.TaskCollaborators (
CREATE TABLE dbo.TaskCollaborators (
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment