Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,7 @@ vite.config.ts.timestamp-*

# Markdown documentation files (except README)
*.md
!README.md
!README.md
# Supabase seed scripts (contain service role key)
supabase/run-seed*.mjs
supabase/seed.sql
74 changes: 54 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,46 @@
**Taskry**는 복잡한 절차 없이 누구나 쉽게 업무 흐름을 관리할 수 있는 웹 기반 협업 도구입니다.
직관적인 UI와 실시간 동기화를 통해 팀의 생산성을 극대화합니다.

업데이트일: 2026-05-10
업데이트일: 2026-05-17

## 📸 Screen Shots

| 메인 대시보드 | 칸반보드 | 캘린더 |
<!-- | 메인 대시보드 | 칸반보드 | 캘린더 |
| :----------------------------------------------------------------------: | :------------------------------------------------------------------------: | :---------------------------------------------------------------------: |
| <img src="assets/readme/dashboard.png" width="300" alt="Main Dashboard"> | <img src="assets/readme/kanbanboard.png" width="300" alt="Main Dashboard"> | <img src="assets/readme/calendar.png" width="300" alt="Main Dashboard"> |
| <img src="assets/readme/dashboard.png" width="300" alt="Main Dashboard"> | <img src="assets/readme/kanbanboard.png" width="300" alt="Main Dashboard"> | <img src="assets/readme/calendar.png" width="300" alt="Main Dashboard"> | -->

## 💡 주요 기능 (Key Features)

- **📊 업무 진행 현황의 시각화**

- 업무 상태(To Do, In Progress, Done)를 단계별로 구분하여 시각적으로 표현합니다.
- 드래그 앤 드롭 등을 통해 상태를 손쉽게 변경할 수 있습니다. (사용 라이브러리: @dnd-kit)
- 드래그 앤 드롭으로 상태를 손쉽게 변경할 수 있습니다. (사용 라이브러리: @dnd-kit)

- **🗂️ 대시보드**

- 진행 중인 프로젝트 현황과 주요 지표를 한눈에 확인할 수 있습니다.

- **📅 유연하고 확장 가능한 일정 관리**

- 월/주/일/어젠다 4가지 뷰로 일정을 확인할 수 있습니다.
- 일정 추가/수정/삭제(CRUD), 팀원 지정, 중요도 설정, 검색 및 필터링을 지원합니다.

- **📝 프로젝트 메모**

- 워크스페이스별 메모를 작성하고 고정/해제, 이모지 반응, 색상 레이블로 관리할 수 있습니다.

- **📢 공지사항**

- Lexical 리치 텍스트 에디터와 GitHub Flavored Markdown을 지원합니다.
- 중요도 플래그, 페이지네이션 기능이 포함됩니다.

- **🔐 팀원 초대 및 권한 관리**

- 워크스페이스에 팀원을 초대하고 역할(Role)을 지정할 수 있습니다.

- **🌙 다크모드**

- 라이트/다크 테마를 헤더에서 즉시 전환할 수 있습니다. (next-themes)

- **🎨 직관적이고 간결한 UX**

Expand All @@ -36,24 +62,30 @@
- 팀원의 작업 내역과 상태 변경이 실시간으로 보드에 반영됩니다.
- Supabase를 활용하여 데이터의 정합성을 유지합니다.

- **📅 유연하고 확장 가능한 일정 관리**
- 일정 추가/수정/삭제(CRUD) 기능.
- 팀원 지정, 중요도 설정, 검색 및 필터링을 통해 원하는 정보를 빠르게 찾을 수 있습니다.
- **🛡️ 관리자 패널**

- 사용자 역할 관리, 전체 프로젝트 조회, 공지사항 관리 기능을 제공합니다.

## 🛠️ 기술 스택 (Tech Stack)

