feat: 풀스택 할일관리 앱 구현 (통합 모달 + 간트차트)
- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드) - Frontend: Next.js 15 + Tailwind + React Query + Zustand - 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부) - 간트차트: 카테고리별 할일 타임라인 시각화 - TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시 - Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start production server
|
||||
CMD ["npm", "run", "start"]
|
||||
16
frontend/eslint.config.mjs
Normal file
16
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6439
frontend/package-lock.json
generated
Normal file
6439
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "todos2-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
122
frontend/src/app/categories/page.tsx
Normal file
122
frontend/src/app/categories/page.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Category, CategoryCreate, CategoryUpdate } from "@/types";
|
||||
import {
|
||||
useCategoryList,
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
} from "@/hooks/useCategories";
|
||||
import CategoryList from "@/components/categories/CategoryList";
|
||||
import CategoryForm from "@/components/categories/CategoryForm";
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { data: categories, isLoading } = useCategoryList();
|
||||
const createCategory = useCreateCategory();
|
||||
const updateCategory = useUpdateCategory();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormMode("create");
|
||||
setEditingCategory(null);
|
||||
setShowForm(true);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleEdit = (category: Category) => {
|
||||
setFormMode("edit");
|
||||
setEditingCategory(category);
|
||||
setShowForm(true);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleDelete = async (category: Category) => {
|
||||
try {
|
||||
await deleteCategory.mutateAsync(category.id);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { detail?: string };
|
||||
setError(apiErr.detail || "삭제에 실패했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: CategoryCreate | CategoryUpdate) => {
|
||||
setError("");
|
||||
try {
|
||||
if (formMode === "create") {
|
||||
await createCategory.mutateAsync(data as CategoryCreate);
|
||||
} else if (editingCategory) {
|
||||
await updateCategory.mutateAsync({
|
||||
id: editingCategory.id,
|
||||
data: data as CategoryUpdate,
|
||||
});
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingCategory(null);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { detail?: string };
|
||||
setError(apiErr.detail || "저장에 실패했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setEditingCategory(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">카테고리 관리</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
할일을 분류할 카테고리를 관리하세요
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
새 카테고리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showForm && (
|
||||
<CategoryForm
|
||||
mode={formMode}
|
||||
category={editingCategory}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
isSubmitting={
|
||||
createCategory.isPending || updateCategory.isPending
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Category List */}
|
||||
<CategoryList
|
||||
categories={categories}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/app/globals.css
Normal file
1
frontend/src/app/globals.css
Normal file
@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
28
frontend/src/app/layout.tsx
Normal file
28
frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import QueryProvider from "@/components/providers/QueryProvider";
|
||||
import MainLayout from "@/components/layout/MainLayout";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "todos2 - 할일 관리",
|
||||
description: "카테고리, 태그, 우선순위, 마감일을 갖춘 확장형 할일 관리 애플리케이션",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className={inter.className}>
|
||||
<QueryProvider>
|
||||
<MainLayout>{children}</MainLayout>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
48
frontend/src/app/page.tsx
Normal file
48
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStats } from "@/hooks/useDashboard";
|
||||
import StatsCards from "@/components/dashboard/StatsCards";
|
||||
import CategoryChart from "@/components/dashboard/CategoryChart";
|
||||
import PriorityChart from "@/components/dashboard/PriorityChart";
|
||||
import UpcomingDeadlines from "@/components/dashboard/UpcomingDeadlines";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: stats, isLoading, isError } = useDashboardStats();
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-red-500">
|
||||
<p className="text-sm font-medium">대시보드를 불러올 수 없습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
잠시 후 다시 시도해주세요
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">대시보드</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
할일 현황을 한눈에 확인하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<StatsCards stats={stats?.overview} isLoading={isLoading} />
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CategoryChart data={stats?.by_category} isLoading={isLoading} />
|
||||
<PriorityChart data={stats?.by_priority} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
<UpcomingDeadlines
|
||||
deadlines={stats?.upcoming_deadlines}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/app/search/page.tsx
Normal file
60
frontend/src/app/search/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import SearchResults from "@/components/search/SearchResults";
|
||||
import Pagination from "@/components/common/Pagination";
|
||||
|
||||
function SearchContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryParam = searchParams.get("q") || "";
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [queryParam]);
|
||||
|
||||
const { data, isLoading } = useSearch(queryParam, page);
|
||||
|
||||
const totalPages = data
|
||||
? Math.ceil(data.total / (data.limit || 20))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">검색 결과</h1>
|
||||
</div>
|
||||
|
||||
<SearchResults
|
||||
results={data?.items}
|
||||
query={queryParam}
|
||||
total={data?.total || 0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-gray-500">검색 중...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
22
frontend/src/app/todos/[id]/page.tsx
Normal file
22
frontend/src/app/todos/[id]/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
|
||||
export default function TodoDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const openTodoForm = useUIStore((s) => s.openTodoForm);
|
||||
|
||||
useEffect(() => {
|
||||
openTodoForm("edit", id);
|
||||
router.replace("/todos");
|
||||
}, [id, router, openTodoForm]);
|
||||
|
||||
return null;
|
||||
}
|
||||
239
frontend/src/app/todos/page.tsx
Normal file
239
frontend/src/app/todos/page.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Plus, Loader2, List, BarChart3 } from "lucide-react";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
import {
|
||||
useTodoList,
|
||||
useToggleTodo,
|
||||
useDeleteTodo,
|
||||
useBatchAction,
|
||||
useTodoDetail,
|
||||
} from "@/hooks/useTodos";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TodoFilter from "@/components/todos/TodoFilter";
|
||||
import TodoList from "@/components/todos/TodoList";
|
||||
import TodoModal from "@/components/todos/TodoModal";
|
||||
import BatchActions from "@/components/todos/BatchActions";
|
||||
import Pagination from "@/components/common/Pagination";
|
||||
import GanttChart from "@/components/todos/GanttChart";
|
||||
|
||||
function TodosContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [viewMode, setViewMode] = useState<"list" | "gantt">("list");
|
||||
const {
|
||||
filters,
|
||||
setFilter,
|
||||
setFilters,
|
||||
selectedIds,
|
||||
toggleSelect,
|
||||
clearSelection,
|
||||
todoFormOpen,
|
||||
todoFormMode,
|
||||
editingTodoId,
|
||||
openTodoForm,
|
||||
closeTodoForm,
|
||||
} = useUIStore();
|
||||
|
||||
// Apply URL query params as filters
|
||||
useEffect(() => {
|
||||
const categoryId = searchParams.get("category_id");
|
||||
const tag = searchParams.get("tag");
|
||||
|
||||
if (categoryId || tag) {
|
||||
setFilters({
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
});
|
||||
}
|
||||
}, [searchParams, setFilters]);
|
||||
|
||||
// Queries
|
||||
const {
|
||||
data: todosData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useTodoList(filters);
|
||||
|
||||
// Get detail for editing
|
||||
const { data: editingTodo } = useTodoDetail(editingTodoId || "");
|
||||
|
||||
// Mutations
|
||||
const toggleTodo = useToggleTodo();
|
||||
const deleteTodo = useDeleteTodo();
|
||||
const batchAction = useBatchAction();
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
toggleTodo.mutate(id);
|
||||
},
|
||||
[toggleTodo]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
if (window.confirm("이 할일을 삭제하시겠습니까?")) {
|
||||
deleteTodo.mutate(id);
|
||||
}
|
||||
},
|
||||
[deleteTodo]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(id: string) => {
|
||||
openTodoForm("edit", id);
|
||||
},
|
||||
[openTodoForm]
|
||||
);
|
||||
|
||||
const handleTagClick = useCallback(
|
||||
(tag: string) => {
|
||||
setFilter("tag", tag);
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
const handleBatchComplete = useCallback(async () => {
|
||||
await batchAction.mutateAsync({
|
||||
action: "complete",
|
||||
ids: selectedIds,
|
||||
});
|
||||
clearSelection();
|
||||
}, [batchAction, selectedIds, clearSelection]);
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
await batchAction.mutateAsync({
|
||||
action: "delete",
|
||||
ids: selectedIds,
|
||||
});
|
||||
clearSelection();
|
||||
}, [batchAction, selectedIds, clearSelection]);
|
||||
|
||||
const handleBatchMove = useCallback(
|
||||
async (categoryId: string | null) => {
|
||||
await batchAction.mutateAsync({
|
||||
action: "move_category",
|
||||
ids: selectedIds,
|
||||
category_id: categoryId,
|
||||
});
|
||||
clearSelection();
|
||||
},
|
||||
[batchAction, selectedIds, clearSelection]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">할일 목록</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{todosData ? `총 ${todosData.total}개` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||
viewMode === "list"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
)}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
목록
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("gantt")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||
viewMode === "gantt"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
간트차트
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openTodoForm("create")}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
새 할일
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "list" ? (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<TodoFilter />
|
||||
|
||||
{/* Batch Actions */}
|
||||
<BatchActions
|
||||
selectedCount={selectedIds.length}
|
||||
onBatchComplete={handleBatchComplete}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onBatchMove={handleBatchMove}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
|
||||
{/* Todo List */}
|
||||
<TodoList
|
||||
todos={todosData?.items}
|
||||
selectedIds={selectedIds}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onToggle={handleToggle}
|
||||
onSelect={toggleSelect}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onTagClick={handleTagClick}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{todosData && todosData.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={filters.page}
|
||||
totalPages={todosData.total_pages}
|
||||
onPageChange={(page) => setFilter("page", page)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<GanttChart categoryId={filters.category_id} />
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<TodoModal
|
||||
mode={todoFormMode}
|
||||
todo={todoFormMode === "edit" ? editingTodo : null}
|
||||
isOpen={todoFormOpen}
|
||||
onClose={closeTodoForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodosPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
||||
<span className="ml-3 text-gray-500">로딩 중...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TodosContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/categories/CategoryForm.tsx
Normal file
106
frontend/src/components/categories/CategoryForm.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Save, X } from "lucide-react";
|
||||
import { Category, CategoryCreate, CategoryUpdate } from "@/types";
|
||||
import ColorPicker from "./ColorPicker";
|
||||
|
||||
interface CategoryFormProps {
|
||||
mode: "create" | "edit";
|
||||
category?: Category | null;
|
||||
onSubmit: (data: CategoryCreate | CategoryUpdate) => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryForm({
|
||||
mode,
|
||||
category,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
}: CategoryFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#6B7280");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && category) {
|
||||
setName(category.name);
|
||||
setColor(category.color);
|
||||
} else {
|
||||
setName("");
|
||||
setColor("#6B7280");
|
||||
}
|
||||
setError("");
|
||||
}, [mode, category]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("카테고리 이름을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
if (trimmedName.length > 50) {
|
||||
setError("이름은 50자 이하로 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({ name: trimmedName, color });
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-3 p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{mode === "create" ? "새 카테고리 이름" : "카테고리 이름"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="카테고리 이름..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
<ColorPicker selectedColor={color} onChange={setColor} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{mode === "create" ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
추가
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/categories/CategoryItem.tsx
Normal file
63
frontend/src/components/categories/CategoryItem.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Category } from "@/types";
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: Category;
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
}
|
||||
|
||||
export default function CategoryItem({
|
||||
category,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryItemProps) {
|
||||
const handleDelete = () => {
|
||||
const message =
|
||||
category.todo_count > 0
|
||||
? `"${category.name}" 카테고리를 삭제하시겠습니까?\n이 카테고리에 속한 ${category.todo_count}개의 할일이 "미분류"로 변경됩니다.`
|
||||
: `"${category.name}" 카테고리를 삭제하시겠습니까?`;
|
||||
|
||||
if (window.confirm(message)) {
|
||||
onDelete(category);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-lg hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{category.todo_count}개 할일
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(category)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/categories/CategoryList.tsx
Normal file
51
frontend/src/components/categories/CategoryList.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { FolderOpen, Loader2 } from "lucide-react";
|
||||
import { Category } from "@/types";
|
||||
import CategoryItem from "./CategoryItem";
|
||||
|
||||
interface CategoryListProps {
|
||||
categories: Category[] | undefined;
|
||||
isLoading: boolean;
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
}
|
||||
|
||||
export default function CategoryList({
|
||||
categories,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
||||
<span className="ml-3 text-gray-500">카테고리를 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<FolderOpen className="h-12 w-12 mb-3" />
|
||||
<p className="text-sm font-medium">카테고리가 없습니다</p>
|
||||
<p className="text-xs mt-1">새 카테고리를 추가해보세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{categories.map((category) => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/categories/ColorPicker.tsx
Normal file
65
frontend/src/components/categories/ColorPicker.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { COLOR_PRESETS, cn } from "@/lib/utils";
|
||||
|
||||
interface ColorPickerProps {
|
||||
selectedColor: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function ColorPicker({
|
||||
selectedColor,
|
||||
onChange,
|
||||
}: ColorPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="w-5 h-5 rounded-full border border-gray-200"
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{selectedColor}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="absolute top-full left-0 mt-1 p-3 bg-white border border-gray-200 rounded-lg shadow-lg z-20">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{COLOR_PRESETS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center transition-transform hover:scale-110",
|
||||
selectedColor === color && "ring-2 ring-offset-2 ring-gray-400"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<Check className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/common/AttachmentList.tsx
Normal file
78
frontend/src/components/common/AttachmentList.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Trash2, FileText, Image as ImageIcon, File } from "lucide-react";
|
||||
import { Attachment } from "@/types";
|
||||
|
||||
interface AttachmentListProps {
|
||||
attachments: Attachment[];
|
||||
onDownload: (attachment: Attachment) => void;
|
||||
onDelete?: (attachmentId: string) => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FileIcon({ contentType }: { contentType: string }) {
|
||||
if (contentType.startsWith("image/")) {
|
||||
return <ImageIcon className="h-4 w-4" />;
|
||||
}
|
||||
if (contentType === "application/pdf") {
|
||||
return <FileText className="h-4 w-4" />;
|
||||
}
|
||||
return <File className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export default function AttachmentList({
|
||||
attachments,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: AttachmentListProps) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div className="flex-shrink-0 text-gray-500">
|
||||
<FileIcon contentType={attachment.content_type} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{attachment.filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(attachment.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onDownload(attachment)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(attachment.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/common/DatePicker.tsx
Normal file
53
frontend/src/components/common/DatePicker.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
interface DatePickerProps {
|
||||
value: string | null | undefined;
|
||||
onChange: (date: string | null) => void;
|
||||
label?: string;
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
export default function DatePicker({ value, onChange, label, clearLabel = "날짜 해제" }: DatePickerProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (val) {
|
||||
onChange(new Date(val + "T00:00:00Z").toISOString());
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = value
|
||||
? new Date(value).toISOString().split("T")[0]
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="mt-1 text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/common/FileUpload.tsx
Normal file
73
frontend/src/components/common/FileUpload.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Upload, Loader2 } from "lucide-react";
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
maxFiles?: number;
|
||||
maxSizeMB?: number;
|
||||
isUploading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function FileUpload({
|
||||
onFilesSelected,
|
||||
maxFiles = 5,
|
||||
maxSizeMB = 10,
|
||||
isUploading = false,
|
||||
disabled = false,
|
||||
}: FileUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length > maxFiles) {
|
||||
alert(`최대 ${maxFiles}개의 파일만 선택할 수 있습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxBytes = maxSizeMB * 1024 * 1024;
|
||||
const oversized = files.filter((f) => f.size > maxBytes);
|
||||
if (oversized.length > 0) {
|
||||
alert(`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onFilesSelected(files);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || isUploading}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{isUploading ? "업로드 중..." : "파일 첨부"}
|
||||
</button>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
최대 {maxFiles}개, 파일당 {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/common/Pagination.tsx
Normal file
99
frontend/src/components/common/Pagination.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = (): (number | "...")[] => {
|
||||
const pages: (number | "...")[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push("...");
|
||||
}
|
||||
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push("...");
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 mt-6">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
currentPage <= 1
|
||||
? "text-gray-300 cursor-not-allowed"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
page === "..." ? (
|
||||
<span key={`dots-${idx}`} className="px-2 py-1 text-gray-400 text-sm">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={cn(
|
||||
"min-w-[2rem] h-8 px-2 text-sm font-medium rounded-lg transition-colors",
|
||||
currentPage === page
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-colors",
|
||||
currentPage >= totalPages
|
||||
? "text-gray-300 cursor-not-allowed"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/common/PriorityBadge.tsx
Normal file
28
frontend/src/components/common/PriorityBadge.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Priority } from "@/types";
|
||||
import { getPriorityConfig } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
priority: Priority;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PriorityBadge({
|
||||
priority,
|
||||
className,
|
||||
}: PriorityBadgeProps) {
|
||||
const config = getPriorityConfig(priority);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full",
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", config.dotColor)} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/common/TagBadge.tsx
Normal file
46
frontend/src/components/common/TagBadge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TagBadgeProps {
|
||||
name: string;
|
||||
onClick?: () => void;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TagBadge({
|
||||
name,
|
||||
onClick,
|
||||
onRemove,
|
||||
className,
|
||||
}: TagBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full transition-colors",
|
||||
onClick
|
||||
? "bg-blue-50 text-blue-700 hover:bg-blue-100 cursor-pointer"
|
||||
: "bg-gray-100 text-gray-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span onClick={onClick} className={onClick ? "cursor-pointer" : ""}>
|
||||
#{name}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="ml-0.5 hover:text-red-500 transition-colors"
|
||||
aria-label={`${name} 태그 제거`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/common/TagInput.tsx
Normal file
99
frontend/src/components/common/TagInput.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import TagBadge from "./TagBadge";
|
||||
import { useTagList } from "@/hooks/useTags";
|
||||
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
onAdd: (tag: string) => void;
|
||||
onRemove: (tag: string) => void;
|
||||
}
|
||||
|
||||
export default function TagInput({ tags, onAdd, onRemove }: TagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { data: allTags } = useTagList();
|
||||
|
||||
const suggestions = allTags
|
||||
?.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(input.toLowerCase()) &&
|
||||
!tags.includes(t.name)
|
||||
)
|
||||
.slice(0, 5);
|
||||
|
||||
const addTag = useCallback(
|
||||
(tag: string) => {
|
||||
const trimmed = tag.trim().toLowerCase();
|
||||
if (trimmed && !tags.includes(trimmed) && tags.length < 10) {
|
||||
onAdd(trimmed);
|
||||
setInput("");
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
},
|
||||
[tags, onAdd]
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
addTag(input);
|
||||
}
|
||||
} else if (e.key === "Backspace" && !input && tags.length > 0) {
|
||||
onRemove(tags[tags.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap gap-1.5 p-2 border border-gray-300 rounded-lg bg-white min-h-[2.5rem] focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent">
|
||||
{tags.map((tag) => (
|
||||
<TagBadge key={tag} name={tag} onRemove={() => onRemove(tag)} />
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
placeholder={tags.length === 0 ? "태그 입력 (Enter로 추가)..." : ""}
|
||||
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
||||
disabled={tags.length >= 10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && input && suggestions && suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 overflow-hidden">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.name}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
addTag(suggestion.name);
|
||||
}}
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span>#{suggestion.name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{suggestion.count}회
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length >= 10 && (
|
||||
<p className="mt-1 text-xs text-gray-400">최대 10개까지 추가 가능합니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/dashboard/CategoryChart.tsx
Normal file
95
frontend/src/components/dashboard/CategoryChart.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { CategoryStat } from "@/types";
|
||||
|
||||
interface CategoryChartProps {
|
||||
data: CategoryStat[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function CategoryChart({ data, isLoading }: CategoryChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
카테고리별 분포
|
||||
</h3>
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<div className="w-40 h-40 rounded-full border-8 border-gray-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
카테고리별 분포
|
||||
</h3>
|
||||
<div className="h-[250px] flex items-center justify-center text-gray-400 text-sm">
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.count,
|
||||
color: item.color,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
카테고리별 분포
|
||||
</h3>
|
||||
<div className="h-[250px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
`${value}개`,
|
||||
name,
|
||||
]}
|
||||
contentStyle={{
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value) => (
|
||||
<span className="text-xs text-gray-600">{value}</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/dashboard/PriorityChart.tsx
Normal file
106
frontend/src/components/dashboard/PriorityChart.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { PriorityStat } from "@/types";
|
||||
import { PRIORITY_CONFIG } from "@/lib/utils";
|
||||
|
||||
interface PriorityChartProps {
|
||||
data: PriorityStat | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function PriorityChart({ data, isLoading }: PriorityChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
우선순위별 현황
|
||||
</h3>
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<div className="w-full space-y-4 animate-pulse px-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
||||
<div className="h-8 bg-gray-200 rounded w-full" />
|
||||
<div className="h-8 bg-gray-200 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
우선순위별 현황
|
||||
</h3>
|
||||
<div className="h-[250px] flex items-center justify-center text-gray-400 text-sm">
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: PRIORITY_CONFIG.high.label,
|
||||
value: data.high,
|
||||
color: PRIORITY_CONFIG.high.barColor,
|
||||
},
|
||||
{
|
||||
name: PRIORITY_CONFIG.medium.label,
|
||||
value: data.medium,
|
||||
color: PRIORITY_CONFIG.medium.barColor,
|
||||
},
|
||||
{
|
||||
name: PRIORITY_CONFIG.low.label,
|
||||
value: data.low,
|
||||
color: PRIORITY_CONFIG.low.barColor,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
우선순위별 현황
|
||||
</h3>
|
||||
<div className="h-[250px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 20, bottom: 5 }}
|
||||
>
|
||||
<XAxis type="number" allowDecimals={false} fontSize={12} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={40}
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value}개`]}
|
||||
contentStyle={{
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={24}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/dashboard/StatsCards.tsx
Normal file
107
frontend/src/components/dashboard/StatsCards.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ListChecks,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { DashboardOverview } from "@/types";
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats: DashboardOverview | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
label: "전체 할일",
|
||||
value: stats?.total ?? 0,
|
||||
icon: ListChecks,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
label: "완료",
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
},
|
||||
{
|
||||
label: "미완료",
|
||||
value: stats?.incomplete ?? 0,
|
||||
icon: Circle,
|
||||
color: "text-orange-600",
|
||||
bgColor: "bg-orange-50",
|
||||
},
|
||||
{
|
||||
label: "완료율",
|
||||
value: `${(stats?.completion_rate ?? 0).toFixed(0)}%`,
|
||||
icon: TrendingUp,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse"
|
||||
>
|
||||
<div className="h-4 w-20 bg-gray-200 rounded mb-3" />
|
||||
<div className="h-8 w-16 bg-gray-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
{card.label}
|
||||
</span>
|
||||
<div className={`${card.bgColor} p-2 rounded-lg`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">0</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
{card.label}
|
||||
</span>
|
||||
<div className={`${card.bgColor} p-2 rounded-lg`}>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/dashboard/UpcomingDeadlines.tsx
Normal file
100
frontend/src/components/dashboard/UpcomingDeadlines.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Clock, AlertTriangle } from "lucide-react";
|
||||
import { UpcomingDeadline } from "@/types";
|
||||
import { getDDayText, getPriorityConfig } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UpcomingDeadlinesProps {
|
||||
deadlines: UpcomingDeadline[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function UpcomingDeadlines({
|
||||
deadlines,
|
||||
isLoading,
|
||||
}: UpcomingDeadlinesProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
마감 임박 할일
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse flex items-center gap-3">
|
||||
<div className="h-4 w-4 bg-gray-200 rounded" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-32 bg-gray-200 rounded" />
|
||||
</div>
|
||||
<div className="h-5 w-12 bg-gray-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deadlines || deadlines.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
마감 임박 할일
|
||||
</h3>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
|
||||
<Clock className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">마감 임박 할일이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">
|
||||
마감 임박 할일
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{deadlines.map((item, index) => {
|
||||
const dday = getDDayText(item.due_date);
|
||||
const priorityConfig = getPriorityConfig(item.priority);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => router.push(`/todos/${item.id}`)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2.5 text-left rounded-lg hover:bg-gray-50 transition-colors group"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-400 w-5">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full",
|
||||
priorityConfig.color
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn("w-1.5 h-1.5 rounded-full", priorityConfig.dotColor)}
|
||||
/>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 text-xs font-bold rounded-full bg-red-50 text-red-700">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{dday}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/layout/Header.tsx
Normal file
51
frontend/src/components/layout/Header.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Menu, Bell } from "lucide-react";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
import SearchBar from "@/components/search/SearchBar";
|
||||
|
||||
export default function Header() {
|
||||
const { toggleSidebar } = useUIStore();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between h-16 px-4 bg-white border-b border-gray-200 lg:px-6">
|
||||
{/* Left: Logo + Menu Toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 lg:hidden"
|
||||
aria-label="메뉴 토글"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-blue-600 rounded-lg">
|
||||
<span className="text-white font-bold text-sm">T</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900 hidden sm:inline">
|
||||
todos2
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Search Bar */}
|
||||
<div className="flex-1 max-w-md mx-4 hidden sm:block">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Right: Notifications */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="sm:hidden">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<button
|
||||
className="relative p-2 rounded-lg hover:bg-gray-100"
|
||||
aria-label="알림"
|
||||
>
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/layout/MainLayout.tsx
Normal file
22
frontend/src/components/layout/MainLayout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import Header from "./Header";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-[calc(100vh-4rem)] p-4 lg:p-6 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/layout/Sidebar.tsx
Normal file
157
frontend/src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
FolderOpen,
|
||||
Tag,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
import { useCategoryList } from "@/hooks/useCategories";
|
||||
import { useTagList } from "@/hooks/useTags";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "대시보드", icon: LayoutDashboard },
|
||||
{ href: "/todos", label: "할일 목록", icon: ListTodo },
|
||||
{ href: "/categories", label: "카테고리 관리", icon: FolderOpen },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
const { data: categories } = useCategoryList();
|
||||
const { data: tags } = useTagList();
|
||||
|
||||
const handleCategoryClick = (categoryId: string) => {
|
||||
router.push(`/todos?category_id=${categoryId}`);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleTagClick = (tagName: string) => {
|
||||
router.push(`/todos?tag=${encodeURIComponent(tagName)}`);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleNavClick = (href: string) => {
|
||||
router.push(href);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed top-16 left-0 z-40 h-[calc(100vh-4rem)] w-64 bg-white border-r border-gray-200 transition-transform duration-200 ease-in-out overflow-y-auto",
|
||||
"lg:translate-x-0 lg:static lg:z-auto",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
{/* Mobile close button */}
|
||||
<div className="flex items-center justify-end p-2 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="p-4">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<button
|
||||
onClick={() => handleNavClick(item.href)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 w-full px-3 py-2 text-sm font-medium rounded-lg transition-colors",
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="px-4 pb-4">
|
||||
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
카테고리
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{categories?.map((category) => (
|
||||
<li key={category.id}>
|
||||
<button
|
||||
onClick={() => handleCategoryClick(category.id)}
|
||||
className="flex items-center justify-between w-full px-3 py-1.5 text-sm text-gray-700 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<span className="truncate">{category.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{category.todo_count}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{(!categories || categories.length === 0) && (
|
||||
<li className="px-3 py-1.5 text-sm text-gray-400">
|
||||
카테고리 없음
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Popular Tags */}
|
||||
<div className="px-4 pb-4">
|
||||
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
인기 태그
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5 px-3">
|
||||
{tags?.slice(0, 10).map((tag) => (
|
||||
<button
|
||||
key={tag.name}
|
||||
onClick={() => handleTagClick(tag.name)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-50 rounded-full hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
{(!tags || tags.length === 0) && (
|
||||
<span className="text-sm text-gray-400">태그 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/providers/QueryProvider.tsx
Normal file
28
frontend/src/components/providers/QueryProvider.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function QueryProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/search/SearchBar.tsx
Normal file
53
frontend/src/components/search/SearchBar.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, KeyboardEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
|
||||
export default function SearchBar() {
|
||||
const router = useRouter();
|
||||
const { searchQuery, setSearchQuery } = useUIStore();
|
||||
const [inputValue, setInputValue] = useState(searchQuery);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (trimmed) {
|
||||
setSearchQuery(trimmed);
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
}
|
||||
}, [inputValue, setSearchQuery, router]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue("");
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center w-full max-w-md">
|
||||
<Search className="absolute left-3 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="할일 검색..."
|
||||
className="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{inputValue && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/search/SearchResults.tsx
Normal file
137
frontend/src/components/search/SearchResults.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { Todo } from "@/types";
|
||||
import { cn, getDueDateStatus, getDueDateColor, getDueDateLabel, getDDayText } from "@/lib/utils";
|
||||
import PriorityBadge from "@/components/common/PriorityBadge";
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: Todo[] | undefined;
|
||||
query: string;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, "gi"));
|
||||
|
||||
return parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark key={i} className="bg-yellow-200 text-yellow-900 rounded px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchResults({
|
||||
results,
|
||||
query,
|
||||
total,
|
||||
isLoading,
|
||||
}: SearchResultsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
||||
<span className="ml-3 text-gray-500">검색 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<Search className="h-12 w-12 mb-3" />
|
||||
<p className="text-sm font-medium">검색 결과가 없습니다</p>
|
||||
<p className="text-xs mt-1">다른 키워드로 검색해보세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
"{query}" 검색 결과 ({total}건)
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.map((todo) => {
|
||||
const dueDateStatus = getDueDateStatus(todo.due_date, todo.completed);
|
||||
const dday = getDDayText(todo.due_date);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={todo.id}
|
||||
onClick={() => router.push(`/todos/${todo.id}`)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:shadow-sm hover:border-blue-200 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{highlightText(todo.title, query)}
|
||||
</h3>
|
||||
{todo.content && (
|
||||
<p className="mt-1 text-xs text-gray-500 line-clamp-2">
|
||||
{highlightText(todo.content, query)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{todo.category_name && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${todo.category_color}20`,
|
||||
color: todo.category_color || "#6B7280",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: todo.category_color || "#6B7280",
|
||||
}}
|
||||
/>
|
||||
{todo.category_name}
|
||||
</span>
|
||||
)}
|
||||
<PriorityBadge priority={todo.priority} />
|
||||
{todo.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs text-blue-600"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{todo.due_date && (
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{dueDateStatus && !todo.completed && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1.5 py-0.5 text-xs font-medium rounded mb-1",
|
||||
getDueDateColor(dueDateStatus)
|
||||
)}
|
||||
>
|
||||
{getDueDateLabel(dueDateStatus)}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">{dday}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/todos/BatchActions.tsx
Normal file
109
frontend/src/components/todos/BatchActions.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, Trash2, FolderOpen, X } from "lucide-react";
|
||||
import { useCategoryList } from "@/hooks/useCategories";
|
||||
|
||||
interface BatchActionsProps {
|
||||
selectedCount: number;
|
||||
onBatchComplete: () => void;
|
||||
onBatchDelete: () => void;
|
||||
onBatchMove: (categoryId: string | null) => void;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
export default function BatchActions({
|
||||
selectedCount,
|
||||
onBatchComplete,
|
||||
onBatchDelete,
|
||||
onBatchMove,
|
||||
onClearSelection,
|
||||
}: BatchActionsProps) {
|
||||
const { data: categories } = useCategoryList();
|
||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (
|
||||
window.confirm(`${selectedCount}개의 할일을 삭제하시겠습니까?`)
|
||||
) {
|
||||
onBatchDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
{selectedCount}개 선택됨
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
onClick={onBatchComplete}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-green-700 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
일괄 완료
|
||||
</button>
|
||||
|
||||
{/* Category move dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-white border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
카테고리 변경
|
||||
</button>
|
||||
|
||||
{showCategoryDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-20 overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
onBatchMove(null);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
미분류
|
||||
</button>
|
||||
{categories?.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => {
|
||||
onBatchMove(cat.id);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: cat.color }}
|
||||
/>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
일괄 삭제
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||
title="선택 해제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
frontend/src/components/todos/GanttChart.tsx
Normal file
318
frontend/src/components/todos/GanttChart.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useGanttData, GanttCategory, GanttTodo } from "@/hooks/useGantt";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
|
||||
const DAY_WIDTH = 32;
|
||||
const ROW_HEIGHT = 32;
|
||||
const LABEL_WIDTH = 160;
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const m = date.getMonth() + 1;
|
||||
const d = date.getDate();
|
||||
return `${m}/${d}`;
|
||||
}
|
||||
|
||||
function getMonthLabel(date: Date): string {
|
||||
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.ceil((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function getPriorityColor(priority: string, baseColor: string): string {
|
||||
if (priority === "high") return baseColor;
|
||||
if (priority === "medium") return baseColor + "CC";
|
||||
return baseColor + "99";
|
||||
}
|
||||
|
||||
interface GanttBarProps {
|
||||
todo: GanttTodo;
|
||||
minDate: Date;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function GanttBar({ todo, minDate, color }: GanttBarProps) {
|
||||
const openTodoForm = useUIStore((s) => s.openTodoForm);
|
||||
const offsetDays = daysBetween(minDate, todo.startDate);
|
||||
const durationDays = Math.max(1, daysBetween(todo.startDate, todo.endDate));
|
||||
const left = offsetDays * DAY_WIDTH;
|
||||
const width = durationDays * DAY_WIDTH;
|
||||
const barColor = getPriorityColor(todo.priority, color);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-1 cursor-pointer group"
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
height: `${ROW_HEIGHT - 8}px`,
|
||||
}}
|
||||
onClick={() => openTodoForm("edit", todo.id)}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-md transition-shadow hover:shadow-md relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: barColor,
|
||||
opacity: todo.completed ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="absolute inset-0 flex items-center px-2 text-xs text-white font-medium truncate">
|
||||
{todo.title}
|
||||
</span>
|
||||
{todo.completed && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="w-full border-t border-white/60"
|
||||
style={{ marginLeft: "4px", marginRight: "4px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Tooltip */}
|
||||
<div className="hidden group-hover:block absolute z-20 bottom-full left-0 mb-1 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{todo.title}</div>
|
||||
<div className="text-gray-300 mt-0.5">
|
||||
{formatDate(todo.startDate)} ~ {formatDate(todo.endDate)} ({durationDays}일)
|
||||
</div>
|
||||
{todo.completed && <div className="text-green-400 mt-0.5">완료</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GanttChartProps {
|
||||
categoryId?: string;
|
||||
}
|
||||
|
||||
export default function GanttChart({ categoryId }: GanttChartProps) {
|
||||
const { data, isLoading, isError } = useGanttData(categoryId);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 날짜 헤더 생성
|
||||
const { dateHeaders, monthHeaders } = useMemo(() => {
|
||||
if (!data) return { dateHeaders: [], monthHeaders: [] };
|
||||
|
||||
const dateHeaders: { date: Date; label: string; isWeekend: boolean }[] = [];
|
||||
const monthHeaders: { label: string; span: number; startIdx: number }[] = [];
|
||||
|
||||
let currentMonth = "";
|
||||
let monthStartIdx = 0;
|
||||
let monthSpan = 0;
|
||||
|
||||
for (let i = 0; i <= data.totalDays; i++) {
|
||||
const d = new Date(data.minDate);
|
||||
d.setDate(d.getDate() + i);
|
||||
const dayOfWeek = d.getDay();
|
||||
|
||||
dateHeaders.push({
|
||||
date: d,
|
||||
label: String(d.getDate()),
|
||||
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
||||
});
|
||||
|
||||
const monthKey = getMonthLabel(d);
|
||||
if (monthKey !== currentMonth) {
|
||||
if (currentMonth) {
|
||||
monthHeaders.push({
|
||||
label: currentMonth,
|
||||
span: monthSpan,
|
||||
startIdx: monthStartIdx,
|
||||
});
|
||||
}
|
||||
currentMonth = monthKey;
|
||||
monthStartIdx = i;
|
||||
monthSpan = 1;
|
||||
} else {
|
||||
monthSpan++;
|
||||
}
|
||||
}
|
||||
if (currentMonth) {
|
||||
monthHeaders.push({
|
||||
label: currentMonth,
|
||||
span: monthSpan,
|
||||
startIdx: monthStartIdx,
|
||||
});
|
||||
}
|
||||
|
||||
return { dateHeaders, monthHeaders };
|
||||
}, [data]);
|
||||
|
||||
// 행 데이터: 카테고리 헤더 + Todo 행
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const result: { type: "category" | "todo"; category: GanttCategory; todo?: GanttTodo }[] = [];
|
||||
data.categories.forEach((cat) => {
|
||||
result.push({ type: "category", category: cat });
|
||||
cat.todos.forEach((todo) => {
|
||||
result.push({ type: "todo", category: cat, todo });
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
간트차트 로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-center py-20 text-red-500">
|
||||
데이터를 불러오는 중 오류가 발생했습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.categories.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<p className="text-lg font-medium">표시할 일정이 없습니다</p>
|
||||
<p className="text-sm mt-1">
|
||||
할일에 시작일과 마감일을 설정하면 간트차트에 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartWidth = (data.totalDays + 1) * DAY_WIDTH;
|
||||
|
||||
// 오늘 표시 위치
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayOffset = daysBetween(data.minDate, today);
|
||||
const showTodayLine =
|
||||
todayOffset >= 0 && todayOffset <= data.totalDays;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* 좌측 라벨 영역 */}
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 bg-gray-50"
|
||||
style={{ width: `${LABEL_WIDTH}px` }}
|
||||
>
|
||||
{/* 월/일 헤더 높이 맞춤 */}
|
||||
<div className="h-[52px] border-b border-gray-200 flex items-end px-3 pb-1">
|
||||
<span className="text-xs font-medium text-gray-500">카테고리 / 할일</span>
|
||||
</div>
|
||||
{/* 행 라벨 */}
|
||||
{rows.map((row) =>
|
||||
row.type === "category" ? (
|
||||
<div
|
||||
key={`label-cat-${row.category.id}`}
|
||||
className="flex items-center gap-2 px-3 border-b border-gray-100 bg-gray-50 font-medium text-sm text-gray-800"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: row.category.color }}
|
||||
/>
|
||||
<span className="truncate">{row.category.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={`label-todo-${row.todo!.id}`}
|
||||
className="flex items-center px-3 pl-7 border-b border-gray-50 text-xs text-gray-600 truncate"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
{row.todo!.title}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 차트 영역 (스크롤) */}
|
||||
<div className="flex-1 overflow-x-auto" ref={scrollRef}>
|
||||
<div style={{ width: `${chartWidth}px` }}>
|
||||
{/* 헤더: 월 */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
{monthHeaders.map((mh) => (
|
||||
<div
|
||||
key={`month-${mh.label}-${mh.startIdx}`}
|
||||
className="text-xs font-medium text-gray-700 text-center border-r border-gray-100 py-1"
|
||||
style={{ width: `${mh.span * DAY_WIDTH}px` }}
|
||||
>
|
||||
{mh.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 헤더: 일 */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
{dateHeaders.map((dh, i) => (
|
||||
<div
|
||||
key={`day-${i}`}
|
||||
className={`text-center text-[10px] py-1 border-r border-gray-50 ${
|
||||
dh.isWeekend ? "bg-gray-50 text-gray-400" : "text-gray-600"
|
||||
}`}
|
||||
style={{ width: `${DAY_WIDTH}px` }}
|
||||
>
|
||||
{dh.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 차트 바디 */}
|
||||
<div className="relative">
|
||||
{/* 그리드 라인 (주말 배경) */}
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{dateHeaders.map((dh, i) => (
|
||||
<div
|
||||
key={`grid-${i}`}
|
||||
className={`border-r border-gray-50 ${
|
||||
dh.isWeekend ? "bg-gray-50/50" : ""
|
||||
}`}
|
||||
style={{
|
||||
width: `${DAY_WIDTH}px`,
|
||||
height: `${rows.length * ROW_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 오늘 선 */}
|
||||
{showTodayLine && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-400 z-10 pointer-events-none"
|
||||
style={{
|
||||
left: `${todayOffset * DAY_WIDTH + DAY_WIDTH / 2}px`,
|
||||
height: `${rows.length * ROW_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 행 */}
|
||||
{rows.map((row) =>
|
||||
row.type === "category" ? (
|
||||
<div
|
||||
key={`row-cat-${row.category.id}`}
|
||||
className="relative border-b border-gray-100 bg-gray-50/30"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={`row-todo-${row.todo!.id}`}
|
||||
className="relative border-b border-gray-50"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
<GanttBar
|
||||
todo={row.todo!}
|
||||
minDate={data!.minDate}
|
||||
color={row.category.color}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
frontend/src/components/todos/TodoCard.tsx
Normal file
160
frontend/src/components/todos/TodoCard.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2, Check, Paperclip } from "lucide-react";
|
||||
import { Todo } from "@/types";
|
||||
import { cn, getDueDateStatus, getDueDateLabel, getDueDateColor, getDDayText } from "@/lib/utils";
|
||||
import PriorityBadge from "@/components/common/PriorityBadge";
|
||||
import TagBadge from "@/components/common/TagBadge";
|
||||
|
||||
interface TodoCardProps {
|
||||
todo: Todo;
|
||||
isSelected: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
}
|
||||
|
||||
export default function TodoCard({
|
||||
todo,
|
||||
isSelected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTagClick,
|
||||
}: TodoCardProps) {
|
||||
const dueDateStatus = getDueDateStatus(todo.due_date, todo.completed);
|
||||
const dueDateLabel = getDueDateLabel(dueDateStatus);
|
||||
const dueDateColor = getDueDateColor(dueDateStatus);
|
||||
const dday = getDDayText(todo.due_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-lg transition-all hover:shadow-sm",
|
||||
isSelected && "ring-2 ring-blue-200 bg-blue-50/30",
|
||||
todo.completed && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onSelect(todo.id)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Completion toggle */}
|
||||
<button
|
||||
onClick={() => onToggle(todo.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full border-2 transition-colors flex-shrink-0",
|
||||
todo.completed
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: "border-gray-300 hover:border-green-400"
|
||||
)}
|
||||
>
|
||||
{todo.completed && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => onEdit(todo.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<h3
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-900 truncate",
|
||||
todo.completed && "line-through text-gray-500"
|
||||
)}
|
||||
>
|
||||
{todo.title}
|
||||
</h3>
|
||||
|
||||
{/* Category badge */}
|
||||
{todo.category_name && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${todo.category_color}20`,
|
||||
color: todo.category_color || "#6B7280",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: todo.category_color || "#6B7280" }}
|
||||
/>
|
||||
{todo.category_name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<PriorityBadge priority={todo.priority} />
|
||||
|
||||
{/* Tags */}
|
||||
{todo.tags.map((tag) => (
|
||||
<TagBadge
|
||||
key={tag}
|
||||
name={tag}
|
||||
onClick={onTagClick ? () => onTagClick(tag) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Attachment indicator */}
|
||||
{todo.attachments?.length > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs text-gray-500 bg-gray-100 rounded">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{todo.attachments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
{todo.due_date && (
|
||||
<div className="flex-shrink-0 flex items-center gap-1">
|
||||
{dueDateLabel && !todo.completed && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 text-xs font-medium rounded",
|
||||
dueDateColor
|
||||
)}
|
||||
>
|
||||
{dueDateLabel}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
dueDateStatus === "overdue" && !todo.completed
|
||||
? "text-red-600"
|
||||
: "text-gray-500"
|
||||
)}
|
||||
>
|
||||
{dday}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onEdit(todo.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(todo.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/todos/TodoFilter.tsx
Normal file
111
frontend/src/components/todos/TodoFilter.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useUIStore } from "@/store/uiStore";
|
||||
import { Priority, SortField, SortOrder } from "@/types";
|
||||
|
||||
export default function TodoFilter() {
|
||||
const { filters, setFilter, resetFilters } = useUIStore();
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.completed !== undefined ||
|
||||
filters.priority !== undefined ||
|
||||
filters.category_id !== undefined ||
|
||||
filters.tag !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Status filter */}
|
||||
<div>
|
||||
<select
|
||||
value={
|
||||
filters.completed === undefined
|
||||
? "all"
|
||||
: filters.completed
|
||||
? "completed"
|
||||
: "incomplete"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "all") {
|
||||
setFilter("completed", undefined);
|
||||
} else {
|
||||
setFilter("completed", val === "completed");
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">전체 상태</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="incomplete">미완료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority filter */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.priority || "all"}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setFilter(
|
||||
"priority",
|
||||
val === "all" ? undefined : (val as Priority)
|
||||
);
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">전체 우선순위</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="medium">중간</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<select
|
||||
value={`${filters.sort}_${filters.order}`}
|
||||
onChange={(e) => {
|
||||
const [sort, order] = e.target.value.split("_") as [
|
||||
SortField,
|
||||
SortOrder
|
||||
];
|
||||
setFilter("sort", sort);
|
||||
setFilter("order", order);
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="created_at_desc">최신순</option>
|
||||
<option value="created_at_asc">오래된순</option>
|
||||
<option value="due_date_asc">마감일 가까운순</option>
|
||||
<option value="due_date_desc">마감일 먼순</option>
|
||||
<option value="priority_asc">우선순위 높은순</option>
|
||||
<option value="priority_desc">우선순위 낮은순</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Active tag filter */}
|
||||
{filters.tag && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full">
|
||||
#{filters.tag}
|
||||
<button
|
||||
onClick={() => setFilter("tag", undefined)}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
필터 초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/todos/TodoList.tsx
Normal file
78
frontend/src/components/todos/TodoList.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ListX, Loader2 } from "lucide-react";
|
||||
import { Todo } from "@/types";
|
||||
import TodoCard from "./TodoCard";
|
||||
|
||||
interface TodoListProps {
|
||||
todos: Todo[] | undefined;
|
||||
selectedIds: string[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
onToggle: (id: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
}
|
||||
|
||||
export default function TodoList({
|
||||
todos,
|
||||
selectedIds,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTagClick,
|
||||
}: TodoListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
||||
<span className="ml-3 text-gray-500">할일 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-red-500">
|
||||
<p className="text-sm font-medium">오류가 발생했습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{error?.message || "할일 목록을 불러올 수 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<ListX className="h-12 w-12 mb-3" />
|
||||
<p className="text-sm font-medium">할일이 없습니다</p>
|
||||
<p className="text-xs mt-1">새 할일을 추가해보세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<TodoCard
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
isSelected={selectedIds.includes(todo.id)}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onTagClick={onTagClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
420
frontend/src/components/todos/TodoModal.tsx
Normal file
420
frontend/src/components/todos/TodoModal.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Trash2, Loader2 } from "lucide-react";
|
||||
import { Todo, TodoCreate, TodoUpdate, Priority, Attachment } from "@/types";
|
||||
import { useCategoryList } from "@/hooks/useCategories";
|
||||
import {
|
||||
useCreateTodo,
|
||||
useUpdateTodo,
|
||||
useDeleteTodo,
|
||||
useUploadAttachments,
|
||||
useDeleteAttachment,
|
||||
} from "@/hooks/useTodos";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TagInput from "@/components/common/TagInput";
|
||||
import DatePicker from "@/components/common/DatePicker";
|
||||
import FileUpload from "@/components/common/FileUpload";
|
||||
import AttachmentList from "@/components/common/AttachmentList";
|
||||
|
||||
interface TodoModalProps {
|
||||
mode: "create" | "edit";
|
||||
todo?: Todo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabType = "basic" | "extra";
|
||||
|
||||
export default function TodoModal({
|
||||
mode,
|
||||
todo,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: TodoModalProps) {
|
||||
const { data: categories } = useCategoryList();
|
||||
const createTodo = useCreateTodo();
|
||||
const updateTodo = useUpdateTodo();
|
||||
const deleteTodo = useDeleteTodo();
|
||||
const uploadAttachments = useUploadAttachments();
|
||||
const deleteAttachment = useDeleteAttachment();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>("basic");
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||||
const [priority, setPriority] = useState<Priority>("medium");
|
||||
const [startDate, setStartDate] = useState<string | null>(null);
|
||||
const [dueDate, setDueDate] = useState<string | null>(null);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && todo) {
|
||||
setTitle(todo.title);
|
||||
setContent(todo.content || "");
|
||||
setCategoryId(todo.category_id || null);
|
||||
setPriority(todo.priority);
|
||||
setStartDate(todo.start_date || null);
|
||||
setDueDate(todo.due_date || null);
|
||||
setTags(todo.tags || []);
|
||||
} else if (mode === "create") {
|
||||
setTitle("");
|
||||
setContent("");
|
||||
setCategoryId(null);
|
||||
setPriority("medium");
|
||||
setStartDate(null);
|
||||
setDueDate(null);
|
||||
setTags([]);
|
||||
}
|
||||
setActiveTab("basic");
|
||||
setError("");
|
||||
setSuccessMessage("");
|
||||
}, [mode, todo, isOpen]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError("");
|
||||
setSuccessMessage("");
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) {
|
||||
setError("제목을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
if (trimmedTitle.length > 200) {
|
||||
setError("제목은 200자 이하로 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const data: TodoCreate = {
|
||||
title: trimmedTitle,
|
||||
content: content.trim() || undefined,
|
||||
category_id: categoryId,
|
||||
priority,
|
||||
start_date: startDate,
|
||||
due_date: dueDate,
|
||||
tags,
|
||||
};
|
||||
await createTodo.mutateAsync(data);
|
||||
onClose();
|
||||
} else if (todo) {
|
||||
const data: TodoUpdate = {
|
||||
title: trimmedTitle,
|
||||
content: content.trim() || null,
|
||||
category_id: categoryId,
|
||||
priority,
|
||||
start_date: startDate,
|
||||
due_date: dueDate,
|
||||
tags,
|
||||
};
|
||||
await updateTodo.mutateAsync({ id: todo.id, data });
|
||||
setSuccessMessage("저장되었습니다");
|
||||
setTimeout(() => setSuccessMessage(""), 3000);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { detail?: string };
|
||||
setError(apiErr.detail || "저장에 실패했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!todo) return;
|
||||
if (!window.confirm("이 할일을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await deleteTodo.mutateAsync(todo.id);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { detail?: string };
|
||||
setError(apiErr.detail || "삭제에 실패했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
if (!todo) return;
|
||||
setError("");
|
||||
try {
|
||||
await uploadAttachments.mutateAsync({ todoId: todo.id, files });
|
||||
setSuccessMessage("파일이 업로드되었습니다");
|
||||
setTimeout(() => setSuccessMessage(""), 3000);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { detail?: string };
|
||||
setError(apiErr.detail || "파일 업로드에 실패했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDelete = async (attachmentId: string) => {
|
||||
if (!todo) return;
|
||||
if (!window.confirm("이 파일을 삭제하시겠습니까?")) return;
|
||||
setError("");
|
||||
try {
|
||||
await deleteAttachment.mutateAsync({ todoId: todo.id, attachmentId });
|
||||
setSuccessMessage("파일이 삭제되었습니다");
|
||||
setTimeout(() => setSuccessMessage(""), 3000);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { detail?: string };
|
||||
setError(apiErr.detail || "파일 삭제에 실패했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDownload = (attachment: Attachment) => {
|
||||
if (!todo) return;
|
||||
apiClient.downloadFile(
|
||||
`/api/todos/${todo.id}/attachments/${attachment.id}/download`,
|
||||
attachment.filename
|
||||
);
|
||||
};
|
||||
|
||||
const isSubmitting =
|
||||
createTodo.isPending || updateTodo.isPending || deleteTodo.isPending;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{mode === "create" ? "새 할일 추가" : "할일 수정"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="p-3 text-sm text-green-700 bg-green-50 rounded-lg">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (always visible, above tabs) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="할일 제목을 입력하세요"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab buttons */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("basic")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === "basic"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
)}
|
||||
>
|
||||
기본
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("extra")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === "extra"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
)}
|
||||
>
|
||||
태그와 첨부
|
||||
{mode === "edit" && todo?.attachments && todo.attachments.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-5 h-5 text-xs bg-blue-100 text-blue-600 rounded-full">
|
||||
{todo.attachments.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content - both tabs rendered, inactive hidden via grid overlap */}
|
||||
<div className="grid">
|
||||
<div className={cn(
|
||||
"col-start-1 row-start-1 space-y-4",
|
||||
activeTab !== "basic" && "invisible pointer-events-none"
|
||||
)}>
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
내용
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="상세 내용을 입력하세요"
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
maxLength={2000}
|
||||
tabIndex={activeTab === "basic" ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category + Priority (2 columns) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리
|
||||
</label>
|
||||
<select
|
||||
value={categoryId || ""}
|
||||
onChange={(e) => setCategoryId(e.target.value || null)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
tabIndex={activeTab === "basic" ? 0 : -1}
|
||||
>
|
||||
<option value="">미분류</option>
|
||||
{categories?.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
우선순위
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as Priority)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
tabIndex={activeTab === "basic" ? 0 : -1}
|
||||
>
|
||||
<option value="high">높음</option>
|
||||
<option value="medium">중간</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start Date + Due Date (2 columns) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
label="시작일"
|
||||
clearLabel="시작일 해제"
|
||||
/>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
label="마감일"
|
||||
clearLabel="마감일 해제"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"col-start-1 row-start-1 space-y-4",
|
||||
activeTab !== "extra" && "invisible pointer-events-none"
|
||||
)}>
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
태그
|
||||
</label>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onAdd={(tag) => setTags([...tags, tag])}
|
||||
onRemove={(tag) => setTags(tags.filter((t) => t !== tag))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
첨부파일
|
||||
</label>
|
||||
{mode === "edit" && todo ? (
|
||||
<div className="space-y-3">
|
||||
<FileUpload
|
||||
onFilesSelected={handleFileUpload}
|
||||
maxFiles={5 - (todo.attachments?.length || 0)}
|
||||
isUploading={uploadAttachments.isPending}
|
||||
disabled={(todo.attachments?.length || 0) >= 5}
|
||||
/>
|
||||
<AttachmentList
|
||||
attachments={todo.attachments || []}
|
||||
onDownload={handleFileDownload}
|
||||
onDelete={handleFileDelete}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-sm text-gray-500 bg-gray-50 rounded-lg border border-gray-200 text-center">
|
||||
할일을 먼저 저장하면 파일을 첨부할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 flex-shrink-0">
|
||||
{mode === "edit" && todo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{mode === "create" ? "추가" : "저장"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/hooks/useCategories.ts
Normal file
63
frontend/src/hooks/useCategories.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
Category,
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
} from "@/types";
|
||||
import { categoryKeys, todoKeys, dashboardKeys } from "./useTodos";
|
||||
|
||||
export { categoryKeys };
|
||||
|
||||
export function useCategoryList() {
|
||||
return useQuery({
|
||||
queryKey: categoryKeys.list(),
|
||||
queryFn: () => apiClient.get<Category[]>("/api/categories"),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CategoryCreate) =>
|
||||
apiClient.post<Category>("/api/categories", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: CategoryUpdate }) =>
|
||||
apiClient.put<Category>(`/api/categories/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/api/categories/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
15
frontend/src/hooks/useDashboard.ts
Normal file
15
frontend/src/hooks/useDashboard.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { DashboardStats } from "@/types";
|
||||
import { dashboardKeys } from "./useTodos";
|
||||
|
||||
export { dashboardKeys };
|
||||
|
||||
export function useDashboardStats() {
|
||||
return useQuery({
|
||||
queryKey: dashboardKeys.stats(),
|
||||
queryFn: () => apiClient.get<DashboardStats>("/api/dashboard/stats"),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
128
frontend/src/hooks/useGantt.ts
Normal file
128
frontend/src/hooks/useGantt.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { Category, TodoListResponse } from "@/types";
|
||||
import { categoryKeys, todoKeys } from "./useTodos";
|
||||
|
||||
export interface GanttTodo {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
completed: boolean;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface GanttCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
todos: GanttTodo[];
|
||||
}
|
||||
|
||||
export interface GanttData {
|
||||
categories: GanttCategory[];
|
||||
minDate: Date;
|
||||
maxDate: Date;
|
||||
totalDays: number;
|
||||
}
|
||||
|
||||
export function useGanttData(categoryId?: string) {
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: categoryKeys.list(),
|
||||
queryFn: () => apiClient.get<Category[]>("/api/categories"),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const todosQuery = useQuery({
|
||||
queryKey: [...todoKeys.all, "gantt", categoryId ?? "all"],
|
||||
queryFn: () =>
|
||||
apiClient.get<TodoListResponse>("/api/todos", {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
}),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const isLoading = categoriesQuery.isLoading || todosQuery.isLoading;
|
||||
const isError = categoriesQuery.isError || todosQuery.isError;
|
||||
|
||||
let data: GanttData | null = null;
|
||||
|
||||
if (categoriesQuery.data && todosQuery.data) {
|
||||
const categories = categoriesQuery.data;
|
||||
const todos = todosQuery.data.items;
|
||||
|
||||
// start_date와 due_date가 모두 있는 Todo만 포함
|
||||
const validTodos = todos.filter((t) => t.start_date && t.due_date);
|
||||
|
||||
if (validTodos.length > 0) {
|
||||
// 날짜 범위 계산
|
||||
let minDate = new Date(validTodos[0].start_date!);
|
||||
let maxDate = new Date(validTodos[0].due_date!);
|
||||
|
||||
validTodos.forEach((t) => {
|
||||
const s = new Date(t.start_date!);
|
||||
const e = new Date(t.due_date!);
|
||||
if (s < minDate) minDate = s;
|
||||
if (e > maxDate) maxDate = e;
|
||||
});
|
||||
|
||||
// 앞뒤 여유 3일 추가
|
||||
minDate = new Date(minDate);
|
||||
minDate.setDate(minDate.getDate() - 3);
|
||||
maxDate = new Date(maxDate);
|
||||
maxDate.setDate(maxDate.getDate() + 3);
|
||||
|
||||
const totalDays = Math.ceil(
|
||||
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
// 카테고리별 그룹핑
|
||||
const categoryMap = new Map<string | null, GanttTodo[]>();
|
||||
|
||||
validTodos.forEach((t) => {
|
||||
const key = t.category_id || null;
|
||||
if (!categoryMap.has(key)) categoryMap.set(key, []);
|
||||
categoryMap.get(key)!.push({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
startDate: new Date(t.start_date!),
|
||||
endDate: new Date(t.due_date!),
|
||||
completed: t.completed,
|
||||
priority: t.priority,
|
||||
});
|
||||
});
|
||||
|
||||
// 카테고리 목록 구성
|
||||
const ganttCategories: GanttCategory[] = [];
|
||||
|
||||
categories.forEach((cat) => {
|
||||
const catTodos = categoryMap.get(cat.id);
|
||||
if (catTodos && catTodos.length > 0) {
|
||||
ganttCategories.push({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
color: cat.color,
|
||||
todos: catTodos,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 미분류 카테고리
|
||||
const uncategorized = categoryMap.get(null);
|
||||
if (uncategorized && uncategorized.length > 0) {
|
||||
ganttCategories.push({
|
||||
id: "uncategorized",
|
||||
name: "미분류",
|
||||
color: "#9CA3AF",
|
||||
todos: uncategorized,
|
||||
});
|
||||
}
|
||||
|
||||
data = { categories: ganttCategories, minDate, maxDate, totalDays };
|
||||
}
|
||||
}
|
||||
|
||||
return { data, isLoading, isError };
|
||||
}
|
||||
24
frontend/src/hooks/useSearch.ts
Normal file
24
frontend/src/hooks/useSearch.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { SearchResponse } from "@/types";
|
||||
|
||||
export const searchKeys = {
|
||||
all: ["search"] as const,
|
||||
results: (query: string, page: number) =>
|
||||
[...searchKeys.all, query, page] as const,
|
||||
};
|
||||
|
||||
export function useSearch(query: string, page: number = 1, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: searchKeys.results(query, page),
|
||||
queryFn: () =>
|
||||
apiClient.get<SearchResponse>("/api/search", {
|
||||
q: query,
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
staleTime: 0,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
enabled: !!query && query.trim().length > 0,
|
||||
});
|
||||
}
|
||||
15
frontend/src/hooks/useTags.ts
Normal file
15
frontend/src/hooks/useTags.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { TagInfo } from "@/types";
|
||||
import { tagKeys } from "./useTodos";
|
||||
|
||||
export { tagKeys };
|
||||
|
||||
export function useTagList() {
|
||||
return useQuery({
|
||||
queryKey: tagKeys.list(),
|
||||
queryFn: () => apiClient.get<TagInfo[]>("/api/tags"),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
190
frontend/src/hooks/useTodos.ts
Normal file
190
frontend/src/hooks/useTodos.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
Todo,
|
||||
TodoCreate,
|
||||
TodoUpdate,
|
||||
TodoListResponse,
|
||||
TodoFilters,
|
||||
ToggleResponse,
|
||||
BatchRequest,
|
||||
BatchResponse,
|
||||
Attachment,
|
||||
} from "@/types";
|
||||
|
||||
// === Query Keys ===
|
||||
export const todoKeys = {
|
||||
all: ["todos"] as const,
|
||||
lists: () => [...todoKeys.all, "list"] as const,
|
||||
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
|
||||
details: () => [...todoKeys.all, "detail"] as const,
|
||||
detail: (id: string) => [...todoKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: ["dashboard"] as const,
|
||||
stats: () => [...dashboardKeys.all, "stats"] as const,
|
||||
};
|
||||
|
||||
export const tagKeys = {
|
||||
all: ["tags"] as const,
|
||||
list: () => [...tagKeys.all, "list"] as const,
|
||||
};
|
||||
|
||||
export const categoryKeys = {
|
||||
all: ["categories"] as const,
|
||||
list: () => [...categoryKeys.all, "list"] as const,
|
||||
};
|
||||
|
||||
// === Queries ===
|
||||
|
||||
export function useTodoList(filters: TodoFilters) {
|
||||
return useQuery({
|
||||
queryKey: todoKeys.list(filters),
|
||||
queryFn: () =>
|
||||
apiClient.get<TodoListResponse>("/api/todos", {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
completed: filters.completed,
|
||||
category_id: filters.category_id,
|
||||
priority: filters.priority,
|
||||
tag: filters.tag,
|
||||
sort: filters.sort,
|
||||
order: filters.order,
|
||||
}),
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTodoDetail(id: string) {
|
||||
return useQuery({
|
||||
queryKey: todoKeys.detail(id),
|
||||
queryFn: () => apiClient.get<Todo>(`/api/todos/${id}`),
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// === Mutations ===
|
||||
|
||||
export function useCreateTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: TodoCreate) =>
|
||||
apiClient.post<Todo>("/api/todos", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: tagKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: TodoUpdate }) =>
|
||||
apiClient.put<Todo>(`/api/todos/${id}`, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: todoKeys.detail(variables.id),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: tagKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/api/todos/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: tagKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiClient.patch<ToggleResponse>(`/api/todos/${id}/toggle`),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBatchAction() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: BatchRequest) =>
|
||||
apiClient.post<BatchResponse>("/api/todos/batch", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: tagKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// === Attachment Mutations ===
|
||||
|
||||
export function useUploadAttachments() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ todoId, files }: { todoId: string; files: File[] }) =>
|
||||
apiClient.uploadFiles<Attachment[]>(
|
||||
`/api/todos/${todoId}/attachments`,
|
||||
files
|
||||
),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: todoKeys.detail(variables.todoId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAttachment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
todoId,
|
||||
attachmentId,
|
||||
}: {
|
||||
todoId: string;
|
||||
attachmentId: string;
|
||||
}) => apiClient.delete(`/api/todos/${todoId}/attachments/${attachmentId}`),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: todoKeys.detail(variables.todoId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
121
frontend/src/lib/api.ts
Normal file
121
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
body?: unknown;
|
||||
params?: Record<string, string | number | boolean | undefined | null>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
|
||||
if (options?.params) {
|
||||
Object.entries(options.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res
|
||||
.json()
|
||||
.catch(() => ({ detail: "알 수 없는 오류가 발생했습니다" }));
|
||||
throw {
|
||||
detail: error.detail || `HTTP ${res.status} Error`,
|
||||
status: res.status,
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
get<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | number | boolean | undefined | null>
|
||||
) {
|
||||
return this.request<T>("GET", path, { params });
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown) {
|
||||
return this.request<T>("POST", path, { body });
|
||||
}
|
||||
|
||||
put<T>(path: string, body?: unknown) {
|
||||
return this.request<T>("PUT", path, { body });
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown) {
|
||||
return this.request<T>("PATCH", path, { body });
|
||||
}
|
||||
|
||||
delete<T>(path: string) {
|
||||
return this.request<T>("DELETE", path);
|
||||
}
|
||||
|
||||
async uploadFiles<T>(path: string, files: File[]): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file));
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res
|
||||
.json()
|
||||
.catch(() => ({ detail: "파일 업로드에 실패했습니다" }));
|
||||
throw {
|
||||
detail: error.detail || `HTTP ${res.status} Error`,
|
||||
status: res.status,
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async downloadFile(path: string, filename: string): Promise<void> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
const res = await fetch(url.toString());
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("다운로드에 실패했습니다");
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient(API_BASE_URL);
|
||||
135
frontend/src/lib/utils.ts
Normal file
135
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { format, formatDistanceToNow, differenceInDays } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { Priority } from "@/types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// === Date Utilities ===
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "";
|
||||
return format(new Date(dateStr), "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "";
|
||||
return format(new Date(dateStr), "yyyy-MM-dd HH:mm");
|
||||
}
|
||||
|
||||
export function formatRelativeDate(dateStr: string): string {
|
||||
return formatDistanceToNow(new Date(dateStr), { addSuffix: true, locale: ko });
|
||||
}
|
||||
|
||||
// === Due Date Status ===
|
||||
|
||||
export type DueDateStatus = "overdue" | "urgent" | "soon" | "normal" | null;
|
||||
|
||||
export function getDueDateStatus(
|
||||
dueDate: string | null | undefined,
|
||||
completed: boolean
|
||||
): DueDateStatus {
|
||||
if (!dueDate || completed) return null;
|
||||
|
||||
const now = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffMs = due.getTime() - now.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (diffDays < 0) return "overdue";
|
||||
if (diffDays <= 1) return "urgent";
|
||||
if (diffDays <= 3) return "soon";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
export function getDueDateLabel(status: DueDateStatus): string {
|
||||
switch (status) {
|
||||
case "overdue":
|
||||
return "초과";
|
||||
case "urgent":
|
||||
return "임박";
|
||||
case "soon":
|
||||
return "곧 마감";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getDueDateColor(status: DueDateStatus): string {
|
||||
switch (status) {
|
||||
case "overdue":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "urgent":
|
||||
return "bg-orange-100 text-orange-700";
|
||||
case "soon":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
export function getDDayText(dueDate: string | null | undefined): string {
|
||||
if (!dueDate) return "";
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const due = new Date(dueDate);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
const diff = differenceInDays(due, now);
|
||||
|
||||
if (diff === 0) return "D-Day";
|
||||
if (diff > 0) return `D-${diff}`;
|
||||
return `D+${Math.abs(diff)}`;
|
||||
}
|
||||
|
||||
// === Priority ===
|
||||
|
||||
export const PRIORITY_CONFIG = {
|
||||
high: {
|
||||
label: "높음",
|
||||
color: "bg-red-100 text-red-700",
|
||||
dotColor: "bg-red-500",
|
||||
barColor: "#EF4444",
|
||||
},
|
||||
medium: {
|
||||
label: "중간",
|
||||
color: "bg-yellow-100 text-yellow-700",
|
||||
dotColor: "bg-yellow-500",
|
||||
barColor: "#F59E0B",
|
||||
},
|
||||
low: {
|
||||
label: "낮음",
|
||||
color: "bg-blue-100 text-blue-700",
|
||||
dotColor: "bg-blue-500",
|
||||
barColor: "#3B82F6",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getPriorityConfig(priority: Priority) {
|
||||
return PRIORITY_CONFIG[priority];
|
||||
}
|
||||
|
||||
// === Color Presets ===
|
||||
|
||||
export const COLOR_PRESETS = [
|
||||
"#EF4444", // red
|
||||
"#F97316", // orange
|
||||
"#F59E0B", // amber
|
||||
"#EAB308", // yellow
|
||||
"#84CC16", // lime
|
||||
"#22C55E", // green
|
||||
"#10B981", // emerald
|
||||
"#14B8A6", // teal
|
||||
"#06B6D4", // cyan
|
||||
"#0EA5E9", // sky
|
||||
"#3B82F6", // blue
|
||||
"#6366F1", // indigo
|
||||
"#8B5CF6", // violet
|
||||
"#A855F7", // purple
|
||||
"#D946EF", // fuchsia
|
||||
"#EC4899", // pink
|
||||
"#F43F5E", // rose
|
||||
"#6B7280", // gray
|
||||
];
|
||||
90
frontend/src/store/uiStore.ts
Normal file
90
frontend/src/store/uiStore.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { create } from "zustand";
|
||||
import { TodoFilters, SortField, SortOrder } from "@/types";
|
||||
|
||||
interface UIState {
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
|
||||
// Todo List Filters
|
||||
filters: TodoFilters;
|
||||
setFilter: <K extends keyof TodoFilters>(key: K, value: TodoFilters[K]) => void;
|
||||
setFilters: (filters: Partial<TodoFilters>) => void;
|
||||
resetFilters: () => void;
|
||||
|
||||
// Batch Selection
|
||||
selectedIds: string[];
|
||||
toggleSelect: (id: string) => void;
|
||||
selectAll: (ids: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// Todo Form Modal
|
||||
todoFormOpen: boolean;
|
||||
todoFormMode: "create" | "edit";
|
||||
editingTodoId: string | null;
|
||||
openTodoForm: (mode: "create" | "edit", todoId?: string) => void;
|
||||
closeTodoForm: () => void;
|
||||
|
||||
// Search
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: TodoFilters = {
|
||||
sort: "created_at" as SortField,
|
||||
order: "desc" as SortOrder,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
// Sidebar
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
|
||||
// Filters
|
||||
filters: { ...DEFAULT_FILTERS },
|
||||
setFilter: (key, value) =>
|
||||
set((s) => ({
|
||||
filters: {
|
||||
...s.filters,
|
||||
[key]: value,
|
||||
...(key !== "page" ? { page: 1 } : {}),
|
||||
},
|
||||
})),
|
||||
setFilters: (newFilters) =>
|
||||
set((s) => ({
|
||||
filters: { ...s.filters, ...newFilters, page: 1 },
|
||||
})),
|
||||
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
|
||||
|
||||
// Batch Selection
|
||||
selectedIds: [],
|
||||
toggleSelect: (id) =>
|
||||
set((s) => ({
|
||||
selectedIds: s.selectedIds.includes(id)
|
||||
? s.selectedIds.filter((i) => i !== id)
|
||||
: [...s.selectedIds, id],
|
||||
})),
|
||||
selectAll: (ids) => set({ selectedIds: ids }),
|
||||
clearSelection: () => set({ selectedIds: [] }),
|
||||
|
||||
// Todo Form Modal
|
||||
todoFormOpen: false,
|
||||
todoFormMode: "create",
|
||||
editingTodoId: null,
|
||||
openTodoForm: (mode, todoId) =>
|
||||
set({
|
||||
todoFormOpen: true,
|
||||
todoFormMode: mode,
|
||||
editingTodoId: todoId ?? null,
|
||||
}),
|
||||
closeTodoForm: () =>
|
||||
set({ todoFormOpen: false, editingTodoId: null }),
|
||||
|
||||
// Search
|
||||
searchQuery: "",
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
}));
|
||||
169
frontend/src/types/index.ts
Normal file
169
frontend/src/types/index.ts
Normal file
@ -0,0 +1,169 @@
|
||||
// === Enums ===
|
||||
export type Priority = "high" | "medium" | "low";
|
||||
export type SortField = "created_at" | "due_date" | "priority";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
export type BatchActionType = "complete" | "delete" | "move_category";
|
||||
|
||||
// === Attachment ===
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
stored_filename: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
// === Todo ===
|
||||
export interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string | null;
|
||||
completed: boolean;
|
||||
priority: Priority;
|
||||
category_id?: string | null;
|
||||
category_name?: string | null;
|
||||
category_color?: string | null;
|
||||
tags: string[];
|
||||
start_date?: string | null;
|
||||
due_date?: string | null;
|
||||
attachments: Attachment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TodoCreate {
|
||||
title: string;
|
||||
content?: string;
|
||||
category_id?: string | null;
|
||||
tags?: string[];
|
||||
priority?: Priority;
|
||||
start_date?: string | null;
|
||||
due_date?: string | null;
|
||||
}
|
||||
|
||||
export interface TodoUpdate {
|
||||
title?: string;
|
||||
content?: string | null;
|
||||
category_id?: string | null;
|
||||
tags?: string[];
|
||||
priority?: Priority;
|
||||
start_date?: string | null;
|
||||
due_date?: string | null;
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
export interface TodoListResponse {
|
||||
items: Todo[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface ToggleResponse {
|
||||
id: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
// === Batch ===
|
||||
export interface BatchRequest {
|
||||
action: BatchActionType;
|
||||
ids: string[];
|
||||
category_id?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchResponse {
|
||||
action: string;
|
||||
processed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
// === Category ===
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
order: number;
|
||||
todo_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CategoryCreate {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface CategoryUpdate {
|
||||
name?: string;
|
||||
color?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
// === Tag ===
|
||||
export interface TagInfo {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// === Search ===
|
||||
export interface SearchResponse {
|
||||
items: Todo[];
|
||||
total: number;
|
||||
query: string;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// === Dashboard ===
|
||||
export interface DashboardOverview {
|
||||
total: number;
|
||||
completed: number;
|
||||
incomplete: number;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface CategoryStat {
|
||||
category_id: string | null;
|
||||
name: string;
|
||||
color: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PriorityStat {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface UpcomingDeadline {
|
||||
id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
overview: DashboardOverview;
|
||||
by_category: CategoryStat[];
|
||||
by_priority: PriorityStat;
|
||||
upcoming_deadlines: UpcomingDeadline[];
|
||||
}
|
||||
|
||||
// === Filters ===
|
||||
export interface TodoFilters {
|
||||
completed?: boolean;
|
||||
category_id?: string;
|
||||
priority?: Priority;
|
||||
tag?: string;
|
||||
sort: SortField;
|
||||
order: SortOrder;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// === Error ===
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
status: number;
|
||||
}
|
||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user