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
3 changes: 2 additions & 1 deletion src/api/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export async function getAllUserApplications({

// 가게의 특정 공고 지원 등록
export const postApplication = async (shopId: string, noticeId: string) => {
await axiosInstance.post(`/shops/${shopId}/notices/${noticeId}/applications`);
const res = await axiosInstance.post(`/shops/${shopId}/notices/${noticeId}/applications`);
return res.data;
};

// 가게의 특정 공고 지원 취소
Expand Down
27 changes: 1 addition & 26 deletions src/components/ui/badge/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,12 @@
import { Button } from '@/components/ui/button';
import { UserRole } from '@/types/user';
import Badge from './Badge';

export type StatusType = 'pending' | 'accepted' | 'rejected';

interface StatusBadgeProps {
status: StatusType;
userRole: UserRole;
onApprove: () => void;
onReject: () => void;
applicationId: string;
onStatusChange: (id: string, status: StatusType) => void;
}

export default function StatusBadge({
status,
userRole: variant,
onApprove,
onReject,
}: StatusBadgeProps) {
if (status === 'pending' && variant === 'employer') {
return (
<div className='flex w-1/2 flex-col gap-2 md:flex-row'>
<Button variant='reject' size='md' className='whitespace-nowrap' onClick={onReject}>
거절하기
</Button>
<Button variant='approve' size='md' className='whitespace-nowrap' onClick={onApprove}>
승인하기
</Button>
</div>
);
}

export default function StatusBadge({ status }: StatusBadgeProps) {
const BADGE_CLASS =
status === 'pending'
? 'bg-green-100 text-green-200'
Expand Down
6 changes: 0 additions & 6 deletions src/components/ui/badge/statusbadge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,26 @@ type Story = StoryObj<typeof StatusBadge>;
export const Accept: Story = {
args: {
status: 'accepted',
userRole: 'employer',
},
};

// Reject 거절 뱃지
export const Reject: Story = {
args: {
status: 'rejected',
userRole: 'employer',
},
};

// Pending 대기중 employee 뱃지
export const PendingEmployee: Story = {
args: {
status: 'pending',
userRole: 'employee',
},
};

// Pending 대기중 employer
export const PendingEmployer: Story = {
args: {
status: 'pending',
userRole: 'employer',
onApprove: () => alert('승인!'),
onReject: () => alert('거절!'),
},
};
127 changes: 65 additions & 62 deletions src/components/ui/input/TimeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import TimeSelector from '@/components/ui/calendar/TimeSelector';
import useClickOutside from '@/hooks/useClickOutside';
import useToggle from '@/hooks/useToggle';
import { formatTime } from '@/lib/utils/dateFormatter';
import { Period } from '@/types/calendar';
import { Period, TimeValue } from '@/types/calendar';
import { useCallback, useEffect, useRef, useState } from 'react';
import Input from './input';

interface TimeInputProps {
label?: string;
requiredMark?: boolean;
value?: Date | null;
onChange?: (value: Date | null) => void;
value?: TimeValue | null;
onChange?: (value: TimeValue | null) => void;
}

export default function TimeInput({
Expand All @@ -20,100 +20,101 @@ export default function TimeInput({
onChange,
}: TimeInputProps) {
const { isOpen, toggle, setClose } = useToggle(false);
const [period, setPeriod] = useState<Period>('오전');
const [selectedTime, setSelectedTime] = useState<Date | null>(null);
const [inputValue, setInputValue] = useState(''); // typing 사용

const [selectedTime, setSelectedTime] = useState<TimeValue | null>(null);
const [inputValue, setInputValue] = useState('');
const wrapperRef = useRef<HTMLDivElement>(null);

useClickOutside(wrapperRef, () => {
if (isOpen) setClose();
});

// 시간 업데이트 중앙 관리
const updateTime = useCallback(
(date: Date, selectedPeriod: Period) => {
setPeriod(selectedPeriod);
setSelectedTime(date);
(date: Date, period: Period) => {
const newTime: TimeValue = { date, period };
setSelectedTime(newTime);
setInputValue(formatTime(date));
onChange?.(date);
onChange?.(newTime);
},
[onChange]
);

useEffect(() => {
if (value) {
setSelectedTime(value);
setInputValue(formatTime(value));
setInputValue(formatTime(value.date));
} else {
setSelectedTime(null);
setInputValue('');
}
}, [value]);

// 시간 선택
const handleTimeSelect = useCallback(
(value: string) => {
const parts = value.split(' ');
const periodValue = parts.length === 2 ? (parts[0] as Period) : period;
const timePart = parts.length === 2 ? parts[1] : parts[0];

const [hours, minutes] = timePart.split(':').map(Number);
(timeString: string) => {
const parts: string[] = timeString.split(' ');
const period: Period = parts.length === 2 ? (parts[0] as Period) : '오전';
const [hoursStr, minutesStr] = parts[1].split(':');
const hours = Number(hoursStr);
const minutes = Number(minutesStr);
if (isNaN(hours) || isNaN(minutes)) return;

const baseDate = selectedTime ?? new Date();
const newDate = new Date(baseDate);
newDate.setHours(hours, minutes);

updateTime(newDate, periodValue);
const baseDate: Date = selectedTime?.date ?? new Date();
const date = new Date(baseDate);
const hours24 =
period === '오후' && hours !== 12
? hours + 12
: period === '오전' && hours === 12
? 0
: hours;
date.setHours(hours24, minutes);

updateTime(date, period);
},
[selectedTime, updateTime, period]
[selectedTime, updateTime]
);

// typing
const handleTimeInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTypedNumbers = e.target.value.replace(/[^0-9]/g, '');
const typedLength = newTypedNumbers.length;
const digitsOnly: string = e.target.value.replace(/[^0-9]/g, '');
setInputValue(digitsOnly);

setInputValue(newTypedNumbers);
if (digitsOnly.length < 3) return;

if (typedLength > 4) {
const hours = parseInt(newTypedNumbers.slice(0, typedLength - 2));

if (isNaN(hours) || hours < 1 || hours > 12) {
setInputValue(newTypedNumbers.slice(-1));
return;
}
}
const hoursNum: number = parseInt(digitsOnly.slice(0, digitsOnly.length - 2), 10);
const minutesNum: number = parseInt(digitsOnly.slice(-2), 10);

if (typedLength < 3) return;
if (isNaN(hoursNum) || isNaN(minutesNum)) return;
if (!(hoursNum >= 1 && hoursNum <= 12 && minutesNum >= 0 && minutesNum < 60)) return;

const hoursTyped = newTypedNumbers.slice(0, typedLength - 2);
const minutesTyped = newTypedNumbers.slice(-2);
const period: Period = selectedTime?.period ?? (hoursNum >= 12 ? '오후' : '오전');

const h = parseInt(hoursTyped);
const m = parseInt(minutesTyped);
const baseDate: Date = selectedTime?.date ?? new Date();
const date = new Date(baseDate);
const hours24 =
period === '오후' && hoursNum !== 12
? hoursNum + 12
: period === '오전' && hoursNum === 12
? 0
: hoursNum;
date.setHours(hours24, minutesNum);

if (!isNaN(h) && !isNaN(m)) {
if (!(h >= 1 && h <= 12 && m >= 0 && m < 60)) return;

const periodValue: Period = h > 12 ? '오후' : '오전';

const baseDate = selectedTime ?? new Date();
const newDate = new Date(baseDate);
newDate.setHours(h, m);

updateTime(newDate, periodValue);
}
updateTime(date, period);
};

const hours = selectedTime ? String(selectedTime.getHours() % 12 || 12).padStart(2, '0') : '12';
const minutes = selectedTime ? String(selectedTime.getMinutes()).padStart(2, '0') : '00';
const hoursDisplay: string = selectedTime
? String(selectedTime.date.getHours() % 12 || 12).padStart(2, '0')
: '12';
const minutesDisplay: string = selectedTime
? String(selectedTime.date.getMinutes()).padStart(2, '0')
: '00';
const periodDisplay: Period = selectedTime
? selectedTime.date.getHours() >= 12
? '오후'
: '오전'
: '오전';

return (
<div ref={wrapperRef} className='relative'>
<Input
value={inputValue ? `${period} ${inputValue}` : ''}
value={inputValue ? `${periodDisplay} ${hoursDisplay}:${minutesDisplay}` : ''}
label={label}
requiredMark={requiredMark}
placeholder='오전 12:30'
Expand All @@ -122,14 +123,16 @@ export default function TimeInput({
/>

<div
className={`overflow-hidden transition-all duration-300 ${isOpen ? 'mt-2 max-h-[300px] opacity-100' : 'max-h-0 opacity-0'} `}
className={`overflow-hidden transition-all duration-300 ${
isOpen ? 'mt-2 max-h-[300px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<TimeSelector
onSelect={handleTimeSelect}
period={period}
hours={hours}
minutes={minutes}
value={selectedTime ? formatTime(selectedTime) : ''}
period={periodDisplay}
hours={hoursDisplay}
minutes={minutesDisplay}
value={selectedTime ? formatTime(selectedTime.date) : ''}
/>
</div>
</div>
Expand Down
60 changes: 35 additions & 25 deletions src/components/ui/modal/notification/Notification.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Icon from '@/components/ui/icon/icon';
import { cn } from '@/lib/utils/cn';
import { Notice } from '@/types/notice';
import { Shop } from '@/types/shop';
import { useState } from 'react';
Expand All @@ -21,7 +22,6 @@ interface NotificationProps {
}

export default function Notification({ alerts, onRead, isOpen, onClose }: NotificationProps) {
// 제어 모드인지 판별
const controlled = typeof isOpen === 'boolean';
const [internalOpen, setInternalOpen] = useState(false);
const open = controlled ? (isOpen as boolean) : internalOpen;
Expand All @@ -32,41 +32,51 @@ export default function Notification({ alerts, onRead, isOpen, onClose }: Notifi

return (
<>
{/* 제어 모드가 아니면 내부 트리거 버튼을 노출 */}
{!controlled && (
<div className='relative flex justify-end'>
<button
onClick={() => setInternalOpen(v => !v)}
className={`${open ? 'hidden' : 'block'} relative md:block`}
>
<Icon iconName='notificationOn' iconSize='sm' ariaLabel='알림' />
<Icon
iconName='notificationOn'
iconSize='sm'
ariaLabel='알림'
className={notificationCount > 0 ? 'bg-red-400' : 'bg-gray-400'}
/>
</button>
</div>
)}
{open && (
<div className='flex min-h-screen flex-col gap-4 bg-red-100 px-5 py-10'>
<div className='flex justify-between'>
<div className='text-[20px] font-bold'>알림 {notificationCount}개</div>
<div>
<button onClick={() => (controlled ? onClose?.() : setInternalOpen(false))}>
<Icon iconName='close' iconSize='lg' ariaLabel='닫기' />
</button>
</div>

<div
className={cn(
'scroll-bar mt-2 w-full overflow-hidden bg-red-100 transition-all duration-300',
open ? 'max-h-[700px] px-5 py-6 opacity-100' : 'max-h-0 opacity-0',
'md:absolute md:right-0 md:top-1 md:max-h-[400px] md:rounded-xl md:border md:border-gray-300 md:py-6',
'fixed left-0 top-0 z-50 h-screen'
)}
>
<div className='flex justify-between'>
<div className='text-[20px] font-bold'>알림 {notificationCount}개</div>
<div>
<button onClick={() => (controlled ? onClose?.() : setInternalOpen(false))}>
<Icon iconName='close' iconSize='lg' ariaLabel='닫기' />
</button>
</div>
<div></div>
{SORTED_ALERTS.length === 0 ? (
<div className='flex flex-1 items-center justify-center'>
<p>알림이 없습니다.</p>
</div>
) : (
<div className='flex flex-col items-center gap-4'>
{SORTED_ALERTS.map(alert => (
<NotificationMessage key={alert.id} alert={alert} onRead={onRead} />
))}
</div>
)}
</div>
)}
<div></div>
{SORTED_ALERTS.length === 0 ? (
<div className='flex flex-1 items-center justify-center'>
<p className='font-medium'>알림이 없습니다.</p>
</div>
) : (
<div className='flex w-full flex-col items-center gap-4 overflow-y-auto md:max-h-[368px] md:flex-1'>
{SORTED_ALERTS.map(alert => (
<NotificationMessage key={alert.id} alert={alert} onRead={onRead} />
))}
</div>
)}
</div>
</>
);
}
8 changes: 4 additions & 4 deletions src/components/ui/modal/notification/NotificationMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,16 @@ export default function NotificationMessage({
<ResultBadge result={result} />
<p
className={clsx('text-sm', {
'text-[var(--gray-400)]': read,
'text-gray-400': read,
})}
>
{`${shopName} (${DATE_RANGE.date} ${DATE_RANGE.startTime} ~
${DATE_RANGE.endTime}) 공고 지원이 `}
<span
className={clsx({
'text-[var(--gray-500)]': read,
'text-[var(--blue-200)]': !read && result === 'accepted',
'text-[var(--red-500)]': !read && result === 'rejected',
'text-gray-400': read,
'text-blue-200': !read && result === 'accepted',
'text-red-400': !read && result === 'rejected',
})}
>
{RESULT_TEXT}
Expand Down
Loading