@locusai/web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/next.config.js +7 -0
- package/package.json +37 -0
- package/postcss.config.mjs +5 -0
- package/src/app/backlog/page.tsx +19 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/globals.css +603 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/app/providers.tsx +16 -0
- package/src/app/settings/page.tsx +194 -0
- package/src/components/BoardFilter.tsx +98 -0
- package/src/components/Header.tsx +21 -0
- package/src/components/PropertyItem.tsx +98 -0
- package/src/components/Sidebar.tsx +109 -0
- package/src/components/TaskCard.tsx +138 -0
- package/src/components/TaskCreateModal.tsx +243 -0
- package/src/components/TaskPanel.tsx +765 -0
- package/src/components/index.ts +7 -0
- package/src/components/ui/Badge.tsx +77 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Checkbox.tsx +52 -0
- package/src/components/ui/Dropdown.tsx +107 -0
- package/src/components/ui/Input.tsx +36 -0
- package/src/components/ui/Modal.tsx +79 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/index.ts +7 -0
- package/src/hooks/useTasks.ts +119 -0
- package/src/lib/api-client.ts +24 -0
- package/src/lib/utils.ts +6 -0
- package/src/services/doc.service.ts +27 -0
- package/src/services/index.ts +3 -0
- package/src/services/sprint.service.ts +26 -0
- package/src/services/task.service.ts +75 -0
- package/src/views/Backlog.tsx +691 -0
- package/src/views/Board.tsx +306 -0
- package/src/views/Docs.tsx +625 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
interface BadgeProps {
|
|
2
|
+
children: React.ReactNode;
|
|
3
|
+
variant?: "default" | "success" | "warning" | "error" | "info" | "purple";
|
|
4
|
+
size?: "sm" | "md";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Badge({
|
|
8
|
+
children,
|
|
9
|
+
variant = "default",
|
|
10
|
+
size = "sm",
|
|
11
|
+
}: BadgeProps) {
|
|
12
|
+
const variants = {
|
|
13
|
+
default: "bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
|
14
|
+
success: "bg-status-done/15 text-status-done border-status-done/20",
|
|
15
|
+
warning:
|
|
16
|
+
"bg-status-progress/15 text-status-progress border-status-progress/20",
|
|
17
|
+
error: "bg-status-blocked/15 text-status-blocked border-status-blocked/20",
|
|
18
|
+
info: "bg-status-todo/15 text-status-todo border-status-todo/20",
|
|
19
|
+
purple: "bg-status-review/15 text-status-review border-status-review/20",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const sizes = {
|
|
23
|
+
sm: "px-2.5 py-0.5 text-xs font-semibold",
|
|
24
|
+
md: "px-3 py-1 text-sm font-semibold",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
className={`inline-flex items-center rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${variants[variant]} ${sizes[size]}`}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PriorityBadgeProps {
|
|
37
|
+
priority: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PRIORITY_CONFIG = {
|
|
41
|
+
LOW: { label: "Low", variant: "default" as const },
|
|
42
|
+
MEDIUM: { label: "Medium", variant: "info" as const },
|
|
43
|
+
HIGH: { label: "High", variant: "warning" as const },
|
|
44
|
+
CRITICAL: { label: "Critical", variant: "error" as const },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function PriorityBadge({ priority }: PriorityBadgeProps) {
|
|
48
|
+
const config = PRIORITY_CONFIG[priority];
|
|
49
|
+
return <Badge variant={config.variant}>{config.label}</Badge>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface StatusBadgeProps {
|
|
53
|
+
status: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const STATUS_CONFIG: Record<
|
|
57
|
+
string,
|
|
58
|
+
{
|
|
59
|
+
label: string;
|
|
60
|
+
variant: "default" | "success" | "warning" | "error" | "info" | "purple";
|
|
61
|
+
}
|
|
62
|
+
> = {
|
|
63
|
+
BACKLOG: { label: "Backlog", variant: "default" },
|
|
64
|
+
IN_PROGRESS: { label: "In Progress", variant: "warning" },
|
|
65
|
+
REVIEW: { label: "Review", variant: "purple" },
|
|
66
|
+
VERIFICATION: { label: "Verification", variant: "info" },
|
|
67
|
+
DONE: { label: "Done", variant: "success" },
|
|
68
|
+
BLOCKED: { label: "Blocked", variant: "error" },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function StatusBadge({ status }: StatusBadgeProps) {
|
|
72
|
+
const config = STATUS_CONFIG[status] || {
|
|
73
|
+
label: status,
|
|
74
|
+
variant: "default" as const,
|
|
75
|
+
};
|
|
76
|
+
return <Badge variant={config.variant}>{config.label}</Badge>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, forwardRef } from "react";
|
|
2
|
+
|
|
3
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
4
|
+
variant?:
|
|
5
|
+
| "primary"
|
|
6
|
+
| "secondary"
|
|
7
|
+
| "outline"
|
|
8
|
+
| "ghost"
|
|
9
|
+
| "danger"
|
|
10
|
+
| "success";
|
|
11
|
+
size?: "sm" | "md" | "lg" | "icon";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
15
|
+
({ className, variant = "primary", size = "md", ...props }, ref) => {
|
|
16
|
+
const variants = {
|
|
17
|
+
primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
18
|
+
secondary:
|
|
19
|
+
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
20
|
+
outline:
|
|
21
|
+
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
22
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
23
|
+
danger:
|
|
24
|
+
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
25
|
+
success: "bg-emerald-600 text-white shadow-sm hover:bg-emerald-700",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sizes = {
|
|
29
|
+
sm: "h-8 px-3 text-xs",
|
|
30
|
+
md: "h-9 px-4 py-2 text-sm",
|
|
31
|
+
lg: "h-10 px-8 text-base",
|
|
32
|
+
icon: "h-9 w-9",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
ref={ref}
|
|
38
|
+
className={`inline-flex items-center justify-center gap-2 font-semibold rounded-lg transition-all duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none ${variants[variant]} ${sizes[size]} ${className || ""}`}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
Button.displayName = "Button";
|
|
46
|
+
|
|
47
|
+
export { Button };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
interface CheckboxProps {
|
|
5
|
+
checked: boolean;
|
|
6
|
+
onChange: (checked: boolean) => void;
|
|
7
|
+
label?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Checkbox({
|
|
13
|
+
checked,
|
|
14
|
+
onChange,
|
|
15
|
+
label,
|
|
16
|
+
disabled = false,
|
|
17
|
+
className,
|
|
18
|
+
}: CheckboxProps) {
|
|
19
|
+
return (
|
|
20
|
+
<label
|
|
21
|
+
className={cn(
|
|
22
|
+
`flex items-center gap-2.5 select-none transition-opacity duration-200`,
|
|
23
|
+
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
24
|
+
className || ""
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
<input
|
|
28
|
+
type="checkbox"
|
|
29
|
+
checked={checked}
|
|
30
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
31
|
+
disabled={disabled}
|
|
32
|
+
className="hidden"
|
|
33
|
+
/>
|
|
34
|
+
<span
|
|
35
|
+
className={`h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-150 flex items-center justify-center ${
|
|
36
|
+
checked ? "bg-primary text-primary-foreground" : "bg-transparent"
|
|
37
|
+
}`}
|
|
38
|
+
>
|
|
39
|
+
{checked && <Check size={12} strokeWidth={3} />}
|
|
40
|
+
</span>
|
|
41
|
+
{label && (
|
|
42
|
+
<span
|
|
43
|
+
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 transition-all duration-150 ${
|
|
44
|
+
checked ? "line-through text-muted-foreground" : "text-foreground"
|
|
45
|
+
}`}
|
|
46
|
+
>
|
|
47
|
+
{label}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
</label>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
5
|
+
|
|
6
|
+
interface DropdownOption<T extends string> {
|
|
7
|
+
value: T;
|
|
8
|
+
label: string;
|
|
9
|
+
color?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DropdownProps<T extends string> {
|
|
13
|
+
value: T | undefined;
|
|
14
|
+
onChange: (value: T) => void;
|
|
15
|
+
options: DropdownOption<T>[];
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
renderOption?: (option: DropdownOption<T>) => ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Dropdown<T extends string>({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
options,
|
|
26
|
+
placeholder = "Select...",
|
|
27
|
+
label,
|
|
28
|
+
disabled = false,
|
|
29
|
+
renderOption,
|
|
30
|
+
}: DropdownProps<T>) {
|
|
31
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
32
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
36
|
+
if (
|
|
37
|
+
containerRef.current &&
|
|
38
|
+
!containerRef.current.contains(e.target as Node)
|
|
39
|
+
) {
|
|
40
|
+
setIsOpen(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
45
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const selectedOption = options.find((o) => o.value === value);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="relative w-full" ref={containerRef}>
|
|
52
|
+
{label && (
|
|
53
|
+
<label className="block text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
|
|
54
|
+
{label}
|
|
55
|
+
</label>
|
|
56
|
+
)}
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="w-full flex items-center justify-between gap-2 px-3.5 py-2.5 bg-background border border-input rounded-md text-foreground text-sm cursor-pointer transition-all hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
60
|
+
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
>
|
|
63
|
+
<span className={selectedOption ? "" : "text-muted-foreground"}>
|
|
64
|
+
{selectedOption ? selectedOption.label : placeholder}
|
|
65
|
+
</span>
|
|
66
|
+
<ChevronDown
|
|
67
|
+
size={16}
|
|
68
|
+
className={`transition-transform duration-200 text-muted-foreground ${isOpen ? "rotate-180" : "rotate-0"}`}
|
|
69
|
+
/>
|
|
70
|
+
</button>
|
|
71
|
+
|
|
72
|
+
{isOpen && (
|
|
73
|
+
<div className="absolute top-full left-0 right-0 mt-2 bg-popover border border-border rounded-md p-1 z-50 max-h-[240px] overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-200 shadow-md">
|
|
74
|
+
{options.map((option) => (
|
|
75
|
+
<button
|
|
76
|
+
key={option.value}
|
|
77
|
+
type="button"
|
|
78
|
+
className={`w-full flex items-center gap-2 px-3 py-2 bg-transparent border-none rounded-sm text-popover-foreground text-sm cursor-pointer transition-colors text-left hover:bg-accent hover:text-accent-foreground ${
|
|
79
|
+
option.value === value
|
|
80
|
+
? "bg-accent/10 text-accent-foreground"
|
|
81
|
+
: ""
|
|
82
|
+
}`}
|
|
83
|
+
onClick={() => {
|
|
84
|
+
onChange(option.value);
|
|
85
|
+
setIsOpen(false);
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{renderOption ? (
|
|
89
|
+
renderOption(option)
|
|
90
|
+
) : (
|
|
91
|
+
<>
|
|
92
|
+
{option.color && (
|
|
93
|
+
<span
|
|
94
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
95
|
+
style={{ background: option.color }}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
{option.label}
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
</button>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
4
|
+
icon?: ReactNode;
|
|
5
|
+
rightElement?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
9
|
+
({ className, icon, rightElement, ...props }, ref) => {
|
|
10
|
+
return (
|
|
11
|
+
<div className="relative w-full group">
|
|
12
|
+
{icon && (
|
|
13
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted group-focus-within:text-accent transition-colors">
|
|
14
|
+
{icon}
|
|
15
|
+
</div>
|
|
16
|
+
)}
|
|
17
|
+
<input
|
|
18
|
+
ref={ref}
|
|
19
|
+
className={`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
20
|
+
icon ? "pl-10" : "pl-3"
|
|
21
|
+
} ${rightElement ? "pr-10" : "pr-3"} ${className || ""}`}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
{rightElement && (
|
|
25
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
26
|
+
{rightElement}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
Input.displayName = "Input";
|
|
35
|
+
|
|
36
|
+
export { Input };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
|
|
7
|
+
interface ModalProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
title?: string;
|
|
12
|
+
size?: "sm" | "md" | "lg";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Modal({
|
|
16
|
+
isOpen,
|
|
17
|
+
onClose,
|
|
18
|
+
children,
|
|
19
|
+
title,
|
|
20
|
+
size = "md",
|
|
21
|
+
}: ModalProps) {
|
|
22
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
26
|
+
if (e.key === "Escape") onClose();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (isOpen) {
|
|
30
|
+
document.addEventListener("keydown", handleEscape);
|
|
31
|
+
document.body.style.overflow = "hidden";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
document.removeEventListener("keydown", handleEscape);
|
|
36
|
+
document.body.style.overflow = "";
|
|
37
|
+
};
|
|
38
|
+
}, [isOpen, onClose]);
|
|
39
|
+
|
|
40
|
+
if (!isOpen) return null;
|
|
41
|
+
|
|
42
|
+
const widths = {
|
|
43
|
+
sm: "w-[400px]",
|
|
44
|
+
md: "w-[520px]",
|
|
45
|
+
lg: "w-[680px]",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return createPortal(
|
|
49
|
+
<div
|
|
50
|
+
ref={overlayRef}
|
|
51
|
+
className="fixed inset-0 bg-black/40 backdrop-blur-md flex items-center justify-center z-1000 animate-in fade-in duration-300"
|
|
52
|
+
onClick={(e) => {
|
|
53
|
+
if (e.target === overlayRef.current) onClose();
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
className={`bg-background border border-border rounded-xl shadow-lg animate-in zoom-in-95 duration-200 max-h-[90vh] overflow-hidden flex flex-col ${
|
|
58
|
+
widths[size]
|
|
59
|
+
} max-w-[90vw]`}
|
|
60
|
+
>
|
|
61
|
+
{title && (
|
|
62
|
+
<div className="flex justify-between items-center p-5 border-b border-border">
|
|
63
|
+
<h3 className="text-lg font-semibold m-0 text-foreground">
|
|
64
|
+
{title}
|
|
65
|
+
</h3>
|
|
66
|
+
<button
|
|
67
|
+
className="bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer p-0 transition-colors duration-200"
|
|
68
|
+
onClick={onClose}
|
|
69
|
+
>
|
|
70
|
+
<X size={20} />
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<div className="p-6 overflow-y-auto">{children}</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>,
|
|
77
|
+
document.body
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { forwardRef, type TextareaHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
4
|
+
|
|
5
|
+
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
6
|
+
({ className, ...props }, ref) => {
|
|
7
|
+
return (
|
|
8
|
+
<textarea
|
|
9
|
+
ref={ref}
|
|
10
|
+
className={`flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none ${
|
|
11
|
+
className || ""
|
|
12
|
+
}`}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
Textarea.displayName = "Textarea";
|
|
20
|
+
|
|
21
|
+
export { Textarea };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AssigneeRole, TaskPriority, TaskStatus } from "@locusai/shared";
|
|
4
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useCallback, useMemo, useState } from "react";
|
|
6
|
+
|
|
7
|
+
import { taskService } from "@/services";
|
|
8
|
+
|
|
9
|
+
export function useTasks() {
|
|
10
|
+
const queryClient = useQueryClient();
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
data: tasks = [],
|
|
14
|
+
isLoading: loading,
|
|
15
|
+
error: queryError,
|
|
16
|
+
refetch: refreshTasks,
|
|
17
|
+
} = useQuery({
|
|
18
|
+
queryKey: ["tasks"],
|
|
19
|
+
queryFn: () => taskService.getAll(),
|
|
20
|
+
refetchInterval: 15000,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const error = queryError ? (queryError as Error).message : null;
|
|
24
|
+
|
|
25
|
+
// Filter state
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
27
|
+
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "ALL">(
|
|
28
|
+
"ALL"
|
|
29
|
+
);
|
|
30
|
+
const [assigneeFilter, setAssigneeFilter] = useState<AssigneeRole | "ALL">(
|
|
31
|
+
"ALL"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const hasActiveFilters = useMemo(
|
|
35
|
+
() =>
|
|
36
|
+
searchQuery !== "" ||
|
|
37
|
+
priorityFilter !== "ALL" ||
|
|
38
|
+
assigneeFilter !== "ALL",
|
|
39
|
+
[searchQuery, priorityFilter, assigneeFilter]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const updateMutation = useMutation({
|
|
43
|
+
mutationFn: ({ taskId, status }: { taskId: number; status: TaskStatus }) =>
|
|
44
|
+
taskService.update(taskId, { status }),
|
|
45
|
+
onSuccess: () => {
|
|
46
|
+
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const deleteMutation = useMutation({
|
|
51
|
+
mutationFn: (taskId: number) => taskService.delete(taskId),
|
|
52
|
+
onSuccess: () => {
|
|
53
|
+
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const clearFilters = useCallback(() => {
|
|
58
|
+
setSearchQuery("");
|
|
59
|
+
setPriorityFilter("ALL");
|
|
60
|
+
setAssigneeFilter("ALL");
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const filteredTasks = useMemo(() => {
|
|
64
|
+
return tasks.filter((task) => {
|
|
65
|
+
if (searchQuery) {
|
|
66
|
+
const query = searchQuery.toLowerCase();
|
|
67
|
+
if (!task.title.toLowerCase().includes(query)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (priorityFilter !== "ALL" && task.priority !== priorityFilter) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (assigneeFilter !== "ALL" && task.assigneeRole !== assigneeFilter) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}, [tasks, searchQuery, priorityFilter, assigneeFilter]);
|
|
80
|
+
|
|
81
|
+
const getTasksByStatus = useCallback(
|
|
82
|
+
(status: TaskStatus) => {
|
|
83
|
+
return filteredTasks.filter((t) => t.status === status);
|
|
84
|
+
},
|
|
85
|
+
[filteredTasks]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const updateTaskStatus = useCallback(
|
|
89
|
+
async (taskId: number, status: TaskStatus) => {
|
|
90
|
+
await updateMutation.mutateAsync({ taskId, status });
|
|
91
|
+
},
|
|
92
|
+
[updateMutation]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const deleteTask = useCallback(
|
|
96
|
+
async (taskId: number) => {
|
|
97
|
+
await deleteMutation.mutateAsync(taskId);
|
|
98
|
+
},
|
|
99
|
+
[deleteMutation]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
tasks,
|
|
104
|
+
loading,
|
|
105
|
+
error,
|
|
106
|
+
searchQuery,
|
|
107
|
+
setSearchQuery,
|
|
108
|
+
priorityFilter,
|
|
109
|
+
setPriorityFilter,
|
|
110
|
+
assigneeFilter,
|
|
111
|
+
setAssigneeFilter,
|
|
112
|
+
hasActiveFilters,
|
|
113
|
+
clearFilters,
|
|
114
|
+
getTasksByStatus,
|
|
115
|
+
updateTaskStatus,
|
|
116
|
+
deleteTask,
|
|
117
|
+
refreshTasks,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
const apiClient = axios.create({
|
|
4
|
+
baseURL: "/api",
|
|
5
|
+
headers: {
|
|
6
|
+
"Content-Type": "application/json",
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Response interceptor for consistent error handling
|
|
11
|
+
apiClient.interceptors.response.use(
|
|
12
|
+
(response) => response,
|
|
13
|
+
(error) => {
|
|
14
|
+
// We can add global error handling here (e.g., logging, toast notifications)
|
|
15
|
+
const message =
|
|
16
|
+
error.response?.data?.message ||
|
|
17
|
+
error.message ||
|
|
18
|
+
"An unexpected error occurred";
|
|
19
|
+
console.error(`[API Error] ${message}`, error);
|
|
20
|
+
return Promise.reject(error);
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export default apiClient;
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import apiClient from "@/lib/api-client";
|
|
2
|
+
|
|
3
|
+
export interface DocNode {
|
|
4
|
+
type: "file" | "directory";
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
children?: DocNode[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const docService = {
|
|
11
|
+
getTree: async () => {
|
|
12
|
+
const response = await apiClient.get<DocNode[]>("/docs/tree");
|
|
13
|
+
return response.data;
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
read: async (path: string) => {
|
|
17
|
+
const response = await apiClient.get<{ content: string }>(
|
|
18
|
+
`/docs/read?path=${encodeURIComponent(path)}`
|
|
19
|
+
);
|
|
20
|
+
return response.data;
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
write: async (path: string, content: string) => {
|
|
24
|
+
const response = await apiClient.post("/docs/write", { path, content });
|
|
25
|
+
return response.data;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Sprint } from "@locusai/shared";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
|
|
4
|
+
const API_URL = "http://localhost:3080/api/sprints";
|
|
5
|
+
|
|
6
|
+
export const sprintService = {
|
|
7
|
+
getAll: async () => {
|
|
8
|
+
const res = await axios.get<Sprint[]>(API_URL);
|
|
9
|
+
return res.data;
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
create: async (data: Partial<Sprint>) => {
|
|
13
|
+
const res = await axios.post<{ id: number }>(API_URL, data);
|
|
14
|
+
return res.data;
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
update: async (id: number, data: Partial<Sprint>) => {
|
|
18
|
+
const res = await axios.patch(`${API_URL}/${id}`, data);
|
|
19
|
+
return res.data;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
delete: async (id: number) => {
|
|
23
|
+
const res = await axios.delete(`${API_URL}/${id}`);
|
|
24
|
+
return res.data;
|
|
25
|
+
},
|
|
26
|
+
};
|