feat(WEB): 권한 관리 시스템 구현 및 상세 페이지 권한 통합
- PermissionContext, usePermission 훅, PermissionGuard 컴포넌트 신규 추가 - AccessDenied 접근 거부 페이지 추가 - permissions lib (체커, 매퍼, 타입) 구현 - BadDebtDetail, BoardDetail, LaborDetail, PricingDetail 등 상세 페이지 권한 적용 - ProcessDetail, StepDetail, ItemDetail, PermissionDetail 권한 연동 - RootProvider에 PermissionProvider 통합 - protected layout 권한 체크 추가 - Claude 프로젝트 설정 파일 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
||||
} from './types';
|
||||
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface BadDebtDetailProps {
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
@@ -95,6 +96,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
|
||||
|
||||
export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProps) {
|
||||
const router = useRouter();
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
@@ -346,12 +348,16 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -365,7 +371,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode]);
|
||||
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode, canUpdate, canDelete]);
|
||||
|
||||
// 입력 필드 렌더링 헬퍼
|
||||
const renderField = (
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
|
||||
interface BoardDetailProps {
|
||||
board: Board;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷
|
||||
@@ -100,14 +100,18 @@ export function BoardDetail({ board, onEdit, onDelete }: BoardDetailProps) {
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ErrorCard } from '@/components/ui/error-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
type DetailMode = 'view' | 'edit' | 'create';
|
||||
|
||||
@@ -40,6 +41,7 @@ const generateBoardCode = (): string => {
|
||||
export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
@@ -268,8 +270,8 @@ export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV
|
||||
<>
|
||||
<BoardDetail
|
||||
board={boardData}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onEdit={canUpdate ? handleEdit : undefined}
|
||||
onDelete={canDelete ? handleDelete : undefined}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -45,6 +46,7 @@ export default function LaborDetailClient({
|
||||
}: LaborDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
|
||||
// 모드 상태
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
||||
@@ -410,18 +412,22 @@ export default function LaborDetailClient({
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEditMode}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -69,6 +70,7 @@ const initialFormData: FormData = {
|
||||
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate, canDelete } = usePermission();
|
||||
const [pricing, setPricing] = useState<Pricing | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
|
||||
@@ -403,18 +405,22 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
<div className="flex items-center gap-2">
|
||||
{isViewMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditMode && (
|
||||
|
||||
67
src/components/common/AccessDenied.tsx
Normal file
67
src/components/common/AccessDenied.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShieldOff, ArrowLeft, Home } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface AccessDeniedProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
showBackButton?: boolean;
|
||||
showHomeButton?: boolean;
|
||||
}
|
||||
|
||||
export function AccessDenied({
|
||||
title = '접근 권한이 없습니다',
|
||||
description = '이 페이지에 대한 접근 권한이 없습니다. 관리자에게 문의하세요.',
|
||||
showBackButton = true,
|
||||
showHomeButton = true,
|
||||
}: AccessDeniedProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg border border-border/20 bg-card/50 backdrop-blur">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-amber-500/20 to-orange-500/10 rounded-2xl flex items-center justify-center">
|
||||
<ShieldOff className="w-10 h-10 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-xl md:text-2xl font-bold text-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="text-center space-y-6">
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-2">
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showHomeButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
대시보드로 이동
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/common/PermissionGuard.tsx
Normal file
50
src/components/common/PermissionGuard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import type { PermissionAction } from '@/lib/permissions/types';
|
||||
|
||||
interface PermissionGuardProps {
|
||||
action: PermissionAction;
|
||||
/** 다른 메뉴 권한 체크 시 URL 직접 지정 (생략하면 현재 URL 자동 매칭) */
|
||||
url?: string;
|
||||
/** 권한 없을 때 대체 UI (기본: 렌더링 안 함) */
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼/영역 레벨 권한 제어 컴포넌트
|
||||
*
|
||||
* @example
|
||||
* // 현재 페이지 기준 (URL 자동매칭)
|
||||
* <PermissionGuard action="delete">
|
||||
* <Button variant="destructive">삭제</Button>
|
||||
* </PermissionGuard>
|
||||
*
|
||||
* // 다른 메뉴 권한 체크
|
||||
* <PermissionGuard action="approve" url="/approval/inbox">
|
||||
* <Button>승인</Button>
|
||||
* </PermissionGuard>
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
action,
|
||||
url,
|
||||
fallback = null,
|
||||
children,
|
||||
}: PermissionGuardProps) {
|
||||
const permission = usePermission(url);
|
||||
|
||||
const actionMap: Record<PermissionAction, boolean> = {
|
||||
view: permission.canView,
|
||||
create: permission.canCreate,
|
||||
update: permission.canUpdate,
|
||||
delete: permission.canDelete,
|
||||
approve: permission.canApprove,
|
||||
};
|
||||
|
||||
if (!actionMap[action]) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calenda
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -96,6 +97,7 @@ function getStorageUrl(path: string | undefined): string | null {
|
||||
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
@@ -625,13 +627,15 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { getProcessSteps } from './actions';
|
||||
import type { Process, ProcessStep } from '@/types/process';
|
||||
|
||||
@@ -28,6 +29,7 @@ interface ProcessDetailProps {
|
||||
export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
|
||||
// 단계 목록 상태
|
||||
const [steps, setSteps] = useState<ProcessStep[]>([]);
|
||||
@@ -325,10 +327,12 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import type { ProcessStep } from '@/types/process';
|
||||
|
||||
interface StepDetailProps {
|
||||
@@ -27,6 +28,7 @@ interface StepDetailProps {
|
||||
export function StepDetail({ step, processId }: StepDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(
|
||||
@@ -129,10 +131,12 @@ export function StepDetail({ step, processId }: StepDetailProps) {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
공정으로 돌아가기
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
resetPermissions,
|
||||
} from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermissionContext } from '@/contexts/PermissionContext';
|
||||
|
||||
// 플랫 배열을 트리 구조로 변환
|
||||
interface FlatMenuItem {
|
||||
@@ -128,6 +129,7 @@ const PERMISSION_LABELS_MAP: Record<PermissionType, string> = {
|
||||
export function PermissionDetailClient({ permissionId, isNew = false, mode = 'view' }: PermissionDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { reloadPermissions } = usePermissionContext();
|
||||
|
||||
// 역할 데이터
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
@@ -655,6 +657,15 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
reloadPermissions();
|
||||
toast.success('권한 정보가 저장되었습니다.');
|
||||
}}
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
권한 정보 저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
|
||||
Reference in New Issue
Block a user