| 분류 | 기술 | 버전 | 비고 |
| :-------------: | :----------------------- | :-------: | :---------------------------- |
| **Framework** | **Next.js** (App Router) | `16.0.1` | React 기반 풀스택 프레임워크 |
| **Runtime** | **React** | `19.2.0` | 최신 React 19 사용 |
| **Language** | **TypeScript** | `^5` | 안정적인 타입 시스템 |
| **Styling** | **Tailwind CSS** | `^4` | 유틸리티 퍼스트 CSS |
| **DnD** | **@dnd-kit** | `^6.3.1` | 드래그 앤 드롭 기능 |
| **Auth / DB** | **Supabase** | `^2.81.1` | Google OAuth 및 PostgreSQL DB |
| **Editor** | **Lexical** | `^0.38.2` | 리치 텍스트 에디터 |
| **Date** | **date-fns** | `^4.1.0` | 날짜 유틸리티 |
| **Linting** | ESLint | - | 코드 품질 관리 |
| **Package Mgr** | npm | - | 패키지 관리 |
| 분류 | 기술 | 버전 | 비고 |
| :-------------: | :--------------------------- | :--------: | :-------------------------------- |
| **Framework** | **Next.js** (App Router) | `16.0.1` | React 기반 풀스택 프레임워크 |
| **Runtime** | **React** | `19.2.0` | 최신 React 19 사용 |
| **Language** | **TypeScript** | `^5` | 안정적인 타입 시스템 |
| **Styling** | **Tailwind CSS** | `^4` | 유틸리티 퍼스트 CSS |
| **DnD** | **@dnd-kit** | `^6.3.1` | 드래그 앤 드롭 기능 |
| **Auth / DB** | **Supabase** | `^2.81.1` | Google·GitHub OAuth, PostgreSQL |
| **Auth** | **NextAuth** | `^4` | 세션 관리, Google·GitHub OAuth |
| **Fetching** | **TanStack Query** | `^5` | 서버 상태 관리 및 데이터 페칭 |
| **Editor** | **Lexical** | `^0.38.2` | 리치 텍스트 에디터 |
| **Calendar** | **react-big-calendar** | `^1.19.4` | 월/주/일/어젠다 캘린더 뷰 |
| **Markdown** | **react-markdown + remark-gfm** | `^10.1.0` | GitHub Flavored Markdown 렌더링 |
| **Toast** | **react-hot-toast** | `^2.6.0` | 토스트 알림 |
| **Theming** | **next-themes** | `^0` | 다크/라이트 모드 전환 |
| **Date** | **date-fns** | `^4.1.0` | 날짜 유틸리티 |
| **Linting** | ESLint | - | 코드 품질 관리 |
| **Package Mgr** | npm | - | 패키지 관리 |

## 📦 시작 가이드 (Getting Started)

Expand All @@ -73,6 +105,8 @@ NEXTAUTH_URL=your_url
NEXTAUTH_SECRET=your_secret
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret

# Database URLs
DATABASE_URL=your_database_url
Expand All @@ -87,7 +121,7 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
### 3. 인증 운영 방식

- 현재 인증은 NextAuth와 Supabase Auth를 병행 운영합니다.
- 로그인 페이지에서 NextAuth Google 또는 Supabase Google 로그인으로 진입할 수 있습니다.
- 로그인 페이지에서 **Google** 또는 **GitHub** 소셜 로그인으로 진입할 수 있습니다.
- 보호 라우트는 NextAuth 토큰과 Supabase 세션을 함께 확인합니다.
- 서버 API는 통합 인증 유틸을 통해 사용자 컨텍스트를 추출합니다.
- 관리자 API는 role === "admin" 기준으로만 허용합니다.
Expand Down
101 changes: 72 additions & 29 deletions src/app/(admin)/admin/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,35 @@ import Button from "@/components/ui/Button";
import AdminPageWrapper from "@/components/features/admin/AdminPageWrapper";
import { primaryBgColor, primaryBorderColor } from "@/app/sample/color/page";
import { Icon } from "@/components/shared/Icon";
import { mockProjects } from "@/app/data/mockProjects";
import { mockProjectMembers } from "@/app/data/mockProjectMembers";
import { mockTasks } from "@/app/data/mockTasks";

