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:
jungwoo choi
2026-02-12 15:45:03 +09:00
parent b54811ad8d
commit 074b5133bf
81 changed files with 17027 additions and 19 deletions

39
frontend/.gitignore vendored Normal file
View 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
View 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"]

View 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
View 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

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View 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>
);
}

View File

@ -0,0 +1 @@
@import "tailwindcss";

View 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
View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&quot;{query}&quot; ({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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
},
});
}

View 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,
});
}

View 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 };
}

View 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,
});
}

View 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,
});
}

View 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
View 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
View 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
];

View 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
View 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
View 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"]
}