- UniversalListPage에 externalIsLoading prop 추가 - CardTransactionDetailClient DevFill 자동입력 기능 추가 - 여러 컴포넌트 로딩 상태 처리 개선 - skeleton 컴포넌트 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
import { CalendarDays, Loader2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Input } from '@/components/ui/input';
|
|
import { QuantityInput } from '@/components/ui/quantity-input';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { toast } from 'sonner';
|
|
import { getLeavePolicy, updateLeavePolicy } from './actions';
|
|
import {
|
|
type LeavePolicySettings,
|
|
type LeaveStandardType,
|
|
DEFAULT_LEAVE_POLICY,
|
|
MONTH_OPTIONS,
|
|
DAY_OPTIONS,
|
|
} from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
export function LeavePolicyManagement() {
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [settings, setSettings] = useState<LeavePolicySettings>(DEFAULT_LEAVE_POLICY);
|
|
|
|
// 데이터 로드
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await getLeavePolicy();
|
|
if (result.success && result.data) {
|
|
setSettings(result.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load leave policy:', error);
|
|
toast.error('휴가 정책을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 초기 로드
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
const result = await updateLeavePolicy(settings);
|
|
if (result.success) {
|
|
toast.success('휴가 정책이 저장되었습니다.');
|
|
if (result.data) {
|
|
setSettings(result.data);
|
|
}
|
|
} else {
|
|
toast.error(result.error || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save leave policy:', error);
|
|
toast.error('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// 필드 업데이트
|
|
const updateField = <K extends keyof LeavePolicySettings>(
|
|
field: K,
|
|
value: LeavePolicySettings[K]
|
|
) => {
|
|
setSettings(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="휴가관리"
|
|
description="휴가 정책을 관리합니다"
|
|
icon={CalendarDays}
|
|
/>
|
|
<ContentSkeleton type="form" rows={6} />
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageLayout>
|
|
{/* 헤더 */}
|
|
<PageHeader
|
|
title="휴가관리"
|
|
description="휴가 정책을 관리합니다"
|
|
icon={CalendarDays}
|
|
/>
|
|
|
|
{/* 저장 버튼 */}
|
|
<div className="flex justify-end mb-4">
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
저장
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* 기본 정보 카드 */}
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<h3 className="text-lg font-semibold mb-6">기준 설정</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
{/* 기준 셀렉트 */}
|
|
<div className="space-y-2">
|
|
<Label>기준</Label>
|
|
<Select
|
|
value={settings.standardType}
|
|
onValueChange={(v: LeaveStandardType) => updateField('standardType', v)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="fiscal">회계연도</SelectItem>
|
|
<SelectItem value="hire">입사일</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 기준일 셀렉트 (월/일) */}
|
|
<div className="space-y-2">
|
|
<Label>기준일</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={settings.fiscalStartMonth.toString()}
|
|
onValueChange={(v) => updateField('fiscalStartMonth', parseInt(v))}
|
|
disabled={settings.standardType === 'hire'}
|
|
>
|
|
<SelectTrigger className="w-24">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MONTH_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value.toString()}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={settings.fiscalStartDay.toString()}
|
|
onValueChange={(v) => updateField('fiscalStartDay', parseInt(v))}
|
|
disabled={settings.standardType === 'hire'}
|
|
>
|
|
<SelectTrigger className="w-24">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DAY_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value.toString()}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 안내 문구 */}
|
|
<div className="mt-6 text-sm text-muted-foreground space-y-1">
|
|
<p>! 휴가 기준일 설정에 따라서 휴가 조회 범위 및 자동 휴가 부여 정책의 기본 값이 변경됩니다.</p>
|
|
<ul className="list-disc list-inside ml-2 space-y-1">
|
|
<li>입사일 기준: 사원의 입사일 기준으로 휴가를 부여하고 조회할 수 있습니다.</li>
|
|
<li>회계연도 기준: 회사의 회계연도 기준으로 휴가를 부여하고 조회할 수 있습니다.</li>
|
|
</ul>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 연차 설정 카드 */}
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<h3 className="text-lg font-semibold mb-6">연차 설정</h3>
|
|
|
|
<div className="grid grid-cols-3 gap-6">
|
|
{/* 기본 연차 일수 */}
|
|
<div className="space-y-2">
|
|
<Label>기본 연차 일수</Label>
|
|
<div className="flex items-center gap-2">
|
|
<QuantityInput
|
|
min={0}
|
|
max={100}
|
|
value={settings.defaultAnnualLeave}
|
|
onChange={(value) => updateField('defaultAnnualLeave', value ?? 0)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">일</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 근속년수당 추가 연차 */}
|
|
<div className="space-y-2">
|
|
<Label>근속년수당 추가 연차</Label>
|
|
<div className="flex items-center gap-2">
|
|
<QuantityInput
|
|
min={0}
|
|
max={10}
|
|
value={settings.additionalLeavePerYear}
|
|
onChange={(value) => updateField('additionalLeavePerYear', value ?? 0)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">일</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 최대 연차 일수 */}
|
|
<div className="space-y-2">
|
|
<Label>최대 연차 일수</Label>
|
|
<div className="flex items-center gap-2">
|
|
<QuantityInput
|
|
min={0}
|
|
max={100}
|
|
value={settings.maxAnnualLeave}
|
|
onChange={(value) => updateField('maxAnnualLeave', value ?? 0)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">일</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 안내 문구 */}
|
|
<div className="mt-6 text-sm text-muted-foreground">
|
|
<p>! 신규 입사자에게 기본 연차가 부여되며, 근속년수에 따라 추가 연차가 부여됩니다.</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 이월 설정 카드 */}
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<h3 className="text-lg font-semibold mb-6">이월 설정</h3>
|
|
|
|
<div className="space-y-6">
|
|
{/* 이월 허용 여부 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label>연차 이월 허용</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
미사용 연차를 다음 해로 이월할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.carryOverEnabled}
|
|
onCheckedChange={(checked) => updateField('carryOverEnabled', checked)}
|
|
/>
|
|
</div>
|
|
|
|
{settings.carryOverEnabled && (
|
|
<div className="grid grid-cols-2 gap-6 pt-4 border-t">
|
|
{/* 최대 이월 일수 */}
|
|
<div className="space-y-2">
|
|
<Label>최대 이월 일수</Label>
|
|
<div className="flex items-center gap-2">
|
|
<QuantityInput
|
|
min={0}
|
|
max={100}
|
|
value={settings.carryOverMaxDays}
|
|
onChange={(value) => updateField('carryOverMaxDays', value ?? 0)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">일</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 이월 연차 소멸 기간 */}
|
|
<div className="space-y-2">
|
|
<Label>이월 연차 소멸 기간</Label>
|
|
<div className="flex items-center gap-2">
|
|
<QuantityInput
|
|
min={0}
|
|
max={24}
|
|
value={settings.carryOverExpiryMonths}
|
|
onChange={(value) => updateField('carryOverExpiryMonths', value ?? 0)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">개월</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 안내 문구 */}
|
|
{settings.carryOverEnabled && (
|
|
<div className="text-sm text-muted-foreground">
|
|
<p>! 이월된 연차는 설정된 기간 내에 사용하지 않으면 자동으로 소멸됩니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|