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
54046b0e
Commit
54046b0e
authored
Aug 04, 2026
by
Paulo Avila
Browse files
junção de todas
parent
36e1afbc
Changes
10
Hide whitespace changes
Inline
Side-by-side
src/components/tasks/meta-badge.tsx
View file @
54046b0e
...
@@ -29,17 +29,27 @@ export function MetaBadge({
...
@@ -29,17 +29,27 @@ export function MetaBadge({
below
:
"
bg-amber-500 text-white hover:bg-amber-500
"
,
below
:
"
bg-amber-500 text-white hover:bg-amber-500
"
,
}
as
const
;
}
as
const
;
const
stateWord
=
info
.
state
===
"
exceeded
"
?
"
acima
"
:
info
.
state
===
"
below
"
?
"
abaixo
"
:
null
;
return
(
return
(
<
Badge
<
Badge
title
=
{
info
.
label
}
title
=
{
info
.
label
}
className
=
{
cn
(
className
=
{
cn
(
"
gap-
0.5
px-
1.5
py-0 text-
[10px] font-medium leading-4
"
,
"
gap-
1
px-
2
py-0
.5
text-
xs font-semibold
"
,
styles
[
info
.
state
],
styles
[
info
.
state
],
className
className
)
}
)
}
>
>
<
Target
className
=
"h-
2
.5 w-
2
.5 shrink-0"
/>
<
Target
className
=
"h-
3
.5 w-
3
.5 shrink-0"
/>
{
info
.
shortLabel
}
{
info
.
shortLabel
}
{
stateWord
&&
(
<
span
className
=
"font-medium opacity-90"
>
·
{
stateWord
}
</
span
>
)
}
</
Badge
>
</
Badge
>
);
);
}
}
src/components/tasks/task-board.tsx
View file @
54046b0e
...
@@ -7,8 +7,10 @@ import { Button } from "@/components/ui/button";
...
@@ -7,8 +7,10 @@ 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
{
DueBadge
}
from
"
./due-badge
"
;
import
{
DueBadge
}
from
"
./due-badge
"
;
import
{
MetaBadge
}
from
"
./meta-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
TaskProductionButton
}
from
"
./task-production-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
"
;
...
@@ -92,10 +94,25 @@ export function TaskBoard({
...
@@ -92,10 +94,25 @@ export function TaskBoard({
<
div
className
=
"flex flex-wrap items-center gap-1.5"
>
<
div
className
=
"flex flex-wrap items-center gap-1.5"
>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
<
DueBadge
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
<
DueBadge
dueDate
=
{
task
.
DueDate
}
status
=
{
task
.
Status
}
/>
<
MetaBadge
metaType
=
{
task
.
MetaType
}
metaValue
=
{
task
.
MetaValue
}
subtaskCount
=
{
task
.
SubtaskCount
}
subtaskDone
=
{
task
.
SubtaskDone
}
/>
<
SubtaskOverdueBadge
<
SubtaskOverdueBadge
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
subtasks
=
{
subtasksByTask
[
task
.
Id
]
??
[]
}
compact
compact
/>
/>
{
task
.
StandardSeconds
!=
null
&&
(
<
TaskProductionButton
taskId
=
{
task
.
Id
}
standardSeconds
=
{
task
.
StandardSeconds
}
prodQty
=
{
task
.
ProdQty
}
prodSeconds
=
{
task
.
ProdSeconds
}
editable
=
{
editable
}
/>
)
}
</
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"
/>
...
...
src/components/tasks/task-dialog.tsx
View file @
54046b0e
...
@@ -30,6 +30,7 @@ import {
...
@@ -30,6 +30,7 @@ import {
SelectValue
,
SelectValue
,
}
from
"
@/components/ui/select
"
;
}
from
"
@/components/ui/select
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
import
{
Checkbox
}
from
"
@/components/ui/checkbox
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
export
interface
TaskFormValues
{
export
interface
TaskFormValues
{
...
@@ -45,6 +46,9 @@ export interface TaskFormValues {
...
@@ -45,6 +46,9 @@ export interface TaskFormValues {
progress
:
number
;
progress
:
number
;
entry
:
number
|
null
;
entry
:
number
|
null
;
collaboratorIds
:
number
[];
collaboratorIds
:
number
[];
metaType
:
string
|
null
;
// 'teto' | 'piso' | null
metaValue
:
number
|
null
;
standardMinutes
:
number
|
null
;
}
}
function
SubmitButton
({
function
SubmitButton
({
...
@@ -107,6 +111,8 @@ export function TaskDialog({
...
@@ -107,6 +111,8 @@ export function TaskDialog({
const
[
collabs
,
setCollabs
]
=
useState
<
number
[]
>
(
const
[
collabs
,
setCollabs
]
=
useState
<
number
[]
>
(
task
?.
collaboratorIds
??
[]
task
?.
collaboratorIds
??
[]
);
);
const
[
metaType
,
setMetaType
]
=
useState
<
string
>
(
task
?.
metaType
??
"
none
"
);
const
[
measure
,
setMeasure
]
=
useState
(
task
?.
standardMinutes
!=
null
);
const
action
=
task
const
action
=
task
?
updateTaskAction
.
bind
(
null
,
task
.
id
)
?
updateTaskAction
.
bind
(
null
,
task
.
id
)
:
createTaskAction
;
:
createTaskAction
;
...
@@ -327,6 +333,75 @@ export function TaskDialog({
...
@@ -327,6 +333,75 @@ export function TaskDialog({
:
"
Digite quanto da tarefa já foi concluído (0 a 100).
"
}
:
"
Digite quanto da tarefa já foi concluído (0 a 100).
"
}
</
p
>
</
p
>
</
div
>
</
div
>
{
isMotherTask
&&
(
<>
<
div
className
=
"space-y-2 rounded-lg border bg-muted/30 p-3"
>
<
div
className
=
"grid grid-cols-2 gap-4"
>
<
div
className
=
"space-y-2"
>
<
Label
>
Meta de subtarefas em aberto
</
Label
>
<
Select
name
=
"metaType"
value
=
{
metaType
}
onValueChange
=
{
setMetaType
}
>
<
SelectTrigger
>
<
SelectValue
/>
</
SelectTrigger
>
<
SelectContent
>
<
SelectItem
value
=
"none"
>
Sem meta
</
SelectItem
>
<
SelectItem
value
=
"teto"
>
Teto (máximo)
</
SelectItem
>
<
SelectItem
value
=
"piso"
>
Piso (mínimo)
</
SelectItem
>
</
SelectContent
>
</
Select
>
</
div
>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
"task-meta-value"
>
Quantidade
</
Label
>
<
Input
id
=
"task-meta-value"
name
=
"metaValue"
type
=
"number"
min
=
{
1
}
defaultValue
=
{
task
?.
metaValue
??
""
}
disabled
=
{
metaType
===
"
none
"
}
placeholder
=
"Ex.: 10"
/>
</
div
>
</
div
>
<
p
className
=
"text-xs text-muted-foreground"
>
{
metaType
===
"
teto
"
?
"
Fica fora da meta quando houver mais subtarefas em aberto que o teto.
"
:
metaType
===
"
piso
"
?
"
Fica fora da meta quando houver menos subtarefas em aberto que o piso.
"
:
"
Defina um teto (máximo) ou piso (mínimo) de subtarefas em aberto.
"
}
</
p
>
</
div
>
<
div
className
=
"space-y-2 rounded-lg border bg-muted/30 p-3"
>
<
label
className
=
"flex items-center gap-2 text-sm font-medium"
>
<
Checkbox
checked
=
{
measure
}
onCheckedChange
=
{
(
v
)
=>
setMeasure
(
v
===
true
)
}
/>
Medir em produção
</
label
>
{
measure
&&
(
<
Input
name
=
"standardMinutes"
type
=
"number"
min
=
{
0
}
step
=
"any"
required
defaultValue
=
{
task
?.
standardMinutes
??
""
}
placeholder
=
"Tempo-padrão por unidade, em minutos (ex.: 5)"
/>
)
}
<
p
className
=
"text-xs text-muted-foreground"
>
Registre a produção por lote (quantidade + tempo) na própria
tarefa. Quando o ritmo ultrapassar o tempo-padrão, ela entra em
“Fora da meta”.
</
p
>
</
div
>
</>
)
}
<
SubmitButton
isEdit
=
{
Boolean
(
task
)
}
isSubtask
=
{
isSubtask
}
/>
<
SubmitButton
isEdit
=
{
Boolean
(
task
)
}
isSubtask
=
{
isSubtask
}
/>
</
form
>
</
form
>
</
DialogContent
>
</
DialogContent
>
...
...
src/components/tasks/task-production-button.tsx
0 → 100644
View file @
54046b0e
"
use client
"
;
import
{
useEffect
,
useState
,
useTransition
}
from
"
react
"
;
import
{
ChevronRight
,
Gauge
,
Plus
,
Trash2
}
from
"
lucide-react
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
deleteProductionLogAction
,
listProductionLogAction
,
registerProductionAction
,
}
from
"
@/lib/actions
"
;
import
type
{
ProductionLogEntry
}
from
"
@/lib/data
"
;
import
{
formatDuration
,
formatPace
,
getProductionInfo
}
from
"
@/lib/production
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogHeader
,
DialogTitle
,
DialogTrigger
,
}
from
"
@/components/ui/dialog
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
import
{
cn
}
from
"
@/lib/utils
"
;
/**
* Pílula de produção da tarefa mensurável: mostra a quantidade e o ritmo ao
* vivo e, para quem pode editar, abre o registro de lotes (quantidade + tempo)
* com histórico.
*/
export
function
TaskProductionButton
({
taskId
,
standardSeconds
,
prodQty
,
prodSeconds
,
editable
,
}:
{
taskId
:
number
;
standardSeconds
:
number
|
null
;
prodQty
:
number
;
prodSeconds
:
number
;
editable
:
boolean
;
})
{
const
info
=
getProductionInfo
(
standardSeconds
,
prodQty
,
prodSeconds
);
const
[
open
,
setOpen
]
=
useState
(
false
);
const
[
entries
,
setEntries
]
=
useState
<
ProductionLogEntry
[]
|
null
>
(
null
);
const
[
qty
,
setQty
]
=
useState
(
""
);
const
[
minutes
,
setMinutes
]
=
useState
(
""
);
const
[
note
,
setNote
]
=
useState
(
""
);
const
[
pending
,
startTransition
]
=
useTransition
();
useEffect
(()
=>
{
if
(
!
open
)
return
;
let
active
=
true
;
setEntries
(
null
);
listProductionLogAction
(
taskId
).
then
((
res
)
=>
{
if
(
active
)
setEntries
(
res
.
entries
??
[]);
});
return
()
=>
{
active
=
false
;
};
},
[
open
,
taskId
]);
if
(
!
info
)
return
null
;
const
toneClass
=
info
.
withinMeta
==
null
?
"
border-border bg-muted text-foreground hover:bg-muted/70
"
:
info
.
withinMeta
?
"
border-emerald-600 bg-emerald-500 text-white hover:bg-emerald-600
"
:
"
border-amber-600 bg-amber-500 text-white hover:bg-amber-600
"
;
const
summary
=
info
.
perUnitSeconds
!=
null
?
`
${
info
.
totalQty
}
·
${
formatPace
(
info
.
perUnitSeconds
)}
/un`
:
`
${
info
.
totalQty
}
`
;
const
tooltip
=
`
${
info
.
totalQty
}
·
${
formatDuration
(
info
.
totalSeconds
)}
`
+
(
info
.
perUnitSeconds
!=
null
?
` ·
${
formatPace
(
info
.
perUnitSeconds
)}
/un (padrão
${
formatPace
(
info
.
standardSeconds
)}
/un) —
${
info
.
withinMeta
?
"
na meta
"
:
"
acima da meta
"
}
`
:
` · padrão
${
formatPace
(
info
.
standardSeconds
)}
/un`
);
const
pillClass
=
cn
(
"
inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-semibold shadow-sm transition-colors
"
,
toneClass
);
if
(
!
editable
)
{
return
(
<
span
className
=
{
pillClass
}
title
=
{
tooltip
}
>
<
Gauge
className
=
"h-3.5 w-3.5 shrink-0"
/>
{
summary
}
</
span
>
);
}
function
handleRegister
()
{
const
q
=
Number
(
qty
);
const
m
=
Number
(
minutes
);
if
(
!
Number
.
isInteger
(
q
)
||
q
<=
0
)
{
toast
.
error
(
"
Quantidade deve ser um inteiro maior que zero.
"
);
return
;
}
if
(
!
(
m
>
0
))
{
toast
.
error
(
"
Informe o tempo do lote em minutos.
"
);
return
;
}
startTransition
(
async
()
=>
{
const
res
=
await
registerProductionAction
(
taskId
,
q
,
m
,
note
);
if
(
res
.
error
)
{
toast
.
error
(
res
.
error
);
return
;
}
toast
.
success
(
"
Produção registrada.
"
);
setQty
(
""
);
setMinutes
(
""
);
setNote
(
""
);
const
list
=
await
listProductionLogAction
(
taskId
);
setEntries
(
list
.
entries
??
[]);
});
}
function
handleDelete
(
logId
:
number
)
{
startTransition
(
async
()
=>
{
const
res
=
await
deleteProductionLogAction
(
logId
,
taskId
);
if
(
res
.
error
)
{
toast
.
error
(
res
.
error
);
return
;
}
const
list
=
await
listProductionLogAction
(
taskId
);
setEntries
(
list
.
entries
??
[]);
});
}
return
(
<
Dialog
open
=
{
open
}
onOpenChange
=
{
setOpen
}
>
<
DialogTrigger
asChild
>
<
button
type
=
"button"
className
=
{
pillClass
}
title
=
{
`
${
tooltip
}
— clique para registrar`
}
>
<
Gauge
className
=
"h-3.5 w-3.5 shrink-0"
/>
{
summary
}
<
ChevronRight
className
=
"h-3.5 w-3.5 shrink-0 opacity-80"
/>
</
button
>
</
DialogTrigger
>
<
DialogContent
className
=
"max-h-[90vh] overflow-y-auto sm:max-w-md"
>
<
DialogHeader
>
<
DialogTitle
>
Produção
</
DialogTitle
>
<
DialogDescription
>
Registre a produção por lote — quantidade e tempo gasto.
</
DialogDescription
>
</
DialogHeader
>
<
div
className
=
"rounded-lg border p-3 text-sm"
>
<
div
className
=
"flex items-center justify-between"
>
<
span
className
=
"font-medium"
>
{
info
.
totalQty
}
</
span
>
<
span
className
=
"text-muted-foreground"
>
{
formatDuration
(
info
.
totalSeconds
)
}
</
span
>
</
div
>
<
div
className
=
"mt-1 flex items-center justify-between gap-2 text-xs"
>
<
span
className
=
"text-muted-foreground"
>
Padrão:
{
formatPace
(
info
.
standardSeconds
)
}
/un
</
span
>
{
info
.
perUnitSeconds
!=
null
&&
(
<
span
className
=
{
cn
(
"
rounded-full px-1.5 py-0.5 font-medium
"
,
info
.
withinMeta
?
"
bg-emerald-500/15 text-emerald-600 dark:text-emerald-400
"
:
"
bg-amber-500/15 text-amber-600 dark:text-amber-400
"
)
}
>
{
formatPace
(
info
.
perUnitSeconds
)
}
/un —
{
"
"
}
{
info
.
withinMeta
?
"
na meta
"
:
"
acima
"
}
</
span
>
)
}
</
div
>
</
div
>
<
div
className
=
"space-y-3"
>
<
div
className
=
"grid grid-cols-2 gap-3"
>
<
div
className
=
"space-y-1.5"
>
<
Label
htmlFor
=
"prod-qty"
>
Quantidade
</
Label
>
<
Input
id
=
"prod-qty"
type
=
"number"
min
=
{
1
}
step
=
{
1
}
value
=
{
qty
}
onChange
=
{
(
e
)
=>
setQty
(
e
.
target
.
value
)
}
placeholder
=
"Ex.: 12"
/>
</
div
>
<
div
className
=
"space-y-1.5"
>
<
Label
htmlFor
=
"prod-min"
>
Tempo (min)
</
Label
>
<
Input
id
=
"prod-min"
type
=
"number"
min
=
{
0
}
step
=
"any"
value
=
{
minutes
}
onChange
=
{
(
e
)
=>
setMinutes
(
e
.
target
.
value
)
}
placeholder
=
"Ex.: 50"
/>
</
div
>
</
div
>
<
div
className
=
"space-y-1.5"
>
<
Label
htmlFor
=
"prod-note"
>
Observação (opcional)
</
Label
>
<
Input
id
=
"prod-note"
maxLength
=
{
200
}
value
=
{
note
}
onChange
=
{
(
e
)
=>
setNote
(
e
.
target
.
value
)
}
/>
</
div
>
<
Button
onClick
=
{
handleRegister
}
disabled
=
{
pending
}
className
=
"w-full"
>
<
Plus
className
=
"mr-1.5 h-4 w-4"
/>
{
pending
?
"
Salvando...
"
:
"
Registrar lote
"
}
</
Button
>
</
div
>
<
div
className
=
"space-y-2"
>
<
p
className
=
"text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
Lançamentos
</
p
>
{
entries
==
null
?
(
<
p
className
=
"text-sm text-muted-foreground"
>
Carregando...
</
p
>
)
:
entries
.
length
===
0
?
(
<
p
className
=
"text-sm text-muted-foreground"
>
Nenhum lançamento ainda.
</
p
>
)
:
(
<
ul
className
=
"space-y-1.5"
>
{
entries
.
map
((
e
)
=>
(
<
li
key
=
{
e
.
Id
}
className
=
"flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-sm"
>
<
div
className
=
"min-w-0"
>
<
span
className
=
"font-medium"
>
{
e
.
Quantity
}
</
span
>
<
span
className
=
"text-muted-foreground"
>
{
"
·
"
}
{
formatDuration
(
e
.
DurationSeconds
)
}
{
"
·
"
}
{
formatPace
(
e
.
DurationSeconds
/
e
.
Quantity
)
}
/un
</
span
>
{
e
.
Note
&&
(
<
span
className
=
"block truncate text-xs text-muted-foreground"
>
{
e
.
Note
}
</
span
>
)
}
<
span
className
=
"block text-xs text-muted-foreground"
>
{
e
.
UserName
}
·
{
"
"
}
{
new
Date
(
e
.
LoggedAt
).
toLocaleTimeString
(
"
pt-BR
"
,
{
hour
:
"
2-digit
"
,
minute
:
"
2-digit
"
,
})
}
</
span
>
</
div
>
<
Button
variant
=
"ghost"
size
=
"icon"
className
=
"h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
title
=
"Excluir lançamento"
disabled
=
{
pending
}
onClick
=
{
()
=>
handleDelete
(
e
.
Id
)
}
>
<
Trash2
className
=
"h-3.5 w-3.5"
/>
</
Button
>
</
li
>
))
}
</
ul
>
)
}
</
div
>
</
DialogContent
>
</
Dialog
>
);
}
src/components/tasks/task-table.tsx
View file @
54046b0e
"
use client
"
;
"
use client
"
;
import
{
Fragment
,
useState
}
from
"
react
"
;
import
{
Fragment
,
useState
}
from
"
react
"
;
import
{
AlertTriangle
,
ChevronRight
,
Pencil
,
Plus
}
from
"
lucide-react
"
;
import
{
AlertTriangle
,
ChevronRight
,
Pencil
,
Plus
,
Target
}
from
"
lucide-react
"
;
import
type
{
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
type
{
Task
,
WorkspaceMember
}
from
"
@/lib/data
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
{
TASK_STATUSES
}
from
"
@/lib/constants
"
;
import
{
formatDateShort
,
getDueInfo
}
from
"
@/lib/dates
"
;
import
{
formatDateShort
,
getDueInfo
}
from
"
@/lib/dates
"
;
import
{
getMetaInfo
}
from
"
@/lib/meta
"
;
import
{
getProductionInfo
}
from
"
@/lib/production
"
;
import
{
canDeleteTask
,
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
canDeleteTask
,
canEditTask
}
from
"
@/lib/permissions
"
;
import
{
initials
,
toFormValues
}
from
"
./task-utils
"
;
import
{
initials
,
toFormValues
}
from
"
./task-utils
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
import
{
Avatar
,
AvatarFallback
}
from
"
@/components/ui/avatar
"
;
...
@@ -19,11 +21,13 @@ import {
...
@@ -19,11 +21,13 @@ import {
}
from
"
@/components/ui/table
"
;
}
from
"
@/components/ui/table
"
;
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
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
PriorityBadge
}
from
"
./priority-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
import
{
SubtaskOverdueBadge
}
from
"
./subtask-overdue-badge
"
;
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
"
;
import
{
TaskProductionButton
}
from
"
./task-production-button
"
;
import
{
TaskProgress
}
from
"
./task-progress
"
;
import
{
TaskProgress
}
from
"
./task-progress
"
;
import
{
TaskProgressEditor
}
from
"
./task-progress-editor
"
;
import
{
TaskProgressEditor
}
from
"
./task-progress-editor
"
;
import
{
TaskStatusSelect
}
from
"
./task-status-select
"
;
import
{
TaskStatusSelect
}
from
"
./task-status-select
"
;
...
@@ -67,6 +71,20 @@ export function TaskTable({
...
@@ -67,6 +71,20 @@ export function TaskTable({
const
isOverdue
=
(
t
:
Task
)
=>
const
isOverdue
=
(
t
:
Task
)
=>
getDueInfo
(
t
.
DueDate
,
t
.
Status
)?.
state
===
"
overdue
"
;
getDueInfo
(
t
.
DueDate
,
t
.
Status
)?.
state
===
"
overdue
"
;
// Fora da meta: teto/piso de subtarefas estourado OU ritmo de produção
// acima do tempo-padrão.
const
isOffTarget
=
(
t
:
Task
)
=>
{
const
meta
=
getMetaInfo
(
t
.
MetaType
,
t
.
MetaValue
,
t
.
SubtaskCount
,
t
.
SubtaskDone
);
if
(
meta
!=
null
&&
!
meta
.
ok
)
return
true
;
const
prod
=
getProductionInfo
(
t
.
StandardSeconds
,
t
.
ProdQty
,
t
.
ProdSeconds
);
return
prod
!=
null
&&
prod
.
withinMeta
===
false
;
};
// Ordena por criação mais recente primeiro (ID decrescente), em todos os
// Ordena por criação mais recente primeiro (ID decrescente), em todos os
// estados — a tarefa adicionada por último aparece no topo.
// estados — a tarefa adicionada por último aparece no topo.
const
sortTasks
=
(
list
:
Task
[])
=>
[...
list
].
sort
((
a
,
b
)
=>
b
.
Id
-
a
.
Id
);
const
sortTasks
=
(
list
:
Task
[])
=>
[...
list
].
sort
((
a
,
b
)
=>
b
.
Id
-
a
.
Id
);
...
@@ -76,16 +94,19 @@ export function TaskTable({
...
@@ -76,16 +94,19 @@ export function TaskTable({
?
tasks
?
tasks
:
statusFilter
===
"
overdue
"
:
statusFilter
===
"
overdue
"
?
tasks
.
filter
(
isOverdue
)
?
tasks
.
filter
(
isOverdue
)
:
tasks
.
filter
((
t
)
=>
t
.
Status
===
statusFilter
)
:
statusFilter
===
"
off_target
"
?
tasks
.
filter
(
isOffTarget
)
:
tasks
.
filter
((
t
)
=>
t
.
Status
===
statusFilter
)
);
);
const
overdueCount
=
tasks
.
filter
(
isOverdue
).
length
;
const
overdueCount
=
tasks
.
filter
(
isOverdue
).
length
;
const
offTargetCount
=
tasks
.
filter
(
isOffTarget
).
length
;
const
filterOptions
:
{
const
filterOptions
:
{
value
:
string
;
value
:
string
;
label
:
string
;
label
:
string
;
count
:
number
;
count
:
number
;
tone
?:
"
danger
"
;
tone
?:
"
danger
"
|
"
warning
"
;
}[]
=
[
}[]
=
[
{
value
:
"
all
"
,
label
:
"
Todas
"
,
count
:
tasks
.
length
},
{
value
:
"
all
"
,
label
:
"
Todas
"
,
count
:
tasks
.
length
},
...
TASK_STATUSES
.
map
((
s
)
=>
({
...
TASK_STATUSES
.
map
((
s
)
=>
({
...
@@ -94,6 +115,12 @@ export function TaskTable({
...
@@ -94,6 +115,12 @@ export function TaskTable({
count
:
tasks
.
filter
((
t
)
=>
t
.
Status
===
s
.
value
).
length
,
count
:
tasks
.
filter
((
t
)
=>
t
.
Status
===
s
.
value
).
length
,
})),
})),
{
value
:
"
overdue
"
,
label
:
"
Vencidas
"
,
count
:
overdueCount
,
tone
:
"
danger
"
},
{
value
:
"
overdue
"
,
label
:
"
Vencidas
"
,
count
:
overdueCount
,
tone
:
"
danger
"
},
{
value
:
"
off_target
"
,
label
:
"
Fora da meta
"
,
count
:
offTargetCount
,
tone
:
"
warning
"
,
},
];
];
return
(
return
(
...
@@ -101,15 +128,24 @@ export function TaskTable({
...
@@ -101,15 +128,24 @@ export function TaskTable({
<
div
className
=
"flex flex-wrap items-center gap-2"
>
<
div
className
=
"flex flex-wrap items-center gap-2"
>
{
filterOptions
.
map
((
option
)
=>
{
{
filterOptions
.
map
((
option
)
=>
{
const
isActive
=
statusFilter
===
option
.
value
;
const
isActive
=
statusFilter
===
option
.
value
;
const
Icon
=
option
.
tone
===
"
danger
"
?
AlertTriangle
:
null
;
const
Icon
=
option
.
tone
===
"
danger
"
?
AlertTriangle
:
option
.
tone
===
"
warning
"
?
Target
:
null
;
const
activeClass
=
const
activeClass
=
option
.
tone
===
"
danger
"
option
.
tone
===
"
danger
"
?
"
border-destructive bg-destructive text-destructive-foreground
"
?
"
border-destructive bg-destructive text-destructive-foreground
"
:
"
border-primary bg-primary text-primary-foreground
"
;
:
option
.
tone
===
"
warning
"
?
"
border-amber-500 bg-amber-500 text-white
"
:
"
border-primary bg-primary text-primary-foreground
"
;
const
idleClass
=
const
idleClass
=
option
.
tone
===
"
danger
"
&&
option
.
count
>
0
option
.
tone
===
"
danger
"
&&
option
.
count
>
0
?
"
border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20
"
?
"
border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20
"
:
"
border-border bg-card text-muted-foreground hover:bg-muted
"
;
:
option
.
tone
===
"
warning
"
&&
option
.
count
>
0
?
"
border-amber-500/40 bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 dark:text-amber-400
"
:
"
border-border bg-card text-muted-foreground hover:bg-muted
"
;
return
(
return
(
<
button
<
button
key
=
{
option
.
value
}
key
=
{
option
.
value
}
...
@@ -213,7 +249,22 @@ export function TaskTable({
...
@@ -213,7 +249,22 @@ export function TaskTable({
{
task
.
Title
}
{
task
.
Title
}
</
span
>
</
span
>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
<
PriorityBadge
priority
=
{
task
.
Priority
}
/>
<
MetaBadge
metaType
=
{
task
.
MetaType
}
metaValue
=
{
task
.
MetaValue
}
subtaskCount
=
{
task
.
SubtaskCount
}
subtaskDone
=
{
task
.
SubtaskDone
}
/>
<
SubtaskOverdueBadge
subtasks
=
{
subtasks
}
/>
<
SubtaskOverdueBadge
subtasks
=
{
subtasks
}
/>
{
task
.
StandardSeconds
!=
null
&&
(
<
TaskProductionButton
taskId
=
{
task
.
Id
}
standardSeconds
=
{
task
.
StandardSeconds
}
prodQty
=
{
task
.
ProdQty
}
prodSeconds
=
{
task
.
ProdSeconds
}
editable
=
{
editable
}
/>
)
}
</
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"
>
...
...
src/components/tasks/task-utils.ts
View file @
54046b0e
...
@@ -26,6 +26,10 @@ export function toFormValues(task: Task): TaskFormValues {
...
@@ -26,6 +26,10 @@ export function toFormValues(task: Task): TaskFormValues {
progress
:
task
.
Progress
,
progress
:
task
.
Progress
,
entry
:
task
.
Entry
??
null
,
entry
:
task
.
Entry
??
null
,
collaboratorIds
:
task
.
Collaborators
.
map
((
c
)
=>
c
.
UserId
),
collaboratorIds
:
task
.
Collaborators
.
map
((
c
)
=>
c
.
UserId
),
metaType
:
task
.
MetaType
,
metaValue
:
task
.
MetaValue
,
standardMinutes
:
task
.
StandardSeconds
!=
null
?
task
.
StandardSeconds
/
60
:
null
,
};
};
}
}
...
...
src/lib/actions.ts
View file @
54046b0e
...
@@ -311,11 +311,30 @@ const taskSchema = z
...
@@ -311,11 +311,30 @@ const taskSchema = z
entry
:
z
.
coerce
.
number
().
int
().
positive
().
optional
(),
entry
:
z
.
coerce
.
number
().
int
().
positive
().
optional
(),
// Usuários vinculados (compartilhamento) — só na tarefa-mãe.
// Usuários vinculados (compartilhamento) — só na tarefa-mãe.
collaborators
:
z
.
array
(
z
.
coerce
.
number
().
int
().
positive
()).
optional
(),
collaborators
:
z
.
array
(
z
.
coerce
.
number
().
int
().
positive
()).
optional
(),
// Meta de subtarefas em aberto (teto/piso) — só na tarefa-mãe.
metaType
:
z
.
enum
([
"
teto
"
,
"
piso
"
]).
optional
(),
metaValue
:
z
.
coerce
.
number
()
.
int
()
.
positive
(
"
A meta deve ser um número maior que zero.
"
)
.
optional
(),
// Medição por produção: tempo-padrão por unidade (em minutos).
standardMinutes
:
z
.
coerce
.
number
().
positive
().
max
(
1440
).
optional
(),
})
})
.
refine
(
.
refine
(
(
t
)
=>
!
t
.
startDate
||
!
t
.
dueDate
||
t
.
startDate
<=
t
.
dueDate
,
(
t
)
=>
!
t
.
startDate
||
!
t
.
dueDate
||
t
.
startDate
<=
t
.
dueDate
,
{
message
:
"
A data de início não pode ser depois do vencimento.
"
}
{
message
:
"
A data de início não pode ser depois do vencimento.
"
}
);
)
.
refine
((
t
)
=>
!
t
.
metaType
||
t
.
metaValue
!==
undefined
,
{
message
:
"
Informe a quantidade da meta.
"
,
path
:
[
"
metaValue
"
],
});
function
parseMetaType
(
value
:
FormDataEntryValue
|
null
):
"
teto
"
|
"
piso
"
|
undefined
{
return
value
===
"
teto
"
||
value
===
"
piso
"
?
value
:
undefined
;
}
function
parseTaskForm
(
formData
:
FormData
)
{
function
parseTaskForm
(
formData
:
FormData
)
{
return
taskSchema
.
safeParse
({
return
taskSchema
.
safeParse
({
...
@@ -330,6 +349,9 @@ function parseTaskForm(formData: FormData) {
...
@@ -330,6 +349,9 @@ function parseTaskForm(formData: FormData) {
progress
:
formData
.
get
(
"
progress
"
)
||
undefined
,
progress
:
formData
.
get
(
"
progress
"
)
||
undefined
,
entry
:
formData
.
get
(
"
entry
"
)
||
undefined
,
entry
:
formData
.
get
(
"
entry
"
)
||
undefined
,
collaborators
:
formData
.
getAll
(
"
collaborators
"
),
collaborators
:
formData
.
getAll
(
"
collaborators
"
),
metaType
:
parseMetaType
(
formData
.
get
(
"
metaType
"
)),
metaValue
:
formData
.
get
(
"
metaValue
"
)
||
undefined
,
standardMinutes
:
formData
.
get
(
"
standardMinutes
"
)
||
undefined
,
});
});
}
}
...
@@ -386,6 +408,13 @@ export async function createTaskAction(
...
@@ -386,6 +408,13 @@ export async function createTaskAction(
dueDate
:
parsed
.
data
.
dueDate
??
null
,
dueDate
:
parsed
.
data
.
dueDate
??
null
,
progress
:
parsed
.
data
.
progress
??
0
,
progress
:
parsed
.
data
.
progress
??
0
,
entry
:
parsed
.
data
.
entry
??
null
,
entry
:
parsed
.
data
.
entry
??
null
,
// Meta e produção só valem para tarefa-mãe.
metaType
:
parsed
.
data
.
entry
?
null
:
(
parsed
.
data
.
metaType
??
null
),
metaValue
:
parsed
.
data
.
entry
?
null
:
(
parsed
.
data
.
metaValue
??
null
),
standardSeconds
:
parsed
.
data
.
entry
||
parsed
.
data
.
standardMinutes
==
null
?
null
:
Math
.
round
(
parsed
.
data
.
standardMinutes
*
60
),
},
},
user
.
id
user
.
id
);
);
...
@@ -443,6 +472,13 @@ export async function updateTaskAction(
...
@@ -443,6 +472,13 @@ export async function updateTaskAction(
startDate
:
parsed
.
data
.
startDate
??
null
,
startDate
:
parsed
.
data
.
startDate
??
null
,
dueDate
:
parsed
.
data
.
dueDate
??
null
,
dueDate
:
parsed
.
data
.
dueDate
??
null
,
progress
:
parsed
.
data
.
progress
??
task
.
Progress
,
progress
:
parsed
.
data
.
progress
??
task
.
Progress
,
// Meta e produção só valem para tarefa-mãe.
metaType
:
task
.
Entry
!=
null
?
null
:
(
parsed
.
data
.
metaType
??
null
),
metaValue
:
task
.
Entry
!=
null
?
null
:
(
parsed
.
data
.
metaValue
??
null
),
standardSeconds
:
task
.
Entry
!=
null
||
parsed
.
data
.
standardMinutes
==
null
?
null
:
Math
.
round
(
parsed
.
data
.
standardMinutes
*
60
),
});
});
// Compartilhamento só em tarefa-mãe.
// Compartilhamento só em tarefa-mãe.
if
(
task
.
Entry
==
null
)
{
if
(
task
.
Entry
==
null
)
{
...
@@ -463,6 +499,80 @@ export async function updateTaskAction(
...
@@ -463,6 +499,80 @@ export async function updateTaskAction(
return
{
success
:
true
};
return
{
success
:
true
};
}
}
// ---------- Apontamentos de produção ----------
export
async
function
registerProductionAction
(
taskId
:
number
,
quantity
:
number
,
minutes
:
number
,
note
:
string
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
if
(
!
Number
.
isInteger
(
quantity
)
||
quantity
<=
0
)
{
return
{
error
:
"
A quantidade deve ser um inteiro maior que zero.
"
};
}
if
(
!
Number
.
isFinite
(
minutes
)
||
minutes
<=
0
)
{
return
{
error
:
"
O tempo do lote deve ser maior que zero.
"
};
}
try
{
const
task
=
await
data
.
getTask
(
taskId
);
if
(
!
task
)
return
{
error
:
"
Tarefa não encontrada.
"
};
if
(
!
task
.
StandardSeconds
)
{
return
{
error
:
"
Esta tarefa não mede produção.
"
};
}
await
assertCanEditTask
(
task
,
user
);
await
data
.
addProductionLog
(
taskId
,
user
.
id
,
quantity
,
Math
.
round
(
minutes
*
60
),
note
.
trim
()
||
null
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao registrar produção.
"
,
};
}
revalidatePath
(
"
/dashboard
"
);
return
{
success
:
true
};
}
export
async
function
deleteProductionLogAction
(
logId
:
number
,
taskId
:
number
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
try
{
const
task
=
await
data
.
getTask
(
taskId
);
if
(
!
task
)
return
{
error
:
"
Tarefa não encontrada.
"
};
await
assertCanEditTask
(
task
,
user
);
await
data
.
deleteProductionLog
(
logId
,
taskId
);
revalidatePath
(
`/workspaces/
${
task
.
WorkspaceId
}
`
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao excluir lançamento.
"
,
};
}
return
{
success
:
true
};
}
export
async
function
listProductionLogAction
(
taskId
:
number
):
Promise
<
{
entries
?:
data
.
ProductionLogEntry
[];
error
?:
string
}
>
{
const
user
=
await
requireUser
();
try
{
const
task
=
await
data
.
getTask
(
taskId
);
if
(
!
task
)
return
{
error
:
"
Tarefa não encontrada.
"
};
await
assertCanEditTask
(
task
,
user
);
return
{
entries
:
await
data
.
listProductionLog
(
taskId
)
};
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao carregar lançamentos.
"
,
};
}
}
export
async
function
updateTaskStatusAction
(
export
async
function
updateTaskStatusAction
(
taskId
:
number
,
taskId
:
number
,
status
:
string
status
:
string
...
...
src/lib/data.ts
View file @
54046b0e
...
@@ -46,6 +46,14 @@ export interface Task {
...
@@ -46,6 +46,14 @@ export interface Task {
CreatedAt
:
Date
;
CreatedAt
:
Date
;
SubtaskCount
:
number
;
SubtaskCount
:
number
;
SubtaskDone
:
number
;
SubtaskDone
:
number
;
/** Meta de subtarefas em aberto (teto/piso); null = sem meta. */
MetaType
:
string
|
null
;
MetaValue
:
number
|
null
;
/** Tempo-padrão por unidade em segundos (null = não mensurável). */
StandardSeconds
:
number
|
null
;
/** Totais apontados (0 quando não há apontamentos). */
ProdQty
:
number
;
ProdSeconds
:
number
;
/** Usuários vinculados além do responsável (compartilhamento). */
/** Usuários vinculados além do responsável (compartilhamento). */
Collaborators
:
{
UserId
:
number
;
Name
:
string
}[];
Collaborators
:
{
UserId
:
number
;
Name
:
string
}[];
}
}
...
@@ -270,9 +278,12 @@ const TASK_SELECT = `
...
@@ -270,9 +278,12 @@ 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.CreatedAt, w.Name AS WorkspaceName,
t.StartDate, t.DueDate, t.CompletedDate, t.Progress,
t.MetaType, t.MetaValue, t.StandardSeconds, 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,
COALESCE((SELECT SUM(pl.Quantity) FROM dbo.TaskProductionLog pl WHERE pl.TaskId = t.Id), 0) AS ProdQty,
COALESCE((SELECT SUM(pl.DurationSeconds) FROM dbo.TaskProductionLog pl WHERE pl.TaskId = t.Id), 0) AS ProdSeconds,
(SELECT u.Id AS UserId, u.Name
(SELECT u.Id AS UserId, u.Name
FROM dbo.TaskCollaborators tc JOIN dbo.Users u ON u.Id = tc.UserId
FROM dbo.TaskCollaborators tc JOIN dbo.Users u ON u.Id = tc.UserId
WHERE tc.TaskId = t.Id
WHERE tc.TaskId = t.Id
...
@@ -317,6 +328,9 @@ export interface TaskInput {
...
@@ -317,6 +328,9 @@ export interface TaskInput {
dueDate
:
string
|
null
;
dueDate
:
string
|
null
;
progress
:
number
;
progress
:
number
;
entry
?:
number
|
null
;
entry
?:
number
|
null
;
metaType
?:
string
|
null
;
metaValue
?:
number
|
null
;
standardSeconds
?:
number
|
null
;
}
}
export
async
function
createTask
(
export
async
function
createTask
(
input
:
TaskInput
,
input
:
TaskInput
,
...
@@ -335,13 +349,16 @@ export async function createTask(
...
@@ -335,13 +349,16 @@ export async function createTask(
.
input
(
"
startDate
"
,
sql
.
Date
,
input
.
startDate
)
.
input
(
"
startDate
"
,
sql
.
Date
,
input
.
startDate
)
.
input
(
"
dueDate
"
,
sql
.
Date
,
input
.
dueDate
)
.
input
(
"
dueDate
"
,
sql
.
Date
,
input
.
dueDate
)
.
input
(
"
progress
"
,
sql
.
Int
,
input
.
progress
)
.
input
(
"
progress
"
,
sql
.
Int
,
input
.
progress
)
.
input
(
"
metaType
"
,
sql
.
NVarChar
(
10
),
input
.
metaType
??
null
)
.
input
(
"
metaValue
"
,
sql
.
Int
,
input
.
metaValue
??
null
)
.
input
(
"
standardSeconds
"
,
sql
.
Int
,
input
.
standardSeconds
??
null
)
.
input
(
"
createdById
"
,
sql
.
Int
,
createdById
)
.
input
(
"
createdById
"
,
sql
.
Int
,
createdById
)
.
query
(
.
query
(
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress, CreatedById)
`INSERT INTO dbo.Tasks (WorkspaceId, Title, Description, Status, Priority, Entry, AssigneeId, StartDate, DueDate, CompletedDate, Progress,
MetaType, MetaValue, StandardSeconds,
CreatedById)
OUTPUT INSERTED.Id
OUTPUT INSERTED.Id
VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate,
VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
@progress, @createdById)`
@progress,
@metaType, @metaValue, @standardSeconds,
@createdById)`
);
);
return
result
.
recordset
[
0
].
Id
;
return
result
.
recordset
[
0
].
Id
;
}
}
...
@@ -375,6 +392,75 @@ export async function setTaskCollaborators(
...
@@ -375,6 +392,75 @@ export async function setTaskCollaborators(
}
}
}
}
// ---------- Apontamentos de produção (tarefa mensurável) ----------
export
interface
ProductionLogEntry
{
Id
:
number
;
TaskId
:
number
;
UserId
:
number
;
UserName
:
string
;
Quantity
:
number
;
DurationSeconds
:
number
;
Note
:
string
|
null
;
LoggedAt
:
Date
;
}
/** Lançamentos de produção de uma tarefa, do mais recente ao mais antigo. */
export
async
function
listProductionLog
(
taskId
:
number
):
Promise
<
ProductionLogEntry
[]
>
{
const
pool
=
await
getPool
();
const
result
=
await
pool
.
request
()
.
input
(
"
taskId
"
,
sql
.
Int
,
taskId
)
.
query
(
`SELECT pl.Id, pl.TaskId, pl.UserId, u.Name AS UserName,
pl.Quantity, pl.DurationSeconds, pl.Note, pl.LoggedAt
FROM dbo.TaskProductionLog pl
JOIN dbo.Users u ON u.Id = pl.UserId
WHERE pl.TaskId = @taskId
ORDER BY pl.Id DESC`
);
return
result
.
recordset
;
}
/** Registra um lote produzido (quantidade + tempo gasto). */
export
async
function
addProductionLog
(
taskId
:
number
,
userId
:
number
,
quantity
:
number
,
durationSeconds
:
number
,
note
:
string
|
null
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
taskId
"
,
sql
.
Int
,
taskId
)
.
input
(
"
userId
"
,
sql
.
Int
,
userId
)
.
input
(
"
quantity
"
,
sql
.
Int
,
quantity
)
.
input
(
"
durationSeconds
"
,
sql
.
Int
,
durationSeconds
)
.
input
(
"
note
"
,
sql
.
NVarChar
(
200
),
note
)
.
query
(
`INSERT INTO dbo.TaskProductionLog (TaskId, UserId, Quantity, DurationSeconds, Note)
VALUES (@taskId, @userId, @quantity, @durationSeconds, @note)`
);
}
/** Remove um lançamento (restrito à tarefa informada, validada na action). */
export
async
function
deleteProductionLog
(
id
:
number
,
taskId
:
number
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
id
)
.
input
(
"
taskId
"
,
sql
.
Int
,
taskId
)
.
query
(
"
DELETE FROM dbo.TaskProductionLog WHERE Id = @id AND TaskId = @taskId
"
);
}
export
async
function
updateTask
(
id
:
number
,
input
:
TaskInput
):
Promise
<
void
>
{
export
async
function
updateTask
(
id
:
number
,
input
:
TaskInput
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
const
pool
=
await
getPool
();
// Início e vencimento são imutáveis após a criação: não entram no UPDATE.
// Início e vencimento são imutáveis após a criação: não entram no UPDATE.
...
@@ -388,10 +474,15 @@ export async function updateTask(id: number, input: TaskInput): Promise<void> {
...
@@ -388,10 +474,15 @@ export async function updateTask(id: number, input: TaskInput): Promise<void> {
.
input
(
"
priority
"
,
sql
.
NVarChar
(
10
),
input
.
priority
)
.
input
(
"
priority
"
,
sql
.
NVarChar
(
10
),
input
.
priority
)
.
input
(
"
assigneeId
"
,
sql
.
Int
,
input
.
assigneeId
)
.
input
(
"
assigneeId
"
,
sql
.
Int
,
input
.
assigneeId
)
.
input
(
"
progress
"
,
sql
.
Int
,
input
.
progress
)
.
input
(
"
progress
"
,
sql
.
Int
,
input
.
progress
)
.
input
(
"
metaType
"
,
sql
.
NVarChar
(
10
),
input
.
metaType
??
null
)
.
input
(
"
metaValue
"
,
sql
.
Int
,
input
.
metaValue
??
null
)
.
input
(
"
standardSeconds
"
,
sql
.
Int
,
input
.
standardSeconds
??
null
)
.
query
(
.
query
(
`UPDATE dbo.Tasks
`UPDATE dbo.Tasks
SET Title = @title, Description = @description, Status = @status,
SET Title = @title, Description = @description, Status = @status,
Priority = @priority, AssigneeId = @assigneeId, Progress = @progress,
Priority = @priority, AssigneeId = @assigneeId, Progress = @progress,
MetaType = @metaType, MetaValue = @metaValue,
StandardSeconds = @standardSeconds,
CompletedDate = CASE WHEN @status = 'done'
CompletedDate = CASE WHEN @status = 'done'
THEN COALESCE(CompletedDate, CAST(GETDATE() AS DATE))
THEN COALESCE(CompletedDate, CAST(GETDATE() AS DATE))
ELSE NULL END,
ELSE NULL END,
...
...
src/lib/db-init.ts
View file @
54046b0e
...
@@ -49,6 +49,9 @@ const DDL_STATEMENTS: string[] = [
...
@@ -49,6 +49,9 @@ const DDL_STATEMENTS: string[] = [
DueDate DATE NULL,
DueDate DATE NULL,
CompletedDate DATE NULL,
CompletedDate DATE NULL,
Progress INT NOT NULL DEFAULT 0,
Progress INT NOT NULL DEFAULT 0,
MetaType NVARCHAR(10) NULL,
MetaValue INT NULL,
StandardSeconds INT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`
,
)`
,
...
@@ -66,6 +69,32 @@ const DDL_STATEMENTS: string[] = [
...
@@ -66,6 +69,32 @@ const DDL_STATEMENTS: string[] = [
SET CompletedDate = CAST(UpdatedAt AS DATE)
SET CompletedDate = CAST(UpdatedAt AS DATE)
WHERE Status = 'done' AND CompletedDate IS NULL`
,
WHERE Status = 'done' AND CompletedDate IS NULL`
,
// Meta de subtarefas em aberto para tarefas recorrentes (teto/piso)
`IF COL_LENGTH('dbo.Tasks', 'MetaType') IS NULL
ALTER TABLE dbo.Tasks ADD MetaType NVARCHAR(10) NULL`
,
`IF COL_LENGTH('dbo.Tasks', 'MetaValue') IS NULL
ALTER TABLE dbo.Tasks ADD MetaValue INT NULL`
,
// Tarefa mensurável por produção: tempo-padrão por unidade (em segundos)
`IF COL_LENGTH('dbo.Tasks', 'StandardSeconds') IS NULL
ALTER TABLE dbo.Tasks ADD StandardSeconds INT NULL`
,
// Apontamentos de produção por lote (quantidade + tempo)
`IF OBJECT_ID('dbo.TaskProductionLog', 'U') IS NULL
CREATE TABLE dbo.TaskProductionLog (
Id INT IDENTITY(1,1) PRIMARY KEY,
TaskId INT NOT NULL REFERENCES dbo.Tasks(Id) ON DELETE CASCADE,
UserId INT NOT NULL REFERENCES dbo.Users(Id),
Quantity INT NOT NULL,
DurationSeconds INT NOT NULL,
Note NVARCHAR(200) NULL,
LoggedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`
,
`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TaskProductionLog_TaskId')
CREATE INDEX IX_TaskProductionLog_TaskId ON dbo.TaskProductionLog (TaskId)`
,
// 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 (
...
...
src/lib/production.ts
0 → 100644
View file @
54046b0e
export
interface
ProductionInfo
{
/** Total de unidades apontadas. */
totalQty
:
number
;
/** Tempo total apontado (segundos). */
totalSeconds
:
number
;
/** Tempo-padrão por unidade (segundos) — a meta. */
standardSeconds
:
number
;
/** Ritmo real (segundos por unidade); null quando não há apontamentos. */
perUnitSeconds
:
number
|
null
;
/** true = dentro da meta, false = acima, null = sem apontamentos. */
withinMeta
:
boolean
|
null
;
}
/**
* Avalia a produção de uma tarefa mensurável.
* Ritmo real = tempo total ÷ unidades; comparado ao tempo-padrão (meta).
* Retorna null quando a tarefa não está configurada para medir produção.
*/
export
function
getProductionInfo
(
standardSeconds
:
number
|
null
,
totalQty
:
number
,
totalSeconds
:
number
):
ProductionInfo
|
null
{
if
(
!
standardSeconds
)
return
null
;
const
qty
=
totalQty
??
0
;
const
secs
=
totalSeconds
??
0
;
const
perUnitSeconds
=
qty
>
0
?
secs
/
qty
:
null
;
const
withinMeta
=
perUnitSeconds
==
null
?
null
:
perUnitSeconds
<=
standardSeconds
;
return
{
totalQty
:
qty
,
totalSeconds
:
secs
,
standardSeconds
,
perUnitSeconds
,
withinMeta
,
};
}
/** Segundos → "4h10", "50min" ou "45s". */
export
function
formatDuration
(
totalSeconds
:
number
):
string
{
const
s
=
Math
.
max
(
0
,
Math
.
round
(
totalSeconds
));
if
(
s
<
60
)
return
`
${
s
}
s`
;
const
totalMin
=
Math
.
round
(
s
/
60
);
const
h
=
Math
.
floor
(
totalMin
/
60
);
const
m
=
totalMin
%
60
;
if
(
h
>
0
)
return
m
>
0
?
`
${
h
}
h
${
String
(
m
).
padStart
(
2
,
"
0
"
)}
`
:
`
${
h
}
h`
;
return
`
${
totalMin
}
min`
;
}
/** Segundos por unidade → "4,2 min" (ou "48s" quando abaixo de 1 min). */
export
function
formatPace
(
perUnitSeconds
:
number
):
string
{
if
(
perUnitSeconds
<
60
)
return
`
${
Math
.
round
(
perUnitSeconds
)}
s`
;
return
`
${(
perUnitSeconds
/
60
).
toFixed
(
1
).
replace
(
"
.
"
,
"
,
"
)}
min`
;
}
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