filter-calendar

This commit is contained in:
focp212@naver.com
2025-09-12 15:21:52 +09:00
parent 0224ed6c14
commit 2bbae7eed3
6 changed files with 424 additions and 115 deletions

View File

@@ -5,10 +5,12 @@ import { App } from './app';
import { initAxios } from '@/shared/configs/axios';
import { initSentry } from '@/shared/configs/sentry';
import { AppProvider } from './providers/app-provider';
// import appBridge from '@/shared/lib/appBridge';
const initApp = async () => {
initAxios();
initSentry();
// appBridge.sendMessage('login')
};
(async () => {

View File

@@ -1,3 +1,4 @@
import moment from 'moment';
import { ChangeEvent, useState } from 'react';
import { motion } from 'framer-motion';
import { IMAGE_ROOT } from '@/shared/constants/common';
@@ -9,8 +10,7 @@ import {
BillingSearchType
} from '../../model/types';
import { FilterDateOptions } from '@/entities/common/model/types';
import moment from 'moment';
import NiceCalendar from '@/shared/ui/calendar';
import { FilterCalendar } from '@/shared/ui/calendar/filter-calendar';
export const BillingFilter = ({
filterOn,
@@ -163,73 +163,13 @@ export const BillingFilter = ({
/>
</div>
</div>
<FilterCalendar
startDate={ filterStartDate }
endDate={ filterEndDate }
setStartDate={ setFilterStartDate }
setEndDate={ setFilterEndDate }
></FilterCalendar>
<div className="opt-field">
<div className="opt-label"></div>
<div className="opt-controls col below h36">
<div className="chip-row">
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Today)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Today) }
></span>
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Week)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Week) }
></span>
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Month)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Month) }
>1</span>
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Input)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Input) }
></span>
</div>
<div className="range-row">
<div className="input-wrapper date">
<input
className="date-input"
type="text"
placeholder="날짜 선택"
value={ filterStartDate }
readOnly={ true }
/>
<button
type="button"
className="date-btn"
onClick={ () => onClickToOpenCalendar() }
>
<img
src={ IMAGE_ROOT + '/ico_date.svg' }
alt="날짜 선택"
/>
</button>
</div>
<span className="beetween">~</span>
<div className="input-wrapper date">
<input
className="date-input"
type="text"
placeholder="날짜 선택"
value={ filterEndDate }
readOnly={ true }
/>
<button
type="button"
className="date-btn"
onClick={ () => onClickToOpenCalendar() }
>
<img
src={ IMAGE_ROOT + '/ico_date.svg' }
alt="날짜 선택"
/>
</button>
</div>
</div>
</div>
</div>
<div className="opt-field">
<div className="opt-label"></div>
<div className="opt-controls col below h36">
<div className="chip-row">
@@ -327,10 +267,6 @@ export const BillingFilter = ({
></button>
</div>
</div>
<NiceCalendar
calendarOpen={ calendarOpen }
setNewDate={ setNewDate }
></NiceCalendar>
</motion.div>
</>
);

255
src/shared/lib/appBridge.ts Normal file
View File

@@ -0,0 +1,255 @@
import {
AppBridgeMessage,
AppBridgeResponse,
BridgeMessageType,
DeviceInfo,
ShareContent
} from '@/types';
import { LoginCredentials, UserInfo } from '@/types/auth';
class AppBridge {
private static instance: AppBridge;
private messageId = 0;
private pendingCallbacks = new Map<string, (response: AppBridgeResponse) => void>();
private responseListeners: Set<(response: AppBridgeResponse) => void> = new Set();
private constructor() {
this.setupMessageListener();
}
// 외부에서 네이티브 응답을 구독할 수 있도록 리스너 등록/해제 메서드 추가
public addResponseListener(listener: (response: AppBridgeResponse) => void) {
this.responseListeners.add(listener);
}
public removeResponseListener(listener: (response: AppBridgeResponse) => void) {
this.responseListeners.delete(listener);
}
static getInstance(): AppBridge {
if (!AppBridge.instance) {
AppBridge.instance = new AppBridge();
}
return AppBridge.instance;
}
private setupMessageListener(): void {
window.addEventListener('message', (event) => {
try {
const response: AppBridgeResponse & { callbackId?: string } = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
console.log('response', response);
if (response.callbackId && this.pendingCallbacks.has(response.callbackId)) {
const callback = this.pendingCallbacks.get(response.callbackId);
if (callback) {
callback(response);
this.pendingCallbacks.delete(response.callbackId);
}
}
// 등록된 리스너들에게 모든 응답 전달
this.responseListeners.forEach(listener => listener(response));
} catch (error) {
console.error('Failed to parse bridge message:', error);
}
});
}
private generateMessageId(): string {
return `bridge_${++this.messageId}_${Date.now()}`;
}
private sendMessage<T>(type: BridgeMessageType, data?: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const callbackId = this.generateMessageId();
const message: AppBridgeMessage & { callbackId: string } = {
type,
data,
callbackId
};
this.pendingCallbacks.set(callbackId, (response: AppBridgeResponse) => {
if (response.success) {
resolve(response.data);
} else {
reject(new Error(response.error || 'Bridge call failed'));
}
});
// Android WebView 인터페이스
if (window.AndroidBridge && window.AndroidBridge.postMessage) {
console.log('Android postMessage', message);
window.AndroidBridge.postMessage(JSON.stringify(message));
return;
}
// iOS WKWebView 인터페이스
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge) {
console.log('iOS postMessage', message);
window.webkit.messageHandlers.bridge.postMessage(message);
return;
}
// 네이티브 환경이 아닌 경우 에러 발생
setTimeout(() => {
this.pendingCallbacks.delete(callbackId);
reject(new Error('Native bridge not available'));
}, 100);
});
}
// 앱 정보 관련
async getAppInfo(): Promise<{ version: string; buildNumber: string }> {
return this.sendMessage(BridgeMessageType.GET_APP_INFO);
}
async getDeviceInfo(): Promise<DeviceInfo> {
return this.sendMessage(BridgeMessageType.GET_DEVICE_INFO);
}
// 네비게이션 관련
async navigateBack(): Promise<void> {
return this.sendMessage(BridgeMessageType.NAVIGATE_BACK);
}
async navigateTo(path: string): Promise<void> {
return this.sendMessage(BridgeMessageType.NAVIGATE_TO, { path });
}
async navigateToLogin(): Promise<void> {
return this.sendMessage(BridgeMessageType.NAVIGATE_TO_LOGIN);
}
async closeWebView(): Promise<void> {
return this.sendMessage(BridgeMessageType.CLOSE_WEBVIEW);
}
// 알림 관련
async showToast(message: string, duration: number = 3000): Promise<void> {
return this.sendMessage(BridgeMessageType.SHOW_TOAST, { message, duration });
}
async showAlert(title: string, message: string): Promise<void> {
return this.sendMessage(BridgeMessageType.SHOW_ALERT, { title, message });
}
async showConfirm(title: string, message: string): Promise<boolean> {
return this.sendMessage(BridgeMessageType.SHOW_CONFIRM, { title, message });
}
// 저장소 관련
async setStorage(key: string, value: unknown): Promise<void> {
return this.sendMessage(BridgeMessageType.SET_STORAGE, { key, value: JSON.stringify(value) });
}
async getStorage<T>(key: string): Promise<T | null> {
try {
const result = await this.sendMessage<string>(BridgeMessageType.GET_STORAGE, { key });
return result ? JSON.parse(result) : null;
} catch {
return null;
}
}
async removeStorage(key: string): Promise<void> {
return this.sendMessage(BridgeMessageType.REMOVE_STORAGE, { key });
}
// 공유 관련
async shareContent(content: ShareContent): Promise<void> {
return this.sendMessage(BridgeMessageType.SHARE_CONTENT, content);
}
// 로그인 요청
async login(credentials?: LoginCredentials): Promise<UserInfo> {
return this.sendMessage(BridgeMessageType.LOGIN, credentials);
}
async logout(): Promise<void> {
return this.sendMessage(BridgeMessageType.LOGOUT);
}
// 언어 설정
async setLanguage(language: string): Promise<void> {
return this.sendMessage(BridgeMessageType.SET_LANGUAGE, { language });
}
async getLanguage(): Promise<string> {
return this.sendMessage(BridgeMessageType.GET_LANGUAGE);
}
// 메시지 카운트 업데이트
async updateMessageCount(count: number): Promise<void> {
return this.sendMessage(BridgeMessageType.UPDATE_MESSAGE_COUNT, { count });
}
// 네이티브 환경 체크
isNativeEnvironment(): boolean {
return !!(
(window.AndroidBridge && window.AndroidBridge.postMessage) ||
(window.Android && window.Android.processMessage) ||
(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge)
);
}
isAndroid(): boolean {
return !!(
(window.AndroidBridge && window.AndroidBridge.postMessage) ||
(window.Android && window.Android.processMessage)
);
}
isIOS(): boolean {
return !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge);
}
// 에러 핸들링을 위한 래퍼 메소드들
async safeCall<T>(
bridgeMethod: () => Promise<T>,
fallback?: T,
onError?: (error: Error) => void
): Promise<T | undefined> {
try {
return await bridgeMethod();
} catch (error) {
console.error('Bridge call failed:', error);
if (onError) {
onError(error as Error);
}
return fallback;
}
}
// 타임아웃을 가진 브리지 호출
async callWithTimeout<T>(
bridgeMethod: () => Promise<T>,
timeout: number = 5000
): Promise<T> {
return Promise.race([
bridgeMethod(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Bridge call timeout')), timeout)
)
]);
}
}
// 글로벌 타입 선언 확장
declare global {
interface Window {
AndroidBridge?: {
postMessage: (message: string) => void;
};
Android?: {
processMessage: (message: string) => void;
};
webkit?: {
messageHandlers?: {
bridge?: {
postMessage: (message: unknown) => void;
};
};
};
}
}
export const appBridge = AppBridge.getInstance();
export default appBridge;

View File

@@ -0,0 +1,128 @@
import moment from 'moment';
import { ChangeEvent, useState } from 'react';
import { FilterDateOptions } from '@/entities/common/model/types';
import { IMAGE_ROOT } from '@/shared/constants/common';
import NiceCalendar from './nice-calendar';
interface FilterCalendarProps {
startDate: string;
endDate: string;
setStartDate: (startDate: string) => void;
setEndDate: (endDate: string) => void;
};
export const FilterCalendar = ({
startDate,
endDate,
setStartDate,
setEndDate
}: FilterCalendarProps) => {
const [dateReadOnly, setDateReadyOnly] = useState<boolean>(true);
const [filterDateOptionsBtn, setFilterDateOptionsBtn] = useState<FilterDateOptions>(FilterDateOptions.Input);
const [filterStartDate, setFilterStartDate] = useState<string>(startDate);
const [filterEndDate, setFilterEndDate] = useState<string>(endDate);
const [calendarOpen, setCalendarOpen] = useState<boolean>(false);
const setFilterDate = (dateOptions: FilterDateOptions) => {
if(dateOptions === FilterDateOptions.Today){
setFilterStartDate(moment().format('YYYY-MM-DD'));
setFilterEndDate(moment().format('YYYY-MM-DD'));
setDateReadyOnly(true);
setFilterDateOptionsBtn(FilterDateOptions.Today);
}
else if(dateOptions === FilterDateOptions.Week){
setFilterStartDate(moment().subtract(1, 'week').format('YYYY-MM-DD'));
setFilterEndDate(moment().format('YYYY-MM-DD'));
setDateReadyOnly(true);
setFilterDateOptionsBtn(FilterDateOptions.Week);
}
else if(dateOptions === FilterDateOptions.Month){
setFilterStartDate(moment().subtract(1, 'month').format('YYYY-MM-DD'));
setFilterEndDate(moment().format('YYYY-MM-DD'));
setDateReadyOnly(true);
setFilterDateOptionsBtn(FilterDateOptions.Month);
}
else if(dateOptions === FilterDateOptions.Input){
setDateReadyOnly(false);
setFilterDateOptionsBtn(FilterDateOptions.Input);
}
};
const onClickToOpenCalendar = () => {
if(!dateReadOnly){
setCalendarOpen(true);
}
else{
setCalendarOpen(false);
}
};
return (
<>
<div className="opt-field">
<div className="opt-label"></div>
<div className="opt-controls col below h36">
<div className="chip-row">
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Today)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Today) }
></span>
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Week)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Week) }
></span>
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Month)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Month) }
>1</span>
<span
className={ `keyword-tag ${(filterDateOptionsBtn === FilterDateOptions.Input)? 'active': ''}` }
onClick={ () => setFilterDate(FilterDateOptions.Input) }
></span>
</div>
<div className="range-row">
<div className="input-wrapper date">
<input
className="date-input"
type="text"
placeholder="날짜 선택"
value={ filterStartDate }
readOnly={ true }
/>
<button
type="button"
className="date-btn"
onClick={ () => onClickToOpenCalendar() }
>
<img
src={ IMAGE_ROOT + '/ico_date.svg' }
alt="날짜 선택"
/>
</button>
</div>
<span className="beetween">~</span>
<div className="input-wrapper date">
<input
className="date-input"
type="text"
placeholder="날짜 선택"
value={ filterEndDate }
readOnly={ true }
/>
<button
type="button"
className="date-btn"
onClick={ () => onClickToOpenCalendar() }
>
<img
src={ IMAGE_ROOT + '/ico_date.svg' }
alt="날짜 선택"
/>
</button>
</div>
</div>
</div>
</div>
</>
);
};

