Commit 54046b0e authored by Paulo Avila's avatar Paulo Avila
Browse files

junção de todas

parent 36e1afbc
......@@ -29,17 +29,27 @@ export function MetaBadge({
below: "bg-amber-500 text-white hover:bg-amber-500",
} as const;
const stateWord =
info.state === "exceeded"
? "acima"
: info.state === "below"
? "abaixo"
: null;
return (
<Badge
title={info.label}
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],
className
)}
>
<Target className="h-2.5 w-2.5 shrink-0" />
<Target className="h-3.5 w-3.5 shrink-0" />
{info.shortLabel}
{stateWord && (
<span className="font-medium opacity-90">· {stateWord}</span>
)}
</Badge>
);
}
......@@ -7,8 +7,10 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { DueBadge } from "./due-badge";
import { MetaBadge } from "./meta-badge";
import { PriorityBadge } from "./priority-badge";
import { SubtaskOverdueBadge } from "./subtask-overdue-badge";
import { TaskProductionButton } from "./task-production-button";
import { StatusBadge } from "./status-badge";
import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog";
......@@ -92,10 +94,25 @@ export function TaskBoard({
<div className="flex flex-wrap items-center gap-1.5">
<PriorityBadge priority={task.Priority} />
<DueBadge dueDate={task.DueDate} status={task.Status} />
<MetaBadge
metaType={task.MetaType}
metaValue={task.MetaValue}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
/>
<SubtaskOverdueBadge
subtasks={subtasksByTask[task.Id] ?? []}
compact
/>
{task.StandardSeconds != null && (
<TaskProductionButton
taskId={task.Id}
standardSeconds={task.StandardSeconds}
prodQty={task.ProdQty}
prodSeconds={task.ProdSeconds}
editable={editable}
/>
)}
</div>
<div className="flex items-center gap-2">
<Progress value={percent} className="h-1.5" />
......
......@@ -30,6 +30,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
export interface TaskFormValues {
......@@ -45,6 +46,9 @@ export interface TaskFormValues {
progress: number;
entry: number | null;
collaboratorIds: number[];
metaType: string | null; // 'teto' | 'piso' | null
metaValue: number | null;
standardMinutes: number | null;
}
function SubmitButton({
......@@ -107,6 +111,8 @@ export function TaskDialog({
const [collabs, setCollabs] = useState<number[]>(
task?.collaboratorIds ?? []
);
const [metaType, setMetaType] = useState<string>(task?.metaType ?? "none");
const [measure, setMeasure] = useState(task?.standardMinutes != null);
const action = task
? updateTaskAction.bind(null, task.id)
: createTaskAction;
......@@ -327,6 +333,75 @@ export function TaskDialog({
: "Digite quanto da tarefa já foi concluído (0 a 100)."}
</p>
</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} />
</form>
</DialogContent>
......
"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>
);
}
"use client";
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 { TASK_STATUSES } from "@/lib/constants";
import { formatDateShort, getDueInfo } from "@/lib/dates";
import { getMetaInfo } from "@/lib/meta";
import { getProductionInfo } from "@/lib/production";
import { canDeleteTask, canEditTask } from "@/lib/permissions";
import { initials, toFormValues } from "./task-utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
......@@ -19,11 +21,13 @@ import {
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge";
import { MetaBadge } from "./meta-badge";
import { PriorityBadge } from "./priority-badge";
import { SubtaskOverdueBadge } from "./subtask-overdue-badge";
import { StatusBadge } from "./status-badge";
import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog } from "./task-dialog";
import { TaskProductionButton } from "./task-production-button";
import { TaskProgress } from "./task-progress";
import { TaskProgressEditor } from "./task-progress-editor";
import { TaskStatusSelect } from "./task-status-select";
......@@ -67,6 +71,20 @@ export function TaskTable({
const isOverdue = (t: Task) =>
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
// estados — a tarefa adicionada por último aparece no topo.
const sortTasks = (list: Task[]) => [...list].sort((a, b) => b.Id - a.Id);
......@@ -76,16 +94,19 @@ export function TaskTable({
? tasks
: statusFilter === "overdue"
? 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 offTargetCount = tasks.filter(isOffTarget).length;
const filterOptions: {
value: string;
label: string;
count: number;
tone?: "danger";
tone?: "danger" | "warning";
}[] = [
{ value: "all", label: "Todas", count: tasks.length },
...TASK_STATUSES.map((s) => ({
......@@ -94,6 +115,12 @@ export function TaskTable({
count: tasks.filter((t) => t.Status === s.value).length,
})),
{ value: "overdue", label: "Vencidas", count: overdueCount, tone: "danger" },
{
value: "off_target",
label: "Fora da meta",
count: offTargetCount,
tone: "warning",
},
];
return (
......@@ -101,15 +128,24 @@ export function TaskTable({
<div className="flex flex-wrap items-center gap-2">
{filterOptions.map((option) => {
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 =
option.tone === "danger"
? "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 =
option.tone === "danger" && option.count > 0
? "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 (
<button
key={option.value}
......@@ -213,7 +249,22 @@ export function TaskTable({
{task.Title}
</span>
<PriorityBadge priority={task.Priority} />
<MetaBadge
metaType={task.MetaType}
metaValue={task.MetaValue}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
/>
<SubtaskOverdueBadge subtasks={subtasks} />
{task.StandardSeconds != null && (
<TaskProductionButton
taskId={task.Id}
standardSeconds={task.StandardSeconds}
prodQty={task.ProdQty}
prodSeconds={task.ProdSeconds}
editable={editable}
/>
)}
</div>
{task.Description && (
<p className="mt-0.5 text-xs text-muted-foreground">
......
......@@ -26,6 +26,10 @@ export function toFormValues(task: Task): TaskFormValues {
progress: task.Progress,
entry: task.Entry ?? null,
collaboratorIds: task.Collaborators.map((c) => c.UserId),
metaType: task.MetaType,
metaValue: task.MetaValue,
standardMinutes:
task.StandardSeconds != null ? task.StandardSeconds / 60 : null,
};
}
......
......@@ -311,11 +311,30 @@ const taskSchema = z
entry: z.coerce.number().int().positive().optional(),
// Usuários vinculados (compartilhamento) — só na tarefa-mãe.
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(
(t) => !t.startDate || !t.dueDate || t.startDate <= t.dueDate,
{ 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) {
return taskSchema.safeParse({
......@@ -330,6 +349,9 @@ function parseTaskForm(formData: FormData) {
progress: formData.get("progress") || undefined,
entry: formData.get("entry") || undefined,
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(
dueDate: parsed.data.dueDate ?? null,
progress: parsed.data.progress ?? 0,
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
);
......@@ -443,6 +472,13 @@ export async function updateTaskAction(
startDate: parsed.data.startDate ?? null,
dueDate: parsed.data.dueDate ?? null,
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.
if (task.Entry == null) {
......@@ -463,6 +499,80 @@ export async function updateTaskAction(
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(
taskId: number,
status: string
......
......@@ -46,6 +46,14 @@ export interface Task {
CreatedAt: Date;
SubtaskCount: 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). */
Collaborators: { UserId: number; Name: string }[];
}
......@@ -270,9 +278,12 @@ const TASK_SELECT = `
SELECT t.Id, t.WorkspaceId, t.Title, t.Description, t.Status, t.Priority,
t.Entry,
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 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
FROM dbo.TaskCollaborators tc JOIN dbo.Users u ON u.Id = tc.UserId
WHERE tc.TaskId = t.Id
......@@ -317,6 +328,9 @@ export interface TaskInput {
dueDate: string | null;
progress: number;
entry?: number | null;
metaType?: string | null;
metaValue?: number | null;
standardSeconds?: number | null;
}
export async function createTask(
input: TaskInput,
......@@ -335,13 +349,16 @@ export async function createTask(
.input("startDate", sql.Date, input.startDate)
.input("dueDate", sql.Date, input.dueDate)
.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)
.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
VALUES (@workspaceId, @title, @description, @status, @priority, @entry, @assigneeId, @startDate, @dueDate,
CASE WHEN @status = 'done' THEN CAST(GETDATE() AS DATE) ELSE NULL END,
@progress, @createdById)`
@progress, @metaType, @metaValue, @standardSeconds, @createdById)`
);
return result.recordset[0].Id;
}
......@@ -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> {
const pool = await getPool();
// 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> {
.input("priority", sql.NVarChar(10), input.priority)
.input("assigneeId", sql.Int, input.assigneeId)
.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(
`UPDATE dbo.Tasks
SET Title = @title, Description = @description, Status = @status,
Priority = @priority, AssigneeId = @assigneeId, Progress = @progress,
MetaType = @metaType, MetaValue = @metaValue,
StandardSeconds = @standardSeconds,
CompletedDate = CASE WHEN @status = 'done'
THEN COALESCE(CompletedDate, CAST(GETDATE() AS DATE))
ELSE NULL END,
......
......@@ -49,6 +49,9 @@ const DDL_STATEMENTS: string[] = [
DueDate DATE NULL,
CompletedDate DATE NULL,
Progress INT NOT NULL DEFAULT 0,
MetaType NVARCHAR(10) NULL,
MetaValue INT NULL,
StandardSeconds INT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
)`,
......@@ -66,6 +69,32 @@ const DDL_STATEMENTS: string[] = [
SET CompletedDate = CAST(UpdatedAt AS DATE)
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
`IF OBJECT_ID('dbo.TaskCollaborators', 'U') IS NULL
CREATE TABLE dbo.TaskCollaborators (
......
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`;
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment