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
56 changes: 56 additions & 0 deletions src/components/Dropdown/AlarmDropdownContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect } from "react";

import { useAlarmStore } from "@/store/useAlarmStore";

export default function AlarmDropdownContent({
onClose,
}: {
onClose: () => void;
}) {
const { alarms, markAllAsRead } = useAlarmStore();

useEffect(() => {
markAllAsRead();
}, [markAllAsRead]);

return (
<div className="flex flex-col gap-4 text-sm text-black">
<div className="flex justify-between items-center">
<span className="text-xl font-bold">알림 {alarms.length}개</span>
<button onClick={onClose}>✕</button>
</div>

{alarms.length === 0 ? (
<div className="text-center text-gray-400 py-6">알림이 없습니다.</div>
) : (
<ul className="flex flex-col gap-3 overflow-y-auto max-h-[20rem] pr-1">
{alarms.map((alarm) => (
<li
key={alarm.id}
className="border border-gray-200 bg-white p-3 rounded-md w-full h-[6.5rem]"
onClick={() => {
onClose();
}}
>
<div className="flex flex-col items-start gap-2 mb-1">
<span
className={`w-2 h-2 mt-1 rounded-full ${alarm.status === "승인" ? "bg-blue-400" : "bg-red-400"}`}
/>
<p className="text-sm leading-snug break-keep">
{alarm.message}{" "}
<span
className={`font-bold ${alarm.status === "승인" ? "text-blue-400" : "text-red-400"}`}
>
{alarm.status}
</span>
되었어요.
</p>
</div>
<p className="text-xs text-gray-500 mt-auto">{alarm.createdAt}</p>
</li>
))}
</ul>
)}
</div>
);
}
108 changes: 108 additions & 0 deletions src/components/Dropdown/DropdownPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, { useCallback, useEffect, useRef, useState } from "react";

import clsx from "clsx";
import ReactDOM from "react-dom";

interface DropdownPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
align?: "left" | "right";
variant?: "filter" | "alarm";
children: React.ReactNode;
}

const DropdownPopover = ({
anchorRef,
onClose,
align = "left",
variant = "filter",
children,
}: DropdownPopoverProps) => {
const popoverRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });

// 위치 계산 함수
const calculatePosition = useCallback(() => {
const anchor = anchorRef.current;
const isMobile = window.innerWidth < 768;
const POPUP_WIDTH = variant === "alarm" ? 368 : 390;

if (!anchor) return;

const rect = anchor.getBoundingClientRect();

if (isMobile) {
setPosition({ top: 0, left: 0 });
} else {
setPosition({
top: rect.bottom + window.scrollY + 8,
left:
align === "right"
? rect.right + window.scrollX - POPUP_WIDTH
: rect.left + window.scrollX,
});
}
}, [anchorRef, align, variant]);

// 창 크기 변경 대응
useEffect(() => {
calculatePosition();
window.addEventListener("resize", calculatePosition);
return () => {
window.removeEventListener("resize", calculatePosition);
};
}, [calculatePosition]);

// 클릭 외부 감지 → 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(e.target as Node)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [anchorRef, onClose]);

const isFilter = variant === "filter";

const sizeClass = isFilter
? "md:w-[24.375rem] md:h-[52.75rem]"
: "md:w-[23rem] md:h-[26.25rem]";

const colorClass = isFilter
? "bg-white border-gray-200"
: "bg-red-50 border-gray-300";

const popoverClass = clsx(
"z-[9999]",
"rounded-2xl p-6 pr-5 pb-6 pl-5",
"shadow-[0px_2px_8px_0px_#78748640]",
"border fixed md:absolute w-full h-full left-0 top-0",
colorClass,
sizeClass,
);

return ReactDOM.createPortal(
<div
ref={popoverRef}
className={popoverClass}
style={{
top: position.top,
left: position.left,
}}
>
{children}
</div>,
document.body,
);
};

export default DropdownPopover;
125 changes: 125 additions & 0 deletions src/components/Dropdown/FilterDropdownContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Button from "@/components/Button";
import { useFilterStore } from "@/store/useFilterStore";
import { SeoulDistricts } from "@/types/common";