View File

@@ -1,43 +0,0 @@
import moment from 'moment';
import styled from "styled-components";
import { useState } from 'react';
import Calendar from 'react-calendar';
import 'react-calendar/dist/Calendar.css';
const NiceCalendar = ({
setNewDate
}: any) => {
const [calendarDate, setCalendarDate] = useState<string>(moment().format('YYYY-MM-DD'));
const [isOpen, setIsOpen] = useState<boolean>(false);
const onchangeToDate = (selectedDate: any) => {
setNewDate(selectedDate)
setIsOpen(false);
};
return (
<>
<CalendarContainer>
<CalendarWrapper isOpen={isOpen}>
<Calendar
onChange={ onchangeToDate }
value={ calendarDate }
></Calendar>
</CalendarWrapper>
</CalendarContainer>
</>
);
};
const CalendarContainer = styled.div`
position: relative;
`;
const CalendarWrapper: any = styled.div`
z-index: 11;
position: absolute;
top: 100%;
left: 0;
display: ${(props: any) => ((props.isOpen)? 'block': 'none')};
`;
export default NiceCalendar;

View File

@@ -0,0 +1,31 @@
import moment from 'moment';
import styled from "styled-components";
import { useState } from 'react';
import Calendar from 'react-calendar';
import 'react-calendar/dist/Calendar.css';
const NiceCalendar = ({
calendarOpen,
setNewDate
}: any) => {
const [calendarDate, setCalendarDate] = useState<string>(moment().format('YYYY-MM-DD'));
const [isOpen, setIsOpen] = useState<boolean>(calendarOpen);
const onchangeToDate = (selectedDate: any) => {
setNewDate(selectedDate)
setIsOpen(false);
};
return (
<>
<Calendar
onChange={ onchangeToDate }
value={ calendarDate }
></Calendar>
</>
);
};
export default NiceCalendar;