로그인 페이지: 사용자가 관리자 또는 직원으로 로그인할 수 있습니다. 관리자는 휴가 및 요청 페이지에 액세스 할 수 있으며, 직원은 휴가 페이지에만 액세스 할 수 있습니다.
휴가 페이지: 직원이 휴가를 요청, 보기 및 취소할 수 있습니다. 또한 관리자는 새로운 휴가를 할당할 수 있습니다.
요청 페이지: HR 관리자만이 휴가 요청을 승인 또는 거부할 수 있습니다.
참고: 이 튜토리얼에서 구축 할 앱의 전체 소스 코드는 이 GitHub 리포지토리에서 얻을 수 있습니다.
이 작업을 수행하는 동안 사용할 것입니다:
레스트 API: 데이터를 가져오고 업데이트하기 위한 것입니다. Refine에는 기본 데이터 제공자 패키지와 REST API가 내장되어 있지만, 사용자 고유의 요구 사항에 맞게 직접 만들 수도 있습니다. 이 안내서에서는 백엔드 서비스로 NestJs CRUD를 사용하고 데이터 제공자로 @refinedev/nestjsx-crud 패키지를 사용할 것입니다.
매터리얼 UI: UI 구성 요소에 사용하며, 디자인에 따라 완전히 사용자 정의할 것입니다. Refine에는 매터리얼 UI를 지원하는 기능이 내장되어 있지만, 원하는 UI 라이브러리를 사용할 수 있습니다.
앱을 만든 후에는 DigitalOcean의 앱 플랫폼을 사용하여 온라인으로 배포할 것입니다. 이를 통해 앱 및 정적 웹사이트를 쉽게 설정, 시작 및 확장할 수 있습니다. GitHub 저장소를 가리키기만 하면 코드를 배포하고 앱 플랫폼이 인프라, 앱 런타임 및 종속성 관리의 부담을 대신할 것입니다.
Refine은 복잡한 B2B 웹 애플리케이션을 구축하기 위한 오픈 소스 React 메타 프레임워크로, 주로 내부 도구, 관리자 패널 및 대시보드와 같은 데이터 관리 중심 사용 사례에 중점을 둡니다. 개발자의 작업 흐름을 개선하기 위해 일련의 훅(hooks)과 컴포넌트를 제공하여 설계되었습니다.
이는 기업급 앱에 대해 제품 출시 준비가 완료된 기능을 제공하여 상태 및 데이터 관리, 인증 및 액세스 제어와 같은 유료 작업을 간소화합니다. 이를 통해 개발자가 많은 방대한 구현 세부 정보와 추상화된 상태로 핵심 응용프로그램에 집중할 수 있도록 합니다.
npm create refine-app 명령을 사용하여 프로젝트를 대화형으로 초기화합니다.
npm create refine-app@latest
프롬프트에 다음 옵션을 선택합니다:
✔ Choose a project template · Vite
✔ What would you like to name your project?: · hr-app
✔ Choose your backend service to connect: · nestjsx-crud
✔ Do you want to use a UI Framework?: · Material UI
✔ Do you want to add example pages?: · No
✔ Do you need any Authentication logic?: · None
✔ Choose a package manager: · npm
의존성을 설치한 후 vite.config.ts 및 tsconfig.json 파일을 업데이트하여 vite-tsconfig-paths 플러그인을 사용하십시오. 이를 통해 Vite 프로젝트에서 TypeScript 경로 별칭을 사용할 수 있게 되며, @ 별칭을 사용한 가져오기가 가능해집니다.
src/contexts: 이 폴더에는 ColorModeContext 파일이 하나 들어 있습니다. 앱의 다크/라이트 모드를 처리합니다. 이 튜토리얼에서는 사용하지 않을 것입니다.
src/components: 이 폴더에는 <Header /> 컴포넌트가 들어 있습니다. 이 튜토리얼에서는 사용자 정의 헤더 컴포넌트를 사용할 것입니다.
rm-rf src/contexts src/components
파일 및 폴더를 제거한 후, App.tsx 파일에서 오류가 발생하는데, 이를 다음 단계에서 수정할 것입니다.
튜토리얼 동안 핵심 페이지 및 컴포넌트를 코딩해볼 것입니다. 따라서 GitHub 저장소에서 필요한 파일 및 폴더를 가져오십시오. 이 파일들을 사용하여 HR 관리 애플리케이션의 기본 구조를 갖추게 될 것입니다.
리소스: Refine이 가져올 데이터 엔티티(employee와 manager)를 지정하는 배열입니다. 우리는 부모 및 자식 리소스를 사용하여 데이터를 정리하고 권한을 관리합니다. 각 리소스는 사용자 역할을 정의하는 scope를 가지고 있으며, 이는 앱의 다양한 부분에 대한 접근을 제어합니다.
queryClient: 데이터 가져오기를 완전히 제어하고 사용자 정의할 수 있는 맞춤형 쿼리 클라이언트입니다.
사이드바에는 앱 로고와 내비게이션 링크가 포함되어 있습니다. 모바일 기기에서는 사용자가 메뉴 아이콘을 클릭할 때 열리는 접이식 사이드바입니다. 내비게이션 링크는 Refine의 useMenu 훅으로 준비되었으며, 사용자의 역할에 따라 <CanAccess /> 컴포넌트의 도움으로 렌더링됩니다.
사이드바에 장착되어 로그인한 사용자의 아바타와 이름을 표시합니다. 클릭하면 사용자 세부 정보와 로그아웃 버튼이 있는 팝오버가 열립니다. 사용자는 드롭다운에서 선택하여 다른 역할 간에 전환할 수 있습니다. 이 컴포넌트를 사용하여 다른 역할을 가진 사용자 간에 전환하면서 테스트할 수 있습니다.
Refine에서 인증은 authProvider에 의해 처리됩니다. 이를 통해 앱의 인증 로직을 정의할 수 있습니다. 이전 단계에서 우리는 이미 GitHub 리포지토리에서 authProvider를 복사하여 <Refine /> 컴포넌트에 prop으로 제공했습니다. 우리는 사용자가 로그인했는지 여부에 따라 앱의 동작을 제어하기 위해 다음 훅과 컴포넌트를 사용할 것입니다.
Refine에서 권한 부여는 accessControlProvider에 의해 처리됩니다. 사용자 역할과 권한을 정의하고 사용자의 역할에 따라 응용 프로그램의 다른 부분에 대한 액세스를 제어할 수 있습니다. 이전 단계에서 이미 GitHub 저장소에서 accessControlProvider를 복사하여 <Refine /> 컴포넌트에 속성으로 제공했습니다. accessControlProvider를 더 자세히 살펴보겠습니다.
src/providers/access-control/index.ts
import type {AccessControlBindings}from"@refinedev/core";import{Role}from"@/types";exportconstaccessControlProvider:AccessControlBindings={options:{queryOptions:{keepPreviousData:true,},buttons:{hideIfUnauthorized:true,},},can:async({ params, action })=>{const user =JSON.parse(localStorage.getItem("user")||"{}");if(!user)return{can:false};const scope = params?.resource?.meta?.scope;// 리소스에 범위가 없으면 액세스할 수 없습니다if(!scope)return{can:false};if(user.role===Role.MANAGER){return{can:true,};}if(action ==="manager"){return{can: user.role===Role.MANAGER,};}if(action ==="employee"){return{can: user.role===Role.EMPLOYEE,};}// 사용자는 자신의 역할이 리소스 범위와 일치하는 경우에만 리소스에 액세스할 수 있습니다return{can: user.role=== scope,};},};
우리 앱에서는 MANAGER와 EMPLOYEE 두 가지 역할이 있습니다.
관리자는 Requests 페이지에 접근할 수 있는 반면, 직원은 Time Off 페이지에만 접근할 수 있습니다. accessControlProvider는 사용자의 역할과 리소스 범위를 확인하여 사용자가 리소스에 접근할 수 있는지 여부를 판단합니다. 사용자의 역할이 리소스 범위와 일치하면 리소스에 접근할 수 있습니다. 그렇지 않으면 접근이 거부됩니다. 우리는 useCan 훅과 <CanAccess /> 컴포넌트를 사용하여 사용자 역할에 따라 앱의 동작을 제어할 것입니다.
인증 프로세스를 간단하게하기 위해 mockUsers 객체를 만들었습니다. 두 개의 배열, managers와 employees,를 포함합니다. 각 배열에는 미리 정의된 사용자 객체가 포함되어 있습니다. 사용자가 드롭다운에서 이메일을 선택하고 Sign in 버튼을 클릭하면 선택한 이메일로 login 함수가 호출됩니다. login 함수는 Refine에서 제공하는 useLogin 훅으로부터 제공되는 변이 함수입니다. 이 함수는 선택한 이메일로 authProvider.login을 호출합니다.
다음으로, <PageLogin /> 컴포넌트를 가져와 App.tsx 파일을 강조된 변경 사항으로 업데이트합니다.
업데이트된 App.tsx 파일에서는 Refine에서 <Authenticated /> 컴포넌트를 추가했습니다. 이 컴포넌트는 인증이 필요한 라우트를 보호하는 데 사용됩니다. 컴포넌트를 고유하게 식별하는 key prop, 사용자가 인증되지 않았을 때 렌더링할 fallback prop 및 인증이 실패했을 때 사용자를 지정된 경로로 리디렉션하는 redirectOnFail prop을 가져옵니다. 내부에서는 사용자가 인증되었는지 확인하기 위해 authProvider.check 메소드를 호출합니다.
<Authenticated /> 컴포넌트는 사용자의 인증 상태를 확인하기 위해 path="*" 경로에 래핑됩니다. 이 경로는 사용자가 인증되었을 때 <ErrorComponent />를 렌더링하는 캐치올 경로입니다. 이는 사용자가 존재하지 않는 경로에 접근하려고 할 때 404 페이지를 표시할 수 있게 합니다.
이제 앱을 실행하고 http://localhost:5173/login으로 이동하면 사용자 선택을 위한 드롭다운이 있는 로그인 페이지를 볼 수 있어야 합니다.
현재 “/” 페이지는 아무 것도 하지 않고 있습니다. 다음 단계에서는 Time Off 및 Requests 페이지를 구현할 것입니다.
이 단계에서는 휴가 페이지를 만들 것입니다. 직원들은 휴가를 요청하고 휴가 이력을 볼 수 있습니다. 매니저들도 이력을 볼 수 있지만, 직접 휴가를 할당할 수 있습니다. 우리는 Refine의 accessControlProvider, <CanAccess /> 컴포넌트, 그리고 useCan 훅을 사용하여 이를 구현할 것입니다.
<PageEmployeeTimeOffsList />
휴가 페이지를 만들기 전에, 휴가 이력, 예정된 휴가 요청, 사용된 휴가 통계를 보여주는 몇 가지 컴포넌트를 만들어야 합니다. 이 단계의 끝에서, 이러한 컴포넌트들을 사용하여 휴가 페이지를 만들 것입니다.
<TimeOffList /> 컴포넌트를 만들어 휴가 이력을 보여주기
src/components 폴더 안에 time-offs라는 새 폴더를 만들어주세요. time-offs 폴더 안에 list.tsx라는 새 파일을 만들고 아래 코드를 추가해주세요:
constfilters:Record<Props["type"],CrudFilters>={history:[{field:"status",operator:"eq",value:TimeOffStatus.APPROVED,},{field:"endsAt",operator:"lt",value: today,},],// ... 기타 유형들};
상태 및 날짜에 기반하여 유급 휴가를 가져오기 위한 기준을 정의합니다.
history: 이미 종료된 승인된 유급 휴가를 가져옵니다.
upcoming: 곧 시작될 승인된 유급 휴가를 가져옵니다.
정렬기:
constsorters:Record<Props["type"],CrudSort[]>={history:[{field:"startsAt",order:"desc"}],// ... 기타 유형들};
가져온 데이터의 순서를 결정합니다.
history: 시작일 기준으로 내림차순 정렬합니다.
사용된 유급 휴가의 통계를 표시하기 위한 <TimeOffLeaveCards /> 컴포넌트 구축
src/components/time-offs 폴더에 leave-cards.tsx 파일을 만들고 다음 코드를 추가하십시오:
src/components/time-offs/leave-cards.tsx
import{ useGetIdentity, useList }from"@refinedev/core";import{Box,Grid,Skeleton,Typography}from"@mui/material";import{AnnualLeaveIcon,CasualLeaveIcon,SickLeaveIcon}from"@/icons";import{
type Employee,TimeOffStatus,TimeOffType,
type TimeOff,}from"@/types";
type Props={
employeeId?: number;};exportconstTimeOffLeaveCards=(props:Props)=>{const{data: employee,isLoading: isLoadingEmployee }=
useGetIdentity<Employee>({queryOptions:{enabled:!props.employeeId,},});
const {data: timeOffsSick,isLoading: isLoadingTimeOffsSick } =
useList<TimeOff>({resource:"time-offs",// we only need total number of sick leaves, so we can set pageSize to 1 to reduce the loadpagination:{pageSize:1},filters:[{field:"status",operator:"eq",value:TimeOffStatus.APPROVED,},{field:"timeOffType",operator:"eq",value:TimeOffType.SICK,},{field:"employeeId",operator:"eq",value: employee?.id,},],queryOptions:{enabled:!!employee?.id,},});
const {data: timeOffsCasual,isLoading: isLoadingTimeOffsCasual } =
useList<TimeOff>({resource:"time-offs",// we only need total number of sick leaves, so we can set pageSize to 1 to reduce the loadpagination:{pageSize:1},filters:[{field:"status",operator:"eq",value:TimeOffStatus.APPROVED,},{field:"timeOffType",operator:"eq",value:TimeOffType.CASUAL,},{field:"employeeId",operator:"eq",value: employee?.id,},],queryOptions:{enabled:!!employee?.id,},});
const loading =
isLoadingEmployee || isLoadingTimeOffsSick || isLoadingTimeOffsCasual;
return (
<Gridcontainerspacing="24px"><Griditemxs={12}sm={4}><Cardloading={loading}type="annual"value={employee?.availableAnnualLeaveDays ||0}/></Grid><Griditemxs={12}sm={4}><Cardloading={loading}type="sick"value={timeOffsSick?.total ||0}/></Grid><Griditemxs={12}sm={4}><Cardloading={loading}type="casual"value={timeOffsCasual?.total ||0}/></Grid></Grid>
);
};
const variantMap = {annual:{label:"Annual Leave",description:"Days available",bgColor:"primary.50",titleColor:"primary.900",descriptionColor:"primary.700",iconColor:"primary.700",icon:<AnnualLeaveIcon/>,},sick:{label:"Sick Leave",description:"Days used",bgColor:"#FFF7ED",titleColor:"#7C2D12",descriptionColor:"#C2410C",iconColor:"#C2410C",icon:<SickLeaveIcon/>,},casual:{label:"Casual Leave",description:"Days used",bgColor:"grey.50",titleColor:"grey.900",descriptionColor:"grey.700",iconColor:"grey.700",icon:<CasualLeaveIcon/>,},};
const Card = (props: {type:"annual"|"sick"|"casual";value: number;
loading?: boolean;}) => {return(<Boxsx={{backgroundColor: variantMap[props.type].bgColor,padding:"24px",borderRadius:"12px",}}><Boxsx={{display:"flex",alignItems:"center",justifyContent:"space-between",}}><Typographyvariant="h6"sx={{color: variantMap[props.type].titleColor,fontSize:"16px",fontWeight:500,lineHeight:"24px",}}>{variantMap[props.type].label}</Typography><Boxsx={{color: variantMap[props.type].iconColor,}}>{variantMap[props.type].icon}</Box></Box><Boxsx={{marginTop:"8px",display:"flex",flexDirection:"column"}}>{props.loading?(<Boxsx={{width:"40%",height:"32px",display:"flex",alignItems:"center",justifyContent:"center",}}><Skeletonvariant="rounded"sx={{width:"100%",height:"20px",}}/></Box>):(<Typographyvariant="caption"sx={{color: variantMap[props.type].descriptionColor,fontSize:"24px",lineHeight:"32px",fontWeight:600,}}>{props.value}</Typography>)}<Typographyvariant="body1"sx={{color: variantMap[props.type].descriptionColor,fontSize:"12px",lineHeight:"16px",}}>{variantMap[props.type].description}</Typography></Box></Box>);};
<TimeOffLeaveCards />
<TimeOffLeaveCards /> 컴포넌트는 직원의 휴가에 관한 통계를 표시합니다. 연차 휴가, 병가, 비정기 휴가에 대한 세 가지 카드를 보여주며 사용 가능한 날짜나 사용된 날짜를 나타냅니다.
컴포넌트의 주요 부분을 살펴보겠습니다:
1. 데이터 가져오기
직원 데이터: 현재 직원의 정보(예: 사용 가능한 연차 휴가 일수)를 가져오기 위해 useGetIdentity를 사용합니다.
휴가 일수: 직원이 사용한 총 병가 및 비정기 휴가 일수를 가져오기 위해 useList를 사용합니다. 모든 세부 정보가 아닌 총 수만 필요하기 때문에 pageSize를 1로 설정합니다.
2. 카드 표시
이 컴포넌트는 세 가지 휴가 유형 각각에 대한 카드 컴포넌트를 렌더링합니다.
각 카드는 다음을 표시합니다:
휴가 유형(예: 연차 휴가).
사용 가능한 날짜 수 또는 사용된 날짜 수.
휴가 유형을 나타내는 아이콘.
3. 로딩 상태 처리
데이터가 여전히 로딩 중인 경우, 실제 숫자 대신 스켈레톤 플레이스홀더가 표시됩니다.
이 상태를 관리하기 위해 loading 속성이 카드에 전달됩니다.
4. 카드 구성 요소
type, value, 및 loading을 속성으로 받습니다.
variantMap을 사용하여 휴가 유형에 따라 올바른 레이블, 색상, 아이콘을 가져옵니다.
적절한 스타일링으로 휴가 정보를 표시합니다.
<PageEmployeeTimeOffsList /> 구축
휴가 목록 및 휴가 카드를 표시하기 위한 구성 요소가 준비되었으니, src/pages/employee/time-offs/ 폴더에 list.tsx라는 새 파일을 생성하고 다음 코드를 추가합니다:
src/pages/time-off.tsx
import{CanAccess, useCan }from"@refinedev/core";import{CreateButton}from"@refinedev/mui";import{Box,Grid}from"@mui/material";import{PageHeader}from"@/components/layout/page-header";import{TimeOffList}from"@/components/time-offs/list";import{TimeOffLeaveCards}from"@/components/time-offs/leave-cards";import{TimeOffIcon}from"@/icons";import{ThemeProvider}from"@/providers/theme-provider";import{Role}from"@/types";exportconstPageEmployeeTimeOffsList=()=>{const{data: useCanData }=useCan({action:"manager",params:{resource:{name:"time-offs",meta:{scope:"manager",},},},});const isManager = useCanData?.can;return(<ThemeProviderrole={isManager ?Role.MANAGER:Role.EMPLOYEE}><Box><PageHeadertitle="Time Off"rightSlot={<CreateButtonsize="large"variant="contained"startIcon={<TimeOffIcon/>}><CanAccessaction="manager"fallback="Request Time Off">
Assign Time Off
</CanAccess></CreateButton>}/><TimeOffLeaveCards/><Gridcontainerspacing="24px"sx={{marginTop:"24px",}}><Griditemxs={12}md={6}><Boxsx={{display:"flex",flexDirection:"column",gap:"24px",}}><TimeOffListtype="inReview"/><TimeOffListtype="upcoming"/></Box></Grid><Griditemxs={12}md={6}><TimeOffListtype="history"/></Grid></Grid></Box></ThemeProvider>);};
<PageEmployeeTimeOffsList />는 휴가 페이지의 주요 구성 요소로, 사용자가 /employee/time-offs 경로로 이동할 때 휴가 목록과 휴가 카드를 표시하기 위해 이 구성 요소를 사용할 것입니다.
우리는 중첩된 경로를 사용하여 직원 페이지를 구성합니다. 먼저, 직원별 테마와 레이아웃으로 콘텐츠를 감싸는 path='employee'를 가진 주 경로를 생성합니다. 이 경로 안에 path='time-offs'를 추가하여 PageEmployeeTimeOffsList 컴포넌트를 표시합니다. 이 구조는 모든 직원 기능을 하나의 경로 아래에 그룹화하고 스타일을 일관되게 유지합니다.
이러한 변경을 추가한 후에는 /employee/time-offs 경로로 이동하여 시간이 지난 목록 페이지를 확인할 수 있습니다.
/employee/time-offs
현재 시간이 지난 목록 페이지는 기능적이지만 새로운 휴가 요청을 만들 수 있는 기능이 없습니다. 새로운 휴가 요청을 만들 수 있는 기능을 추가해 봅시다.
요청 또는 휴가 일정 지정을 위한 새 페이지를 생성할 것입니다. 이 페이지에는 사용자가 휴가 유형, 시작 및 종료 날짜, 그리고 추가 사항을 지정할 수 있는 양식이 포함될 것입니다.
시작하기 전에, 양식에서 사용할 새 구성 요소를 만들어야 합니다:
<TimeOffFormSummary /> 구성 요소 작성
src/components/time-offs/ 폴더에 form-summary.tsx라는 새 파일을 만들고 다음 코드를 추가하세요:
src/components/time-offs/form-summary.tsx
import{Box,Divider,Typography}from"@mui/material";
type Props={availableAnnualDays: number;requestedDays: number;};exportconstTimeOffFormSummary=(props:Props)=>{const remainingDays = props.availableAnnualDays- props.requestedDays;return(<Boxsx={{display:"flex",flexDirection:"column",alignItems:"flex-end",gap:"16px",whiteSpace:"nowrap",}}><Boxsx={{display:"flex",gap:"16px",}}><Typographyvariant="body2"color="text.secondary">
Available Annual Leave Days:
</Typography><Typographyvariant="body2">{props.availableAnnualDays}</Typography></Box><Boxsx={{display:"flex",gap:"16px",}}><Typographyvariant="body2"color="text.secondary">
Requested Days:
</Typography><Typographyvariant="body2">{props.requestedDays}</Typography></Box><Dividersx={{width:"100%",}}/><Boxsx={{display:"flex",gap:"16px",height:"40px",}}><Typographyvariant="body2"color="text.secondary">
Remaining Days:
</Typography><Typographyvariant="body2"fontWeight={500}>{remainingDays}</Typography></Box></Box>);};
<TimeOffFormSummary />
<TimeOffFormSummary /> 구성 요소는 휴가 요청의 요약을 표시합니다. 사용 가능한 연차 휴가 일수, 요청된 일수, 남은 일수를 보여줍니다. 이 구성 요소를 사용하여 사용자에게 요청 사항에 대한 명확한 개요를 제공할 것입니다.
<PageEmployeeTimeOffsCreate /> 구성 요소 작성
src/pages/employee/time-offs/ 폴더에 create.tsx라는 새 파일을 만들고 다음 코드를 추가하세요:
src/pages/time-offs/create.tsx
import{ useCan, useGetIdentity, type HttpError}from"@refinedev/core";import{ useForm }from"@refinedev/react-hook-form";import{Controller}from"react-hook-form";import type {DateRange}from"@mui/x-date-pickers-pro/models";import{Box,Button,MenuItem,Select,Typography}from"@mui/material";importdayjsfrom"dayjs";import{PageHeader}from"@/components/layout/page-header";import{InputText}from"@/components/input/text";import{LoadingOverlay}from"@/components/loading-overlay";import{InputDateStartsEnds}from"@/components/input/date-starts-ends";import{TimeOffFormSummary}from"@/components/time-offs/form-summary";import{ThemeProvider}from"@/providers/theme-provider";import{
type Employee,
type TimeOff,TimeOffType,TimeOffStatus,Role,}from"@/types";import{CheckRectangleIcon}from"@/icons";
type FormValues=Omit<TimeOff,"id"|"notes">&{notes: string;dates:DateRange<dayjs.Dayjs>;
};
export const PageEmployeeTimeOffsCreate = () => {const{data: useCanData }=useCan({action:"manager",params:{resource:{name:"time-offs",meta:{scope:"manager",},},},});const isManager = useCanData?.can;const{data: employee }=
useGetIdentity<Employee>();
const {refineCore:{ formLoading, onFinish },...formMethods
} = useForm<TimeOff, HttpError, FormValues>({defaultValues:{timeOffType:TimeOffType.ANNUAL,notes:"",dates:[null,null],},refineCoreProps:{successNotification:()=>{return{message: isManager
?"Time off assigned":"Your time off request is submitted for review.",type:"success",};},},});
const { control, handleSubmit, formState, watch } = formMethods;
const onFinishHandler = async (values: FormValues) => {constpayload:FormValues={...values,startsAt:dayjs(values.dates[0]).format("YYYY-MM-DD"),endsAt:dayjs(values.dates[1]).format("YYYY-MM-DD"),...(isManager &&{status:TimeOffStatus.APPROVED,}),};awaitonFinish(payload);};
const timeOffType = watch("timeOffType");
const selectedDays = watch("dates");
const startsAt = selectedDays[0];
const endsAt = selectedDays[1];
const availableAnnualDays = employee?.availableAnnualLeaveDays ?? 0;
const requestedDays =
startsAt && endsAt ? endsAt.diff(startsAt, "day") + 1 : 0;
return (
<ThemeProviderrole={isManager ?Role.MANAGER:Role.EMPLOYEE}><LoadingOverlayloading={formLoading}><Box><PageHeadertitle={isManager ?"Assign Time Off":"Request Time Off"}showListButtonshowDivider/><Boxcomponent="form"onSubmit={handleSubmit(onFinishHandler)}sx={{display:"flex",flexDirection:"column",gap:"24px",marginTop:"24px",}}><Box><Typographyvariant="body2"sx={{mb:"8px",}}>
Time Off Type
</Typography>
<Controller
name="timeOffType"
control={control}
render={({ field })=>(<Select{...field}size="small"sx={{minWidth:"240px",height:"40px","& .MuiSelect-select":{paddingBlock:"10px",},}}><MenuItemvalue={TimeOffType.ANNUAL}>Annual Leave</MenuItem><MenuItemvalue={TimeOffType.CASUAL}>Casual Leave</MenuItem><MenuItemvalue={TimeOffType.SICK}>Sick Leave</MenuItem></Select>)}
/>
</Box><Box><Typographyvariant="body2"sx={{mb:"16px",}}>
Requested Dates
</Typography>
<Controller
name="dates"
control={control}
rules={{validate:(value)=>{if(!value[0]||!value[1]){return"Please select both start and end dates";}returntrue;},}}
render={({ field })=>{return(<Box
sx={{display:"grid",gridTemplateColumns:()=>{return{sm:"1fr",lg:"628px 1fr",};},gap:"40px",}}><InputDateStartsEnds{...field}error={formState.errors.dates?.message}availableAnnualDays={availableAnnualDays}requestedDays={requestedDays}/>{timeOffType ===TimeOffType.ANNUAL&&(<Box
sx={{display:"flex",maxWidth:"628px",alignItems:()=>{return{lg:"flex-end",};},justifyContent:()=>{return{xs:"flex-end",lg:"flex-start",};},}}><TimeOffFormSummaryavailableAnnualDays={availableAnnualDays}requestedDays={requestedDays}/></Box>
)}
</Box>
);
}}
/>
</Box><Boxsx={{maxWidth:"628px",}}><Controllername="notes"control={control}render={({ field, fieldState })=>{return(<InputText{...field}label="Notes"error={fieldState.error?.message}placeholder="Place enter your notes"multilinerows={3}/>);}}/></Box><Buttonvariant="contained"size="large"type="submit"startIcon={isManager ?<CheckRectangleIcon/>:undefined}>{isManager ?"Assign":"Send Request"}</Button></Box></Box></LoadingOverlay></ThemeProvider>
);
};
<PageEmployeeTimeOffsCreate />
<PageEmployeeTimeOffsCreate /> 구성 요소는 HR 관리 앱에서 새로운 휴가 요청을 생성하는 양식을 표시합니다. 직원 및 관리자 모두 이를 사용하여 휴가를 요청하거나 지정할 수 있습니다. 양식에는 휴가 유형을 선택하고 시작 및 종료 날짜를 선택하는 옵션이 포함되어 있으며, 메모를 추가할 수 있습니다. 또한 요청된 휴가의 요약을 표시합니다.
useCan 훅을 사용하여 현재 사용자가 관리자 권한을 가지고 있는지 확인합니다. 이는 사용자가 휴가를 할당할 수 있는지 아니면 요청만 할 수 있는지를 결정합니다. 사용자의 역할에 따라 onFinishHandler에서 양식 제출을 다르게 처리할 것입니다.
2. 양식 상태 및 제출
const{refineCore:{ formLoading, onFinish },...formMethods
}= useForm<TimeOff,HttpError,FormValues>({defaultValues:{timeOffType:TimeOffType.ANNUAL,notes:"",dates:[null,null],},refineCoreProps:{successNotification:()=>{return{message: isManager
?"Time off assigned":"Your time off request is submitted for review.",type:"success",};},},});const{ control, handleSubmit, formState, watch }= formMethods;constonFinishHandler=async(values:FormValues)=>{constpayload:FormValues={...values,startsAt:dayjs(values.dates[0]).format("YYYY-MM-DD"),endsAt:dayjs(values.dates[1]).format("YYYY-MM-DD"),...(isManager &&{status:TimeOffStatus.APPROVED,}),};awaitonFinish(payload);};
useForm은 사용자의 역할에 따라 기본값으로 양식을 초기화하고 성공 알림을 설정합니다. onFinishHandler 함수는 양식 데이터를 제출하기 전에 처리합니다. 관리자의 경우, 상태가 즉시 APPROVED로 설정되지만, 직원의 요청은 검토를 위해 제출됩니다.
<RequestsList /> 구성 요소는 무한 스크롤을 사용하여 휴가 요청 목록을 표시합니다. 로딩 인디케이터, 스켈레톤 자리 표시자 및 데이터가 없을 때 메시지를 포함합니다. 이 구성 요소는 대규모 데이터 세트를 효율적으로 처리하고 원활한 사용자 경험을 제공하도록 설계되었습니다.
<RequestsListItem /> 컴포넌트 만들기
src/components/requests/ 폴더에 list-item.tsx라는 새 파일을 만들고 다음 코드를 추가하세요:
<RequestsListItem /> 컴포넌트는 목록에서 단일 휴가 요청을 표시합니다. 여기에는 직원의 아바타, 이름, 설명 및 요청 세부정보를 보기 위한 버튼이 포함됩니다. 이 컴포넌트는 재사용 가능하며 휴가 요청 목록의 각 항목을 렌더링하는 데 사용할 수 있습니다.
<PageManagerRequestsList /> 컴포넌트 만들기
src/pages/manager/requests/ 폴더에 list.tsx라는 새 파일을 만들고 다음 코드를 추가하세요:
import type {PropsWithChildren}from"react";import{ useGo, useInfiniteList }from"@refinedev/core";import{Box,Typography}from"@mui/material";importdayjsfrom"dayjs";import{Frame}from"@/components/frame";import{PageHeader}from"@/components/layout/page-header";import{RequestsListItem}from"@/components/requests/list-item";import{RequestsList}from"@/components/requests/list";import{ indigo }from"@/providers/theme-provider/colors";import{TimeOffIcon,RequestTypeIcon,NoTimeOffIcon}from"@/icons";import{TimeOffStatus, type Employee, type TimeOff}from"@/types";exportconstPageManagerRequestsList=({ children }:PropsWithChildren)=>{return(<><Box><PageHeadertitle="Awaiting Requests"/><TimeOffsList/></Box>{children}</>);};constTimeOffsList=()=>{const go =useGo();const{data: timeOffsData,isLoading: timeOffsLoading,fetchNextPage: timeOffsFetchNextPage,hasNextPage: timeOffsHasNextPage,}= useInfiniteList<TimeOff&{employee:Employee;}>({resource:"time-offs",filters:[{field:"status",operator:"eq",value:TimeOffStatus.PENDING},],sorters:[{field:"createdAt",order:"desc"}],meta:{join:["employee"],},});const timeOffs = timeOffsData?.pages.flatMap((page)=> page.data)||[];const totalCount = timeOffsData?.pages[0].total;return(<Frametitle="Time off Requests"titleSuffix={!!totalCount &&
totalCount >0&&(<Boxsx={{padding:"4px",display:"flex",alignItems:"center",justifyContent:"center",minWidth:"24px",height:"24px",borderRadius:"4px",backgroundColor: indigo[100],}}><Typographyvariant="caption"sx={{color: indigo[500],fontSize:"12px",lineHeight:"16px",}}>{totalCount}</Typography></Box>)}icon={<TimeOffIconwidth={24}height={24}/>}sx={{flex:1,paddingBottom:"0px",}}sxChildren={{padding:0,}}><RequestsListloading={timeOffsLoading}dataLength={timeOffs.length}hasMore={timeOffsHasNextPage ||false}next={timeOffsFetchNextPage}scrollableTarget="scrollableDiv-timeOffs"noDataText="No time off requests right now."noDataIcon={<NoTimeOffIcon/>}>{timeOffs.map((timeOff)=>{const date =dayjs(timeOff.createdAt).fromNow();const fullName =`${timeOff.employee.firstName}${timeOff.employee.lastName}`;const avatarURL = timeOff.employee.avatarUrl;const requestedDay =dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt),"day")+1;const description =`Requested ${requestedDay}${
requestedDay >1?"days":"day"} of time ${timeOff.timeOffType.toLowerCase()} leave.`;return(<RequestsListItem
key={timeOff.id}
date={date}
avatarURL={avatarURL}
title={fullName}
showTimeSince
descriptionIcon={<RequestTypeIcontype={timeOff.timeOffType}/>}
description={description}
onClick={()=>{go({type:"replace",to:{resource:"requests",id: timeOff.id,action:"edit",},});}}/>);})}</RequestsList></Frame>);};
<PageManagerRequestsList /> 컴포넌트는 관리자가 승인해야 하는 보류 중인 휴가 요청을 표시합니다. 직원의 이름, 휴가 유형, 요청 날짜 및 요청이 이루어진 시점 등을 보여줍니다. 관리자는 요청을 클릭하여 더 많은 세부정보를 볼 수 있습니다. 이 컴포넌트는 <RequestsList /> 및 <RequestsListItem />을 사용하여 목록을 렌더링합니다.
위 코드는 특정 자식 경로로 이동할 때 모달이 표시되는 중첩 경로 구조를 설정합니다. <PageManagerRequestsTimeOffsEdit /> 컴포넌트는 모달이며 <PageManagerRequestsList /> 컴포넌트의 자식으로 렌더링됩니다. 이 구조를 통해 목록 페이지를 배경에 유지하면서 목록 페이지 위에 모달을 표시할 수 있습니다.
/manager/requests/:id/edit 경로로 이동하거나 목록에서 휴가 요청을 클릭하면 휴가 요청 세부정보 페이지가 목록 페이지 위에 모달로 표시됩니다.
인증은 기업 수준 애플리케이션에서 중요한 구성 요소이며, 보안과 운영 효율성 모두에 중요한 역할을 합니다. 이는 허가된 사용자만 특정 리소스에 액세스할 수 있도록 보장하여 민감한 데이터와 기능을 보호합니다. Refine의 인가 시스템은 리소스를 보호하고 사용자가 응용 프로그램과 안전하고 통제된 방식으로 상호 작용할 수 있도록 보장하는 필수 인프라를 제공합니다. 이 단계에서는 휴가 요청 관리 기능에 대한 권한 및 액세스 제어를 구현할 것입니다. /manager/requests 및 /manager/requests/:id/edit 경로로의 액세스를 <CanAccess /> 구성 요소의 도움으로 관리자만 제한할 것입니다.
현재 직원으로 로그인하면 사이드바에 Requests 페이지 링크가 보이지 않지만 브라우저의 URL을 입력하여 /manager/requests 경로에는 여전히 액세스할 수 있습니다. 이러한 경로로의 무단 액세스를 방지하는 가드를 추가할 것입니다.
위의 코드에서 “manager” 경로에 <CanAccess /> 구성 요소를 추가했습니다. 이 구성 요소는 사용자가 “manager” 역할을 가지고 있는지 확인한 후 자식 경로를 렌더링합니다. 사용자가 “manager” 역할을 가지고 있지 않은 경우, 직원용 휴가 목록 페이지로 리디렉션됩니다.
이제 직원으로 로그인하고 /manager/requests 경로에 접근하려고 하면, 직원의 휴가 목록 페이지로 리디렉션됩니다.
이 과정에서 React 애플리케이션을 가져와 DigitalOcean의 App 플랫폼을 통해 배포할 준비를 합니다. GitHub 저장소를 DigitalOcean에 연결하고, 앱이 어떻게 빌드될지를 구성한 다음, 프로젝트의 초기 배포를 생성합니다. 프로젝트가 배포된 후에 추가로 변경하는 사항은 자동으로 재빌드되고 업데이트됩니다.
이 단계가 끝나면 지속적인 배달이 제공되는 DigitalOcean에 애플리케이션이 배포됩니다.
DigitalOcean 계정에 로그인하고 앱 페이지로 이동합니다. 앱 생성 버튼을 클릭합니다:
GitHub 계정을 DigitalOcean에 연결하지 않았다면 연결하라는 메시지가 나타납니다. GitHub에 연결 버튼을 클릭하세요. 새 창이 열리면 DigitalOcean이 GitHub 계정에 액세스하도록 허용할지 묻습니다.
DigitalOcean을 허용하면 DigitalOcean 앱 페이지로 리디렉션됩니다. 다음 단계는 GitHub 저장소를 선택하는 것입니다. 저장소를 선택한 후 배포할 브랜치를 선택하라는 메시지가 나타납니다. main 브랜치를 선택하고 다음 버튼을 클릭하세요.
이후에 애플리케이션의 구성 단계가 표시됩니다. 이 튜토리얼에서는 구성 단계를 건너뛰기 위해 다음 버튼을 클릭할 수 있습니다. 그러나 원하는대로 애플리케이션을 구성할 수도 있습니다.
빌드가 완료될 때까지 기다리세요. 빌드가 완료되면 실시간 앱을 눌러 브라우저에서 프로젝트에 액세스할 수 있습니다. 이는 로컬에서 테스트한 프로젝트와 동일하지만 안전한 URL로 웹 상에 실시간으로 제공됩니다. 또한 DigitalOcean 커뮤니티 사이트에서 제공하는 이 튜토리얼을 따라 React 기반 애플리케이션을 App Platform에 배포하는 방법을 배울 수 있습니다.
참고: 빌드가 성공적으로 배포되지 않는 경우 DigitalOcean에서 빌드 명령을 npm install --production=false && npm run build && npm prune --production로 설정하여 npm run build 대신 사용할 수 있습니다.