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
33 changes: 9 additions & 24 deletions src/app/api/projectMemos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export async function GET(request: Request) {
const offset = (page - 1) * limit;
const orderDirection = sortBy === "newest" ? "desc" : "asc";

// user_id만 가져오기 (JOIN은 나중에)
const {
data: memos,
error: fetchError,
Expand All @@ -130,10 +129,9 @@ export async function GET(request: Request) {
);
}

// user_id들을 수집해서 한 번에 조회
const userIds = [...new Set(memos?.map((m) => m.user_id) || [])];

const userMap: Record<string, any> = {};
const userMap: Record<string, { user_id: string; user_name: string; email: string }> = {};
if (userIds.length > 0) {
const { data: users } = await supabase
.from("users")
Expand All @@ -145,7 +143,6 @@ export async function GET(request: Request) {
});
}

// memos에 author 정보 추가
const memosWithAuthor = memos?.map((memo) => ({
...memo,
author: userMap[memo.user_id] || {
Expand All @@ -168,6 +165,7 @@ export async function GET(request: Request) {
return errorResponse(error, "메모 조회에 실패했습니다");
}
}

/**
* POST /api/projectMemos
* 메모 생성
Expand All @@ -185,7 +183,6 @@ export async function POST(request: Request) {
);
}

// 한국 시간(KST, UTC+9)으로 저장
const now = new Date();
const kstTime = new Date(now.getTime() + 9 * 60 * 60 * 1000);

Expand All @@ -198,16 +195,15 @@ export async function POST(request: Request) {
content: content.trim(),
created_at: kstTime.toISOString(),
updated_at: kstTime.toISOString(),
is_pinned: false, // 새 메모는 기본적으로 고정되지 않음
pinned_at: null, // 고정되지 않으므로 null
is_pinned: false,
pinned_at: null,
},
])
.select()
.single();

if (error) throw error;

// 작성자 정보 추가
const { data: author } = await supabase
.from("users")
.select("user_id, user_name, email")
Expand Down Expand Up @@ -301,10 +297,7 @@ export async function DELETE(request: Request) {
if (updateError) throw updateError;

return Response.json(
{
message: "메모가 삭제되었습니다",
memo_id: memoId,
},
{ message: "메모가 삭제되었습니다", memo_id: memoId },
{ status: 200 }
);
} catch (error) {
Expand All @@ -323,8 +316,6 @@ export async function PATCH(request: Request) {
const body = await request.json();
const { is_pinned } = body;

// console.log("🔧 서버 수신 데이터:", { memoId, body, is_pinned });

if (!memoId) {
return Response.json({ error: "메모 ID가 필수입니다" }, { status: 400 });
}
Expand All @@ -336,22 +327,16 @@ export async function PATCH(request: Request) {
);
}

const updateData = {
is_pinned,
pinned_at: is_pinned ? new Date().toISOString() : null,
};

// console.log("💾 DB 업데이트 데이터:", updateData);

const { data, error } = await supabase
.from("project_memos")
.update(updateData)
.update({
is_pinned,
pinned_at: is_pinned ? new Date().toISOString() : null,
})
.eq("memo_id", memoId)
.select()
.single();

// console.log("📊 DB 업데이트 결과:", { data, error });

if (error) throw error;

return Response.json(data, { status: 200 });
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/tasks/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ function sanitizeTaskData<T extends Record<string, any>>(
* 에러 핸들링 헬퍼
*/
function handleApiError<T>(operation: string, error: unknown): ApiResponse<T> {
const err = error instanceof Error ? error : new Error(String(error));
console.error(`${operation} 실패:`, err);
const err = error instanceof Error
? error
: new Error(typeof error === "object" ? JSON.stringify(error) : String(error));
console.error(`${operation} 실패:`, error);
return { data: null, error: err };
}

Expand Down
97 changes: 24 additions & 73 deletions src/features/project/ui/ProjectBoardFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,94 +1,45 @@
"use client";

import { useProjectBoard } from "@/providers/ProjectBoardProvider";
import { ArrowUpNarrowWide, ArrowDownWideNarrow, RotateCcw } from "lucide-react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils/utils";

const VIEW_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "all", label: "전체 프로젝트" },
{ value: "personal", label: "내 프로젝트" },
];

const DATE_OPTIONS = [
{ value: "startedAt", label: "시작일" },
{ value: "endedAt", label: "마감일" },
{ value: "createdAt", label: "생성일" },
{ value: "updatedAt", label: "수정일" },
];

const DEFAULT_FILTER = { view: "all", date: "startedAt", sort: "asc" };

const pill = (active: boolean) =>
cn(
"px-2.5 py-1 rounded-md text-sm transition-colors",
active
? "bg-main-500/10 dark:bg-main-400/10 text-main-600 dark:text-main-300 font-medium"
: "text-gray-500 dark:text-gray-400 hover:text-foreground hover:bg-muted/40"
);

const Divider = () => <span className="w-px h-4 bg-border shrink-0" />;

export default function ProjectBoardFilter() {
const { filter, setFilter } = useProjectBoard();

const isNonDefault =
filter.view !== DEFAULT_FILTER.view ||
filter.date !== DEFAULT_FILTER.date ||
filter.sort !== DEFAULT_FILTER.sort;
const sortLabel = filter.sort === "desc" ? "최근 순" : "오래된 순";

return (
<div className="flex items-center gap-1 mb-5 flex-wrap">
{VIEW_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setFilter((f) => ({ ...f, view: opt.value }))}
className={pill(filter.view === opt.value)}
>
{opt.label}
</button>
))}