function getProgress(projectId: string): number {
const tasks = mockTasks.filter((t) => t.project_id === projectId);
if (tasks.length === 0) return 0;
const done = tasks.filter((t) => t.status === "done").length;
return Math.round((done / tasks.length) * 100);
}

function getLeaderName(projectId: string): string {
const leader = mockProjectMembers.find(
(m) => m.project_id === projectId && m.role === "leader"
);
return leader?.users.user_name ?? "-";
}

function getMemberCount(projectId: string): number {
return mockProjectMembers.filter((m) => m.project_id === projectId).length;
}

const STATUS_BADGE: Record<string, "dueSoon" | "inProgress" | "done"> = {
active: "inProgress",
completed: "done",
archived: "dueSoon",
};

export default function AdminProjectsPage() {
const progress = 65;
return (
<AdminPageWrapper
title="프로젝트 관리"
Expand All @@ -24,35 +50,52 @@ export default function AdminProjectsPage() {
</div>
}
>
<div
className={`border ${primaryBorderColor.Color2[0]} py-7 px-5 rounded-xl mb-4`}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">
프로젝트명 <Badge type="dueSoon" />
</h3>
<ul className="flex gap-5 mt-3">
<li className="font-normal text-sm">리더: 김이름</li>
<li className="font-normal text-sm">멤버: 5명</li>
</ul>
</div>
<Button btnType="icon" icon="trash" size={16} variant="basic" />
</div>
{/* 진행률 */}
<div
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
className={`w-100% ${primaryBgColor.Color2[0]} rounded-full h-2.5 overflow-hidden mt-5`}
>
{mockProjects.map((project) => {
const progress = getProgress(project.project_id);
const leaderName = getLeaderName(project.project_id);
const memberCount = getMemberCount(project.project_id);
const badgeType = STATUS_BADGE[project.status] ?? "dueSoon";

return (
<div
className={`${primaryBgColor.color1[1]} h-2.5 transition-all duration-500 rounded-full`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
key={project.project_id}
className={`border ${primaryBorderColor.Color2[0]} py-7 px-5 rounded-xl mb-4`}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">
{project.project_name} <Badge type={badgeType} />
</h3>
<ul className="flex gap-5 mt-3">
<li className="font-normal text-sm">리더: {leaderName}</li>
<li className="font-normal text-sm">멤버: {memberCount}명</li>
{project.tech_stack && (
<li className="font-normal text-sm text-muted-foreground">
{project.tech_stack}
</li>
)}
</ul>
</div>
<Button btnType="icon" icon="trash" size={16} variant="basic" />
</div>
<div
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
className={`w-100% ${primaryBgColor.Color2[0]} rounded-full h-2.5 overflow-hidden mt-5`}
>
<div
className={`${primaryBgColor.color1[1]} h-2.5 transition-all duration-500 rounded-full`}
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1 text-right">
태스크 완료율 {progress}%
</p>
</div>
);
})}
</AdminPageWrapper>
);
}
5 changes: 5 additions & 0 deletions src/app/(main)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import DashboardPage from "@/features/dashboard/DashboardPage";

export default function Dashboard() {
return <DashboardPage />;
}
17 changes: 6 additions & 11 deletions src/app/(main)/project/workspace/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,12 @@ export default function ProjectPage() {
queryKey: queryKeys.workspace.role(projectId, userId),
queryFn: async () => {
if (!userId || !projectId) return null;
const { data, error } = await supabase
.from("project_members")
.select("role")
.eq("project_id", projectId)
.eq("user_id", userId)
.maybeSingle();
if (error) {
console.error("프로젝트 멤버 역할 조회 오류:", error);
return null;
}
return (data?.role as ProjectRole) ?? null;
const res = await fetch(`/api/projectMembers?id=${projectId}&userId=${userId}`);
if (!res.ok) return null;
const json = await res.json();
const members = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : [];
const me = members.find((m: { user_id: string; role: string }) => m.user_id === userId);
return (me?.role as ProjectRole) ?? null;
},
enabled: !!projectId && !!userId,
staleTime: 1000 * 60 * 5,
Expand Down
28 changes: 5 additions & 23 deletions src/app/api/tasks/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export async function createTask(

/**
* 특정 프로젝트의 모든 Task 조회
* ✅ JOIN을 사용해 한 번의 쿼리로 처리
* ✅ /api/kanban/tasks 서버 라우트를 통해 supabaseAdmin으로 RLS 우회
*/
export async function getTasksByBoardId(
projectId: string
Expand All @@ -103,29 +103,11 @@ export async function getTasksByBoardId(
throw new Error("프로젝트 ID가 필요합니다.");
}

const { data, error } = await supabase
.from("tasks")
.select(
`
*,
kanban_boards!inner(project_id)
`
)
.eq("kanban_boards.project_id", projectId)
.order("created_at", { ascending: false });

if (error) throw error;

// project_id를 Task 객체에 포함시키기
const tasksWithProjectId = (data || []).map((task: any) => {
const { kanban_boards, ...taskData } = task;
return {
...taskData,
project_id: kanban_boards.project_id,
};
});
const res = await fetch(`/api/kanban/tasks?boardId=${projectId}`);
if (!res.ok) throw new Error(`태스크 조회 실패: ${res.status}`);

return { data: tasksWithProjectId as Task[], error: null };
const json = await res.json();
return { data: (json.data ?? []) as Task[], error: null };
} catch (error) {
return handleApiError("Task 조회", error);
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function GET(req: NextRequest) {
return NextResponse.redirect(new URL("/login?error=auth_failed", req.url));
}

return NextResponse.redirect(new URL("/", req.url));
return NextResponse.redirect(new URL("/dashboard", req.url));
} catch (error) {
console.error("Authentication callback error:", error);
return NextResponse.redirect(new URL("/login?error=server_error", req.url));
Expand Down
7 changes: 7 additions & 0 deletions src/app/data/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { mockUsers } from "./mockUsers";
export { mockProjects } from "./mockProjects";
export { mockKanbanBoards } from "./mockKanbanBoards";
export { mockTasks } from "./mockTasks";
export { mockProjectMembers } from "./mockProjectMembers";
export { mockProjectMemos } from "./mockProjectMemos";
export { mockNotices, STORAGE_KEY } from "./mockNotices";
31 changes: 31 additions & 0 deletions src/app/data/mockKanbanBoards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { KanbanBoardType } from "@/types/kanban";

export const mockKanbanBoards: KanbanBoardType[] = [
{
id: "board-001",
name: "웹 서비스 리뉴얼 보드",
description: "웹 서비스 리뉴얼 프로젝트 칸반 보드",
project_id: "project-001",
columns: "todo,inprogress,done",
created_at: "2026-02-20T09:00:00Z",
updated_at: "2026-05-10T09:00:00Z",
},
{
id: "board-002",
name: "모바일 앱 개발 보드",
description: "모바일 앱 개발 프로젝트 칸반 보드",
project_id: "project-002",
columns: "todo,inprogress,done",
created_at: "2026-03-15T09:00:00Z",
updated_at: "2026-05-12T09:00:00Z",
},
{
id: "board-003",
name: "백엔드 API 개선 보드",
description: "백엔드 API 개선 프로젝트 칸반 보드",
project_id: "project-003",
columns: "todo,inprogress,done",
created_at: "2026-01-10T09:00:00Z",
updated_at: "2026-04-30T17:00:00Z",
},
];
Loading
Loading