Compare commits
13 Commits
16174e271b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2aa3b03b6 | ||
|
|
79f210d0ee | ||
|
|
777f94bf13 | ||
|
|
8022c0517f | ||
|
|
8fe6d64e2e | ||
|
|
f7a17a3d1a | ||
|
|
d9228057bb | ||
|
|
605e151f33 | ||
|
|
6f36bbc3d5 | ||
|
|
be69325797 | ||
|
|
b404fbb006 | ||
|
|
048e7e7e6d | ||
|
|
a019479381 |
8
.prettierignore
Normal file
8
.prettierignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.md
|
||||||
|
package-lock.json
|
||||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
67
README.md
67
README.md
@@ -87,6 +87,73 @@ npm run lint
|
|||||||
|
|
||||||
# Format code with Prettier
|
# Format code with Prettier
|
||||||
npm run format
|
npm run format
|
||||||
|
|
||||||
|
# Check formatting without modifying files
|
||||||
|
npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Quality Standards
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- **Strict Mode**: Enabled in `tsconfig.json`
|
||||||
|
- **No `any` Types**: Prohibited by ESLint (`@typescript-eslint/no-explicit-any: error`)
|
||||||
|
- **Type Definitions**: All components, functions, and API responses must have proper type definitions
|
||||||
|
- **Type Safety**: Prefer discriminated unions over type assertions
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
- **ESLint**: Configured with TypeScript and React rules
|
||||||
|
- **Next.js Rules**: Extended from `eslint-config-next`
|
||||||
|
- **Accessibility**: Enforced via `eslint-plugin-jsx-a11y`
|
||||||
|
- **Run**: `npm run lint`
|
||||||
|
|
||||||
|
### Code Formatting
|
||||||
|
- **Prettier**: Configured for consistent code formatting
|
||||||
|
- **Tailwind Plugin**: Automatic class sorting via `prettier-plugin-tailwindcss`
|
||||||
|
- **Configuration**: See `.prettierrc`
|
||||||
|
- **Run**: `npm run format`
|
||||||
|
- **Check**: `npm run format:check`
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
Husky automatically runs checks before each commit:
|
||||||
|
1. **TypeScript Compilation Check** - `npx tsc --noEmit`
|
||||||
|
2. **ESLint + Prettier** - Via `lint-staged` (only on staged files)
|
||||||
|
|
||||||
|
If any check fails, the commit will be blocked. Fix the issues before committing.
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
1. **Make Changes**: Edit your code
|
||||||
|
2. **Format Code**: Run `npm run format` (or let your IDE auto-format)
|
||||||
|
3. **Check Linting**: Run `npm run lint` to check for issues
|
||||||
|
4. **Commit**: Run `git commit` (hooks will run automatically)
|
||||||
|
- TypeScript check runs on all files
|
||||||
|
- ESLint + Prettier run only on staged files (fast)
|
||||||
|
5. **Fix Issues**: If hooks fail, fix the issues and try again
|
||||||
|
|
||||||
|
### Bypassing Hooks (Emergency Only)
|
||||||
|
|
||||||
|
Only bypass hooks in emergency situations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --no-verify -m "Emergency fix"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this sparingly - it's better to fix the issues properly.
|
||||||
|
|
||||||
|
### VS Code Settings (Recommended)
|
||||||
|
|
||||||
|
Add to `.vscode/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features Implemented (Sprint 1)
|
## Features Implemented (Sprint 1)
|
||||||
|
|||||||
53
app/(dashboard)/stories/[id]/error.tsx
Normal file
53
app/(dashboard)/stories/[id]/error.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function StoryDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the error to an error reporting service
|
||||||
|
console.error('Story detail page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px] p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
|
<CardTitle className="text-destructive">Error Loading Story</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{error.message || 'An unexpected error occurred while loading the story.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
<Button onClick={() => reset()} className="w-full">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
app/(dashboard)/stories/[id]/loading.tsx
Normal file
66
app/(dashboard)/stories/[id]/loading.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default function StoryDetailLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Breadcrumb Skeleton */}
|
||||||
|
<Skeleton className="h-5 w-96" />
|
||||||
|
|
||||||
|
{/* Header Skeleton */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
<Skeleton className="h-10 w-3/4" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
516
app/(dashboard)/stories/[id]/page.tsx
Normal file
516
app/(dashboard)/stories/[id]/page.tsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Layers,
|
||||||
|
CheckCircle2,
|
||||||
|
Tag,
|
||||||
|
Target,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
useStory,
|
||||||
|
useUpdateStory,
|
||||||
|
useDeleteStory,
|
||||||
|
useChangeStoryStatus,
|
||||||
|
} from "@/lib/hooks/use-stories";
|
||||||
|
import { useEpic } from "@/lib/hooks/use-epics";
|
||||||
|
import { useProject } from "@/lib/hooks/use-projects";
|
||||||
|
import { StoryForm } from "@/components/projects/story-form";
|
||||||
|
import { TaskList } from "@/components/tasks/task-list";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { WorkItemStatus, WorkItemPriority } from "@/types/project";
|
||||||
|
|
||||||
|
interface StoryDetailPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoryDetailPage({ params }: StoryDetailPageProps) {
|
||||||
|
const { id: storyId } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: story, isLoading: storyLoading, error: storyError } = useStory(storyId);
|
||||||
|
const { data: epic, isLoading: epicLoading } = useEpic(story?.epicId || "");
|
||||||
|
const { data: project, isLoading: projectLoading } = useProject(story?.projectId || "");
|
||||||
|
const updateStory = useUpdateStory();
|
||||||
|
const deleteStory = useDeleteStory();
|
||||||
|
const changeStatus = useChangeStoryStatus();
|
||||||
|
|
||||||
|
const handleDeleteStory = async () => {
|
||||||
|
try {
|
||||||
|
await deleteStory.mutateAsync(storyId);
|
||||||
|
toast.success("Story deleted successfully");
|
||||||
|
// Navigate back to epic detail page
|
||||||
|
router.push(`/epics/${story?.epicId}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to delete story";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (status: WorkItemStatus) => {
|
||||||
|
if (!story) return;
|
||||||
|
try {
|
||||||
|
await changeStatus.mutateAsync({ id: storyId, status });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update status";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriorityChange = async (priority: WorkItemPriority) => {
|
||||||
|
if (!story) return;
|
||||||
|
try {
|
||||||
|
await updateStory.mutateAsync({
|
||||||
|
id: storyId,
|
||||||
|
data: { priority },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update priority";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: WorkItemStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case "Backlog":
|
||||||
|
return "secondary";
|
||||||
|
case "Todo":
|
||||||
|
return "outline";
|
||||||
|
case "InProgress":
|
||||||
|
return "default";
|
||||||
|
case "Done":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: WorkItemPriority) => {
|
||||||
|
switch (priority) {
|
||||||
|
case "Low":
|
||||||
|
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
|
||||||
|
case "Medium":
|
||||||
|
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
|
||||||
|
case "High":
|
||||||
|
return "bg-orange-100 text-orange-700 hover:bg-orange-100";
|
||||||
|
case "Critical":
|
||||||
|
return "bg-red-100 text-red-700 hover:bg-red-100";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (storyLoading || epicLoading || projectLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-10 w-96" />
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<Skeleton className="h-12 w-1/2" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (storyError || !story) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Error Loading Story</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{storyError instanceof Error ? storyError.message : "Story not found"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex gap-2">
|
||||||
|
<Button onClick={() => router.back()}>Go Back</Button>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Breadcrumb Navigation */}
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
|
<Link href="/projects" className="hover:text-foreground">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
{project && (
|
||||||
|
<>
|
||||||
|
<Link href={`/projects/${project.id}`} className="hover:text-foreground">
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link href={`/projects/${story.projectId}/epics`} className="hover:text-foreground">
|
||||||
|
Epics
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
{epic && (
|
||||||
|
<>
|
||||||
|
<Link href={`/epics/${epic.id}`} className="hover:text-foreground">
|
||||||
|
{epic.name}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-foreground">Stories</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground max-w-[200px] truncate" title={story.title}>
|
||||||
|
{story.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(`/epics/${story.epicId}`)}
|
||||||
|
title="Back to Epic"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{story.title}</h1>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={getStatusColor(story.status)}>{story.status}</Badge>
|
||||||
|
<Badge className={getPriorityColor(story.priority)}>{story.priority}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(true)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Story
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content Area (2/3 width) */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
{/* Story Details Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Story Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{story.description ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-muted-foreground mb-2 text-sm font-medium">Description</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{story.description}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm italic">No description</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tasks Section - Sprint 4 Story 2 */}
|
||||||
|
<TaskList storyId={storyId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Sidebar (1/3 width) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
value={story.status}
|
||||||
|
onValueChange={(value) => handleStatusChange(value as WorkItemStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Backlog">Backlog</SelectItem>
|
||||||
|
<SelectItem value="Todo">Todo</SelectItem>
|
||||||
|
<SelectItem value="InProgress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="Done">Done</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Priority</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
value={story.priority}
|
||||||
|
onValueChange={(value) => handlePriorityChange(value as WorkItemPriority)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Low">Low</SelectItem>
|
||||||
|
<SelectItem value="Medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="High">High</SelectItem>
|
||||||
|
<SelectItem value="Critical">Critical</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Story Points - Sprint 4 Story 3 */}
|
||||||
|
{story.storyPoints && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Story Points</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="text-2xl font-semibold">{story.storyPoints}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
{story.assigneeId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Assignee</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="text-sm">{story.assigneeId}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags - Sprint 4 Story 3 */}
|
||||||
|
{story.tags && story.tags.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{story.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
<Tag className="mr-1 h-3 w-3" />
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Tracking */}
|
||||||
|
{(story.estimatedHours !== undefined || story.actualHours !== undefined) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Time Tracking</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{story.estimatedHours !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span>Estimated: {story.estimatedHours}h</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{story.actualHours !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span>Actual: {story.actualHours}h</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Acceptance Criteria - Sprint 4 Story 3 */}
|
||||||
|
{story.acceptanceCriteria && story.acceptanceCriteria.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Acceptance Criteria</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{story.acceptanceCriteria.map((criterion, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{criterion}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Dates</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<Calendar className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">Created</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(story.createdAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<Calendar className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">Updated</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(story.updatedAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Parent Epic Card */}
|
||||||
|
{epic && (
|
||||||
|
<Card className="transition-shadow hover:shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Parent Epic</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Link
|
||||||
|
href={`/epics/${epic.id}`}
|
||||||
|
className="hover:bg-accent block space-y-2 rounded-md border p-3 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{epic.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={getStatusColor(epic.status)} className="text-xs">
|
||||||
|
{epic.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={`${getPriorityColor(epic.priority)} text-xs`}>
|
||||||
|
{epic.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Story Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Story</DialogTitle>
|
||||||
|
<DialogDescription>Update the story details</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<StoryForm
|
||||||
|
story={story}
|
||||||
|
projectId={story.projectId}
|
||||||
|
onSuccess={() => setIsEditDialogOpen(false)}
|
||||||
|
onCancel={() => setIsEditDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Story Confirmation Dialog */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the story and all its
|
||||||
|
associated tasks.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteStory}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={deleteStory.isPending}
|
||||||
|
>
|
||||||
|
{deleteStory.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Delete Story"
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
|
|||||||
const createEpic = useCreateEpic();
|
const createEpic = useCreateEpic();
|
||||||
const updateEpic = useUpdateEpic();
|
const updateEpic = useUpdateEpic();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const isHydrated = useAuthStore((state) => state.isHydrated);
|
||||||
|
|
||||||
const form = useForm<EpicFormValues>({
|
const form = useForm<EpicFormValues>({
|
||||||
resolver: zodResolver(epicSchema),
|
resolver: zodResolver(epicSchema),
|
||||||
@@ -71,9 +72,19 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: EpicFormValues) {
|
async function onSubmit(data: EpicFormValues) {
|
||||||
|
console.log('[EpicForm] onSubmit triggered', { data, user: user?.id, projectId, isHydrated });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check if auth store has completed hydration
|
||||||
|
if (!isHydrated) {
|
||||||
|
console.warn('[EpicForm] Auth store not hydrated yet, waiting...');
|
||||||
|
toast.error('Loading user information, please try again in a moment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
toast.error('User not authenticated');
|
console.error('[EpicForm] User not authenticated');
|
||||||
|
toast.error('Please log in to create an epic');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,20 +93,29 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
|
|||||||
estimatedHours: data.estimatedHours || undefined,
|
estimatedHours: data.estimatedHours || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[EpicForm] Prepared payload', payload);
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
console.log('[EpicForm] Updating epic', { epicId: epic.id });
|
||||||
await updateEpic.mutateAsync({
|
await updateEpic.mutateAsync({
|
||||||
id: epic.id,
|
id: epic.id,
|
||||||
data: payload,
|
data: payload,
|
||||||
});
|
});
|
||||||
|
console.log('[EpicForm] Epic updated successfully');
|
||||||
} else {
|
} else {
|
||||||
await createEpic.mutateAsync({
|
console.log('[EpicForm] Creating epic', { projectId, createdBy: user.id });
|
||||||
|
const result = await createEpic.mutateAsync({
|
||||||
projectId,
|
projectId,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
...payload,
|
...payload,
|
||||||
});
|
});
|
||||||
|
console.log('[EpicForm] Epic created successfully', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[EpicForm] Calling onSuccess callback');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[EpicForm] Operation failed', error);
|
||||||
const message = error instanceof Error ? error.message : 'Operation failed';
|
const message = error instanceof Error ? error.message : 'Operation failed';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
@@ -112,7 +132,16 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
console.log('[EpicForm] Form submit event triggered', {
|
||||||
|
formState: form.formState,
|
||||||
|
values: form.getValues(),
|
||||||
|
errors: form.formState.errors,
|
||||||
|
});
|
||||||
|
form.handleSubmit(onSubmit)(e);
|
||||||
|
}}
|
||||||
|
className="space-y-6">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -218,9 +247,9 @@ export function EpicForm({ projectId, epic, onSuccess, onCancel }: EpicFormProps
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isLoading || !isHydrated}>
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{isEditing ? 'Update Epic' : 'Create Epic'}
|
{!isHydrated ? 'Loading...' : isEditing ? 'Update Epic' : 'Create Epic'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
96
components/projects/acceptance-criteria-editor.tsx
Normal file
96
components/projects/acceptance-criteria-editor.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AcceptanceCriteriaEditorProps {
|
||||||
|
criteria: string[];
|
||||||
|
onChange: (criteria: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcceptanceCriteriaEditor({
|
||||||
|
criteria,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: AcceptanceCriteriaEditorProps) {
|
||||||
|
const [newCriterion, setNewCriterion] = useState('');
|
||||||
|
|
||||||
|
const addCriterion = () => {
|
||||||
|
if (newCriterion.trim()) {
|
||||||
|
onChange([...criteria, newCriterion.trim()]);
|
||||||
|
setNewCriterion('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCriterion = (index: number) => {
|
||||||
|
onChange(criteria.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addCriterion();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Existing criteria list */}
|
||||||
|
{criteria.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{criteria.map((criterion, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md border bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox checked disabled className="mt-0.5" />
|
||||||
|
<span className="flex-1 text-sm">{criterion}</span>
|
||||||
|
{!disabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => removeCriterion(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add new criterion */}
|
||||||
|
{!disabled && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Add acceptance criterion..."
|
||||||
|
value={newCriterion}
|
||||||
|
onChange={(e) => setNewCriterion(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addCriterion}
|
||||||
|
disabled={!newCriterion.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{criteria.length === 0 && disabled && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No acceptance criteria defined
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from 'zod';
|
import * as z from "zod";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -12,39 +12,45 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from "@/components/ui/form";
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from "@/components/ui/select";
|
||||||
import { useCreateStory, useUpdateStory } from '@/lib/hooks/use-stories';
|
import { useCreateStory, useUpdateStory } from "@/lib/hooks/use-stories";
|
||||||
import { useEpics } from '@/lib/hooks/use-epics';
|
import { useEpics } from "@/lib/hooks/use-epics";
|
||||||
import type { Story, WorkItemPriority } from '@/types/project';
|
import type { Story } from "@/types/project";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from "lucide-react";
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
|
||||||
|
import { TagsInput } from "./tags-input";
|
||||||
|
|
||||||
const storySchema = z.object({
|
const storySchema = z.object({
|
||||||
epicId: z.string().min(1, 'Parent Epic is required'),
|
epicId: z.string().min(1, "Parent Epic is required"),
|
||||||
title: z
|
title: z.string().min(1, "Title is required").max(200, "Title must be less than 200 characters"),
|
||||||
.string()
|
description: z.string().max(2000, "Description must be less than 2000 characters").optional(),
|
||||||
.min(1, 'Title is required')
|
priority: z.enum(["Low", "Medium", "High", "Critical"]),
|
||||||
.max(200, 'Title must be less than 200 characters'),
|
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.max(2000, 'Description must be less than 2000 characters')
|
|
||||||
.optional(),
|
|
||||||
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
|
|
||||||
estimatedHours: z
|
estimatedHours: z
|
||||||
.number()
|
.number()
|
||||||
.min(0, 'Estimated hours must be positive')
|
.min(0, "Estimated hours must be positive")
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal('')),
|
.or(z.literal("")),
|
||||||
|
// Sprint 4 Story 3: New fields
|
||||||
|
acceptanceCriteria: z.array(z.string()).optional(),
|
||||||
|
assigneeId: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
storyPoints: z
|
||||||
|
.number()
|
||||||
|
.min(0, "Story points must be positive")
|
||||||
|
.max(100, "Story points must be less than 100")
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
type StoryFormValues = z.infer<typeof storySchema>;
|
type StoryFormValues = z.infer<typeof storySchema>;
|
||||||
@@ -57,13 +63,7 @@ interface StoryFormProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoryForm({
|
export function StoryForm({ story, epicId, projectId, onSuccess, onCancel }: StoryFormProps) {
|
||||||
story,
|
|
||||||
epicId,
|
|
||||||
projectId,
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
}: StoryFormProps) {
|
|
||||||
const isEditing = !!story;
|
const isEditing = !!story;
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const createStory = useCreateStory();
|
const createStory = useCreateStory();
|
||||||
@@ -75,11 +75,16 @@ export function StoryForm({
|
|||||||
const form = useForm<StoryFormValues>({
|
const form = useForm<StoryFormValues>({
|
||||||
resolver: zodResolver(storySchema),
|
resolver: zodResolver(storySchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
epicId: story?.epicId || epicId || '',
|
epicId: story?.epicId || epicId || "",
|
||||||
title: story?.title || '',
|
title: story?.title || "",
|
||||||
description: story?.description || '',
|
description: story?.description || "",
|
||||||
priority: story?.priority || 'Medium',
|
priority: story?.priority || "Medium",
|
||||||
estimatedHours: story?.estimatedHours || ('' as any),
|
estimatedHours: story?.estimatedHours || ("" as const),
|
||||||
|
// Sprint 4 Story 3: New field defaults
|
||||||
|
acceptanceCriteria: story?.acceptanceCriteria || [],
|
||||||
|
assigneeId: story?.assigneeId || "",
|
||||||
|
tags: story?.tags || [],
|
||||||
|
storyPoints: story?.storyPoints || ("" as const),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,17 +98,22 @@ export function StoryForm({
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
estimatedHours:
|
estimatedHours:
|
||||||
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
|
typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
|
||||||
|
// Sprint 4 Story 3: New fields
|
||||||
|
acceptanceCriteria: data.acceptanceCriteria,
|
||||||
|
assigneeId: data.assigneeId || undefined,
|
||||||
|
tags: data.tags,
|
||||||
|
storyPoints: typeof data.storyPoints === "number" ? data.storyPoints : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success('Story updated successfully');
|
toast.success("Story updated successfully");
|
||||||
} else {
|
} else {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
toast.error('User not authenticated');
|
toast.error("User not authenticated");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
toast.error('Project ID is required');
|
toast.error("Project ID is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await createStory.mutateAsync({
|
await createStory.mutateAsync({
|
||||||
@@ -112,15 +122,19 @@ export function StoryForm({
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
estimatedHours:
|
estimatedHours: typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
|
||||||
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
|
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
|
// Sprint 4 Story 3: New fields
|
||||||
|
acceptanceCriteria: data.acceptanceCriteria,
|
||||||
|
assigneeId: data.assigneeId || undefined,
|
||||||
|
tags: data.tags,
|
||||||
|
storyPoints: typeof data.storyPoints === "number" ? data.storyPoints : undefined,
|
||||||
});
|
});
|
||||||
toast.success('Story created successfully');
|
toast.success("Story created successfully");
|
||||||
}
|
}
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Operation failed';
|
const message = error instanceof Error ? error.message : "Operation failed";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,11 +162,9 @@ export function StoryForm({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{epicsLoading ? (
|
{epicsLoading ? (
|
||||||
<div className="p-2 text-sm text-muted-foreground">Loading epics...</div>
|
<div className="text-muted-foreground p-2 text-sm">Loading epics...</div>
|
||||||
) : epics.length === 0 ? (
|
) : epics.length === 0 ? (
|
||||||
<div className="p-2 text-sm text-muted-foreground">
|
<div className="text-muted-foreground p-2 text-sm">No epics available</div>
|
||||||
No epics available
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
epics.map((epic) => (
|
epics.map((epic) => (
|
||||||
<SelectItem key={epic.id} value={epic.id}>
|
<SelectItem key={epic.id} value={epic.id}>
|
||||||
@@ -163,7 +175,7 @@ export function StoryForm({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isEditing ? 'Parent epic cannot be changed' : 'Select the parent epic'}
|
{isEditing ? "Parent epic cannot be changed" : "Select the parent epic"}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -199,9 +211,7 @@ export function StoryForm({
|
|||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>Optional detailed description (max 2000 characters)</FormDescription>
|
||||||
Optional detailed description (max 2000 characters)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -247,9 +257,9 @@ export function StoryForm({
|
|||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
field.onChange(value === '' ? '' : parseFloat(value));
|
field.onChange(value === "" ? "" : parseFloat(value));
|
||||||
}}
|
}}
|
||||||
value={field.value === undefined ? '' : field.value}
|
value={field.value === undefined ? "" : field.value}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Optional time estimate</FormDescription>
|
<FormDescription>Optional time estimate</FormDescription>
|
||||||
@@ -259,20 +269,114 @@ export function StoryForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sprint 4 Story 3: Acceptance Criteria */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="acceptanceCriteria"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Acceptance Criteria</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<AcceptanceCriteriaEditor
|
||||||
|
criteria={field.value || []}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Define conditions that must be met for this story to be complete
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sprint 4 Story 3: Assignee and Story Points */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assigneeId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Assignee</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value} disabled={isLoading}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Unassigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Unassigned</SelectItem>
|
||||||
|
{user?.id && <SelectItem value={user.id}>{user.fullName || "Me"}</SelectItem>}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>Assign to team member</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="storyPoints"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Story Points</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g., 5"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
field.onChange(value === "" ? "" : parseInt(value));
|
||||||
|
}}
|
||||||
|
value={field.value === undefined ? "" : field.value}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Fibonacci: 1, 2, 3, 5, 8, 13...</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sprint 4 Story 3: Tags */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tags</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagsInput
|
||||||
|
tags={field.value || []}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="Add tags (press Enter)..."
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Add tags to categorize this story (e.g., frontend, bug, urgent)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isLoading}>
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{isEditing ? 'Update Story' : 'Create Story'}
|
{isEditing ? "Update Story" : "Create Story"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
78
components/projects/tags-input.tsx
Normal file
78
components/projects/tags-input.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TagsInputProps {
|
||||||
|
tags: string[];
|
||||||
|
onChange: (tags: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsInput({
|
||||||
|
tags,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
placeholder = 'Add tag and press Enter...',
|
||||||
|
}: TagsInputProps) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const tag = inputValue.trim().toLowerCase();
|
||||||
|
if (tag && !tags.includes(tag)) {
|
||||||
|
onChange([...tags, tag]);
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
onChange(tags.filter((tag) => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
||||||
|
removeTag(tags[tags.length - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Display existing tags */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="px-2 py-1">
|
||||||
|
{tag}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 hover:text-destructive"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input for new tags */}
|
||||||
|
{!disabled && (
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => inputValue && addTag()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,16 +7,17 @@ import { useCurrentUser } from '@/lib/hooks/useAuth';
|
|||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isHydrated } = useAuthStore();
|
||||||
const { isLoading: isUserLoading } = useCurrentUser();
|
const { isLoading: isUserLoading } = useCurrentUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isUserLoading && !isAuthenticated) {
|
if (isHydrated && !isUserLoading && !isAuthenticated) {
|
||||||
|
console.log('[AuthGuard] Redirecting to login - user not authenticated');
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, isUserLoading, router]);
|
}, [isAuthenticated, isHydrated, isUserLoading, router]);
|
||||||
|
|
||||||
if (isLoading || isUserLoading) {
|
if (!isHydrated || isUserLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
149
components/tasks/task-card.tsx
Normal file
149
components/tasks/task-card.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Task, WorkItemStatus } from "@/types/project";
|
||||||
|
import { useChangeTaskStatus, useUpdateTask, useDeleteTask } from "@/lib/hooks/use-tasks";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { MoreHorizontal, Pencil, Trash2, Clock, User, CheckCircle2, Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TaskEditDialog } from "./task-edit-dialog";
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Task;
|
||||||
|
storyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors = {
|
||||||
|
Critical: "bg-red-500 text-white",
|
||||||
|
High: "bg-orange-500 text-white",
|
||||||
|
Medium: "bg-yellow-500 text-white",
|
||||||
|
Low: "bg-blue-500 text-white",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
Backlog: "text-slate-500",
|
||||||
|
Todo: "text-gray-500",
|
||||||
|
InProgress: "text-blue-500",
|
||||||
|
Done: "text-green-500",
|
||||||
|
Blocked: "text-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaskCard({ task, storyId }: TaskCardProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const changeStatus = useChangeTaskStatus();
|
||||||
|
const updateTask = useUpdateTask();
|
||||||
|
const deleteTask = useDeleteTask();
|
||||||
|
|
||||||
|
const isDone = task.status === "Done";
|
||||||
|
|
||||||
|
const handleCheckboxChange = (checked: boolean) => {
|
||||||
|
const newStatus: WorkItemStatus = checked ? "Done" : "Todo";
|
||||||
|
changeStatus.mutate({ id: task.id, status: newStatus });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("Are you sure you want to delete this task?")) {
|
||||||
|
deleteTask.mutate(task.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all duration-200 hover:shadow-md",
|
||||||
|
isDone && "opacity-60"
|
||||||
|
)}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isDone}
|
||||||
|
onCheckedChange={handleCheckboxChange}
|
||||||
|
disabled={changeStatus.isPending}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<h4
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isDone && "text-muted-foreground line-through"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary" className={cn("text-xs", priorityColors[task.priority])}>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="text-muted-foreground flex items-center gap-4 text-xs">
|
||||||
|
{task.estimatedHours && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>{task.estimatedHours}h</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.assigneeId && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span>Assigned</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn("flex items-center gap-1", statusColors[task.status])}>
|
||||||
|
{isDone ? <CheckCircle2 className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
|
||||||
|
<span>{task.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description (expanded) */}
|
||||||
|
{isExpanded && task.description && (
|
||||||
|
<div className="text-muted-foreground mt-3 text-sm">{task.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Menu */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<TaskEditDialog task={task} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
components/tasks/task-edit-dialog.tsx
Normal file
273
components/tasks/task-edit-dialog.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { Task, UpdateTaskDto, WorkItemPriority } from '@/types/project';
|
||||||
|
import { useUpdateTask } from '@/lib/hooks/use-tasks';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TaskEditDialogProps {
|
||||||
|
task: Task;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200, 'Title must be less than 200 characters'),
|
||||||
|
description: z.string().max(2000, 'Description must be less than 2000 characters').optional(),
|
||||||
|
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
|
||||||
|
estimatedHours: z
|
||||||
|
.number()
|
||||||
|
.min(0, 'Estimated hours must be positive')
|
||||||
|
.max(1000, 'Estimated hours must be less than 1000')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
actualHours: z
|
||||||
|
.number()
|
||||||
|
.min(0, 'Actual hours must be positive')
|
||||||
|
.max(1000, 'Actual hours must be less than 1000')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TaskFormValues = z.infer<typeof taskSchema>;
|
||||||
|
|
||||||
|
export function TaskEditDialog({ task, open, onOpenChange }: TaskEditDialogProps) {
|
||||||
|
const updateTask = useUpdateTask();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TaskFormValues>({
|
||||||
|
resolver: zodResolver(taskSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: task.title,
|
||||||
|
description: task.description || '',
|
||||||
|
priority: task.priority,
|
||||||
|
estimatedHours: task.estimatedHours || ('' as any),
|
||||||
|
actualHours: task.actualHours || ('' as any),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when task changes
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
title: task.title,
|
||||||
|
description: task.description || '',
|
||||||
|
priority: task.priority,
|
||||||
|
estimatedHours: task.estimatedHours || ('' as any),
|
||||||
|
actualHours: task.actualHours || ('' as any),
|
||||||
|
});
|
||||||
|
}, [task, form]);
|
||||||
|
|
||||||
|
async function onSubmit(data: TaskFormValues) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const updateData: UpdateTaskDto = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description || undefined,
|
||||||
|
priority: data.priority,
|
||||||
|
estimatedHours: typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
|
||||||
|
actualHours: typeof data.actualHours === 'number' ? data.actualHours : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateTask.mutateAsync({
|
||||||
|
id: task.id,
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done in the mutation hook
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Task</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter task title..."
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter task description..."
|
||||||
|
rows={4}
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Provide additional details about this task
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Priority and Estimated Hours */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Low">Low</SelectItem>
|
||||||
|
<SelectItem value="Medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="High">High</SelectItem>
|
||||||
|
<SelectItem value="Critical">Critical</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="estimatedHours"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Estimated Hours</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g., 8"
|
||||||
|
min="0"
|
||||||
|
max="1000"
|
||||||
|
step="0.5"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
field.onChange(value === '' ? '' : parseFloat(value));
|
||||||
|
}}
|
||||||
|
value={field.value === undefined ? '' : field.value}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actual Hours */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="actualHours"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Actual Hours</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g., 6"
|
||||||
|
min="0"
|
||||||
|
max="1000"
|
||||||
|
step="0.5"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
field.onChange(value === '' ? '' : parseFloat(value));
|
||||||
|
}}
|
||||||
|
value={field.value === undefined ? '' : field.value}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Time spent on this task so far
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
components/tasks/task-list.tsx
Normal file
140
components/tasks/task-list.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTasks } from '@/lib/hooks/use-tasks';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { TaskCard } from './task-card';
|
||||||
|
import { TaskQuickAdd } from './task-quick-add';
|
||||||
|
import { WorkItemStatus } from '@/types/project';
|
||||||
|
|
||||||
|
interface TaskListProps {
|
||||||
|
storyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'active' | 'completed';
|
||||||
|
type SortType = 'recent' | 'alphabetical' | 'status';
|
||||||
|
|
||||||
|
export function TaskList({ storyId }: TaskListProps) {
|
||||||
|
const { data: tasks, isLoading, error } = useTasks(storyId);
|
||||||
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
const [sort, setSort] = useState<SortType>('recent');
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Failed to load tasks. Please try again.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTasks = tasks?.filter(task => {
|
||||||
|
if (filter === 'active') return task.status !== 'Done';
|
||||||
|
if (filter === 'completed') return task.status === 'Done';
|
||||||
|
return true;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const sortedTasks = [...filteredTasks].sort((a, b) => {
|
||||||
|
if (sort === 'alphabetical') return a.title.localeCompare(b.title);
|
||||||
|
if (sort === 'status') return a.status.localeCompare(b.status);
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedCount = tasks?.filter(t => t.status === 'Done').length || 0;
|
||||||
|
const totalCount = tasks?.length || 0;
|
||||||
|
const progressPercentage = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Tasks</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{completedCount} of {totalCount} completed
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={filter} onValueChange={(v) => setFilter(v as FilterType)}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={sort} onValueChange={(v) => setSort(v as SortType)}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="recent">Recent</SelectItem>
|
||||||
|
<SelectItem value="alphabetical">Alphabetical</SelectItem>
|
||||||
|
<SelectItem value="status">By Status</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<TaskQuickAdd storyId={storyId} />
|
||||||
|
|
||||||
|
{sortedTasks.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filter === 'all'
|
||||||
|
? 'No tasks yet. Create your first task above!'
|
||||||
|
: `No ${filter} tasks found.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedTasks.map(task => (
|
||||||
|
<TaskCard key={task.id} task={task} storyId={storyId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
components/tasks/task-quick-add.tsx
Normal file
177
components/tasks/task-quick-add.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useCreateTask } from "@/lib/hooks/use-tasks";
|
||||||
|
import { CreateTaskDto, WorkItemPriority } from "@/types/project";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface TaskQuickAddProps {
|
||||||
|
storyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required").max(200, "Title too long"),
|
||||||
|
priority: z.enum(["Critical", "High", "Medium", "Low"]),
|
||||||
|
estimatedHours: z.number().min(0).optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TaskFormData = z.infer<typeof taskSchema>;
|
||||||
|
|
||||||
|
export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const createTask = useCreateTask();
|
||||||
|
|
||||||
|
const form = useForm<TaskFormData>({
|
||||||
|
resolver: zodResolver(taskSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
priority: "Medium",
|
||||||
|
estimatedHours: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TaskFormData) => {
|
||||||
|
const taskData: CreateTaskDto = {
|
||||||
|
storyId,
|
||||||
|
title: data.title,
|
||||||
|
priority: data.priority as WorkItemPriority,
|
||||||
|
estimatedHours: typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
createTask.mutate(taskData, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
// Keep form open for batch creation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.reset();
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<Button onClick={() => setIsOpen(true)} variant="outline" className="w-full" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium">Quick Add Task</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g., Implement login API" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Critical">Critical</SelectItem>
|
||||||
|
<SelectItem value="High">High</SelectItem>
|
||||||
|
<SelectItem value="Medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="Low">Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="estimatedHours"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Est. Hours</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="8"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
field.onChange(value === "" ? "" : parseFloat(value));
|
||||||
|
}}
|
||||||
|
value={field.value === undefined ? "" : field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={createTask.isPending} className="flex-1">
|
||||||
|
{createTask.isPending ? "Creating..." : "Add Task"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -22,6 +22,8 @@ const eslintConfig = defineConfig([
|
|||||||
"jsx-a11y/click-events-have-key-events": "warn",
|
"jsx-a11y/click-events-have-key-events": "warn",
|
||||||
"jsx-a11y/no-static-element-interactions": "warn",
|
"jsx-a11y/no-static-element-interactions": "warn",
|
||||||
"jsx-a11y/interactive-supports-focus": "warn",
|
"jsx-a11y/interactive-supports-focus": "warn",
|
||||||
|
// TypeScript strict rules - prohibit 'any' type usage
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
|
|||||||
@@ -49,9 +49,18 @@ apiClient.interceptors.request.use(
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
console.log('[API] Request:', {
|
||||||
|
method: config.method?.toUpperCase(),
|
||||||
|
url: config.url,
|
||||||
|
hasAuth: !!token,
|
||||||
|
data: config.data,
|
||||||
|
});
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => {
|
||||||
|
console.error('[API] Request interceptor error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response interceptor: automatically refresh Token
|
// Response interceptor: automatically refresh Token
|
||||||
@@ -74,8 +83,22 @@ const processQueue = (error: unknown, token: string | null = null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
console.log('[API] Response:', {
|
||||||
|
status: response.status,
|
||||||
|
url: response.config.url,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
|
console.error('[API] Response error:', {
|
||||||
|
status: error.response?.status,
|
||||||
|
url: error.config?.url,
|
||||||
|
message: error.message,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ export const epicsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
create: async (data: CreateEpicDto): Promise<Epic> => {
|
create: async (data: CreateEpicDto): Promise<Epic> => {
|
||||||
return api.post('/api/v1/epics', data);
|
console.log('[epicsApi.create] Sending request', { url: '/api/v1/epics', data });
|
||||||
|
try {
|
||||||
|
const result = await api.post<Epic>('/api/v1/epics', data);
|
||||||
|
console.log('[epicsApi.create] Request successful', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[epicsApi.create] Request failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (id: string, data: UpdateEpicDto): Promise<Epic> => {
|
update: async (id: string, data: UpdateEpicDto): Promise<Epic> => {
|
||||||
|
|||||||
@@ -30,16 +30,20 @@ export function useLogin() {
|
|||||||
tokenManager.setAccessToken(data.accessToken);
|
tokenManager.setAccessToken(data.accessToken);
|
||||||
tokenManager.setRefreshToken(data.refreshToken);
|
tokenManager.setRefreshToken(data.refreshToken);
|
||||||
|
|
||||||
|
// Map backend field names to frontend User type
|
||||||
|
// Backend returns: { Id, TenantId, Email, FullName, ... }
|
||||||
|
// Frontend expects: { id, tenantId, email, fullName, ... }
|
||||||
|
const backendUser = data.user;
|
||||||
setUser({
|
setUser({
|
||||||
id: data.user.id,
|
id: backendUser.id || backendUser.Id, // Handle both casing
|
||||||
email: data.user.email,
|
email: backendUser.email || backendUser.Email,
|
||||||
fullName: data.user.fullName,
|
fullName: backendUser.fullName || backendUser.FullName,
|
||||||
tenantId: data.user.tenantId,
|
tenantId: backendUser.tenantId || backendUser.TenantId,
|
||||||
tenantName: data.user.tenantName,
|
tenantName: data.tenant?.name || data.tenant?.Name || 'Unknown',
|
||||||
role: data.user.role,
|
role: data.tenant?.role || backendUser.role || 'TenantMember',
|
||||||
isEmailVerified: data.user.isEmailVerified,
|
isEmailVerified: backendUser.isEmailVerified ?? backendUser.IsEmailVerified ?? false,
|
||||||
createdAt: data.user.createdAt || new Date().toISOString(),
|
createdAt: backendUser.createdAt || backendUser.CreatedAt || new Date().toISOString(),
|
||||||
updatedAt: data.user.updatedAt,
|
updatedAt: backendUser.updatedAt || backendUser.UpdatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
@@ -95,9 +99,24 @@ export function useCurrentUser() {
|
|||||||
queryKey: ['currentUser'],
|
queryKey: ['currentUser'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.get(API_ENDPOINTS.ME);
|
const { data } = await apiClient.get(API_ENDPOINTS.ME);
|
||||||
setUser(data);
|
|
||||||
|
// Map backend /me response to frontend User type
|
||||||
|
// Backend returns: { userId, tenantId, email, fullName, tenantSlug, tenantRole, role }
|
||||||
|
// Frontend expects: { id, tenantId, email, fullName, tenantName, role, isEmailVerified, createdAt }
|
||||||
|
const mappedUser = {
|
||||||
|
id: data.userId || data.id, // Backend uses 'userId'
|
||||||
|
email: data.email,
|
||||||
|
fullName: data.fullName,
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
tenantName: data.tenantSlug || 'Unknown', // Use tenantSlug as tenantName fallback
|
||||||
|
role: data.tenantRole || data.role || 'TenantMember',
|
||||||
|
isEmailVerified: true, // Assume verified if token is valid
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(mappedUser);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return data;
|
return mappedUser;
|
||||||
},
|
},
|
||||||
enabled: !!tokenManager.getAccessToken(),
|
enabled: !!tokenManager.getAccessToken(),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export class SignalRConnectionManager {
|
|||||||
|
|
||||||
this.connection = new signalR.HubConnectionBuilder()
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
.withUrl(this.hubUrl, {
|
.withUrl(this.hubUrl, {
|
||||||
accessTokenFactory: () => token,
|
// Use dynamic token factory to always get the latest token
|
||||||
|
accessTokenFactory: () => tokenManager.getAccessToken() || '',
|
||||||
// 备用方案:使用 query string(WebSocket 升级需要)
|
// 备用方案:使用 query string(WebSocket 升级需要)
|
||||||
// transport: signalR.HttpTransportType.WebSockets,
|
// transport: signalR.HttpTransportType.WebSockets,
|
||||||
})
|
})
|
||||||
|
|||||||
457
package-lock.json
generated
457
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@microsoft/signalr": "^9.0.6",
|
"@microsoft/signalr": "^9.0.6",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.1",
|
"eslint-config-next": "16.0.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"lint-staged": "^16.2.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -1489,6 +1491,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -3271,6 +3303,35 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-escapes": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"environment": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -3729,6 +3790,39 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cli-cursor": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"restore-cursor": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cli-truncate": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"slice-ansi": "^7.1.0",
|
||||||
|
"string-width": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -3764,6 +3858,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "2.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -3776,6 +3877,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "14.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
||||||
|
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -4024,6 +4135,19 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/environment": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.0",
|
"version": "1.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
|
||||||
@@ -4653,6 +4777,13 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||||
@@ -4909,6 +5040,19 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-east-asian-width": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -5399,6 +5543,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-east-asian-width": "^1.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
@@ -6060,6 +6220,49 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lint-staged": {
|
||||||
|
"version": "16.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.6.tgz",
|
||||||
|
"integrity": "sha512-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^14.0.1",
|
||||||
|
"listr2": "^9.0.5",
|
||||||
|
"micromatch": "^4.0.8",
|
||||||
|
"nano-spawn": "^2.0.0",
|
||||||
|
"pidtree": "^0.6.0",
|
||||||
|
"string-argv": "^0.3.2",
|
||||||
|
"yaml": "^2.8.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"lint-staged": "bin/lint-staged.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/lint-staged"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/listr2": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cli-truncate": "^5.0.0",
|
||||||
|
"colorette": "^2.0.20",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"log-update": "^6.1.0",
|
||||||
|
"rfdc": "^1.4.1",
|
||||||
|
"wrap-ansi": "^9.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6083,6 +6286,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/log-update": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-escapes": "^7.0.0",
|
||||||
|
"cli-cursor": "^5.0.0",
|
||||||
|
"slice-ansi": "^7.1.0",
|
||||||
|
"strip-ansi": "^7.1.0",
|
||||||
|
"wrap-ansi": "^9.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -6179,6 +6402,19 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mimic-function": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -6209,6 +6445,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nano-spawn": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -6490,6 +6739,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/onetime": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-function": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -6617,6 +6882,19 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pidtree": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"pidtree": "bin/pidtree.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7043,6 +7321,23 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/restore-cursor": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"onetime": "^7.0.0",
|
||||||
|
"signal-exit": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -7054,6 +7349,13 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rfdc": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -7359,6 +7661,49 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slice-ansi": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.2.1",
|
||||||
|
"is-fullwidth-code-point": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slice-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
@@ -7399,6 +7744,33 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-argv": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-east-asian-width": "^1.3.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -7512,6 +7884,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-bom": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -8185,6 +8573,62 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.2.1",
|
||||||
|
"string-width": "^7.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/emoji-regex": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^10.3.0",
|
||||||
|
"get-east-asian-width": "^1.0.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "7.5.10",
|
"version": "7.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||||
@@ -8213,6 +8657,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -8,6 +8,7 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||||
"docker:dev": "docker-compose up -d postgres redis backend",
|
"docker:dev": "docker-compose up -d postgres redis backend",
|
||||||
"docker:all": "docker-compose up -d",
|
"docker:all": "docker-compose up -d",
|
||||||
"docker:stop": "docker-compose down",
|
"docker:stop": "docker-compose down",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"@microsoft/signalr": "^9.0.6",
|
"@microsoft/signalr": "^9.0.6",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -60,10 +62,20 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.1",
|
"eslint-config-next": "16.0.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"lint-staged": "^16.2.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx}": [
|
||||||
|
"prettier --write",
|
||||||
|
"eslint --fix"
|
||||||
|
],
|
||||||
|
"*.{json,css}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface AuthState {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isHydrated: boolean;
|
||||||
|
|
||||||
setUser: (user: User) => void;
|
setUser: (user: User) => void;
|
||||||
clearUser: () => void;
|
clearUser: () => void;
|
||||||
@@ -18,6 +19,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
isHydrated: false,
|
||||||
|
|
||||||
setUser: (user) =>
|
setUser: (user) =>
|
||||||
set({ user, isAuthenticated: true, isLoading: false }),
|
set({ user, isAuthenticated: true, isLoading: false }),
|
||||||
@@ -31,6 +33,38 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: state.user,
|
user: state.user,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
}),
|
}),
|
||||||
|
// 数据迁移函数:将旧格式的 userId 转换为新格式的 id
|
||||||
|
migrate: (persistedState: any, version: number) => {
|
||||||
|
console.log('[AuthStore] Migrating persisted state', { version, persistedState });
|
||||||
|
|
||||||
|
// 如果存在旧的 userId 字段,迁移到 id
|
||||||
|
if (persistedState?.user?.userId && !persistedState?.user?.id) {
|
||||||
|
console.log('[AuthStore] Migrating userId to id');
|
||||||
|
persistedState.user.id = persistedState.user.userId;
|
||||||
|
delete persistedState.user.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedState;
|
||||||
|
},
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
console.log('[AuthStore] Hydration started');
|
||||||
|
if (state) {
|
||||||
|
// 额外的安全检查:确保 user 对象有 id 字段
|
||||||
|
if (state.user && (state.user as any).userId && !state.user.id) {
|
||||||
|
console.log('[AuthStore] Post-hydration migration: userId -> id');
|
||||||
|
state.user.id = (state.user as any).userId;
|
||||||
|
delete (state.user as any).userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isHydrated = true;
|
||||||
|
state.isLoading = false; // 水合完成后停止 loading
|
||||||
|
console.log('[AuthStore] Hydration completed', {
|
||||||
|
userId: state.user?.id,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
isLoading: state.isLoading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export interface Story {
|
|||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
actualHours?: number;
|
actualHours?: number;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
assigneeName?: string; // Sprint 4 Story 3: Assignee display name
|
||||||
|
acceptanceCriteria?: string[]; // Sprint 4 Story 3: Acceptance criteria list
|
||||||
|
tags?: string[]; // Sprint 4 Story 3: Tags/labels
|
||||||
|
storyPoints?: number; // Sprint 4 Story 3: Story points
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -84,6 +88,10 @@ export interface CreateStoryDto {
|
|||||||
priority: WorkItemPriority;
|
priority: WorkItemPriority;
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
createdBy: string; // Required field matching backend API
|
createdBy: string; // Required field matching backend API
|
||||||
|
assigneeId?: string; // Sprint 4 Story 3
|
||||||
|
acceptanceCriteria?: string[]; // Sprint 4 Story 3
|
||||||
|
tags?: string[]; // Sprint 4 Story 3
|
||||||
|
storyPoints?: number; // Sprint 4 Story 3
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStoryDto {
|
export interface UpdateStoryDto {
|
||||||
@@ -92,6 +100,10 @@ export interface UpdateStoryDto {
|
|||||||
priority?: WorkItemPriority;
|
priority?: WorkItemPriority;
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
actualHours?: number;
|
actualHours?: number;
|
||||||
|
assigneeId?: string; // Sprint 4 Story 3
|
||||||
|
acceptanceCriteria?: string[]; // Sprint 4 Story 3
|
||||||
|
tags?: string[]; // Sprint 4 Story 3
|
||||||
|
storyPoints?: number; // Sprint 4 Story 3
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Task ====================
|
// ==================== Task ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user