<Divider />

{DATE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setFilter((f) => ({ ...f, date: opt.value }))}
className={pill(filter.date === opt.value)}
>
{opt.label}
</button>
))}

<Divider />
<div className="flex items-center justify-between border-b border-border mb-6">
<div className="flex items-center gap-6">
{VIEW_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setFilter((f) => ({ ...f, view: opt.value }))}
className={cn(
"pb-2.5 text-base font-medium transition-colors border-b-2 -mb-px",
filter.view === opt.value
? "border-main-500 text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>

<button
onClick={() => setFilter((f) => ({ ...f, sort: "asc" }))}
className={pill(filter.sort === "asc")}
title="오름차순"
>
<ArrowUpNarrowWide className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setFilter((f) => ({ ...f, sort: "desc" }))}
className={pill(filter.sort === "desc")}
title="내림차순"
onClick={() => setFilter((f) => ({ ...f, sort: f.sort === "desc" ? "asc" : "desc" }))}
className="flex items-center gap-1 pb-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowDownWideNarrow className="w-3.5 h-3.5" />
{sortLabel}
<ChevronDown className={cn("w-4 h-4 transition-transform", filter.sort === "asc" && "rotate-180")} />
</button>

{isNonDefault && (
<>
<Divider />
<button
onClick={() => setFilter(DEFAULT_FILTER)}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted/40"
>
<RotateCcw className="w-3 h-3" />
초기화
</button>
</>
)}
</div>
);
}
9 changes: 2 additions & 7 deletions src/features/project/ui/ProjectBoardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,8 @@ export default function ProjectBoardHeader() {

return (
<>
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-lg font-bold text-foreground">프로젝트 목록</h2>
<p className="text-sm text-main-600 dark:text-main-300 mt-0.5">
Taskry에서 프로젝트를 생성하고 관리합니다.
</p>
</div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-2xl font-semibold text-foreground">프로젝트</h2>
<Button btnType="basic" icon="plus" variant="primary" size={16} onClick={() => setOpen(true)}>
새 프로젝트
</Button>
Expand Down
96 changes: 40 additions & 56 deletions src/features/project/ui/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,47 +8,35 @@ import { showToast } from "@/lib/utils/toast";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/constants/queryKeys";
import type { Project, ProjectStatus } from "../model";
import { Pencil, CalendarDays } from "lucide-react";
import { Pencil } from "lucide-react";

interface ProjectCardProps {
project: Project;
projectMember: Record<string, number> | null;
}

const STATUS_CONFIG: Record<ProjectStatus, { label: string; dot: string; text: string }> = {
active: { label: "진행중", dot: "bg-emerald-400", text: "text-emerald-600 dark:text-emerald-400" },
completed: { label: "완료", dot: "bg-gray-300 dark:bg-gray-500", text: "text-gray-400 dark:text-gray-500" },
archived: { label: "일시정지", dot: "bg-amber-400", text: "text-amber-500 dark:text-amber-400" },
const STATUS_DOT: Record<ProjectStatus, string> = {
active: "bg-main-500",
completed: "bg-gray-300 dark:bg-gray-500",
archived: "bg-amber-400",
};

function formatDate(d: string) {
const date = new Date(d);
const dateStr = date.toLocaleDateString("ko-KR", {
function formatDeadline(d?: string) {
if (!d) return null;
return new Date(d).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const timeStr = date.toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return `${dateStr.replace(/\. /g, ".").replace(/\.$/, "")} ${timeStr}`;
}

function formatDateRange(start?: string, end?: string) {
if (!start && !end) return null;
if (!end) return formatDate(start!);
return `${formatDate(start!)} – ${formatDate(end)}`;
}).replace(/\. /g, ".").replace(/\.$/, "") + " 마감";
}

export default function ProjectCard({ project, projectMember }: ProjectCardProps) {
const router = useRouter();
const queryClient = useQueryClient();

const memberCount = projectMember?.[project.project_id] ?? 1;
const status = STATUS_CONFIG[project.status] ?? STATUS_CONFIG.active;
const dateRange = formatDateRange(project.started_at, project.ended_at);
const dot = STATUS_DOT[project.status] ?? STATUS_DOT.active;
const deadline = formatDeadline(project.ended_at);

async function handleDelete() {
await deleteProject(project.project_id);
Expand All @@ -60,52 +48,48 @@ export default function ProjectCard({ project, projectMember }: ProjectCardProps
return (
<div
onClick={() => router.push(`/project/workspace/${project.project_id}`)}
className="group relative flex flex-col bg-card rounded-2xl border border-border p-5 cursor-pointer transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 hover:border-main-200 dark:hover:border-main-700"
className="group relative flex flex-col bg-white dark:bg-card rounded-[14px] border border-[#bde3ec] dark:border-border p-6 cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-0.5"
>
{/* 상태 */}
<div className={`flex items-center gap-1.5 text-xs font-medium mb-3 ${status.text}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${status.dot}`} />
{status.label}
{/* 타입 라벨 */}
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-4">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} />
{project.type || "기타"}
</div>

{/* 제목 + 설명 */}
<div className="flex-1 min-h-0 mb-4">
<h3 className="font-semibold text-base text-foreground line-clamp-1 mb-1.5">
<div className="flex-1 min-h-0 mb-5">
<h3 className="font-semibold text-[18px] text-foreground line-clamp-1 mb-2">
{project.project_name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
<p className="text-sm text-gray-400 dark:text-gray-500 line-clamp-2 leading-relaxed">
{project.description || "설명이 없습니다."}
</p>
</div>

{/* 날짜 */}
{dateRange && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-4">
<CalendarDays className="w-3.5 h-3.5 shrink-0 text-main-400 dark:text-main-300" />
<span>{dateRange}</span>
</div>
)}

{/* 팀원 수 + 호버 액션 */}
<div className="flex items-center justify-between pt-3 border-t border-border/60">
<span className="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Icon type="users" size={12} />
{memberCount}명
{/* 마감일 + 멤버 수 + 호버 액션 */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{deadline ?? "마감일 없음"}
</span>

{/* 호버 시 나타나는 액션 버튼 */}
<div
className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => router.push(`/project/update/${project.project_id}`)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-main-500 hover:bg-main-500/10 transition-colors"
<div className="flex items-center gap-1">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Icon type="users" size={12} />
{memberCount}명
</span>
<div
className="flex items-center gap-1 ml-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<div className="[&>button]:w-7 [&>button]:h-7 [&>button]:rounded-lg">
<DeleteDialog onClick={handleDelete} />
<button
onClick={() => router.push(`/project/update/${project.project_id}`)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-main-500 hover:bg-main-500/10 transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<div className="[&>button]:w-7 [&>button]:h-7 [&>button]:rounded-lg">
<DeleteDialog onClick={handleDelete} />
</div>
</div>
</div>
</div>
Expand Down
Loading
Loading