function FilterDropdownContent({ onClose }: { onClose: () => void }) {
const {
selectedAreas,
startDate,
minPay,
setAreas,
setStartDate,
setMinPay,
reset,
} = useFilterStore();

const toggleArea = (area: string) => {
const updated = selectedAreas.includes(area)
? selectedAreas.filter((a) => a !== area)
: [...selectedAreas, area];
setAreas(updated);
};

return (
<div className="flex flex-col text-black text-sm h-full relative pb-[4.5rem]">
<div className="pb-4 flex justify-between items-center">
<span className="text-xl font-bold">상세 필터</span>
<button onClick={onClose}>✕</button>
</div>

<div className="">
<p className="text-base font-normal mb-2">위치</p>
<div className="grid grid-cols-2 gap-2 h-[16.125rem] overflow-y-auto border border-gray-200 rounded-md p-2">
{SeoulDistricts.map((area) => (
<button
key={area}
onClick={() => toggleArea(area)}
className={`px-2 py-1 rounded-md text-sm whitespace-nowrap ${
selectedAreas.includes(area)
? "bg-orange-500 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
{area}
</button>
))}
</div>

{selectedAreas.length > 0 && (
<div className="flex flex-wrap gap-[0.25rem] mt-4">
{selectedAreas.map((area) => (
<span
key={area}
className="w-[7.5rem] h-[1.875rem] px-[0.625rem] py-[0.375rem] bg-red-10 text-primary text-[0.875rem] font-bold rounded-[1.25rem] flex items-center justify-between"
>
{area}
<button
className="ml-1"
onClick={() =>
setAreas(selectedAreas.filter((a) => a !== area))
}
>
</button>
</span>
))}
</div>
)}
</div>

<div className="h-[0.125rem] bg-gray-100 my-6" />

<div className="">
<p className="text-base font-normal mb-2">시작일</p>
<input
type="date"
value={startDate ?? ""}
onChange={(e) => setStartDate(e.target.value)}
className="w-full h-[3.625rem] border border-gray-300 rounded-md px-4 text-base text-black placeholder:text-gray-400 bg-white"
placeholder="입력"
/>
</div>

<div className="h-[0.125rem] bg-gray-100 my-6" />

<div className="">
<p className="text-base font-normal mb-2">금액</p>
<div className="flex items-center gap-2 w-[15rem]">
<div className="relative">
<input
type="text"
value={minPay ?? ""}
onChange={(e) => setMinPay(Number(e.target.value))}
className="w-[10.5rem] h-[3.625rem] border border-gray-300 rounded-md px-4 pr-[2.5rem] text-base text-black placeholder:text-gray-400"
placeholder="입력"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-base text-black">
</span>
</div>
<span className="text-base text-black">이상부터</span>
</div>
</div>

<div className="absolute bottom-0 left-0 w-full border-t border-gray-200 flex justify-between">
<Button
onClick={reset}
variant="white"
textSize="lg"
className="w-[5.125rem] h-[3rem]"
>
초기화
</Button>
<Button
onClick={onClose}
textSize="lg"
className="w-[16.25rem] h-[3rem]"
>
적용하기
</Button>
</div>
</div>
);
}

export default FilterDropdownContent;
30 changes: 30 additions & 0 deletions src/store/useAlarmStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { create } from "zustand";

export type Alarm = {
id: string;
message: string;
createdAt: string;
read: boolean;
status: "승인" | "거절";
};

interface AlarmStore {
alarms: Alarm[];
hasUnread: boolean;
setAlarms: (alarms: Alarm[]) => void;
markAllAsRead: () => void;
}

export const useAlarmStore = create<AlarmStore>((set, get) => ({
alarms: [],
hasUnread: false,
setAlarms: (alarms) =>
set({
alarms,
hasUnread: alarms.some((a) => !a.read),
}),
markAllAsRead: () => {
const updated = get().alarms.map((a) => ({ ...a, read: true }));
set({ alarms: updated, hasUnread: false });
},
}));
28 changes: 28 additions & 0 deletions src/store/useFilterStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { create } from "zustand";

interface FilterState {
selectedAreas: string[];
startDate: string | null;
minPay: number | null;
setAreas: (areas: string[]) => void;
setStartDate: (date: string) => void;
setMinPay: (pay: number | null) => void;
reset: () => void;
}

export const useFilterStore = create<FilterState>((set) => ({
selectedAreas: [],
startDate: null,
minPay: null,

setAreas: (areas) => set({ selectedAreas: areas }),
setStartDate: (date) => set({ startDate: date }),
setMinPay: (pay) => set({ minPay: pay }),

reset: () =>
set({
selectedAreas: [],
startDate: null,
minPay: null,
}),
}));