chore: merge origin/develop (자재관리 충돌 원격 우선 적용)
This commit is contained in:
@@ -7,6 +7,7 @@ import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
||||
import { FCMProvider } from '@/contexts/FCMProvider';
|
||||
import { DevFillProvider, DevToolbar } from '@/components/dev';
|
||||
import { PermissionGate } from '@/contexts/PermissionContext';
|
||||
import { ModuleGuard } from '@/components/auth/ModuleGuard';
|
||||
|
||||
/**
|
||||
* Protected Layout
|
||||
@@ -44,7 +45,9 @@ export default function ProtectedLayout({
|
||||
<FCMProvider>
|
||||
<DevFillProvider>
|
||||
<AuthenticatedLayout>
|
||||
<PermissionGate>{children}</PermissionGate>
|
||||
<PermissionGate>
|
||||
<ModuleGuard>{children}</ModuleGuard>
|
||||
</PermissionGate>
|
||||
</AuthenticatedLayout>
|
||||
<DevToolbar />
|
||||
</DevFillProvider>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 출하관리 - 등록 페이지
|
||||
* URL: /outbound/shipments/new
|
||||
*/
|
||||
|
||||
import { ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 목록/등록 페이지
|
||||
* 출하관리 - 목록 페이지
|
||||
* URL: /outbound/shipments
|
||||
* URL: /outbound/shipments?mode=new
|
||||
*/
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ShipmentList, ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function ShipmentsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
|
||||
return <ShipmentList />;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import { ReportList } from './components/ReportList';
|
||||
import { RouteList } from './components/RouteList';
|
||||
import { DocumentList } from './components/DocumentList';
|
||||
import { InspectionModal } from './components/InspectionModal';
|
||||
import { InspectionReportModal } from '@/components/production/WorkOrders/documents';
|
||||
import { WorkLogModal } from '@/components/production/WorkOrders/documents';
|
||||
import { InspectionReportModal, WorkLogModal } from '@/components/document-system/modals';
|
||||
import { ProductInspectionViewModal } from '@/components/quality/InspectionManagement/ProductInspectionViewModal';
|
||||
import { getDocumentDetail } from './actions';
|
||||
import { DayTabs } from './components/DayTabs';
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useModules } from "@/hooks/useModules";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle, User } from "lucide-react";
|
||||
import { AssigneeSelectModal } from "@/components/production/WorkOrders/AssigneeSelectModal";
|
||||
import { AssigneeSelectModal } from "@/components/document-system/modals";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -345,6 +346,24 @@ export default function ProductionOrderCreatePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
|
||||
// 생산 모듈 비활성 시 접근 차단 (tenantIndustry 미설정 시 전부 허용)
|
||||
if (tenantIndustry && !isEnabled('production')) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<p className="text-muted-foreground">생산관리 모듈이 활성화되어 있지 않습니다.</p>
|
||||
<button
|
||||
className="mt-4 text-sm text-blue-600 hover:underline"
|
||||
onClick={() => router.push('/sales/order-management-sales')}
|
||||
>
|
||||
← 수주 목록으로
|
||||
</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 수주관리 - IntegratedListTemplateV2 적용
|
||||
*
|
||||
* 수주 관리 페이지
|
||||
* - 상단 통계 카드: 이번 달 수주, 분할 대기, 생산지시 대기, 출하 대기
|
||||
* - 상단 통계 카드: N월 수주(기간 수주), 수주, 생산, 출하
|
||||
* - 상태 필터: 셀렉트박스 (전체, 수주등록, N자수정, 수주확정, 생산지시완료)
|
||||
* - 날짜 범위 필터: 달력
|
||||
* - 완전한 반응형 지원
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { formatAmount, formatAmountManwon } from "@/lib/utils/amount";
|
||||
import { formatAmount } from "@/lib/utils/amount";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
import { ConfirmDialog, DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
@@ -131,9 +131,16 @@ function OrderListContent() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 필터 상태
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
// 날짜 범위 필터 상태 (이번달 초기값)
|
||||
const [startDate, setStartDate] = useState<string>(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
});
|
||||
const [endDate, setEndDate] = useState<string>(() => {
|
||||
const d = new Date();
|
||||
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||
return `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
// 필터 상태
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
@@ -181,7 +188,7 @@ function OrderListContent() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [ordersResult, statsResult] = await Promise.all([
|
||||
getOrders(),
|
||||
getOrders({ order_type: 'ORDER' }),
|
||||
getOrderStats(),
|
||||
]);
|
||||
|
||||
@@ -276,53 +283,57 @@ function OrderListContent() {
|
||||
setMobileDisplayCount(20);
|
||||
}, [searchTerm, filterValues]);
|
||||
|
||||
// 통계 계산 (API stats 우선 사용, 없으면 로컬 계산)
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
// 통계 계산 — 날짜 범위 내 데이터 기준
|
||||
const periodLabel = useMemo(() => {
|
||||
if (!startDate || !endDate) return '전체 수주';
|
||||
const sm = startDate.slice(0, 7); // YYYY-MM
|
||||
const em = endDate.slice(0, 7);
|
||||
if (sm === em) {
|
||||
return `${parseInt(startDate.slice(5, 7))}월 수주`;
|
||||
}
|
||||
return '기간 수주';
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 이번 달 수주 금액 (수주확정 이후 건만 - 취소, 수주등록 제외)
|
||||
const thisMonthOrders = orders.filter(
|
||||
(o) =>
|
||||
new Date(o.orderDate) >= startOfMonth &&
|
||||
o.status !== "cancelled" &&
|
||||
o.status !== "order_registered"
|
||||
);
|
||||
const thisMonthAmount = apiStats?.thisMonthAmount ?? thisMonthOrders.reduce((sum, o) => sum + (Number(o.amount) || 0), 0);
|
||||
// 날짜 범위 내 수주 건수 (취소 제외)
|
||||
const periodOrderCount = orders.filter((o) => o.status !== "cancelled").length;
|
||||
|
||||
|
||||
// 분할 대기 (예시: 수주확정 상태)
|
||||
const splitPendingCount = apiStats?.splitPending ?? orders.filter((o) => o.status === "order_confirmed").length;
|
||||
|
||||
// 생산지시 대기 (수주확정 상태 중 생산지시 안된 것)
|
||||
const productionPendingCount = apiStats?.productionPending ?? orders.filter(
|
||||
(o) => o.status === "order_confirmed" || o.status === "order_registered"
|
||||
// 수주: 생산에 넘어가지 않은 건 (DRAFT + CONFIRMED)
|
||||
const orderCount = orders.filter(
|
||||
(o) => o.status === "order_registered" || o.status === "order_confirmed"
|
||||
).length;
|
||||
|
||||
// 출하 대기 (작업완료 상태)
|
||||
const shipPendingCount = apiStats?.shipPending ?? orders.filter((o) => o.status === "work_completed").length;
|
||||
// 생산: 생산지시대기 + 생산중 (IN_PROGRESS + IN_PRODUCTION)
|
||||
const productionCount = orders.filter(
|
||||
(o) => o.status === "production_ordered" || o.status === "in_production"
|
||||
).length;
|
||||
|
||||
// 출하: 출하대기 ~ 출고중 (PRODUCED + SHIPPING)
|
||||
const shipCount = orders.filter(
|
||||
(o) => o.status === "produced" || o.status === "shipping"
|
||||
).length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: "이번 달 수주",
|
||||
value: formatAmountManwon(thisMonthAmount),
|
||||
label: periodLabel,
|
||||
value: `${periodOrderCount}건`,
|
||||
icon: DollarSign,
|
||||
iconColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
label: "분할 대기",
|
||||
value: `${splitPendingCount}건`,
|
||||
icon: SplitSquareVertical,
|
||||
label: "수주",
|
||||
value: `${orderCount}건`,
|
||||
icon: ClipboardList,
|
||||
iconColor: "text-orange-600",
|
||||
},
|
||||
{
|
||||
label: "생산지시 대기",
|
||||
value: `${productionPendingCount}건`,
|
||||
icon: ClipboardList,
|
||||
label: "생산",
|
||||
value: `${productionCount}건`,
|
||||
icon: SplitSquareVertical,
|
||||
iconColor: "text-green-600",
|
||||
},
|
||||
{
|
||||
label: "출하 대기",
|
||||
value: `${shipPendingCount}건`,
|
||||
label: "출하",
|
||||
value: `${shipCount}건`,
|
||||
icon: Truck,
|
||||
iconColor: "text-purple-600",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useModules } from "@/hooks/useModules";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -48,13 +49,13 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
|
||||
import { getProductionOrderDetail } from "@/lib/api/production-orders";
|
||||
import { createProductionOrder } from "@/components/orders/actions";
|
||||
import type {
|
||||
ProductionOrderDetail,
|
||||
ProductionStatus,
|
||||
ProductionWorkOrder,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
} from "@/lib/api/production-orders";
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
|
||||
@@ -194,6 +195,24 @@ export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
|
||||
// 생산 모듈 비활성 시 접근 차단 (tenantIndustry 미설정 시 전부 허용)
|
||||
if (tenantIndustry && !isEnabled('production')) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<p className="text-muted-foreground">생산관리 모듈이 활성화되어 있지 않습니다.</p>
|
||||
<button
|
||||
className="mt-4 text-sm text-blue-600 hover:underline"
|
||||
onClick={() => router.push('/sales/order-management-sales')}
|
||||
>
|
||||
← 수주 목록으로
|
||||
</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -243,8 +262,8 @@ export default function ProductionOrderDetailPage() {
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setIsSuccessDialogOpen(false);
|
||||
// 작업지시 관리 페이지로 이동
|
||||
router.push("/production/work-orders");
|
||||
// 생산지시 목록으로 이동 (공통 ERP 영역 내 경로)
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* - 서버사이드 페이지네이션
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useModules } from "@/hooks/useModules";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -37,12 +38,12 @@ import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
import {
|
||||
getProductionOrders,
|
||||
getProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/actions";
|
||||
} from "@/lib/api/production-orders";
|
||||
import type {
|
||||
ProductionOrder,
|
||||
ProductionStatus,
|
||||
ProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
} from "@/lib/api/production-orders";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
@@ -174,6 +175,23 @@ const TABLE_COLUMNS: TableColumn[] = [
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
|
||||
// 생산 모듈 비활성 시 접근 차단 (tenantIndustry 미설정 시 전부 허용)
|
||||
if (tenantIndustry && !isEnabled('production')) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<p className="text-muted-foreground">생산관리 모듈이 활성화되어 있지 않습니다.</p>
|
||||
<button
|
||||
className="mt-4 text-sm text-blue-600 hover:underline"
|
||||
onClick={() => router.push('/sales/order-management-sales')}
|
||||
>
|
||||
← 수주 목록으로
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [stats, setStats] = useState<ProductionOrderStats>({
|
||||
total: 0,
|
||||
waiting: 0,
|
||||
@@ -181,15 +199,6 @@ export default function ProductionOrdersListPage() {
|
||||
completed: 0,
|
||||
});
|
||||
|
||||
// 통계 로드
|
||||
useEffect(() => {
|
||||
getProductionOrderStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
@@ -1,71 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';
|
||||
import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
/**
|
||||
* /subscription → /usage 리다이렉트
|
||||
*/
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSubscriptionData>>['data']>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function SubscriptionRedirect() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
getSubscriptionData()
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보를 관리합니다"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
{/* 헤더 액션 버튼 스켈레톤 */}
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* 구독 정보 카드 그리드 스켈레톤 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{/* 구독 정보 카드 스켈레톤 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-6 w-32 mb-6" />
|
||||
<div className="space-y-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-24 flex-shrink-0" />
|
||||
<Skeleton className="h-2 flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return <SubscriptionManagement initialData={data ?? null} />;
|
||||
}
|
||||
router.replace('/usage');
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
|
||||
72
src/app/[locale]/(protected)/usage/page.tsx
Normal file
72
src/app/[locale]/(protected)/usage/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';
|
||||
import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions';
|
||||
import type { SubscriptionInfo } from '@/components/settings/SubscriptionManagement/types';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
function UsageSkeleton() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="이용현황"
|
||||
description="구독 정보와 사용량을 관리합니다"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-28 mb-4" />
|
||||
<div className="space-y-5">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-32 mb-4" />
|
||||
<Skeleton className="h-2 w-full mb-4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsagePage() {
|
||||
const [data, setData] = useState<SubscriptionInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSubscriptionData()
|
||||
.then(result => setData(result.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <UsageSkeleton />;
|
||||
return <SubscriptionManagement initialData={data} />;
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export function ManualJournalEntryModal({
|
||||
const [journalDate, setJournalDate] = useState(() => getTodayString());
|
||||
const [journalNumber, setJournalNumber] = useState('자동생성');
|
||||
const [description, setDescription] = useState('');
|
||||
const [receiptNo, setReceiptNo] = useState('');
|
||||
|
||||
// 분개 행
|
||||
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||
@@ -93,6 +94,7 @@ export function ManualJournalEntryModal({
|
||||
setJournalDate(getTodayString());
|
||||
setJournalNumber('자동생성');
|
||||
setDescription('');
|
||||
setReceiptNo('');
|
||||
setRows([createEmptyRow()]);
|
||||
|
||||
getVendorList().then((vendorsRes) => {
|
||||
@@ -172,6 +174,7 @@ export function ManualJournalEntryModal({
|
||||
const result = await createManualJournal({
|
||||
journalDate,
|
||||
description,
|
||||
receiptNo: receiptNo || undefined,
|
||||
rows,
|
||||
});
|
||||
if (result.success) {
|
||||
@@ -186,7 +189,7 @@ export function ManualJournalEntryModal({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [journalDate, description, rows, totals, onOpenChange, onSuccess]);
|
||||
}, [journalDate, description, receiptNo, rows, totals, onOpenChange, onSuccess]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -197,7 +200,7 @@ export function ManualJournalEntryModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* 거래 정보 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
전표일자 <span className="text-red-500">*</span>
|
||||
@@ -222,6 +225,12 @@ export function ManualJournalEntryModal({
|
||||
onChange={setDescription}
|
||||
placeholder="적요 입력"
|
||||
/>
|
||||
<FormField
|
||||
label="증빙번호"
|
||||
value={receiptNo}
|
||||
onChange={setReceiptNo}
|
||||
placeholder="영수증/승인번호"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 분개 내역 헤더 */}
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function getJournalSummary(params: {
|
||||
export async function createManualJournal(data: {
|
||||
journalDate: string;
|
||||
description: string;
|
||||
receiptNo?: string;
|
||||
rows: JournalEntryRow[];
|
||||
}): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
@@ -70,6 +71,7 @@ export async function createManualJournal(data: {
|
||||
body: {
|
||||
journal_date: data.journalDate,
|
||||
description: data.description,
|
||||
receipt_no: data.receiptNo || null,
|
||||
rows: data.rows.map((r) => ({
|
||||
side: r.side,
|
||||
account_subject_id: r.accountSubjectId,
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
|
||||
import { InspectionReportModal } from '@/components/document-system/modals';
|
||||
|
||||
export function ApprovalBox() {
|
||||
const router = useRouter();
|
||||
|
||||
60
src/components/auth/ModuleGuard.tsx
Normal file
60
src/components/auth/ModuleGuard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 모듈 라우트 가드
|
||||
*
|
||||
* 현재 테넌트가 보유하지 않은 모듈의 페이지에 접근 시 차단.
|
||||
* (protected)/layout.tsx에서 PermissionGate 내부에 래핑.
|
||||
*
|
||||
* Phase 1: 클라이언트 사이드 가드 (백엔드 변경 불필요)
|
||||
* Phase 2: middleware.ts로 이동 (서버 사이드 가드)
|
||||
*/
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ModuleGuard({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { isRouteAllowed, tenantIndustry } = useModules();
|
||||
|
||||
// locale 접두사 제거 (예: /ko/production → /production)
|
||||
const cleanPath = pathname.replace(/^\/[a-z]{2}(?=\/)/, '');
|
||||
|
||||
const allowed = isRouteAllowed(cleanPath);
|
||||
|
||||
useEffect(() => {
|
||||
// industry가 아직 설정되지 않은 테넌트는 가드 비활성 (전부 허용)
|
||||
if (!tenantIndustry) return;
|
||||
|
||||
if (!allowed) {
|
||||
toast.error('접근 권한이 없는 모듈입니다.');
|
||||
}
|
||||
}, [allowed, tenantIndustry]);
|
||||
|
||||
// industry 미설정 시 가드 비활성 (하위 호환)
|
||||
if (!tenantIndustry) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
<ShieldAlert className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">접근 권한 없음</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
현재 계약에 포함되지 않은 모듈입니다.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => router.push('/dashboard')}>
|
||||
대시보드로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -41,17 +41,23 @@ import { getCardManagementModalConfigWithData } from './modalConfigs';
|
||||
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
|
||||
import { toast } from 'sonner';
|
||||
import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { sectionRequiresModule } from './types';
|
||||
|
||||
export function CEODashboard() {
|
||||
const router = useRouter();
|
||||
|
||||
// API 데이터 Hook
|
||||
// 모듈 활성화 정보 (tenantIndustry 미설정 시 모든 모듈 표시)
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
const moduleAware = !!tenantIndustry; // industry 설정 시에만 모듈 필터링 적용
|
||||
|
||||
// API 데이터 Hook (모듈 비활성 시 API 호출 스킵)
|
||||
const apiData = useCEODashboard({
|
||||
salesStatus: true,
|
||||
purchaseStatus: true,
|
||||
dailyProduction: true,
|
||||
unshipped: true,
|
||||
construction: true,
|
||||
dailyProduction: !moduleAware || isEnabled('production'),
|
||||
unshipped: true, // 공통 (outbound/logistics)
|
||||
construction: !moduleAware || isEnabled('construction'),
|
||||
dailyAttendance: true,
|
||||
});
|
||||
|
||||
@@ -548,8 +554,16 @@ export function CEODashboard() {
|
||||
}
|
||||
}, [calendarData]);
|
||||
|
||||
// 섹션 순서
|
||||
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||
// 섹션 순서 (모듈 비활성 섹션 필터링)
|
||||
const sectionOrder = useMemo(() => {
|
||||
const rawOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||
if (!moduleAware) return rawOrder; // industry 미설정 시 전부 표시
|
||||
return rawOrder.filter((key) => {
|
||||
const requiredModule = sectionRequiresModule(key);
|
||||
if (!requiredModule) return true; // 공통 섹션
|
||||
return isEnabled(requiredModule);
|
||||
});
|
||||
}, [dashboardSettings.sectionOrder, moduleAware, isEnabled]);
|
||||
|
||||
// 요약 네비게이션 바 훅
|
||||
const { summaries, activeSectionKey, sectionRefs, scrollToSection } = useSectionSummary({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +19,8 @@ import type {
|
||||
WelfareCalculationType,
|
||||
SectionKey,
|
||||
} from '../types';
|
||||
import { DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
|
||||
import { DEFAULT_SECTION_ORDER, SECTION_LABELS, sectionRequiresModule } from '../types';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import {
|
||||
SectionRow,
|
||||
StatusBoardItemsList,
|
||||
@@ -40,6 +41,10 @@ export function DashboardSettingsDialog({
|
||||
settings,
|
||||
onSave,
|
||||
}: DashboardSettingsDialogProps) {
|
||||
// 모듈 활성화 정보 (industry 미설정 시 전부 표시)
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
const moduleAware = !!tenantIndustry;
|
||||
|
||||
const [localSettings, setLocalSettings] = useState<DashboardSettings>(settings);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
todayIssueList: false,
|
||||
@@ -53,8 +58,16 @@ export function DashboardSettingsDialog({
|
||||
const [draggedSection, setDraggedSection] = useState<SectionKey | null>(null);
|
||||
const [dragOverSection, setDragOverSection] = useState<SectionKey | null>(null);
|
||||
|
||||
// 섹션 순서
|
||||
const sectionOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||
// 섹션 순서 (모듈 비활성 섹션 숨김)
|
||||
const sectionOrder = useMemo<SectionKey[]>(() => {
|
||||
const rawOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||
if (!moduleAware) return rawOrder;
|
||||
return rawOrder.filter((key: SectionKey) => {
|
||||
const requiredModule = sectionRequiresModule(key);
|
||||
if (!requiredModule) return true;
|
||||
return isEnabled(requiredModule);
|
||||
});
|
||||
}, [localSettings.sectionOrder, moduleAware, isEnabled]);
|
||||
|
||||
// settings가 변경될 때 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
|
||||
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
|
||||
import { getCalendarEventsForYear, type CalendarEvent } from '@/constants/calendarEvents';
|
||||
import { useCalendarScheduleStore } from '@/stores/useCalendarScheduleStore';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { CollapsibleDashboardCard } from '../components';
|
||||
import type {
|
||||
CalendarScheduleItem,
|
||||
@@ -117,6 +118,12 @@ const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] =
|
||||
{ value: 'issue', label: '이슈' },
|
||||
];
|
||||
|
||||
// 일정 타입 → 모듈 매핑 (이 타입의 링크/필터가 해당 모듈을 요구)
|
||||
const SCHEDULE_TYPE_MODULE: Record<string, string> = {
|
||||
order: 'production',
|
||||
construction: 'construction',
|
||||
};
|
||||
|
||||
export function CalendarSection({
|
||||
schedules,
|
||||
issues = [],
|
||||
@@ -124,12 +131,24 @@ export function CalendarSection({
|
||||
onScheduleEdit,
|
||||
}: CalendarSectionProps) {
|
||||
const router = useRouter();
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
const moduleAware = !!tenantIndustry;
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [, _setViewType] = useState<CalendarViewType>('month');
|
||||
const [deptFilter, setDeptFilter] = useState<CalendarDeptFilterType>('all');
|
||||
const [taskFilter, setTaskFilter] = useState<ExtendedTaskFilterType>('all');
|
||||
|
||||
// 모듈 기반 업무 필터 옵션 (비활성 모듈 필터 숨김)
|
||||
const filteredTaskFilterOptions = useMemo(() => {
|
||||
if (!moduleAware) return TASK_FILTER_OPTIONS;
|
||||
return TASK_FILTER_OPTIONS.filter((option) => {
|
||||
const requiredModule = SCHEDULE_TYPE_MODULE[option.value];
|
||||
if (!requiredModule) return true;
|
||||
return isEnabled(requiredModule as 'production' | 'construction');
|
||||
});
|
||||
}, [moduleAware, isEnabled]);
|
||||
|
||||
// 스토어에서 공휴일/세무일정 가져오기 (API 연동)
|
||||
const schedulesByYear = useCalendarScheduleStore((s) => s.schedulesByYear);
|
||||
const fetchSchedules = useCalendarScheduleStore((s) => s.fetchSchedules);
|
||||
@@ -272,7 +291,13 @@ export function CalendarSection({
|
||||
};
|
||||
|
||||
// 일정 타입별 상세 페이지 링크 생성 (bill_123 → /ko/accounting/bills/123)
|
||||
// 모듈 비활성 시 해당 타입의 링크 숨김
|
||||
const getScheduleLink = (schedule: CalendarScheduleItem): string | null => {
|
||||
// 모듈 의존 타입인데 해당 모듈이 비활성이면 링크 없음
|
||||
const requiredModule = SCHEDULE_TYPE_MODULE[schedule.type];
|
||||
if (moduleAware && requiredModule && !isEnabled(requiredModule as 'production' | 'construction')) {
|
||||
return null;
|
||||
}
|
||||
const basePath = SCHEDULE_TYPE_ROUTES[schedule.type];
|
||||
if (!basePath) return null;
|
||||
// expected_expense는 목록 페이지만 존재 (상세 페이지 없음)
|
||||
@@ -383,7 +408,7 @@ export function CalendarSection({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_FILTER_OPTIONS.map((option) => (
|
||||
{filteredTaskFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
@@ -432,7 +457,7 @@ export function CalendarSection({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_FILTER_OPTIONS.map((option) => (
|
||||
{filteredTaskFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { ModuleId } from '@/modules/types';
|
||||
|
||||
// 체크포인트 타입 (경고/성공/에러/정보)
|
||||
export type CheckPointType = 'success' | 'warning' | 'error' | 'info';
|
||||
@@ -716,6 +717,21 @@ export interface DetailModalConfig {
|
||||
table?: TableConfig;
|
||||
}
|
||||
|
||||
// ===== 모듈별 섹션 매핑 (Phase 2: Dashboard Decoupling) =====
|
||||
|
||||
/** 특정 모듈이 필요한 섹션 매핑 (여기 없는 섹션은 공통 = 항상 표시) */
|
||||
export const MODULE_DEPENDENT_SECTIONS: Partial<Record<SectionKey, ModuleId>> = {
|
||||
production: 'production',
|
||||
shipment: 'production',
|
||||
construction: 'construction',
|
||||
// unshipped는 공통(outbound/logistics) — 모듈 의존성 없음
|
||||
};
|
||||
|
||||
/** 섹션이 요구하는 모듈 ID 반환. 공통 섹션이면 null */
|
||||
export function sectionRequiresModule(sectionKey: SectionKey): ModuleId | null {
|
||||
return MODULE_DEPENDENT_SECTIONS[sectionKey] ?? null;
|
||||
}
|
||||
|
||||
// 기본 설정값
|
||||
export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
// 새 오늘의 이슈 (리스트 형태)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo, useEffect, useState, useRef, useCallback } from 'react';
|
||||
import type { CEODashboardData, DashboardSettings, SectionKey } from './types';
|
||||
import { SECTION_LABELS } from './types';
|
||||
import { SECTION_LABELS, sectionRequiresModule } from './types';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
|
||||
export type SummaryStatus = 'normal' | 'warning' | 'danger';
|
||||
|
||||
@@ -220,10 +221,21 @@ export function useSectionSummary({
|
||||
// 칩 클릭으로 선택된 키 — 해당 섹션이 화면에 보이는 한 유지
|
||||
const pinnedKey = useRef<SectionKey | null>(null);
|
||||
|
||||
// 활성화된 섹션만 필터
|
||||
// 모듈 활성화 정보 (tenantIndustry 미설정 시 전부 표시)
|
||||
const { isEnabled, tenantIndustry } = useModules();
|
||||
const moduleAware = !!tenantIndustry;
|
||||
|
||||
// 활성화된 섹션만 필터 (설정 + 모듈)
|
||||
const enabledSections = useMemo(
|
||||
() => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)),
|
||||
[sectionOrder, dashboardSettings],
|
||||
() => sectionOrder.filter((key) => {
|
||||
if (!isSectionEnabled(key, dashboardSettings)) return false;
|
||||
if (moduleAware) {
|
||||
const requiredModule = sectionRequiresModule(key);
|
||||
if (requiredModule && !isEnabled(requiredModule)) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[sectionOrder, dashboardSettings, moduleAware, isEnabled],
|
||||
);
|
||||
|
||||
// 요약 데이터 계산
|
||||
|
||||
32
src/components/business/construction/MODULE.md
Normal file
32
src/components/business/construction/MODULE.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Construction Module (건설관리)
|
||||
|
||||
**Module ID**: `construction`
|
||||
**Tenant**: Juil (주일건설)
|
||||
**Route Prefixes**: `/construction`
|
||||
**Component Count**: 161 files
|
||||
|
||||
## Dependencies on Common ERP
|
||||
- `@/lib/api/*` — Server actions, API client
|
||||
- `@/components/ui/*` — UI primitives (shadcn/ui)
|
||||
- `@/components/templates/*` — IntegratedListTemplateV2 등
|
||||
- `@/components/organisms/*` — PageLayout, PageHeader
|
||||
- `@/hooks/*` — usePermission, useModules 등
|
||||
- `@/stores/authStore` — Tenant 정보
|
||||
- `@/components/common/*` — 공통 컴포넌트
|
||||
|
||||
## Exports to Common ERP
|
||||
**NONE** — 건설 모듈은 독립적으로 작동.
|
||||
|
||||
## Related Dashboard Sections
|
||||
- `construction` (시공 현황)
|
||||
|
||||
## Subdirectories
|
||||
- `bidding/` — 입찰 관리
|
||||
- `contract/` — 계약 관리
|
||||
- `estimates/` — 견적 관리
|
||||
- `progress-billing/` — 기성 관리
|
||||
- `site-management/` — 현장 관리
|
||||
- `labor-management/` — 노무 관리
|
||||
- `item-management/` — 자재 관리
|
||||
- `partners/` — 협력업체 관리
|
||||
- 기타 20개 하위 도메인
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
UNIT_OPTIONS,
|
||||
} from './constants';
|
||||
import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions';
|
||||
import { BomTreeViewer } from '@/components/items/BomTreeViewer';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
itemId?: string;
|
||||
@@ -460,6 +461,9 @@ export default function ItemDetailClient({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* BOM 트리 */}
|
||||
{itemId && <BomTreeViewer itemId={itemId} itemType={formData.itemType} />}
|
||||
</div>
|
||||
), [
|
||||
formData,
|
||||
@@ -469,6 +473,7 @@ export default function ItemDetailClient({
|
||||
handleAddOrderItem,
|
||||
handleRemoveOrderItem,
|
||||
handleOrderItemChange,
|
||||
itemId,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AssigneeSelectModal — 공유 래퍼
|
||||
*
|
||||
* 원본: @/components/production/WorkOrders/AssigneeSelectModal
|
||||
* 목적: 공통 ERP(영업)에서 생산 모듈을 직접 import하지 않도록
|
||||
* dynamic import로 정적 의존성 체인을 끊음
|
||||
*/
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const AssigneeSelectModalImpl = dynamic(
|
||||
() =>
|
||||
import('@/components/production/WorkOrders/AssigneeSelectModal').then(
|
||||
(mod) => mod.AssigneeSelectModal,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export { AssigneeSelectModalImpl as AssigneeSelectModal };
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* InspectionReportModal — 공유 래퍼
|
||||
*
|
||||
* 원본: @/components/production/WorkOrders/documents/InspectionReportModal
|
||||
* 목적: 공통 ERP(결재, 품질)에서 생산 모듈을 직접 import하지 않도록
|
||||
* dynamic import로 정적 의존성 체인을 끊음
|
||||
*/
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const InspectionReportModalImpl = dynamic(
|
||||
() =>
|
||||
import('@/components/production/WorkOrders/documents/InspectionReportModal').then(
|
||||
(mod) => mod.InspectionReportModal,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export { InspectionReportModalImpl as InspectionReportModal };
|
||||
29
src/components/document-system/modals/WorkLogModal.tsx
Normal file
29
src/components/document-system/modals/WorkLogModal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* WorkLogModal — 공유 래퍼
|
||||
*
|
||||
* 원본: @/components/production/WorkOrders/documents/WorkLogModal
|
||||
* 목적: 공통 ERP(품질 QMS)에서 생산 모듈을 직접 import하지 않도록
|
||||
* dynamic import로 정적 의존성 체인을 끊음
|
||||
*/
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const WorkLogModalImpl = dynamic(
|
||||
() =>
|
||||
import('@/components/production/WorkOrders/documents/WorkLogModal').then(
|
||||
(mod) => mod.WorkLogModal,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export { WorkLogModalImpl as WorkLogModal };
|
||||
9
src/components/document-system/modals/index.ts
Normal file
9
src/components/document-system/modals/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* document-system/modals — 모듈 경계를 넘는 공유 모달 래퍼
|
||||
*
|
||||
* 공통 ERP 코드에서 테넌트 전용 모달을 사용할 때
|
||||
* 직접 import 대신 이 래퍼를 통해 dynamic import로 접근
|
||||
*/
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { WorkLogModal } from './WorkLogModal';
|
||||
export { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
332
src/components/items/BomTreeViewer.tsx
Normal file
332
src/components/items/BomTreeViewer.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* BOM Tree 시각화 컴포넌트
|
||||
*
|
||||
* API: GET /api/proxy/items/{id}/bom/tree
|
||||
* 3단계 트리: FG(루트) → CAT(카테고리 그룹) → PT(부품)
|
||||
* CAT 노드: 카테고리 헤더로 렌더링 (접힘/펼침, count 표시)
|
||||
* PT 노드: 품목 행으로 렌더링 (코드, 품목명, 수량, 단위)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronDown, ChevronRight, ChevronsUpDown, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Package } from 'lucide-react';
|
||||
|
||||
// BOM 트리 노드 타입
|
||||
interface BomTreeNode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
item_type: string;
|
||||
specification?: string;
|
||||
unit?: string;
|
||||
quantity?: number;
|
||||
count?: number; // CAT 노드 — 하위 품목 건수
|
||||
depth: number;
|
||||
children: BomTreeNode[];
|
||||
}
|
||||
|
||||
// 유형별 뱃지 스타일
|
||||
const ITEM_TYPE_COLORS: Record<string, string> = {
|
||||
FG: 'bg-blue-100 text-blue-800',
|
||||
PT: 'bg-green-100 text-green-800',
|
||||
RM: 'bg-orange-100 text-orange-800',
|
||||
SM: 'bg-purple-100 text-purple-800',
|
||||
CS: 'bg-gray-100 text-gray-800',
|
||||
BN: 'bg-pink-100 text-pink-800',
|
||||
SF: 'bg-cyan-100 text-cyan-800',
|
||||
};
|
||||
|
||||
// 노드별 고유 키 생성 (CAT 노드는 id=0이므로 이름 기반)
|
||||
function getNodeKey(node: BomTreeNode, index: number): string {
|
||||
if (node.item_type === 'CAT') return `cat-${index}-${node.name}`;
|
||||
return `item-${node.id}`;
|
||||
}
|
||||
|
||||
// 모든 노드의 키를 재귀적으로 수집
|
||||
function collectAllKeys(nodes: BomTreeNode[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
function walk(node: BomTreeNode, index: number) {
|
||||
keys.add(getNodeKey(node, index));
|
||||
node.children?.forEach((child, i) => walk(child, i));
|
||||
}
|
||||
nodes.forEach((node, i) => walk(node, i));
|
||||
return keys;
|
||||
}
|
||||
|
||||
// 카테고리 노드 컴포넌트
|
||||
function CategoryNode({
|
||||
node,
|
||||
nodeKey,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
node: BomTreeNode;
|
||||
nodeKey: string;
|
||||
isOpen: boolean;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 카테고리 헤더 */}
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 bg-gray-50 hover:bg-gray-100 cursor-pointer rounded-sm"
|
||||
onClick={() => hasChildren && onToggle(nodeKey)}
|
||||
>
|
||||
{hasChildren && (
|
||||
isOpen
|
||||
? <ChevronDown className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
: <ChevronRight className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-700">{node.name}</span>
|
||||
<Badge variant="outline" className="text-xs">{node.count ?? node.children?.length ?? 0}건</Badge>
|
||||
</div>
|
||||
|
||||
{/* 하위 품목 (접힘/펼침) */}
|
||||
{isOpen && hasChildren && (
|
||||
<div className="border-l-2 border-gray-200 ml-3">
|
||||
{node.children.map((child, i) => (
|
||||
<ItemNode key={child.id || `child-${i}`} node={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 품목(PT) 노드 컴포넌트
|
||||
function ItemNode({ node }: { node: BomTreeNode }) {
|
||||
return (
|
||||
<div className="py-1.5 px-3 ml-3 hover:bg-gray-50 rounded-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 유형 뱃지 */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 font-medium shrink-0 ${ITEM_TYPE_COLORS[node.item_type] || 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{node.item_type}
|
||||
</Badge>
|
||||
|
||||
{/* 코드 — PC만 인라인 표시 */}
|
||||
<code className="text-xs text-gray-400 shrink-0 max-w-[180px] truncate hidden md:inline">
|
||||
{node.code}
|
||||
</code>
|
||||
|
||||
{/* 품목명 */}
|
||||
<span className="text-sm text-gray-700 flex-1 min-w-0 truncate">{node.name}</span>
|
||||
|
||||
{/* 수량 + 단위 */}
|
||||
<span className="text-xs shrink-0 whitespace-nowrap">
|
||||
{node.quantity != null && (
|
||||
<span className="font-medium text-blue-600">x{node.quantity}</span>
|
||||
)}
|
||||
{node.unit && (
|
||||
<span className="text-muted-foreground ml-0.5">{node.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* 코드 2줄 — 모바일만 */}
|
||||
<code className="text-[11px] text-gray-400 pl-6 truncate md:hidden block">
|
||||
{node.code}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 범용 노드 렌더러 (CAT 분기)
|
||||
function BomNodeRenderer({
|
||||
node,
|
||||
index,
|
||||
expandedNodes,
|
||||
onToggle,
|
||||
}: {
|
||||
node: BomTreeNode;
|
||||
index: number;
|
||||
expandedNodes: Set<string>;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
const nodeKey = getNodeKey(node, index);
|
||||
const isCategory = node.item_type === 'CAT';
|
||||
|
||||
if (isCategory) {
|
||||
return (
|
||||
<CategoryNode
|
||||
node={node}
|
||||
nodeKey={nodeKey}
|
||||
isOpen={expandedNodes.has(nodeKey)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ItemNode node={node} />;
|
||||
}
|
||||
|
||||
interface BomTreeViewerProps {
|
||||
itemId: string;
|
||||
itemType: string;
|
||||
}
|
||||
|
||||
export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) {
|
||||
const [treeData, setTreeData] = useState<BomTreeNode | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [allExpanded, setAllExpanded] = useState(true);
|
||||
|
||||
// 트리 데이터 로드
|
||||
const loadTree = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/items/${itemId}/bom/tree`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const root = result.data as BomTreeNode;
|
||||
setTreeData(root);
|
||||
// 기본: 모든 카테고리 펼침
|
||||
if (root.children) {
|
||||
setExpandedNodes(collectAllKeys(root.children));
|
||||
}
|
||||
} else {
|
||||
setTreeData(null);
|
||||
}
|
||||
} catch {
|
||||
setError('BOM 트리를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
const isBomTarget = ['FG', 'PT', '제품', '부품'].includes(itemType);
|
||||
if (isBomTarget) {
|
||||
loadTree();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadTree, itemType]);
|
||||
|
||||
// BOM 대상이 아니면 렌더링하지 않음
|
||||
if (!['FG', 'PT', '제품', '부품'].includes(itemType)) return null;
|
||||
|
||||
// 로딩
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
BOM 트리를 불러오는 중...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
{error}
|
||||
<Button variant="link" size="sm" onClick={loadTree} className="ml-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (!treeData || !treeData.children || treeData.children.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
부품 구성 (BOM)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 BOM 정보가 없습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleNode = (key: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => { setExpandedNodes(collectAllKeys(treeData.children)); setAllExpanded(true); };
|
||||
const collapseAll = () => { setExpandedNodes(new Set()); setAllExpanded(false); };
|
||||
const toggleAll = () => { if (allExpanded) collapseAll(); else expandAll(); };
|
||||
|
||||
// 카테고리 그룹 수 & 총 품목 수
|
||||
const categories = treeData.children.filter(n => n.item_type === 'CAT');
|
||||
const totalItems = categories.reduce((sum, cat) => sum + (cat.count ?? cat.children?.length ?? 0), 0);
|
||||
const groupCount = categories.length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{/* PC: 한 줄 레이아웃 */}
|
||||
<div className="hidden md:flex md:items-center md:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
부품 구성 (BOM)
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
총 {totalItems}개 품목 · {groupCount}개 그룹
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={toggleAll} className="h-7 text-xs">
|
||||
<ChevronsUpDown className="w-3 h-3 mr-1" />
|
||||
{allExpanded ? '전체 접기' : '전체 펼치기'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 모바일: 줄바꿈 레이아웃 */}
|
||||
<div className="md:hidden space-y-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
부품 구성 (BOM)
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 w-fit">
|
||||
총 {totalItems}개 품목 · {groupCount}개 그룹
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={toggleAll} className="h-7 text-xs w-fit">
|
||||
<ChevronsUpDown className="w-3 h-3 mr-1" />
|
||||
{allExpanded ? '전체 접기' : '전체 펼치기'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{treeData.children.map((node, i) => (
|
||||
<BomNodeRenderer
|
||||
key={getNodeKey(node, i)}
|
||||
node={node}
|
||||
index={i}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggle={toggleNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { BomTreeViewer } from './BomTreeViewer';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
@@ -554,60 +555,9 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* BOM 정보 - 절곡 부품은 제외 */}
|
||||
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
부품 구성 (BOM)
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
총 {item.bom.length}개 품목
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>번호</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{item.bom.map((line, index) => (
|
||||
<TableRow key={line.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{line.childItemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{line.childItemName}
|
||||
{line.isBending && (
|
||||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700">
|
||||
절곡품
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{line.quantity}</TableCell>
|
||||
<TableCell>{line.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* BOM 트리 - FG/PT만 표시 (절곡 부품 제외) */}
|
||||
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && (
|
||||
<BomTreeViewer itemId={item.id} itemType={item.itemType} />
|
||||
)}
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
DialogDescription,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -670,12 +670,10 @@ export function ImportInspectionInputModal({
|
||||
// 캡처 실패 시 무시 — rendered_html 없이 저장 진행
|
||||
}
|
||||
|
||||
// 5. 저장 API 호출 (rendered_html이 너무 크면 제외 — 413 방지)
|
||||
const MAX_HTML_SIZE = 500 * 1024; // 500KB 제한
|
||||
const safeHtml = renderedHtml && renderedHtml.length <= MAX_HTML_SIZE ? renderedHtml : undefined;
|
||||
if (renderedHtml && renderedHtml.length > MAX_HTML_SIZE) {
|
||||
console.warn(`[ImportInspection] rendered_html 크기 초과 (${(renderedHtml.length / 1024).toFixed(0)}KB), 제외하고 저장합니다.`);
|
||||
}
|
||||
// 5. 저장 API 호출 (rendered_html 500KB 초과 시 제외 — 413 에러 방지)
|
||||
const MAX_HTML_SIZE = 500 * 1024;
|
||||
const safeHtml = renderedHtml && renderedHtml.length <= MAX_HTML_SIZE
|
||||
? renderedHtml : undefined;
|
||||
|
||||
const result = await saveInspectionData({
|
||||
templateId: parseInt(template.templateId),
|
||||
|
||||
@@ -126,7 +126,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[700px] max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-[95vw] w-[95vw] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">재고 조정</DialogTitle>
|
||||
<VisuallyHidden><DialogDescription>재고 수량 조정</DialogDescription></VisuallyHidden>
|
||||
@@ -165,7 +165,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props)
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
<div className="flex-1 overflow-auto border rounded-md [&::-webkit-scrollbar]:h-[10px] [&::-webkit-scrollbar-thumb]:bg-gray-400 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
RECEIVING_STATUS_OPTIONS,
|
||||
type ReceivingDetail as ReceivingDetailType,
|
||||
type ReceivingStatus,
|
||||
type InventoryAdjustmentRecord,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
@@ -68,7 +67,7 @@ interface Props {
|
||||
mode?: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
// 초기 폼 데이터 생성 (세션 사용자, 오늘 날짜 기본값)
|
||||
// 초기 폼 데이터 (동적 함수 — 세션 사용자 이름 + 오늘 날짜 기본값)
|
||||
function createInitialFormData(): Partial<ReceivingDetailType> {
|
||||
return {
|
||||
materialNo: '',
|
||||
@@ -93,16 +92,6 @@ function createInitialFormData(): Partial<ReceivingDetailType> {
|
||||
};
|
||||
}
|
||||
|
||||
// 로트번호 생성 (YYMMDD-NN)
|
||||
function generateLotNo(): string {
|
||||
const now = new Date();
|
||||
const yy = String(now.getFullYear()).slice(-2);
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
const seq = String(Math.floor(Math.random() * 100)).padStart(2, '0');
|
||||
return `${yy}${mm}${dd}-${seq}`;
|
||||
}
|
||||
|
||||
// localStorage에서 로그인 사용자 정보 가져오기
|
||||
function getLoggedInUser(): { name: string; department: string } {
|
||||
if (typeof window === 'undefined') return { name: '', department: '' };
|
||||
@@ -160,9 +149,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string };
|
||||
}>>([]);
|
||||
|
||||
// 재고 조정 이력 상태
|
||||
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
|
||||
|
||||
// Dev 모드 폼 자동 채우기
|
||||
useDevFill(
|
||||
'receiving',
|
||||
@@ -171,7 +157,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
const data = generateReceivingData();
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
lotNo: generateLotNo(),
|
||||
itemCode: data.itemCode,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
@@ -201,10 +186,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
// 재고 조정 이력 설정
|
||||
if (result.data.inventoryAdjustments) {
|
||||
setAdjustments(result.data.inventoryAdjustments);
|
||||
}
|
||||
// 기존 성적서 파일 정보 설정
|
||||
if (result.data.certificateFileId) {
|
||||
setExistingCertFile({
|
||||
@@ -243,9 +224,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
if (templateCheck.success) {
|
||||
setHasInspectionTemplate(templateCheck.hasTemplate);
|
||||
}
|
||||
if (templateCheck.attachments && templateCheck.attachments.length > 0) {
|
||||
setInspectionAttachments(templateCheck.attachments);
|
||||
}
|
||||
setInspectionAttachments(templateCheck.attachments ?? []);
|
||||
} else {
|
||||
setHasInspectionTemplate(false);
|
||||
}
|
||||
@@ -296,14 +275,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
const result = await createReceiving(saveData);
|
||||
if (result.success) {
|
||||
toast.success('입고가 등록되었습니다.');
|
||||
// 등록 완료 후 생성된 입고 상세 페이지로 바로 이동 (목록 경유 방지)
|
||||
const newId = result.data?.id;
|
||||
if (newId) {
|
||||
router.push(`/ko/material/receiving-management/${newId}?mode=view`);
|
||||
} else {
|
||||
router.push('/ko/material/receiving-management');
|
||||
}
|
||||
return { success: true };
|
||||
// 커스텀 네비게이션 처리: error='' → 템플릿의 navigateToList() 호출 방지
|
||||
return { success: false, error: '' };
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
@@ -345,30 +324,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 재고 조정 행 추가
|
||||
const handleAddAdjustment = () => {
|
||||
const newRecord: InventoryAdjustmentRecord = {
|
||||
id: `adj-${Date.now()}`,
|
||||
adjustmentDate: getTodayString(),
|
||||
quantity: 0,
|
||||
inspector: getLoggedInUserName() || '홍길동',
|
||||
};
|
||||
setAdjustments((prev) => [...prev, newRecord]);
|
||||
};
|
||||
|
||||
// 재고 조정 행 삭제
|
||||
const handleRemoveAdjustment = (adjId: string) => {
|
||||
setAdjustments((prev) => prev.filter((a) => a.id !== adjId));
|
||||
};
|
||||
|
||||
// 재고 조정 수량 변경
|
||||
const handleAdjustmentQtyChange = (adjId: string, value: string) => {
|
||||
const numValue = value === '' || value === '-' ? 0 : Number(value);
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a))
|
||||
);
|
||||
};
|
||||
|
||||
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
|
||||
const handleCancel = () => {
|
||||
if (isNewMode) {
|
||||
@@ -515,47 +470,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">{adj.adjustmentDate}</TableCell>
|
||||
<TableCell className="text-center">{adj.quantity}</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail, adjustments, inspectionAttachments, existingCertFile]);
|
||||
}, [detail, inspectionAttachments, existingCertFile]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -582,15 +499,12 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 원자재로트 - 수정 가능 */}
|
||||
{/* 원자재로트 - 자동채번 (readOnly) */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">원자재로트</Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
value={formData.lotNo || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, lotNo: e.target.value }))}
|
||||
placeholder="원자재로트를 입력하세요"
|
||||
/>
|
||||
<div className="mt-1.5 px-3 py-2 bg-gray-200 border border-gray-300 rounded-md text-sm text-gray-500 cursor-not-allowed select-none">
|
||||
{formData.lotNo || (isNewMode ? '저장 시 자동 생성' : '-')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 - 검색 모달 선택 */}
|
||||
@@ -801,88 +715,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddAdjustment}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
<TableHead className="text-center w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<DatePicker
|
||||
value={adj.adjustmentDate}
|
||||
onChange={(date) => {
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === adj.id ? { ...a, adjustmentDate: date } : a
|
||||
)
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={adj.quantity || ''}
|
||||
onChange={(e) => handleAdjustmentQtyChange(adj.id, e.target.value)}
|
||||
className="h-8 text-sm text-center w-[100px] mx-auto"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveAdjustment(adj.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [formData, adjustments, uploadedFile, existingCertFile]);
|
||||
}, [formData, uploadedFile, existingCertFile]);
|
||||
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
|
||||
import { getReceivings, getReceivingStats } from './actions';
|
||||
import {
|
||||
RECEIVING_STATUS_LABELS,
|
||||
@@ -84,9 +83,6 @@ export function ReceivingList() {
|
||||
const [stats, setStats] = useState<ReceivingStats | null>(null);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// ===== 재고 조정 팝업 상태 =====
|
||||
const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false);
|
||||
|
||||
// ===== 날짜 범위 상태 (최근 30일) =====
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -290,17 +286,9 @@ export function ReceivingList() {
|
||||
// 통계 카드
|
||||
stats: statCards,
|
||||
|
||||
// 헤더 액션 (재고 조정 + 입고 등록 버튼)
|
||||
// 헤더 액션 (입고 등록 버튼)
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdjustmentOpen(true)}
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-1" />
|
||||
재고 조정
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -451,17 +439,9 @@ export function ReceivingList() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 팝업 */}
|
||||
<InventoryAdjustmentDialog
|
||||
open={isAdjustmentOpen}
|
||||
onOpenChange={setIsAdjustmentOpen}
|
||||
/>
|
||||
</>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,16 +25,6 @@ import {
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import type { ReceivingDetail, ReceivingProcessFormData } from './types';
|
||||
|
||||
// LOT 번호 생성 함수 (YYMMDD-NN 형식)
|
||||
function generateLotNo(): string {
|
||||
const now = new Date();
|
||||
const yy = String(now.getFullYear()).slice(-2);
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
const random = String(Math.floor(Math.random() * 100)).padStart(2, '0');
|
||||
return `${yy}${mm}${dd}-${random}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -44,7 +34,7 @@ interface Props {
|
||||
|
||||
export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete }: Props) {
|
||||
// 폼 데이터
|
||||
const [receivingLot, setReceivingLot] = useState(() => generateLotNo());
|
||||
const [receivingLot, setReceivingLot] = useState('');
|
||||
const [supplierLot, setSupplierLot] = useState('');
|
||||
const [receivingQty, setReceivingQty] = useState<string>((detail.orderQty ?? 0).toString());
|
||||
const [receivingLocation, setReceivingLocation] = useState('');
|
||||
@@ -58,9 +48,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!receivingLot.trim()) {
|
||||
errors.push('입고LOT는 필수 입력 항목입니다.');
|
||||
}
|
||||
// 입고LOT는 API가 자동 채번하므로 필수 검증 제거
|
||||
|
||||
if (!receivingQty.trim() || isNaN(Number(receivingQty)) || Number(receivingQty) <= 0) {
|
||||
errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.');
|
||||
@@ -70,7 +58,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [receivingLot, receivingQty]);
|
||||
}, [receivingQty]);
|
||||
|
||||
// 입고 처리
|
||||
const handleSubmit = useCallback(async () => {
|
||||
@@ -164,16 +152,12 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
|
||||
{/* 입력 필드 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고LOT <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Label className="text-sm text-muted-foreground">입고LOT</Label>
|
||||
<Input
|
||||
value={receivingLot}
|
||||
onChange={(e) => {
|
||||
setReceivingLot(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="예: 251223-41"
|
||||
readOnly
|
||||
className="bg-gray-50"
|
||||
placeholder="저장 시 자동 생성"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
VisuallyHidden,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
@@ -30,8 +30,10 @@ export function SuccessDialog({ open, type, lotNo, onClose }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => !newOpen && onClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<VisuallyHidden><DialogTitle>처리 완료</DialogTitle></VisuallyHidden>
|
||||
<VisuallyHidden><DialogDescription>처리 결과 안내</DialogDescription></VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>처리 완료</DialogTitle>
|
||||
<DialogDescription>처리 결과 안내</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<div className="flex flex-col items-center text-center py-6 space-y-4">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600" />
|
||||
|
||||
@@ -497,7 +497,7 @@ function transformFrontendToApi(
|
||||
if (data.remark !== undefined) result.remark = data.remark;
|
||||
if (data.receivingQty !== undefined) result.receiving_qty = data.receivingQty;
|
||||
if (data.receivingDate !== undefined) result.receiving_date = data.receivingDate;
|
||||
if (data.lotNo !== undefined) result.lot_no = data.lotNo;
|
||||
// lot_no는 API가 자동 채번하므로 프론트에서 전송하지 않음
|
||||
if (data.supplierMaterialNo !== undefined) result.material_no = data.supplierMaterialNo;
|
||||
if (data.manufacturer !== undefined) result.manufacturer = data.manufacturer;
|
||||
if (data.certificateFileId !== undefined) result.certificate_file_id = data.certificateFileId;
|
||||
@@ -511,7 +511,7 @@ function transformProcessDataToApi(
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
receiving_qty: data.receivingQty,
|
||||
lot_no: data.receivingLot,
|
||||
// lot_no는 API가 자동 채번하므로 프론트에서 전송하지 않음
|
||||
supplier_lot: data.supplierLot,
|
||||
receiving_location: data.receivingLocation,
|
||||
remark: data.remark,
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
* 기획서 기준:
|
||||
* - 기본 정보: 자재번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량 (읽기 전용)
|
||||
* - 수정 가능: 안전재고, 상태 (사용/미사용)
|
||||
* - 재고 조정: 이력 테이블 + 추가 기능
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -21,10 +24,31 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { stockStatusConfig } from './stockStatusConfig';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import { getStockById, updateStock } from './actions';
|
||||
import {
|
||||
getStockById,
|
||||
updateStock,
|
||||
getStockAdjustments,
|
||||
createStockAdjustment,
|
||||
} from './actions';
|
||||
import type { StockAdjustmentRecord } from './actions';
|
||||
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
|
||||
import type { ItemType } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
@@ -71,6 +95,12 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
// 저장 중 상태
|
||||
const [, setIsSaving] = useState(false);
|
||||
|
||||
// 재고 조정 상태
|
||||
const [adjustments, setAdjustments] = useState<StockAdjustmentRecord[]>([]);
|
||||
const [isAdjustmentDialogOpen, setIsAdjustmentDialogOpen] = useState(false);
|
||||
const [adjustmentForm, setAdjustmentForm] = useState({ quantity: '', remark: '' });
|
||||
const [isAdjustmentSaving, setIsAdjustmentSaving] = useState(false);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -112,10 +142,24 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 재고 조정 이력 로드
|
||||
const loadAdjustments = useCallback(async () => {
|
||||
try {
|
||||
const result = await getStockAdjustments(id);
|
||||
if (result.success && result.data) {
|
||||
setAdjustments(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[StockStatusDetail] loadAdjustments error:', err);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
loadAdjustments();
|
||||
}, [loadData, loadAdjustments]);
|
||||
|
||||
// 폼 값 변경 핸들러
|
||||
const handleInputChange = (field: keyof typeof formData, value: string | number) => {
|
||||
@@ -160,6 +204,39 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 재고 조정 등록
|
||||
const handleAdjustmentSave = async () => {
|
||||
const qty = Number(adjustmentForm.quantity);
|
||||
if (!qty || qty === 0) {
|
||||
toast.error('증감 수량을 입력해주세요. (0 제외)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdjustmentSaving(true);
|
||||
try {
|
||||
const result = await createStockAdjustment(id, {
|
||||
quantity: qty,
|
||||
remark: adjustmentForm.remark || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('재고 조정이 등록되었습니다.');
|
||||
setIsAdjustmentDialogOpen(false);
|
||||
setAdjustmentForm({ quantity: '', remark: '' });
|
||||
// 이력 + 기본 정보 새로고침
|
||||
loadAdjustments();
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '재고 조정 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
toast.error('재고 조정 등록 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsAdjustmentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 읽기 전용 필드 렌더링 (수정 모드에서 구분용)
|
||||
const renderReadOnlyField = (label: string, value: string | number, isEditMode = false) => (
|
||||
<div>
|
||||
@@ -178,114 +255,178 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 재고 조정 섹션 (view/edit 공통)
|
||||
const renderAdjustmentSection = () => (
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdjustmentDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[100px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[100px]">조정 후 재고</TableHead>
|
||||
<TableHead className="min-w-[150px]">사유</TableHead>
|
||||
<TableHead className="text-center min-w-[80px]">검수자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center text-sm">{adj.adjusted_at}</TableCell>
|
||||
<TableCell className={`text-center font-medium ${adj.quantity > 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{adj.quantity > 0 ? `+${adj.quantity}` : adj.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{adj.balance_qty}</TableCell>
|
||||
<TableCell className="text-sm">{adj.remark || '-'}</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// 상세 보기 모드 렌더링
|
||||
const renderViewContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: 재공품, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
|
||||
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
|
||||
{/* Row 3: 재공품, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
|
||||
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{renderAdjustmentSection()}
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
}, [detail, adjustments]);
|
||||
|
||||
// 수정 모드 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode, true)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)}
|
||||
{renderReadOnlyField('품목명', detail.itemName, true)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode, true)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)}
|
||||
{renderReadOnlyField('품목명', detail.itemName, true)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
|
||||
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
|
||||
안전재고
|
||||
</Label>
|
||||
<Input
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
value={formData.safetyStock}
|
||||
onChange={(e) => handleInputChange('safetyStock', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
|
||||
안전재고
|
||||
</Label>
|
||||
<Input
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
value={formData.safetyStock}
|
||||
onChange={(e) => handleInputChange('safetyStock', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 재공품 (읽기 전용) */}
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
|
||||
{/* 상태 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
|
||||
상태
|
||||
</Label>
|
||||
<Select
|
||||
key={`useStatus-${formData.useStatus}`}
|
||||
value={formData.useStatus}
|
||||
onValueChange={(value) => handleInputChange('useStatus', value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">사용</SelectItem>
|
||||
<SelectItem value="inactive">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 재공품 (읽기 전용) */}
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
|
||||
{/* 상태 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
|
||||
상태
|
||||
</Label>
|
||||
<Select
|
||||
key={`useStatus-${formData.useStatus}`}
|
||||
value={formData.useStatus}
|
||||
onValueChange={(value) => handleInputChange('useStatus', value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">사용</SelectItem>
|
||||
<SelectItem value="inactive">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{renderAdjustmentSection()}
|
||||
</div>
|
||||
);
|
||||
}, [detail, formData]);
|
||||
}, [detail, formData, adjustments]);
|
||||
|
||||
// 에러 상태 표시
|
||||
if (!isLoading && (error || !detail)) {
|
||||
@@ -301,15 +442,70 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={stockStatusConfig}
|
||||
mode={initialMode as 'view' | 'edit'}
|
||||
initialData={(detail || undefined) as Record<string, unknown> | undefined}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
renderView={() => renderViewContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
onSubmit={async () => { await handleSave(); return { success: true }; }}
|
||||
/>
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={stockStatusConfig}
|
||||
mode={initialMode as 'view' | 'edit'}
|
||||
initialData={(detail || undefined) as Record<string, unknown> | undefined}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
renderView={() => renderViewContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
onSubmit={async () => { await handleSave(); return { success: true }; }}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 등록 다이얼로그 */}
|
||||
<Dialog open={isAdjustmentDialogOpen} onOpenChange={setIsAdjustmentDialogOpen}>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재고 조정</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label className="text-sm">
|
||||
증감 수량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={adjustmentForm.quantity}
|
||||
onChange={(e) => setAdjustmentForm((prev) => ({ ...prev, quantity: e.target.value }))}
|
||||
placeholder="양수: 증가, 음수: 감소"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">사유</Label>
|
||||
<Textarea
|
||||
value={adjustmentForm.remark}
|
||||
onChange={(e) => setAdjustmentForm((prev) => ({ ...prev, remark: e.target.value }))}
|
||||
placeholder="조정 사유를 입력하세요 (선택)"
|
||||
className="mt-1.5"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAdjustmentDialogOpen(false);
|
||||
setAdjustmentForm({ quantity: '', remark: '' });
|
||||
}}
|
||||
disabled={isAdjustmentSaving}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdjustmentSave}
|
||||
disabled={isAdjustmentSaving}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
{isAdjustmentSaving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +292,41 @@ export async function updateStock(
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 재고 조정 이력 조회 =====
|
||||
export interface StockAdjustmentRecord {
|
||||
id: number;
|
||||
adjusted_at: string;
|
||||
quantity: number;
|
||||
balance_qty: number;
|
||||
remark: string | null;
|
||||
inspector: string;
|
||||
}
|
||||
|
||||
export async function getStockAdjustments(stockId: string): Promise<{ success: boolean; data?: StockAdjustmentRecord[]; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction<{ data: StockAdjustmentRecord[] }, StockAdjustmentRecord[]>({
|
||||
url: buildApiUrl(`/api/v1/stocks/${stockId}/adjustments`),
|
||||
transform: (d) => d.data || [],
|
||||
errorMessage: '재고 조정 이력 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 재고 조정 등록 =====
|
||||
export async function createStockAdjustment(
|
||||
stockId: string,
|
||||
data: { quantity: number; remark?: string }
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/stocks/${stockId}/adjustments`),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '재고 조정 등록에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 재고 실사 (일괄 업데이트) =====
|
||||
export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
|
||||
@@ -212,6 +212,8 @@ interface ApiOrderStats {
|
||||
draft: number;
|
||||
confirmed: number;
|
||||
in_progress: number;
|
||||
in_production?: number;
|
||||
produced?: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
total_amount: number;
|
||||
@@ -379,6 +381,8 @@ export interface OrderStats {
|
||||
draft: number;
|
||||
confirmed: number;
|
||||
inProgress: number;
|
||||
inProduction: number;
|
||||
produced: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
totalAmount: number;
|
||||
@@ -958,6 +962,8 @@ export async function getOrderStats(): Promise<{
|
||||
draft: result.data.draft,
|
||||
confirmed: result.data.confirmed,
|
||||
inProgress: result.data.in_progress,
|
||||
inProduction: result.data.in_production || 0,
|
||||
produced: result.data.produced || 0,
|
||||
completed: result.data.completed,
|
||||
cancelled: result.data.cancelled,
|
||||
totalAmount: result.data.total_amount,
|
||||
|
||||
@@ -1,760 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출고 등록 페이지
|
||||
* 4개 섹션 구조: 기본정보, 수주/배송정보, 배차정보, 제품내용
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Trash2, ChevronDown, Search } from 'lucide-react';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { shipmentCreateConfig } from './shipmentConfig';
|
||||
import {
|
||||
createShipment,
|
||||
getLotOptions,
|
||||
getLogisticsOptions,
|
||||
getVehicleTonnageOptions,
|
||||
} from './actions';
|
||||
import type {
|
||||
ShipmentCreateFormData,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
VehicleDispatch,
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
ProductGroup,
|
||||
ProductPart,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useDevFill } from '@/components/dev';
|
||||
import { generateShipmentData } from '@/components/dev/generators/shipmentData';
|
||||
import { mockProductGroups, mockOtherParts } from './mockData';
|
||||
|
||||
// 배송방식 옵션
|
||||
const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
|
||||
{ value: 'direct_dispatch', label: '직접배차' },
|
||||
{ value: 'loading', label: '상차' },
|
||||
{ value: 'kyungdong_delivery', label: '경동택배' },
|
||||
{ value: 'daesin_delivery', label: '대신택배' },
|
||||
{ value: 'kyungdong_freight', label: '경동화물' },
|
||||
{ value: 'daesin_freight', label: '대신화물' },
|
||||
{ value: 'self_pickup', label: '직접수령' },
|
||||
];
|
||||
|
||||
// 운임비용 옵션 (선불, 착불, 없음)
|
||||
const freightCostOptions: { value: FreightCostType; label: string }[] = [
|
||||
{ value: 'prepaid', label: '선불' },
|
||||
{ value: 'collect', label: '착불' },
|
||||
{ value: 'none', label: '없음' },
|
||||
];
|
||||
|
||||
// 빈 배차 행 생성
|
||||
function createEmptyDispatch(): VehicleDispatch {
|
||||
return {
|
||||
id: `vd-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
logisticsCompany: '',
|
||||
arrivalDateTime: '',
|
||||
tonnage: '',
|
||||
vehicleNo: '',
|
||||
driverContact: '',
|
||||
remarks: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function ShipmentCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<ShipmentCreateFormData>({
|
||||
lotNo: '',
|
||||
scheduledDate: getTodayString(),
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'direct_dispatch',
|
||||
shipmentDate: '',
|
||||
freightCost: 'none',
|
||||
receiver: '',
|
||||
receiverContact: '',
|
||||
zipCode: '',
|
||||
address: '',
|
||||
addressDetail: '',
|
||||
vehicleDispatches: [createEmptyDispatch()],
|
||||
logisticsCompany: '',
|
||||
vehicleTonnage: '',
|
||||
loadingTime: '',
|
||||
loadingManager: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
// API 옵션 데이터 상태
|
||||
const [lotOptions, setLotOptions] = useState<LotOption[]>([]);
|
||||
const [logisticsOptions, setLogisticsOptions] = useState<LogisticsOption[]>([]);
|
||||
const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState<VehicleTonnageOption[]>([]);
|
||||
|
||||
// 제품 데이터 (LOT 선택 시 표시)
|
||||
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
||||
const [otherParts, setOtherParts] = useState<ProductPart[]>([]);
|
||||
|
||||
// 로딩/에러 상태
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
|
||||
// 우편번호 찾기
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
zipCode: result.zonecode,
|
||||
address: result.address,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 옵션 데이터 로드
|
||||
const loadOptions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [lotsResult, logisticsResult, tonnageResult] = await Promise.all([
|
||||
getLotOptions(),
|
||||
getLogisticsOptions(),
|
||||
getVehicleTonnageOptions(),
|
||||
]);
|
||||
|
||||
if (lotsResult.success && lotsResult.data) {
|
||||
setLotOptions(lotsResult.data);
|
||||
}
|
||||
if (logisticsResult.success && logisticsResult.data) {
|
||||
setLogisticsOptions(logisticsResult.data);
|
||||
}
|
||||
if (tonnageResult.success && tonnageResult.data) {
|
||||
setVehicleTonnageOptions(tonnageResult.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ShipmentCreate] loadOptions error:', err);
|
||||
setError('옵션 데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOptions();
|
||||
}, [loadOptions]);
|
||||
|
||||
// DevToolbar 자동 채우기
|
||||
useDevFill(
|
||||
'shipment',
|
||||
useCallback(() => {
|
||||
const lotOptionsForGenerator = lotOptions.map(o => ({
|
||||
lotNo: o.value,
|
||||
customerName: o.customerName,
|
||||
siteName: o.siteName,
|
||||
}));
|
||||
const logisticsOptionsForGenerator = logisticsOptions.map(o => ({
|
||||
id: o.value,
|
||||
name: o.label,
|
||||
}));
|
||||
const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}));
|
||||
const sampleData = generateShipmentData({
|
||||
lotOptions: lotOptionsForGenerator as unknown as LotOption[],
|
||||
logisticsOptions: logisticsOptionsForGenerator as unknown as LogisticsOption[],
|
||||
tonnageOptions: tonnageOptionsForGenerator,
|
||||
});
|
||||
setFormData(prev => ({ ...prev, ...sampleData }));
|
||||
toast.success('[Dev] 출고 폼이 자동으로 채워졌습니다.');
|
||||
}, [lotOptions, logisticsOptions, vehicleTonnageOptions])
|
||||
);
|
||||
|
||||
// LOT 선택 시 현장명/수주처 자동 매핑 + 목데이터 제품 표시
|
||||
const handleLotChange = useCallback((lotNo: string) => {
|
||||
setFormData(prev => ({ ...prev, lotNo }));
|
||||
if (lotNo) {
|
||||
// 목데이터로 제품 그룹 표시
|
||||
setProductGroups(mockProductGroups);
|
||||
setOtherParts(mockOtherParts);
|
||||
} else {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.lotNo) {
|
||||
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
const isFreightCostLocked = (method: DeliveryMethod) =>
|
||||
method === 'direct_dispatch' || method === 'self_pickup';
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
|
||||
if (field === 'deliveryMethod') {
|
||||
const method = value as DeliveryMethod;
|
||||
if (isFreightCostLocked(method)) {
|
||||
setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, deliveryMethod: method }));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 배차 정보 핸들러
|
||||
const handleDispatchChange = (index: number, field: keyof VehicleDispatch, value: string) => {
|
||||
setFormData(prev => {
|
||||
const newDispatches = [...prev.vehicleDispatches];
|
||||
newDispatches[index] = { ...newDispatches[index], [field]: value };
|
||||
return { ...prev, vehicleDispatches: newDispatches };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDispatch = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: [...prev.vehicleDispatches, createEmptyDispatch()],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveDispatch = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: prev.vehicleDispatches.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// 아코디언 제어
|
||||
const handleExpandAll = useCallback(() => {
|
||||
const allIds = [
|
||||
...productGroups.map(g => g.id),
|
||||
...(otherParts.length > 0 ? ['other-parts'] : []),
|
||||
];
|
||||
setAccordionValue(allIds);
|
||||
}, [productGroups, otherParts]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setAccordionValue([]);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
}, [router]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
|
||||
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
|
||||
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!validateForm()) return { success: false, error: '' };
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createShipment(formData);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '출고 등록에 실패했습니다.' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ShipmentCreate] handleSubmit error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData]);
|
||||
|
||||
// 제품 부품 테이블 렌더링
|
||||
const renderPartsTable = (parts: ProductPart[]) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">순번</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-center">수량</TableHead>
|
||||
<TableHead className="w-20 text-center">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((part) => (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell className="text-center">{part.seq}</TableCell>
|
||||
<TableCell>{part.itemName}</TableCell>
|
||||
<TableCell>{part.specification}</TableCell>
|
||||
<TableCell className="text-center">{part.quantity}</TableCell>
|
||||
<TableCell className="text-center">{part.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
// LOT에서 선택한 정보 표시
|
||||
const selectedLot = lotOptions.find(o => o.value === formData.lotNo);
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="space-y-6">
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* 출고번호 - 자동생성 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">출고번호</div>
|
||||
<div className="font-medium text-muted-foreground">자동생성</div>
|
||||
</div>
|
||||
{/* 로트번호 - Select */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">로트번호 *</div>
|
||||
<Select
|
||||
value={formData.lotNo}
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="로트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lotOptions.filter(o => o.value).map((option, index) => (
|
||||
<SelectItem key={`${option.value}-${index}`} value={option.value}>
|
||||
{option.label} ({option.customerName} - {option.siteName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
|
||||
</div>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">현장명</div>
|
||||
<div className="font-medium">{selectedLot?.siteName || '-'}</div>
|
||||
</div>
|
||||
{/* 수주처 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">수주처</div>
|
||||
<div className="font-medium">{selectedLot?.customerName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고 예정일 *</Label>
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
<DatePicker
|
||||
value={formData.shipmentDate || ''}
|
||||
onChange={(date) => handleInputChange('shipmentDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식 *</Label>
|
||||
<Select
|
||||
value={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryMethodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={formData.freightCost || ''}
|
||||
onValueChange={(value) => handleInputChange('freightCost', value)}
|
||||
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{freightCostOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>수신자</Label>
|
||||
<Input
|
||||
value={formData.receiver || ''}
|
||||
onChange={(e) => handleInputChange('receiver', e.target.value)}
|
||||
placeholder="수신자명"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수신처</Label>
|
||||
<Input
|
||||
value={formData.receiverContact || ''}
|
||||
onChange={(e) => handleInputChange('receiverContact', e.target.value)}
|
||||
placeholder="수신처"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.zipCode || ''}
|
||||
placeholder="우편번호"
|
||||
className="w-32"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openPostcode}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-1" />
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address || ''}
|
||||
placeholder="주소"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Input
|
||||
value={formData.addressDetail || ''}
|
||||
onChange={(e) => handleInputChange('addressDetail', e.target.value)}
|
||||
placeholder="상세주소"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 3: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddDispatch}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.vehicleDispatches.map((dispatch, index) => (
|
||||
<TableRow key={dispatch.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.logisticsCompany}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'logisticsCompany', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logisticsOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<DateTimePicker
|
||||
value={dispatch.arrivalDateTime}
|
||||
onChange={(val) => handleDispatchChange(index, 'arrivalDateTime', val)}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.tonnage}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'tonnage', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vehicleTonnageOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.vehicleNo}
|
||||
onChange={(e) => handleDispatchChange(index, 'vehicleNo', e.target.value)}
|
||||
placeholder="차량번호"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.driverContact}
|
||||
onChange={(e) => handleDispatchChange(index, 'driverContact', e.target.value)}
|
||||
placeholder="연락처"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.remarks}
|
||||
onChange={(e) => handleDispatchChange(index, 'remarks', e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{formData.vehicleDispatches.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveDispatch(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 4: 제품내용 (읽기전용 - LOT 선택 시 표시) */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">제품내용</CardTitle>
|
||||
{productGroups.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
펼치기/접기
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
모두 펼치기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
모두 접기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productGroups.length > 0 || otherParts.length > 0 ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={accordionValue}
|
||||
onValueChange={setAccordionValue}
|
||||
>
|
||||
{productGroups.map((group: ProductGroup) => (
|
||||
<AccordionItem key={group.id} value={group.id}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{group.productName}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({group.specification})
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{group.partCount}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(group.parts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{otherParts.length > 0 && (
|
||||
<AccordionItem value="other-parts">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">기타부품</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{otherParts.length}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(otherParts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
{formData.lotNo ? '제품 정보를 불러오는 중...' : '로트를 선택하면 제품 목록이 표시됩니다.'}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [
|
||||
formData, validationErrors, isSubmitting, lotOptions, logisticsOptions,
|
||||
vehicleTonnageOptions, selectedLot, productGroups, otherParts, accordionValue,
|
||||
handleLotChange, handleExpandAll, handleCollapseAll, openPostcode,
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={shipmentCreateConfig}
|
||||
mode="create"
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={shipmentCreateConfig}
|
||||
mode="create"
|
||||
isLoading={isLoading}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={async () => {
|
||||
return await handleSubmit();
|
||||
}}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ClipboardList,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -56,7 +54,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { shipmentConfig } from './shipmentConfig';
|
||||
import { getShipmentById, deleteShipment, updateShipmentStatus } from './actions';
|
||||
import { getShipmentById, updateShipmentStatus } from './actions';
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
@@ -72,6 +70,8 @@ import type {
|
||||
} from './types';
|
||||
import { ShippingSlip } from './documents/ShippingSlip';
|
||||
import { DeliveryConfirmation } from './documents/DeliveryConfirmation';
|
||||
import type { OrderDocumentDetail } from './documents/ShipmentOrderDocument';
|
||||
import { getOrderDocumentDetail } from '@/components/orders/actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -85,14 +85,13 @@ const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
|
||||
ready: 'shipping',
|
||||
shipping: 'completed',
|
||||
completed: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
const router = useRouter();
|
||||
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'delivery' | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const [orderDetail, setOrderDetail] = useState<OrderDocumentDetail | null>(null);
|
||||
// 상태 변경 관련 상태
|
||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||
const [targetStatus, setTargetStatus] = useState<ShipmentStatus | null>(null);
|
||||
@@ -139,6 +138,21 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 문서 모달 열 때 수주 BOM 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!previewDocument || !detail?.orderId) {
|
||||
if (!previewDocument) setOrderDetail(null);
|
||||
return;
|
||||
}
|
||||
getOrderDocumentDetail(String(detail.orderId)).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = result.data as Record<string, any>;
|
||||
setOrderDetail((raw?.data ?? raw) as OrderDocumentDetail);
|
||||
}
|
||||
});
|
||||
}, [previewDocument, detail?.orderId]);
|
||||
|
||||
const _handleGoBack = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
}, [router]);
|
||||
@@ -147,25 +161,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
router.push(`/ko/outbound/shipments/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteShipment(id);
|
||||
if (result.success) {
|
||||
router.push('/ko/outbound/shipments');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ShipmentDetail] handleDelete error:', err);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
const handleOpenStatusDialog = useCallback((status: ShipmentStatus) => {
|
||||
setTargetStatus(status);
|
||||
setStatusFormData({
|
||||
@@ -241,7 +236,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
);
|
||||
|
||||
const canEdit = detail ? (detail.status === 'scheduled' || detail.status === 'ready') : false;
|
||||
const canDelete = detail ? (detail.status === 'scheduled' || detail.status === 'ready') : false;
|
||||
|
||||
// 제품 부품 테이블 렌더링
|
||||
const renderPartsTable = (parts: ProductPart[]) => (
|
||||
@@ -291,16 +285,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
<ClipboardList className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">납품확인서 보기</span>
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">삭제</span>
|
||||
</Button>
|
||||
)}
|
||||
{STATUS_TRANSITIONS[detail.status] && detail.canShip && (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -309,7 +293,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
onClick={() => handleOpenStatusDialog(STATUS_TRANSITIONS[detail.status]!)}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">{SHIPMENT_STATUS_LABELS[STATUS_TRANSITIONS[detail.status]!]}으로 변경</span>
|
||||
<span className="hidden md:inline">{SHIPMENT_STATUS_LABELS[STATUS_TRANSITIONS[detail.status]!]}로 변경</span>
|
||||
</Button>
|
||||
)}
|
||||
{STATUS_TRANSITIONS[detail.status] && !detail.canShip && (
|
||||
@@ -325,7 +309,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [detail, canDelete, handleOpenStatusDialog]);
|
||||
}, [detail, handleOpenStatusDialog]);
|
||||
|
||||
// 컨텐츠 렌더링
|
||||
const renderViewContent = useCallback((_data: Record<string, unknown>) => {
|
||||
@@ -340,7 +324,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('출고로트', detail.lotNo || detail.shipmentNo)}
|
||||
{renderInfoField('수주로트', detail.orderLotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
{renderInfoField('작성자', detail.registrant)}
|
||||
@@ -543,28 +528,12 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} />}
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} orderDetail={orderDetail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} orderDetail={orderDetail} />}
|
||||
</>
|
||||
)}
|
||||
</DocumentViewer>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="출고 정보 삭제"
|
||||
description={
|
||||
<>
|
||||
출고번호 {detail?.shipmentNo}을(를) 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* 상태 변경 다이얼로그 */}
|
||||
<Dialog open={showStatusDialog} onOpenChange={setShowStatusDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -92,11 +91,6 @@ export function ShipmentList() {
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 등록 핸들러 =====
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 통계 카드 (3개: 당일 출고대기, 출고대기, 출고완료) =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
@@ -135,6 +129,7 @@ export function ShipmentList() {
|
||||
ready: 'yellow',
|
||||
shipping: 'blue',
|
||||
completed: 'green',
|
||||
cancelled: 'red',
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -218,17 +213,11 @@ export function ShipmentList() {
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '출고 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 테이블 컬럼 (11개)
|
||||
// 테이블 컬럼 (12개)
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]', copyable: true },
|
||||
{ key: 'lotNo', label: '출고로트', className: 'min-w-[120px]', copyable: true },
|
||||
{ key: 'orderLotNo', label: '수주로트', className: 'min-w-[120px]', copyable: true },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center', copyable: true },
|
||||
@@ -300,7 +289,8 @@ export function ShipmentList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo || item.shipmentNo || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo?.trim() || item.shipmentNo || '-'}</TableCell>
|
||||
<TableCell>{item.orderLotNo || '-'}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer || item.customerName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.receiver || '-'}</TableCell>
|
||||
@@ -350,7 +340,8 @@ export function ShipmentList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
|
||||
<InfoField label="출고로트" value={item.lotNo || item.shipmentNo} />
|
||||
<InfoField label="수주로트" value={item.orderLotNo || '-'} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="수주처" value={item.orderCustomer || item.customerName || '-'} />
|
||||
<InfoField label="수신자" value={item.receiver || '-'} />
|
||||
@@ -403,7 +394,7 @@ export function ShipmentList() {
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCalendarDateClick, handleCalendarEventClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
|
||||
@@ -31,9 +31,7 @@ import type {
|
||||
ShipmentPriority,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
ShipmentCreateFormData,
|
||||
ShipmentEditFormData,
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
} from './types';
|
||||
@@ -127,6 +125,7 @@ interface ShipmentItemApiData {
|
||||
unit?: string;
|
||||
lot_no?: string;
|
||||
stock_lot_id?: number;
|
||||
product_name?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
@@ -151,6 +150,7 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
|
||||
id: String(data.id),
|
||||
shipmentNo: data.shipment_no,
|
||||
lotNo: data.lot_no || '',
|
||||
orderLotNo: data.order_info?.order_no || '',
|
||||
scheduledDate: data.scheduled_date,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
@@ -189,10 +189,40 @@ function transformApiToProduct(data: ShipmentItemApiData): ShipmentProduct {
|
||||
|
||||
// ===== API → Frontend 변환 (상세용) =====
|
||||
function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
// items를 floor_unit 기준으로 productGroups 자동 그룹핑
|
||||
const rawItems = data.items || [];
|
||||
const items = rawItems.map(transformApiToProduct);
|
||||
const groupMap = new Map<string, { productName: string; productCode: string; parts: { product: ShipmentProduct; unit: string }[] }>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const raw = rawItems[i];
|
||||
const key = item.floorUnit || `item-${item.id}`;
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, { productName: key, productCode: raw.product_name || '', parts: [] });
|
||||
}
|
||||
groupMap.get(key)!.parts.push({ product: item, unit: raw.unit || '' });
|
||||
}
|
||||
const productGroups = Array.from(groupMap.entries()).map(([key, g]) => ({
|
||||
id: key,
|
||||
productName: g.productName,
|
||||
specification: g.productCode,
|
||||
partCount: g.parts.length,
|
||||
parts: g.parts.map((p, i) => ({
|
||||
id: p.product.id,
|
||||
seq: i + 1,
|
||||
itemName: p.product.itemName,
|
||||
specification: p.product.specification,
|
||||
quantity: p.product.quantity,
|
||||
unit: p.unit,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
orderId: data.order_id ?? data.order_info?.order_id,
|
||||
shipmentNo: data.shipment_no,
|
||||
lotNo: data.lot_no || '',
|
||||
orderLotNo: data.order_info?.order_no || '',
|
||||
scheduledDate: data.scheduled_date,
|
||||
shipmentDate: (data as unknown as Record<string, unknown>).shipment_date as string | undefined,
|
||||
status: data.status,
|
||||
@@ -238,10 +268,10 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
remarks: '',
|
||||
}]
|
||||
: []),
|
||||
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
|
||||
productGroups: [],
|
||||
// 제품내용 (그룹핑) - floor_unit 기준 자동 그룹핑
|
||||
productGroups,
|
||||
otherParts: [],
|
||||
products: (data.items || []).map(transformApiToProduct),
|
||||
products: items,
|
||||
logisticsCompany: data.logistics_company,
|
||||
vehicleTonnage: data.vehicle_tonnage,
|
||||
shippingCost: data.shipping_cost ? parseFloat(String(data.shipping_cost)) : undefined,
|
||||
@@ -253,11 +283,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 (통계용) =====
|
||||
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number }): ShipmentStats {
|
||||
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number; completed_count?: number; ready_count?: number }): ShipmentStats {
|
||||
return {
|
||||
todayShipmentCount: data.today_shipment_count,
|
||||
scheduledCount: data.scheduled_count,
|
||||
shippingCount: data.shipping_count,
|
||||
completedCount: data.completed_count || 0,
|
||||
urgentCount: data.urgent_count,
|
||||
totalCount: data.total_count || 0,
|
||||
};
|
||||
@@ -285,37 +316,6 @@ function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): Sh
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (등록용) =====
|
||||
function transformCreateFormToApi(
|
||||
data: ShipmentCreateFormData
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {
|
||||
lot_no: data.lotNo,
|
||||
scheduled_date: data.scheduledDate,
|
||||
priority: data.priority,
|
||||
delivery_method: data.deliveryMethod,
|
||||
logistics_company: data.logisticsCompany,
|
||||
vehicle_tonnage: data.vehicleTonnage,
|
||||
loading_time: data.loadingTime,
|
||||
loading_manager: data.loadingManager,
|
||||
remarks: data.remarks,
|
||||
};
|
||||
|
||||
if (data.vehicleDispatches && data.vehicleDispatches.length > 0) {
|
||||
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
|
||||
seq: idx + 1,
|
||||
logistics_company: vd.logisticsCompany || null,
|
||||
arrival_datetime: vd.arrivalDateTime || null,
|
||||
tonnage: vd.tonnage || null,
|
||||
vehicle_no: vd.vehicleNo || null,
|
||||
driver_contact: vd.driverContact || null,
|
||||
remarks: vd.remarks || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (수정용) =====
|
||||
function transformEditFormToApi(
|
||||
data: Partial<ShipmentEditFormData>
|
||||
@@ -423,22 +423,6 @@ export async function getShipmentById(id: string): Promise<{ success: boolean; d
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 출고 등록 =====
|
||||
export async function createShipment(
|
||||
data: ShipmentCreateFormData
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
|
||||
const apiData = transformCreateFormToApi(data);
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/shipments'),
|
||||
method: 'POST',
|
||||
body: apiData,
|
||||
transform: (d: ShipmentApiData) => transformApiToDetail(d),
|
||||
errorMessage: '출고 등록에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 출고 수정 =====
|
||||
export async function updateShipment(
|
||||
id: string, data: Partial<ShipmentEditFormData>
|
||||
@@ -482,27 +466,6 @@ export async function updateShipmentStatus(
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 출고 삭제 =====
|
||||
export async function deleteShipment(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/shipments/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '출고 삭제에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== LOT 옵션 조회 =====
|
||||
export async function getLotOptions(): Promise<{ success: boolean; data: LotOption[]; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction<LotOption[]>({
|
||||
url: buildApiUrl('/api/v1/shipments/options/lots'),
|
||||
errorMessage: 'LOT 옵션 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, data: [], __authError: true };
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
// ===== 물류사 옵션 조회 =====
|
||||
export async function getLogisticsOptions(): Promise<{ success: boolean; data: LogisticsOption[]; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction<LogisticsOption[]>({
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { ShipmentOrderDocument } from './ShipmentOrderDocument';
|
||||
import { ShipmentOrderDocument, type OrderDocumentDetail } from './ShipmentOrderDocument';
|
||||
|
||||
interface DeliveryConfirmationProps {
|
||||
data: ShipmentDetail;
|
||||
orderDetail?: OrderDocumentDetail | null;
|
||||
}
|
||||
|
||||
export function DeliveryConfirmation({ data }: DeliveryConfirmationProps) {
|
||||
return <ShipmentOrderDocument title="납 품 확 인 서" data={data} />;
|
||||
}
|
||||
export function DeliveryConfirmation({ data, orderDetail }: DeliveryConfirmationProps) {
|
||||
return <ShipmentOrderDocument title="납 품 확 인 서" data={data} orderDetail={orderDetail} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { ShipmentOrderDocument } from './ShipmentOrderDocument';
|
||||
import { ShipmentOrderDocument, type OrderDocumentDetail } from './ShipmentOrderDocument';
|
||||
|
||||
interface ShippingSlipProps {
|
||||
data: ShipmentDetail;
|
||||
orderDetail?: OrderDocumentDetail | null;
|
||||
}
|
||||
|
||||
export function ShippingSlip({ data }: ShippingSlipProps) {
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} showDispatchInfo showLotColumn />;
|
||||
}
|
||||
export function ShippingSlip({ data, orderDetail }: ShippingSlipProps) {
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} orderDetail={orderDetail} showDispatchInfo showLotColumn />;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export { ShipmentList } from './ShipmentList';
|
||||
export { ShipmentCreate } from './ShipmentCreate';
|
||||
export { ShipmentDetail } from './ShipmentDetail';
|
||||
export { ShipmentEdit } from './ShipmentEdit';
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
|
||||
*
|
||||
* 특이사항:
|
||||
* - view 모드만 지원 (수정은 별도 /edit 페이지로 이동)
|
||||
* - 삭제 기능 있음 (scheduled, ready 상태에서만)
|
||||
* - 문서 미리보기: 출고증, 거래명세서, 납품확인서
|
||||
*/
|
||||
export const shipmentConfig: DetailConfig = {
|
||||
@@ -24,25 +23,19 @@ export const shipmentConfig: DetailConfig = {
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true, // 상태에 따라 동적으로 처리
|
||||
showDelete: false,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
deleteConfirmMessage: {
|
||||
title: '출고 정보 삭제',
|
||||
description: '이 출고 정보를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 출고 등록 페이지 Config
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 출고 수정 페이지 Config
|
||||
*/
|
||||
export const shipmentCreateConfig: DetailConfig = {
|
||||
export const shipmentEditConfig: DetailConfig = {
|
||||
title: '출고',
|
||||
description: '새로운 출고를 등록합니다',
|
||||
description: '출고 정보를 수정합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/shipments',
|
||||
fields: [],
|
||||
@@ -53,13 +46,4 @@ export const shipmentCreateConfig: DetailConfig = {
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 출고 수정 페이지 Config
|
||||
*/
|
||||
export const shipmentEditConfig: DetailConfig = {
|
||||
...shipmentCreateConfig,
|
||||
title: '출고',
|
||||
description: '출고 정보를 수정합니다',
|
||||
};
|
||||
};
|
||||
@@ -5,9 +5,10 @@
|
||||
// 출고 상태
|
||||
export type ShipmentStatus =
|
||||
| 'scheduled' // 출고예정
|
||||
| 'ready' // 출고대기 (출고대기)
|
||||
| 'ready' // 출고대기
|
||||
| 'shipping' // 배송중
|
||||
| 'completed'; // 배송완료 (출고완료)
|
||||
| 'completed' // 배송완료 (출고완료)
|
||||
| 'cancelled'; // 취소
|
||||
|
||||
// 상태 라벨
|
||||
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
|
||||
@@ -15,6 +16,7 @@ export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
|
||||
ready: '출고대기',
|
||||
shipping: '배송중',
|
||||
completed: '출고완료',
|
||||
cancelled: '취소',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
@@ -23,6 +25,7 @@ export const SHIPMENT_STATUS_STYLES: Record<ShipmentStatus, string> = {
|
||||
ready: 'bg-yellow-100 text-yellow-800',
|
||||
shipping: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
// 출고 우선순위
|
||||
@@ -111,7 +114,8 @@ export const DELIVERY_METHOD_LABELS: Record<DeliveryMethod, string> = {
|
||||
export interface ShipmentItem {
|
||||
id: string;
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
lotNo: string; // 출고로트
|
||||
orderLotNo?: string; // 수주로트 (수주번호)
|
||||
scheduledDate: string; // 출고예정일
|
||||
status: ShipmentStatus; // 상태
|
||||
priority: ShipmentPriority; // 우선순위
|
||||
@@ -158,8 +162,10 @@ export interface ShipmentProduct {
|
||||
export interface ShipmentDetail {
|
||||
// 기본 정보 (읽기전용)
|
||||
id: string;
|
||||
orderId?: number; // 수주 ID (문서 BOM 데이터 조회용)
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
lotNo: string; // 출고로트
|
||||
orderLotNo?: string; // 수주로트 (수주번호)
|
||||
siteName: string; // 현장명
|
||||
customerName: string; // 수주처
|
||||
customerGrade: string; // 거래등급
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Package,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -326,18 +325,19 @@ export function PricingListClient({
|
||||
};
|
||||
|
||||
// 헤더 액션 (함수로 정의)
|
||||
const headerActions = () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// TODO: API 연동 시 품목 마스터 동기화 로직 구현
|
||||
}}
|
||||
className="ml-auto gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
품목 마스터 동기화
|
||||
</Button>
|
||||
);
|
||||
// NOTE: 품목 마스터 동기화 버튼 - 실제 로직 미구현 상태로 주석처리
|
||||
// const headerActions = () => (
|
||||
// <Button
|
||||
// variant="outline"
|
||||
// onClick={() => {
|
||||
// // TODO: API 연동 시 품목 마스터 동기화 로직 구현
|
||||
// }}
|
||||
// className="ml-auto gap-2"
|
||||
// >
|
||||
// <RefreshCw className="h-4 w-4" />
|
||||
// 품목 마스터 동기화
|
||||
// </Button>
|
||||
// );
|
||||
|
||||
// UniversalListPage 설정
|
||||
const pricingConfig: UniversalListConfig<PricingListItem> = {
|
||||
@@ -356,7 +356,7 @@ export function PricingListClient({
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
headerActions,
|
||||
// headerActions, // 품목 마스터 동기화 버튼 미구현으로 주석처리
|
||||
stats,
|
||||
tabs,
|
||||
|
||||
|
||||
@@ -509,7 +509,7 @@ export async function getItemList(params?: GetItemListParams): Promise<ItemOptio
|
||||
const result = await executeServerAction<ItemListResponse>({
|
||||
url: buildApiUrl('/api/v1/items', {
|
||||
size: params?.size || 1000,
|
||||
q: params?.q,
|
||||
search: params?.q,
|
||||
item_type: params?.itemType,
|
||||
exclude_process_id: params?.excludeProcessId,
|
||||
}),
|
||||
|
||||
25
src/components/production/MODULE.md
Normal file
25
src/components/production/MODULE.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Production Module (생산관리)
|
||||
|
||||
**Module ID**: `production`
|
||||
**Tenant**: Kyungdong (경동 셔터 MES)
|
||||
**Route Prefixes**: `/production`
|
||||
**Component Count**: 56 files
|
||||
|
||||
## Dependencies on Common ERP
|
||||
- `@/lib/api/*` — Server actions, API client
|
||||
- `@/components/ui/*` — UI primitives (shadcn/ui)
|
||||
- `@/components/templates/*` — IntegratedListTemplateV2 등
|
||||
- `@/components/organisms/*` — PageLayout, PageHeader
|
||||
- `@/hooks/*` — usePermission, useModules 등
|
||||
- `@/stores/authStore` — Tenant 정보
|
||||
- `@/stores/menuStore` — 사이드바 상태
|
||||
|
||||
## Exports to Common ERP
|
||||
**NONE** — Phase 0에서 모든 교차 참조 해소 완료.
|
||||
- 타입: `@/lib/api/production-orders/types.ts` (re-export)
|
||||
- 서버 액션: `@/lib/api/production-orders/actions.ts` (async wrapper)
|
||||
- 모달: `@/components/document-system/modals/` (dynamic import wrapper)
|
||||
|
||||
## Related Dashboard Sections
|
||||
- `production` (생산 현황)
|
||||
- `shipment` (출고 현황)
|
||||
@@ -7,7 +7,7 @@
|
||||
* 셀 클릭으로 ○/X/△ 토글
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
@@ -67,13 +66,15 @@ interface GridData {
|
||||
nonWorkingDays: string[];
|
||||
}
|
||||
|
||||
const YEAR_OPTIONS = Array.from({ length: 10 }, (_, i) => 2021 + i);
|
||||
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
export function EquipmentInspectionGrid() {
|
||||
const searchParams = useSearchParams();
|
||||
const [cycle, setCycle] = useState<InspectionCycle>('daily');
|
||||
const [period, setPeriod] = useState<string>(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
const [year, setYear] = useState(() => new Date().getFullYear());
|
||||
const [month, setMonth] = useState(() => new Date().getMonth() + 1);
|
||||
const period = `${year}-${String(month).padStart(2, '0')}`;
|
||||
const [lineFilter, setLineFilter] = useState<string>('all');
|
||||
const [equipmentFilter, setEquipmentFilter] = useState<string>(() => {
|
||||
if (typeof window === 'undefined') return 'all';
|
||||
@@ -85,6 +86,40 @@ export function EquipmentInspectionGrid() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
|
||||
// 드래그 스크롤
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const scrollLeft = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isDragging.current = true;
|
||||
startX.current = e.pageX - el.offsetLeft;
|
||||
scrollLeft.current = el.scrollLeft;
|
||||
el.style.cursor = 'grabbing';
|
||||
el.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - el.offsetLeft;
|
||||
el.scrollLeft = scrollLeft.current - (x - startX.current);
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.style.cursor = 'grab';
|
||||
el.style.userSelect = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
getEquipmentOptions().then((r) => {
|
||||
@@ -139,13 +174,19 @@ export function EquipmentInspectionGrid() {
|
||||
: Array.isArray(rawLabels) ? rawLabels : [];
|
||||
// 주말(토/일) 계산
|
||||
const [y, m] = period.split('-').map(Number);
|
||||
const weekends: string[] = [];
|
||||
const weekends = new Set<string>();
|
||||
const daysInMonth = new Date(y, m, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dow = new Date(y, m - 1, d).getDay();
|
||||
if (dow === 0 || dow === 6) weekends.push(String(d));
|
||||
if (dow === 0 || dow === 6) weekends.add(String(d));
|
||||
}
|
||||
setGridData({ rows, labels, nonWorkingDays: weekends });
|
||||
// API에서 받은 임시휴일 추가 (형식: "2026-03-17" → 일자 "17"으로 변환)
|
||||
const apiHolidays: string[] = apiItems.length > 0 ? (apiItems[0].non_working_days ?? []) : [];
|
||||
for (const dateStr of apiHolidays) {
|
||||
const day = String(Number(dateStr.split('-')[2]));
|
||||
weekends.add(day);
|
||||
}
|
||||
setGridData({ rows, labels, nonWorkingDays: Array.from(weekends) });
|
||||
} else {
|
||||
setGridData({ rows: [], labels: [], nonWorkingDays: [] });
|
||||
}
|
||||
@@ -187,7 +228,12 @@ export function EquipmentInspectionGrid() {
|
||||
return { ...prev, rows: newRows };
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '점검 결과 변경에 실패했습니다.');
|
||||
const errorMsg = result.error?.includes('non_working_day')
|
||||
? '비근무일에는 점검을 입력할 수 없습니다.'
|
||||
: result.error?.includes('no_inspect_permission')
|
||||
? '담당자만 점검을 입력할 수 있습니다.'
|
||||
: result.error || '점검 결과 변경에 실패했습니다.';
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
}, [cycle, period]);
|
||||
|
||||
@@ -243,12 +289,28 @@ export function EquipmentInspectionGrid() {
|
||||
<div className="flex flex-wrap items-end gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">점검년월</Label>
|
||||
<DatePicker
|
||||
value={period ? `${period}-01` : ''}
|
||||
onChange={(v) => {
|
||||
if (v) setPeriod(v.substring(0, 7));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger className="w-[100px] h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{YEAR_OPTIONS.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}년</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={String(month)} onValueChange={(v) => setMonth(Number(v))}>
|
||||
<SelectTrigger className="w-[80px] h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTH_OPTIONS.map(m => (
|
||||
<SelectItem key={m} value={String(m)}>{m}월</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">생산라인</Label>
|
||||
@@ -353,7 +415,14 @@ export function EquipmentInspectionGrid() {
|
||||
boxShadow: '4px 0 6px -2px rgba(0,0,0,0.08)',
|
||||
});
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto cursor-grab"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<table className="w-full text-xs" style={{ borderCollapse: 'separate', borderSpacing: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -419,10 +488,13 @@ export function EquipmentInspectionGrid() {
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
|
||||
...(isNonWorking ? { background: '#fef2f2' } : {}),
|
||||
}}
|
||||
onClick={() =>
|
||||
row.canInspect &&
|
||||
handleCellClick(row.equipment.id, template.id, label)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!row.canInspect) {
|
||||
toast.error('담당자만 점검을 입력할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
handleCellClick(row.equipment.id, template.id, label);
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-bold">{getResultSymbol(result)}</span>
|
||||
</td>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function InspectionCreate() {
|
||||
client: '',
|
||||
manager: '',
|
||||
managerContact: '',
|
||||
receptionDate: new Date().toISOString().slice(0, 10),
|
||||
constructionSite: { ...emptyConstructionSite },
|
||||
materialDistributor: { ...emptyMaterialDistributor },
|
||||
constructorInfo: { ...emptyConstructor },
|
||||
|
||||
@@ -54,9 +54,6 @@ const ITEMS_PER_PAGE = 20;
|
||||
export function InspectionList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 통계 =====
|
||||
const { data: statsData, reload: reloadStats } = useStatsLoader(getInspectionStats);
|
||||
|
||||
// ===== 날짜 범위 =====
|
||||
const today = new Date();
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
@@ -68,6 +65,11 @@ export function InspectionList() {
|
||||
return getLocalDateString(d);
|
||||
});
|
||||
|
||||
// ===== 통계 (목록과 동일 날짜 범위 적용) =====
|
||||
const { data: statsData, reload: reloadStats } = useStatsLoader(
|
||||
useCallback(() => getInspectionStats({ dateFrom: startDate, dateTo: endDate }), [startDate, endDate])
|
||||
);
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('month');
|
||||
|
||||
21
src/components/quality/MODULE.md
Normal file
21
src/components/quality/MODULE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Quality Module (품질관리)
|
||||
|
||||
**Module ID**: `quality`
|
||||
**Tenant**: Kyungdong (경동 셔터 MES)
|
||||
**Route Prefixes**: `/quality`
|
||||
**Component Count**: 35 files
|
||||
|
||||
## Dependencies on Common ERP
|
||||
- `@/lib/api/*` — Server actions, API client
|
||||
- `@/components/ui/*` — UI primitives (shadcn/ui)
|
||||
- `@/components/templates/*` — IntegratedListTemplateV2 등
|
||||
- `@/components/organisms/*` — PageLayout, PageHeader
|
||||
- `@/hooks/*` — usePermission, useModules 등
|
||||
- `@/stores/authStore` — Tenant 정보
|
||||
|
||||
## Exports to Common ERP
|
||||
**NONE** — Phase 0에서 교차 참조 해소 완료.
|
||||
- 모달: `@/components/document-system/modals/` (WorkLogModal — dynamic import)
|
||||
|
||||
## Related Dashboard Sections
|
||||
없음 (품질 대시보드 섹션은 아직 미구현)
|
||||
@@ -213,6 +213,8 @@ export interface QuoteApiData {
|
||||
updated_by: number | null;
|
||||
finalized_at: string | null;
|
||||
finalized_by: number | null;
|
||||
// options JSON (부가세 타입 등)
|
||||
options?: Record<string, unknown> | null;
|
||||
// 연결된 수주 ID (수주전환 시 설정)
|
||||
order_id?: number | null;
|
||||
// 관계 데이터 (with 로드 시)
|
||||
@@ -734,6 +736,8 @@ export interface QuoteFormDataV2 {
|
||||
dueDate: string;
|
||||
remarks: string;
|
||||
status: 'draft' | 'temporary' | 'final' | 'converted'; // 작성중, 임시저장, 최종저장, 수주전환
|
||||
|
||||
vatType: 'included' | 'excluded'; // 부가세 포함/별도
|
||||
discountRate: number; // 할인율 (%)
|
||||
discountAmount: number; // 할인 금액
|
||||
locations: LocationItem[];
|
||||
@@ -919,6 +923,9 @@ export function transformV2ToApi(
|
||||
status: data.status === 'final' ? 'finalized' : 'draft',
|
||||
is_final: data.status === 'final',
|
||||
calculation_inputs: calculationInputs,
|
||||
options: {
|
||||
vat_type: data.vatType || 'included',
|
||||
},
|
||||
items: items,
|
||||
};
|
||||
}
|
||||
@@ -1042,6 +1049,9 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
remarks: apiData.remarks || apiData.description || transformed.description || '',
|
||||
status: mapStatus(apiData.status),
|
||||
// raw API: discount_rate, transformed: discountRate
|
||||
vatType: apiData.options?.vat_type as 'included' | 'excluded'
|
||||
|| (transformed as unknown as { vatType?: string })?.vatType as 'included' | 'excluded'
|
||||
|| 'included',
|
||||
discountRate: Number(apiData.discount_rate) || transformed.discountRate || 0,
|
||||
discountAmount: Number(apiData.discount_amount) || transformed.discountAmount || 0,
|
||||
locations: locations,
|
||||
|
||||
@@ -28,14 +28,27 @@ import { SOUND_OPTIONS, DEFAULT_ITEM_VISIBILITY } from './types';
|
||||
import { saveNotificationSettings } from './actions';
|
||||
import { ItemSettingsDialog } from './ItemSettingsDialog';
|
||||
|
||||
// 미리듣기 함수
|
||||
// 미리듣기 - Audio API 재생
|
||||
let previewAudio: HTMLAudioElement | null = null;
|
||||
|
||||
function playPreviewSound(soundType: SoundType) {
|
||||
if (soundType === 'mute') {
|
||||
toast.info('무음으로 설정되어 있습니다.');
|
||||
return;
|
||||
}
|
||||
const soundName = soundType === 'default' ? '기본 알림음' : 'SAM 보이스';
|
||||
toast.info(`${soundName} 미리듣기`);
|
||||
|
||||
// 이전 재생 중지
|
||||
if (previewAudio) {
|
||||
previewAudio.pause();
|
||||
previewAudio = null;
|
||||
}
|
||||
|
||||
const soundFile = soundType === 'sam_voice' ? 'sam_voice.wav' : 'default.wav';
|
||||
previewAudio = new Audio(`/sounds/${soundFile}`);
|
||||
previewAudio.play().catch(() => {
|
||||
const soundName = soundType === 'default' ? '기본 알림음' : 'SAM 보이스';
|
||||
toast.info(`${soundName} 미리듣기 (음원 파일 준비 중)`);
|
||||
});
|
||||
}
|
||||
|
||||
// 알림 항목 컴포넌트
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
/**
|
||||
* 알림 설정 타입 정의
|
||||
*
|
||||
* ========================================
|
||||
* [2026-01-05] 백엔드 API 수정 필요 사항
|
||||
* ========================================
|
||||
* API 응답 구조: { enabled, email, soundType } per item
|
||||
* soundType: 'default' | 'sam_voice' | 'mute'
|
||||
*
|
||||
* 1. NotificationItem에 soundType 필드 추가
|
||||
* - 기존: { enabled: boolean, email: boolean }
|
||||
* - 변경: { enabled: boolean, email: boolean, soundType: 'default' | 'sam_voice' | 'mute' }
|
||||
*
|
||||
* 2. OrderNotificationSettings에 approvalRequest 항목 추가
|
||||
* - 기존: { salesOrder, purchaseOrder }
|
||||
* - 변경: { salesOrder, purchaseOrder, approvalRequest }
|
||||
*
|
||||
* 3. API 응답 예시:
|
||||
* {
|
||||
* "notice": {
|
||||
* "enabled": true,
|
||||
* "notice": { "enabled": true, "email": false, "soundType": "default" },
|
||||
* "event": { "enabled": true, "email": true, "soundType": "sam_voice" }
|
||||
* },
|
||||
* "order": {
|
||||
* "enabled": true,
|
||||
* "salesOrder": { ... },
|
||||
* "purchaseOrder": { ... },
|
||||
* "approvalRequest": { "enabled": false, "email": false, "soundType": "default" } // 새로 추가
|
||||
* }
|
||||
* }
|
||||
* ========================================
|
||||
* [2026-03-18] soundType API 연동 완료
|
||||
*/
|
||||
|
||||
// 알림 소리 타입 (NEW: 2026-01-05)
|
||||
export type SoundType = 'default' | 'sam_voice' | 'mute';
|
||||
|
||||
// 알림 소리 옵션
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { CreditCard, Download, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { cancelSubscription, requestDataExport } from './actions';
|
||||
import type { SubscriptionInfo } from './types';
|
||||
import { PLAN_LABELS, SUBSCRIPTION_STATUS_LABELS } from './types';
|
||||
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
// ===== Props 타입 =====
|
||||
interface SubscriptionClientProps {
|
||||
initialData: SubscriptionInfo;
|
||||
}
|
||||
|
||||
// ===== 날짜 포맷 함수 =====
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}년 ${month}월 ${day}일`;
|
||||
};
|
||||
|
||||
export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
|
||||
const { canExport } = usePermission();
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo>(initialData);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
// ===== 자료 내보내기 =====
|
||||
const handleExportData = useCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const result = await requestDataExport('all');
|
||||
if (result.success) {
|
||||
toast.success('내보내기 요청이 등록되었습니다. 완료되면 알림을 보내드립니다.');
|
||||
} else {
|
||||
toast.error(result.error || '내보내기 요청에 실패했습니다.');
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 서비스 해지 =====
|
||||
const handleCancelService = useCallback(async () => {
|
||||
if (!subscription.id) {
|
||||
toast.error('구독 정보를 찾을 수 없습니다.');
|
||||
setShowCancelDialog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const result = await cancelSubscription(subscription.id, '사용자 요청');
|
||||
if (result.success) {
|
||||
toast.success('서비스가 해지되었습니다.');
|
||||
setSubscription(prev => ({ ...prev, status: 'cancelled' }));
|
||||
} else {
|
||||
toast.error(result.error || '서비스 해지에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
setShowCancelDialog(false);
|
||||
}
|
||||
}, [subscription.id]);
|
||||
|
||||
// ===== Progress 계산 =====
|
||||
const storageProgress = subscription.storageLimit > 0
|
||||
? (subscription.storageUsed / subscription.storageLimit) * 100
|
||||
: 0;
|
||||
const userProgress = subscription.userLimit
|
||||
? (subscription.userCount / subscription.userLimit) * 100
|
||||
: 30; // 무제한일 경우 30%로 표시
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout>
|
||||
{/* ===== 페이지 헤더 ===== */}
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보를 관리합니다"
|
||||
icon={CreditCard}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{canExport && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportData}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? '처리 중...' : '자료 내보내기'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
|
||||
onClick={() => setShowCancelDialog(true)}
|
||||
disabled={subscription.status === 'cancelled'}
|
||||
>
|
||||
서비스 해지
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* ===== 구독 정보 카드 영역 ===== */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 최근 결제일시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">최근 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.lastPaymentDate)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 다음 결제일시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">다음 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.nextPaymentDate)}
|
||||
</div>
|
||||
{subscription.remainingDays != null && subscription.remainingDays > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
({subscription.remainingDays}일 남음)
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독금액 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독금액</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(subscription.subscriptionAmount)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== 구독 정보 영역 ===== */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-muted-foreground">구독 정보</div>
|
||||
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
||||
{(subscription.status && SUBSCRIPTION_STATUS_LABELS[subscription.status]) || subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 플랜명 */}
|
||||
<h3 className="text-xl font-bold mb-6">
|
||||
{subscription.planName || PLAN_LABELS[subscription.plan]}
|
||||
</h3>
|
||||
|
||||
{/* 사용량 정보 */}
|
||||
<div className="space-y-6">
|
||||
{/* 사용자 수 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
|
||||
사용자 수
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Progress value={userProgress} className="h-2" />
|
||||
</div>
|
||||
<div className="text-sm text-blue-600 min-w-[100px] text-right">
|
||||
{subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 공간 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-muted-foreground flex-shrink-0">
|
||||
저장 공간
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Progress value={storageProgress} className="h-2" />
|
||||
</div>
|
||||
<div className="text-sm text-blue-600 min-w-[100px] text-right">
|
||||
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* ===== 서비스 해지 확인 다이얼로그 ===== */}
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
onConfirm={handleCancelService}
|
||||
variant="destructive"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
서비스 해지
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
모든 데이터가 삭제되며 복구할 수 없습니다.
|
||||
<br />
|
||||
<span className="font-medium text-red-600">
|
||||
정말 서비스를 해지하시겠습니까?
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
confirmText="확인"
|
||||
loading={isCancelling}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 구독관리 (구독관리 통합) 페이지
|
||||
*
|
||||
* 4섹션: 구독정보 카드 / 리소스 사용량 / AI 토큰 사용량 / 서비스 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { CreditCard, Download, AlertTriangle } from 'lucide-react';
|
||||
import { CreditCard, Download, AlertTriangle, Cpu } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import type { SubscriptionInfo } from './types';
|
||||
import { PLAN_LABELS } from './types';
|
||||
import { SUBSCRIPTION_STATUS_LABELS } from './types';
|
||||
import { requestDataExport, cancelSubscription } from './actions';
|
||||
import { formatAmountWon as formatCurrency, formatNumber } from '@/lib/utils/amount';
|
||||
import { formatTokenCount, formatKrw, formatPeriod, getProgressColor } from './utils';
|
||||
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
// ===== 기본 저장공간 (10GB) =====
|
||||
const DEFAULT_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024; // 10GB in bytes
|
||||
// ===== 날짜 포맷 =====
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// ===== 기본값 (API 실패시 사용) =====
|
||||
// ===== 기본값 =====
|
||||
const defaultSubscription: SubscriptionInfo = {
|
||||
lastPaymentDate: '',
|
||||
nextPaymentDate: '',
|
||||
subscriptionAmount: 0,
|
||||
plan: 'free',
|
||||
planName: '무료',
|
||||
monthlyFee: 0,
|
||||
status: 'pending',
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
remainingDays: null,
|
||||
userCount: 0,
|
||||
userLimit: null,
|
||||
storageUsed: 0,
|
||||
storageLimit: DEFAULT_STORAGE_LIMIT,
|
||||
storageLimit: 107_374_182_400,
|
||||
storageUsedFormatted: '0 B',
|
||||
storageLimitFormatted: '10 GB',
|
||||
apiCallsUsed: 0,
|
||||
apiCallsLimit: 10000,
|
||||
storageLimitFormatted: '100 GB',
|
||||
storagePercentage: 0,
|
||||
aiTokens: {
|
||||
period: '',
|
||||
totalTokens: 0,
|
||||
limit: 1_000_000,
|
||||
percentage: 0,
|
||||
costKrw: 0,
|
||||
warningThreshold: 80,
|
||||
isOverLimit: false,
|
||||
byModel: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ===== 날짜 포맷 함수 =====
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}년 ${month}월 ${day}일`;
|
||||
};
|
||||
// ===== 색상이 적용된 Progress Bar =====
|
||||
function ColoredProgress({ value, className = '' }: { value: number; className?: string }) {
|
||||
const color = getProgressColor(value);
|
||||
const clampedValue = Math.min(value, 100);
|
||||
|
||||
return (
|
||||
<div className={`relative h-2 w-full overflow-hidden rounded-full bg-gray-100 ${className}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${color}`}
|
||||
style={{ width: `${clampedValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionManagementProps {
|
||||
initialData: SubscriptionInfo | null;
|
||||
@@ -53,13 +91,15 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const { aiTokens } = subscription;
|
||||
|
||||
// ===== 자료 내보내기 =====
|
||||
const handleExportData = useCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const result = await requestDataExport('all');
|
||||
if (result.success) {
|
||||
toast.success('내보내기 요청이 등록되었습니다. 완료되면 알림을 보내드립니다.');
|
||||
toast.success('자료 내보내기가 완료되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '내보내기 요청에 실패했습니다.');
|
||||
}
|
||||
@@ -96,115 +136,207 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr
|
||||
}, [subscription.id]);
|
||||
|
||||
// ===== Progress 계산 =====
|
||||
const storageProgress = subscription.storageLimit ? (subscription.storageUsed / subscription.storageLimit) * 100 : 0;
|
||||
const apiCallsUsed = subscription.apiCallsUsed ?? 0;
|
||||
const apiCallsLimit = subscription.apiCallsLimit ?? 0;
|
||||
const apiProgress = apiCallsLimit > 0 ? (apiCallsUsed / apiCallsLimit) * 100 : 0;
|
||||
const userPercentage = subscription.userLimit
|
||||
? (subscription.userCount / subscription.userLimit) * 100
|
||||
: 30;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout>
|
||||
{/* ===== 페이지 헤더 ===== */}
|
||||
<PageHeader
|
||||
title="구독관리"
|
||||
description="구독 정보를 관리합니다"
|
||||
title="이용현황"
|
||||
description="구독 정보와 사용량을 관리합니다"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
|
||||
{/* ===== 헤더 액션 버튼 ===== */}
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<Button variant="outline" onClick={handleExportData} disabled={isExporting}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? '처리 중...' : '자료 내보내기'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
|
||||
onClick={() => setShowCancelDialog(true)}
|
||||
disabled={subscription.status === 'cancelled'}
|
||||
>
|
||||
서비스 해지
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* ===== 구독 정보 카드 영역 ===== */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 최근 결제일시 */}
|
||||
|
||||
{/* ===== 섹션 1: 구독 정보 카드 ===== */}
|
||||
{subscription.planName ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 요금제 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">요금제</div>
|
||||
<div className="text-2xl font-bold">{subscription.planName}</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
시작: {formatDate(subscription.startedAt)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독 상태 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독 상태</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
||||
{SUBSCRIPTION_STATUS_LABELS[subscription.status] || subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription.remainingDays != null && subscription.remainingDays > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
남은 일: {subscription.remainingDays}일
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독 금액 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독 금액</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(subscription.monthlyFee)}/월
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
종료: {formatDate(subscription.endedAt)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">최근 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.lastPaymentDate)}
|
||||
</div>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
구독 정보가 없습니다. 관리자에게 문의하세요.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 다음 결제일시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">다음 결제일시</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDate(subscription.nextPaymentDate)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독금액 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">구독금액</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(subscription.subscriptionAmount)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== 구독 정보 영역 ===== */}
|
||||
{/* ===== 섹션 2: 리소스 사용량 ===== */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground mb-2">구독 정보</div>
|
||||
|
||||
{/* 플랜명 */}
|
||||
<h3 className="text-xl font-bold mb-6">
|
||||
{PLAN_LABELS[subscription.plan]}
|
||||
</h3>
|
||||
|
||||
{/* 사용량 정보 */}
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">리소스 사용량</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
{/* 사용자 수 */}
|
||||
{/* 사용자 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">사용자 수</span>
|
||||
<span className="text-sm text-blue-600">
|
||||
<span className="text-sm text-muted-foreground">사용자</span>
|
||||
<span className="text-sm font-medium">
|
||||
{subscription.userCount}명 / {subscription.userLimit ? `${subscription.userLimit}명` : '무제한'}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={subscription.userLimit ? (subscription.userCount / subscription.userLimit) * 100 : 30} className="h-2" />
|
||||
<ColoredProgress value={userPercentage} />
|
||||
</div>
|
||||
|
||||
{/* 저장 공간 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">저장 공간</span>
|
||||
<span className="text-sm text-blue-600">
|
||||
<span className="text-sm font-medium">
|
||||
{subscription.storageUsedFormatted} / {subscription.storageLimitFormatted}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={storageProgress} className="h-2" />
|
||||
<ColoredProgress value={subscription.storagePercentage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI API 호출 */}
|
||||
{/* ===== 섹션 3: AI 토큰 사용량 ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
AI 토큰 사용량
|
||||
{aiTokens.period && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
— {formatPeriod(aiTokens.period)}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{aiTokens.isOverLimit && (
|
||||
<Badge variant="destructive">한도 초과 — 초과분 실비 과금</Badge>
|
||||
)}
|
||||
{!aiTokens.isOverLimit && aiTokens.percentage >= aiTokens.warningThreshold && (
|
||||
<Badge className="bg-orange-100 text-orange-800 hover:bg-orange-100">
|
||||
기본 제공량의 {aiTokens.percentage.toFixed(0)}% 사용 중
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
{/* 토큰 사용량 Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">AI API 호출</span>
|
||||
<span className="text-sm text-blue-600">
|
||||
{formatNumber(apiCallsUsed)} / {formatNumber(apiCallsLimit)}
|
||||
<span className="text-sm font-medium">
|
||||
{formatTokenCount(aiTokens.totalTokens)} / {formatTokenCount(aiTokens.limit)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{aiTokens.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={apiProgress} className="h-2" />
|
||||
<ColoredProgress value={aiTokens.percentage} />
|
||||
</div>
|
||||
|
||||
{/* 총 비용 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 비용: <span className="font-medium text-foreground">{formatKrw(aiTokens.costKrw)}</span>
|
||||
</div>
|
||||
|
||||
{/* 모델별 사용량 테이블 */}
|
||||
{aiTokens.byModel.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">모델별 사용량</div>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead>모델</TableHead>
|
||||
<TableHead className="text-right w-[80px]">호출수</TableHead>
|
||||
<TableHead className="text-right w-[80px]">토큰</TableHead>
|
||||
<TableHead className="text-right w-[80px]">비용</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aiTokens.byModel.map((m) => (
|
||||
<TableRow key={m.model}>
|
||||
<TableCell className="font-mono text-sm">{m.model}</TableCell>
|
||||
<TableCell className="text-right">{m.requests.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{formatTokenCount(m.total_tokens)}</TableCell>
|
||||
<TableCell className="text-right">{formatKrw(m.cost_krw)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>※ 기본 제공: 월 {formatTokenCount(aiTokens.limit)} 토큰. 초과 시 실비 과금</p>
|
||||
<p>※ 매월 1일 리셋, 잔여 토큰 이월 불가</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 섹션 4: 서비스 관리 ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">서비스 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={handleExportData} disabled={isExporting}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? '처리 중...' : '자료 내보내기'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300"
|
||||
onClick={() => setShowCancelDialog(true)}
|
||||
disabled={subscription.status === 'cancelled'}
|
||||
>
|
||||
서비스 해지
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -237,4 +369,4 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { SubscriptionApiData, UsageApiData, SubscriptionInfo } from './types';
|
||||
import { transformApiToFrontend } from './utils';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ===== 현재 활성 구독 조회 =====
|
||||
export async function getCurrentSubscription(): Promise<ActionResult<SubscriptionApiData>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/subscriptions/current`,
|
||||
url: buildApiUrl('/api/v1/subscriptions/current'),
|
||||
errorMessage: '구독 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -19,7 +17,7 @@ export async function getCurrentSubscription(): Promise<ActionResult<Subscriptio
|
||||
// ===== 사용량 조회 =====
|
||||
export async function getUsage(): Promise<ActionResult<UsageApiData>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/subscriptions/usage`,
|
||||
url: buildApiUrl('/api/v1/subscriptions/usage'),
|
||||
errorMessage: '사용량 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -30,7 +28,7 @@ export async function cancelSubscription(
|
||||
reason?: string
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/subscriptions/${id}/cancel`,
|
||||
url: buildApiUrl(`/api/v1/subscriptions/${id}/cancel`),
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
errorMessage: '구독 취소에 실패했습니다.',
|
||||
@@ -42,7 +40,7 @@ export async function requestDataExport(
|
||||
exportType: string = 'all'
|
||||
): Promise<ActionResult<{ id: number; status: string }>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/subscriptions/export`,
|
||||
url: buildApiUrl('/api/v1/subscriptions/export'),
|
||||
method: 'POST',
|
||||
body: { export_type: exportType },
|
||||
transform: (data: { id: number; status: string }) => ({ id: data.id, status: data.status }),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { SubscriptionManagement } from './SubscriptionManagement';
|
||||
export { SubscriptionClient } from './SubscriptionClient';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
export * from './utils';
|
||||
|
||||
@@ -4,7 +4,7 @@ export type SubscriptionStatus = 'active' | 'pending' | 'expired' | 'cancelled'
|
||||
// ===== 플랜 타입 =====
|
||||
export type PlanType = 'free' | 'basic' | 'premium' | 'enterprise';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
// ===== API 응답 타입: 구독 정보 =====
|
||||
export interface SubscriptionApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
@@ -30,25 +30,50 @@ export interface SubscriptionApiData {
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입: 사용량 (ai_tokens 포함, api_calls 제거) =====
|
||||
export interface UsageApiData {
|
||||
subscription?: {
|
||||
remaining_days: number | null;
|
||||
};
|
||||
users?: {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
storage?: {
|
||||
used: number;
|
||||
limit: number;
|
||||
used_formatted: string;
|
||||
limit_formatted: string;
|
||||
};
|
||||
api_calls?: {
|
||||
used: number;
|
||||
limit: number;
|
||||
percentage: number;
|
||||
};
|
||||
storage?: {
|
||||
used: number;
|
||||
used_formatted: string;
|
||||
limit: number;
|
||||
limit_formatted: string;
|
||||
percentage: number;
|
||||
};
|
||||
ai_tokens?: {
|
||||
period: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
limit: number;
|
||||
percentage: number;
|
||||
cost_usd: number;
|
||||
cost_krw: number;
|
||||
warning_threshold: number;
|
||||
is_over_limit: boolean;
|
||||
by_model: AiTokenByModel[];
|
||||
};
|
||||
subscription?: {
|
||||
plan: string | null;
|
||||
monthly_fee: number;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
remaining_days: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== AI 토큰 모델별 사용량 =====
|
||||
export interface AiTokenByModel {
|
||||
model: string;
|
||||
requests: number;
|
||||
total_tokens: number;
|
||||
cost_krw: number;
|
||||
}
|
||||
|
||||
// ===== 플랜 코드 → 타입 변환 =====
|
||||
@@ -68,26 +93,37 @@ export interface SubscriptionInfo {
|
||||
// 구독 ID (취소 시 필요)
|
||||
id?: number;
|
||||
|
||||
// 결제 정보
|
||||
lastPaymentDate: string;
|
||||
nextPaymentDate: string;
|
||||
subscriptionAmount: number;
|
||||
|
||||
// 구독 플랜 정보
|
||||
plan: PlanType;
|
||||
planName?: string;
|
||||
status?: SubscriptionStatus;
|
||||
remainingDays?: number | null;
|
||||
planName: string;
|
||||
monthlyFee: number;
|
||||
status: SubscriptionStatus;
|
||||
startedAt: string | null;
|
||||
endedAt: string | null;
|
||||
remainingDays: number | null;
|
||||
|
||||
// 사용량 정보
|
||||
// 사용자
|
||||
userCount: number;
|
||||
userLimit: number | null; // null = 무제한
|
||||
userLimit: number | null;
|
||||
|
||||
// 저장공간
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
storageUsedFormatted?: string;
|
||||
storageLimitFormatted?: string;
|
||||
apiCallsUsed?: number;
|
||||
apiCallsLimit?: number;
|
||||
storageUsedFormatted: string;
|
||||
storageLimitFormatted: string;
|
||||
storagePercentage: number;
|
||||
|
||||
// AI 토큰
|
||||
aiTokens: {
|
||||
period: string;
|
||||
totalTokens: number;
|
||||
limit: number;
|
||||
percentage: number;
|
||||
costKrw: number;
|
||||
warningThreshold: number;
|
||||
isOverLimit: boolean;
|
||||
byModel: AiTokenByModel[];
|
||||
};
|
||||
}
|
||||
|
||||
export const PLAN_LABELS: Record<SubscriptionInfo['plan'], string> = {
|
||||
@@ -110,4 +146,4 @@ export const PLAN_COLORS: Record<SubscriptionInfo['plan'], string> = {
|
||||
basic: 'bg-blue-100 text-blue-800',
|
||||
premium: 'bg-purple-100 text-purple-800',
|
||||
enterprise: 'bg-amber-100 text-amber-800',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,9 +6,6 @@ import type {
|
||||
} from './types';
|
||||
import { mapPlanCodeToType } from './types';
|
||||
|
||||
// ===== 기본 저장공간 제한 (10GB in bytes) =====
|
||||
const DEFAULT_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024; // 10GB
|
||||
|
||||
// ===== 바이트 → 읽기 쉬운 단위 변환 =====
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -18,63 +15,91 @@ export function formatBytes(bytes: number): string {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
|
||||
// 소수점 처리: 정수면 소수점 없이, 아니면 최대 2자리
|
||||
const formatted = value % 1 === 0 ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
|
||||
return `${formatted} ${units[i]}`;
|
||||
}
|
||||
|
||||
// ===== 토큰 수 포맷: 1,000,000 → "1.0M", 496,000 → "496K" =====
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000).toLocaleString()}K`;
|
||||
return tokens.toLocaleString();
|
||||
}
|
||||
|
||||
// ===== 원화 포맷: 1234 → "₩1,234" =====
|
||||
export function formatKrw(amount: number): string {
|
||||
return `₩${Math.round(amount).toLocaleString()}`;
|
||||
}
|
||||
|
||||
// ===== 월 표시: "2026-03" → "2026년 3월" =====
|
||||
export function formatPeriod(period: string): string {
|
||||
const [year, month] = period.split('-');
|
||||
return `${year}년 ${parseInt(month)}월`;
|
||||
}
|
||||
|
||||
// ===== Progress Bar 색상 결정 =====
|
||||
export function getProgressColor(percentage: number): string {
|
||||
if (percentage > 100) return 'bg-red-500';
|
||||
if (percentage >= 80) return 'bg-orange-500';
|
||||
if (percentage >= 60) return 'bg-yellow-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
export function transformApiToFrontend(
|
||||
subscriptionData: SubscriptionApiData | null,
|
||||
usageData: UsageApiData | null
|
||||
): SubscriptionInfo {
|
||||
const plan = subscriptionData?.plan;
|
||||
const payments = subscriptionData?.payments || [];
|
||||
const lastPayment = payments.find(p => p.status === 'completed');
|
||||
|
||||
// 플랜 코드 → 타입 변환
|
||||
const planType = mapPlanCodeToType(plan?.code || null);
|
||||
|
||||
// 다음 결제일 (ended_at이 다음 결제일)
|
||||
const nextPaymentDate = subscriptionData?.ended_at?.split('T')[0] || '';
|
||||
|
||||
// 마지막 결제일
|
||||
const lastPaymentDate = lastPayment?.paid_at?.split('T')[0] || '';
|
||||
|
||||
// 구독 금액
|
||||
const price = plan?.price;
|
||||
const subscriptionAmount = typeof price === 'string' ? parseFloat(price) : (price || 0);
|
||||
const monthlyFee = typeof price === 'string' ? parseFloat(price) : (price || 0);
|
||||
|
||||
// 상태 매핑
|
||||
const status = (subscriptionData?.status || 'pending') as SubscriptionStatus;
|
||||
|
||||
return {
|
||||
// 구독 ID
|
||||
id: subscriptionData?.id,
|
||||
// usage API의 subscription 데이터 (통합 응답)
|
||||
const usageSub = usageData?.subscription;
|
||||
|
||||
// 결제 정보
|
||||
lastPaymentDate,
|
||||
nextPaymentDate,
|
||||
subscriptionAmount,
|
||||
// AI 토큰 기본값
|
||||
const aiTokensRaw = usageData?.ai_tokens;
|
||||
const aiTokens = {
|
||||
period: aiTokensRaw?.period || '',
|
||||
totalTokens: aiTokensRaw?.total_tokens || 0,
|
||||
limit: aiTokensRaw?.limit || 1_000_000,
|
||||
percentage: aiTokensRaw?.percentage || 0,
|
||||
costKrw: aiTokensRaw?.cost_krw || 0,
|
||||
warningThreshold: aiTokensRaw?.warning_threshold || 80,
|
||||
isOverLimit: aiTokensRaw?.is_over_limit || false,
|
||||
byModel: aiTokensRaw?.by_model || [],
|
||||
};
|
||||
|
||||
return {
|
||||
id: subscriptionData?.id,
|
||||
|
||||
// 구독 플랜 정보
|
||||
plan: planType,
|
||||
planName: plan?.name || PLAN_LABELS_LOCAL[planType],
|
||||
status,
|
||||
remainingDays: usageData?.subscription?.remaining_days ?? null,
|
||||
planName: usageSub?.plan || plan?.name || PLAN_LABELS_LOCAL[planType],
|
||||
monthlyFee: usageSub?.monthly_fee ?? monthlyFee,
|
||||
status: (usageSub?.status as SubscriptionStatus) || status,
|
||||
startedAt: usageSub?.started_at || subscriptionData?.started_at || null,
|
||||
endedAt: usageSub?.ended_at || subscriptionData?.ended_at || null,
|
||||
remainingDays: usageSub?.remaining_days ?? null,
|
||||
|
||||
// 사용량 정보
|
||||
// 사용자
|
||||
userCount: usageData?.users?.used ?? 0,
|
||||
userLimit: usageData?.users?.limit === 0 ? null : (usageData?.users?.limit ?? null),
|
||||
storageUsed: usageData?.storage?.used ?? 0,
|
||||
storageLimit: usageData?.storage?.limit || DEFAULT_STORAGE_LIMIT,
|
||||
storageUsedFormatted: usageData?.storage?.used_formatted || formatBytes(usageData?.storage?.used ?? 0),
|
||||
storageLimitFormatted: usageData?.storage?.limit_formatted || formatBytes(usageData?.storage?.limit || DEFAULT_STORAGE_LIMIT),
|
||||
|
||||
// API 호출 사용량 (일간)
|
||||
apiCallsUsed: usageData?.api_calls?.used ?? 0,
|
||||
apiCallsLimit: usageData?.api_calls?.limit ?? 10000,
|
||||
// 저장공간
|
||||
storageUsed: usageData?.storage?.used ?? 0,
|
||||
storageLimit: usageData?.storage?.limit || 107_374_182_400,
|
||||
storageUsedFormatted: usageData?.storage?.used_formatted || formatBytes(usageData?.storage?.used ?? 0),
|
||||
storageLimitFormatted: usageData?.storage?.limit_formatted || '100 GB',
|
||||
storagePercentage: usageData?.storage?.percentage ?? 0,
|
||||
|
||||
// AI 토큰
|
||||
aiTokens,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
20
src/components/vehicle-management/MODULE.md
Normal file
20
src/components/vehicle-management/MODULE.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Vehicle Management Module (차량관리)
|
||||
|
||||
**Module ID**: `vehicle-management`
|
||||
**Tenant**: Optional (경동 + 주일 공통 선택)
|
||||
**Route Prefixes**: `/vehicle-management`, `/vehicle`
|
||||
**Component Count**: 13 files
|
||||
|
||||
## Dependencies on Common ERP
|
||||
- `@/lib/api/*` — Server actions, API client
|
||||
- `@/components/ui/*` — UI primitives (shadcn/ui)
|
||||
- `@/components/templates/*` — IntegratedListTemplateV2 등
|
||||
- `@/components/organisms/*` — PageLayout, PageHeader
|
||||
- `@/hooks/*` — usePermission, useModules 등
|
||||
- `@/stores/authStore` — Tenant 정보
|
||||
|
||||
## Exports to Common ERP
|
||||
**NONE**
|
||||
|
||||
## Related Dashboard Sections
|
||||
없음
|
||||
54
src/hooks/useModules.ts
Normal file
54
src/hooks/useModules.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 테넌트 모듈 접근 훅
|
||||
*
|
||||
* 현재 로그인한 테넌트의 활성 모듈 정보와 헬퍼 함수를 제공.
|
||||
*
|
||||
* @example
|
||||
* const { isEnabled, isRouteAllowed } = useModules();
|
||||
* if (isEnabled('production')) { ... }
|
||||
* if (isRouteAllowed('/production/work-orders')) { ... }
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import type { ModuleId } from '@/modules/types';
|
||||
import { resolveEnabledModules, isModuleEnabled } from '@/modules/tenant-config';
|
||||
import { getModuleForRoute, getEnabledDashboardSections } from '@/modules';
|
||||
|
||||
export function useModules() {
|
||||
const tenant = useAuthStore((state) => state.currentUser?.tenant);
|
||||
|
||||
const enabledModules = useMemo<ModuleId[]>(() => {
|
||||
if (!tenant) return [];
|
||||
return resolveEnabledModules({
|
||||
industry: tenant.options?.industry,
|
||||
// Phase 2: explicitModules: tenant.options?.modules
|
||||
});
|
||||
}, [tenant]);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
return (moduleId: ModuleId) => isModuleEnabled(moduleId, enabledModules);
|
||||
}, [enabledModules]);
|
||||
|
||||
const isRouteAllowed = useMemo(() => {
|
||||
return (pathname: string) => {
|
||||
const owningModule = getModuleForRoute(pathname);
|
||||
if (owningModule === 'common') return true;
|
||||
return enabledModules.includes(owningModule);
|
||||
};
|
||||
}, [enabledModules]);
|
||||
|
||||
const dashboardSections = useMemo(() => {
|
||||
return getEnabledDashboardSections(enabledModules);
|
||||
}, [enabledModules]);
|
||||
|
||||
return {
|
||||
enabledModules,
|
||||
isEnabled,
|
||||
isRouteAllowed,
|
||||
dashboardSections,
|
||||
tenantIndustry: tenant?.options?.industry,
|
||||
};
|
||||
}
|
||||
27
src/lib/api/production-orders/actions.ts
Normal file
27
src/lib/api/production-orders/actions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 생산지시 공유 액션 — 모듈 경계용 래퍼
|
||||
*
|
||||
* 원본: @/components/production/ProductionOrders/actions
|
||||
* 'use server' 파일에서는 re-export 불가 → async 래퍼 함수로 위임
|
||||
*/
|
||||
|
||||
import {
|
||||
getProductionOrders as _getProductionOrders,
|
||||
getProductionOrderStats as _getProductionOrderStats,
|
||||
getProductionOrderDetail as _getProductionOrderDetail,
|
||||
} from '@/components/production/ProductionOrders/actions'; // MODULE_SEPARATION_OK — 공유 액션 래퍼 (Phase 0)
|
||||
import type { ProductionOrderListParams } from './types';
|
||||
|
||||
export async function getProductionOrders(params: ProductionOrderListParams) {
|
||||
return _getProductionOrders(params);
|
||||
}
|
||||
|
||||
export async function getProductionOrderStats() {
|
||||
return _getProductionOrderStats();
|
||||
}
|
||||
|
||||
export async function getProductionOrderDetail(orderId: string) {
|
||||
return _getProductionOrderDetail(orderId);
|
||||
}
|
||||
22
src/lib/api/production-orders/index.ts
Normal file
22
src/lib/api/production-orders/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 생산지시 공유 API — 공통 ERP에서 접근하는 진입점
|
||||
*
|
||||
* 공통 ERP(영업 등)에서는 이 경로로 import:
|
||||
* import { getProductionOrders } from '@/lib/api/production-orders';
|
||||
* import type { ProductionOrder } from '@/lib/api/production-orders';
|
||||
*
|
||||
* 생산 모듈 내부에서는 기존 경로 유지:
|
||||
* import { ... } from './actions';
|
||||
* import type { ... } from './types';
|
||||
*/
|
||||
export { getProductionOrders, getProductionOrderStats, getProductionOrderDetail } from './actions';
|
||||
export type {
|
||||
ProductionStatus,
|
||||
ProductionOrder,
|
||||
ProductionOrderStats,
|
||||
ProductionOrderDetail,
|
||||
ProductionWorkOrder,
|
||||
BomProcessGroup,
|
||||
BomItem,
|
||||
ProductionOrderListParams,
|
||||
} from './types';
|
||||
22
src/lib/api/production-orders/types.ts
Normal file
22
src/lib/api/production-orders/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 생산지시 공유 타입 — 모듈 경계용 re-export
|
||||
*
|
||||
* 원본: @/components/production/ProductionOrders/types
|
||||
* 영업(공통 ERP)에서 생산(테넌트) 타입을 직접 import하지 않도록
|
||||
* 공유 영역에서 re-export
|
||||
*/
|
||||
export type {
|
||||
ProductionStatus,
|
||||
ProductionOrder,
|
||||
ProductionOrderStats,
|
||||
ProductionOrderDetail,
|
||||
ProductionWorkOrder,
|
||||
BomProcessGroup,
|
||||
BomItem,
|
||||
ProductionOrderListParams,
|
||||
ApiProductionOrder,
|
||||
ApiProductionOrderDetail,
|
||||
ApiProductionWorkOrder,
|
||||
ApiBomProcessGroup,
|
||||
ApiBomItem,
|
||||
} from '@/components/production/ProductionOrders/types'; // MODULE_SEPARATION_OK — 공유 인터페이스 (Phase 0)
|
||||
@@ -2,6 +2,10 @@
|
||||
* CEO 대시보드 targeted refetch 시스템
|
||||
*
|
||||
* CUD 발생 시 sessionStorage + CustomEvent로 대시보드 섹션별 갱신 트리거
|
||||
*
|
||||
* 도메인→섹션 매핑은 동적 레지스트리 패턴:
|
||||
* - 공통 ERP 도메인은 여기서 직접 등록
|
||||
* - 테넌트 전용 도메인은 각 모듈에서 registerDashboardDomain()으로 자기 등록
|
||||
*/
|
||||
|
||||
// 대시보드 섹션 키 (useCEODashboard의 refetchMap과 1:1 매핑)
|
||||
@@ -21,49 +25,46 @@ export type DashboardSectionKey =
|
||||
| 'entertainment'
|
||||
| 'welfare';
|
||||
|
||||
// CUD 도메인 → 영향받는 대시보드 섹션 매핑
|
||||
type DomainKey =
|
||||
| 'deposit'
|
||||
| 'withdrawal'
|
||||
| 'sales'
|
||||
| 'purchase'
|
||||
| 'badDebt'
|
||||
| 'expectedExpense'
|
||||
| 'bill'
|
||||
| 'giftCertificate'
|
||||
| 'journalEntry'
|
||||
| 'order'
|
||||
| 'stock'
|
||||
| 'schedule'
|
||||
| 'client'
|
||||
| 'leave'
|
||||
| 'approval'
|
||||
| 'attendance'
|
||||
| 'production'
|
||||
| 'shipment'
|
||||
| 'construction';
|
||||
// 동적 도메인→섹션 레지스트리
|
||||
const domainSectionRegistry = new Map<string, DashboardSectionKey[]>();
|
||||
|
||||
const DOMAIN_SECTION_MAP: Record<DomainKey, DashboardSectionKey[]> = {
|
||||
deposit: ['dailyReport', 'receivable'],
|
||||
withdrawal: ['dailyReport', 'monthlyExpense'],
|
||||
sales: ['dailyReport', 'salesStatus', 'receivable'],
|
||||
purchase: ['dailyReport', 'purchaseStatus', 'monthlyExpense'],
|
||||
badDebt: ['debtCollection', 'receivable'],
|
||||
expectedExpense: ['monthlyExpense'],
|
||||
bill: ['dailyReport', 'receivable'],
|
||||
giftCertificate: ['entertainment', 'cardManagement'],
|
||||
journalEntry: ['entertainment', 'welfare', 'monthlyExpense'],
|
||||
order: ['statusBoard', 'salesStatus'],
|
||||
stock: ['statusBoard'],
|
||||
schedule: ['statusBoard'],
|
||||
client: ['statusBoard'],
|
||||
leave: ['statusBoard', 'dailyAttendance'],
|
||||
approval: ['statusBoard'],
|
||||
attendance: ['statusBoard', 'dailyAttendance'],
|
||||
production: ['statusBoard', 'dailyProduction'],
|
||||
shipment: ['statusBoard', 'unshipped'],
|
||||
construction: ['statusBoard', 'construction'],
|
||||
};
|
||||
/**
|
||||
* 도메인→섹션 매핑 등록 (각 모듈에서 호출)
|
||||
*
|
||||
* @example
|
||||
* // 생산 모듈 초기화 시
|
||||
* registerDashboardDomain('production', ['statusBoard', 'dailyProduction']);
|
||||
*
|
||||
* // 건설 모듈 초기화 시
|
||||
* registerDashboardDomain('construction', ['statusBoard', 'construction']);
|
||||
*/
|
||||
export function registerDashboardDomain(domain: string, sections: DashboardSectionKey[]): void {
|
||||
domainSectionRegistry.set(domain, sections);
|
||||
}
|
||||
|
||||
// ===== 공통 ERP 도메인 (테넌트 무관, 항상 등록) =====
|
||||
registerDashboardDomain('deposit', ['dailyReport', 'receivable']);
|
||||
registerDashboardDomain('withdrawal', ['dailyReport', 'monthlyExpense']);
|
||||
registerDashboardDomain('sales', ['dailyReport', 'salesStatus', 'receivable']);
|
||||
registerDashboardDomain('purchase', ['dailyReport', 'purchaseStatus', 'monthlyExpense']);
|
||||
registerDashboardDomain('badDebt', ['debtCollection', 'receivable']);
|
||||
registerDashboardDomain('expectedExpense', ['monthlyExpense']);
|
||||
registerDashboardDomain('bill', ['dailyReport', 'receivable']);
|
||||
registerDashboardDomain('giftCertificate', ['entertainment', 'cardManagement']);
|
||||
registerDashboardDomain('journalEntry', ['entertainment', 'welfare', 'monthlyExpense']);
|
||||
registerDashboardDomain('order', ['statusBoard', 'salesStatus']);
|
||||
registerDashboardDomain('stock', ['statusBoard']);
|
||||
registerDashboardDomain('schedule', ['statusBoard']);
|
||||
registerDashboardDomain('client', ['statusBoard']);
|
||||
registerDashboardDomain('leave', ['statusBoard', 'dailyAttendance']);
|
||||
registerDashboardDomain('approval', ['statusBoard']);
|
||||
registerDashboardDomain('attendance', ['statusBoard', 'dailyAttendance']);
|
||||
registerDashboardDomain('shipment', ['statusBoard', 'unshipped']);
|
||||
|
||||
// ===== 테넌트 전용 도메인 (각 모듈에서 등록 — 현재는 하위 호환을 위해 여기서도 등록) =====
|
||||
// TODO: Phase 1 완료 후 각 모듈의 초기화 코드로 이동
|
||||
registerDashboardDomain('production', ['statusBoard', 'dailyProduction']);
|
||||
registerDashboardDomain('construction', ['statusBoard', 'construction']);
|
||||
|
||||
const STORAGE_KEY = 'dashboard:stale-sections';
|
||||
const EVENT_NAME = 'dashboard:invalidate';
|
||||
@@ -71,8 +72,8 @@ const EVENT_NAME = 'dashboard:invalidate';
|
||||
/**
|
||||
* CUD 성공 후 호출 — 해당 도메인이 영향 주는 대시보드 섹션을 stale 처리
|
||||
*/
|
||||
export function invalidateDashboard(domain: DomainKey): void {
|
||||
const sections = DOMAIN_SECTION_MAP[domain];
|
||||
export function invalidateDashboard(domain: string): void {
|
||||
const sections = domainSectionRegistry.get(domain);
|
||||
if (!sections || sections.length === 0) return;
|
||||
|
||||
// 1. sessionStorage에 stale 섹션 저장 (navigation 사이 유지)
|
||||
|
||||
73
src/modules/index.ts
Normal file
73
src/modules/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 모듈 레지스트리
|
||||
*
|
||||
* Phase 1: 정적 매니페스트 (여기서 직접 정의)
|
||||
* Phase 2: 백엔드 API에서 로딩으로 교체
|
||||
*/
|
||||
|
||||
import type { ModuleManifest, ModuleId } from './types';
|
||||
|
||||
const MODULE_REGISTRY: Record<ModuleId, ModuleManifest> = {
|
||||
common: {
|
||||
id: 'common',
|
||||
name: '공통 ERP',
|
||||
description: '모든 테넌트가 사용하는 기본 모듈',
|
||||
routePrefixes: [
|
||||
'/dashboard', '/accounting', '/sales', '/hr', '/approval',
|
||||
'/board', '/boards', '/customer-center', '/settings',
|
||||
'/master-data', '/material', '/outbound', '/reports',
|
||||
'/company-info', '/subscription', '/payment-history',
|
||||
],
|
||||
},
|
||||
production: {
|
||||
id: 'production',
|
||||
name: '생산관리',
|
||||
description: '셔터 MES 생산/작업지시 관리',
|
||||
routePrefixes: ['/production'],
|
||||
dashboardSections: ['dailyProduction', 'unshipped'],
|
||||
},
|
||||
quality: {
|
||||
id: 'quality',
|
||||
name: '품질관리',
|
||||
description: '설비/검사/수리 관리',
|
||||
routePrefixes: ['/quality'],
|
||||
},
|
||||
construction: {
|
||||
id: 'construction',
|
||||
name: '건설관리',
|
||||
description: '건설 프로젝트/계약/기성 관리',
|
||||
routePrefixes: ['/construction'],
|
||||
dashboardSections: ['construction'],
|
||||
},
|
||||
'vehicle-management': {
|
||||
id: 'vehicle-management',
|
||||
name: '차량관리',
|
||||
description: '차량/지게차 관리',
|
||||
routePrefixes: ['/vehicle-management', '/vehicle'],
|
||||
},
|
||||
};
|
||||
|
||||
/** 특정 경로가 어떤 모듈에 속하는지 반환 */
|
||||
export function getModuleForRoute(pathname: string): ModuleId {
|
||||
for (const [moduleId, manifest] of Object.entries(MODULE_REGISTRY)) {
|
||||
if (moduleId === 'common') continue;
|
||||
for (const prefix of manifest.routePrefixes) {
|
||||
if (pathname === prefix || pathname.startsWith(prefix + '/')) {
|
||||
return moduleId as ModuleId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'common';
|
||||
}
|
||||
|
||||
/** 활성 모듈들의 대시보드 섹션 키 목록 반환 */
|
||||
export function getEnabledDashboardSections(enabledModules: ModuleId[]): string[] {
|
||||
const sections: string[] = [];
|
||||
for (const moduleId of enabledModules) {
|
||||
const manifest = MODULE_REGISTRY[moduleId];
|
||||
if (manifest?.dashboardSections) {
|
||||
sections.push(...manifest.dashboardSections);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
47
src/modules/tenant-config.ts
Normal file
47
src/modules/tenant-config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 테넌트 → 모듈 매핑 설정
|
||||
*
|
||||
* Phase 1: industry 기반 하드코딩 매핑
|
||||
* Phase 2: 백엔드 tenant.options.modules에서 직접 수신
|
||||
*/
|
||||
|
||||
import type { ModuleId } from './types';
|
||||
|
||||
/** 업종별 기본 모듈 매핑 */
|
||||
const INDUSTRY_MODULE_MAP: Record<string, ModuleId[]> = {
|
||||
shutter_mes: ['production', 'quality', 'vehicle-management'],
|
||||
construction: ['construction', 'vehicle-management'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 테넌트의 활성 모듈 목록을 결정
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. 백엔드에서 명시적 모듈 목록 제공 시 (Phase 2)
|
||||
* 2. industry 기반 기본값 (Phase 1)
|
||||
* 3. 빈 배열 (공통 ERP만)
|
||||
*/
|
||||
export function resolveEnabledModules(options: {
|
||||
industry?: string;
|
||||
explicitModules?: ModuleId[];
|
||||
}): ModuleId[] {
|
||||
const { industry, explicitModules } = options;
|
||||
|
||||
// Phase 2: 백엔드가 명시적 모듈 목록 제공
|
||||
if (explicitModules && explicitModules.length > 0) {
|
||||
return explicitModules;
|
||||
}
|
||||
|
||||
// Phase 1: industry 기반 기본값
|
||||
if (industry) {
|
||||
return INDUSTRY_MODULE_MAP[industry] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 특정 모듈이 활성화됐는지 확인 */
|
||||
export function isModuleEnabled(moduleId: ModuleId, enabledModules: ModuleId[]): boolean {
|
||||
if (moduleId === 'common') return true;
|
||||
return enabledModules.includes(moduleId);
|
||||
}
|
||||
26
src/modules/types.ts
Normal file
26
src/modules/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 모듈 시스템 타입 정의
|
||||
*
|
||||
* Phase 1: 프론트 하드코딩 industry 매핑
|
||||
* Phase 2: 백엔드 API에서 모듈 목록 수신
|
||||
* Phase 3: JSON 스키마 기반 동적 페이지 조립
|
||||
*/
|
||||
|
||||
/** 모듈 식별자 */
|
||||
export type ModuleId =
|
||||
| 'common'
|
||||
| 'production'
|
||||
| 'quality'
|
||||
| 'construction'
|
||||
| 'vehicle-management';
|
||||
|
||||
/** 모듈 매니페스트 — 각 모듈의 메타데이터 */
|
||||
export interface ModuleManifest {
|
||||
id: ModuleId;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 이 모듈이 소유하는 라우트 접두사 (예: ['/production']) */
|
||||
routePrefixes: string[];
|
||||
/** 이 모듈이 대시보드에 기여하는 섹션 키 */
|
||||
dashboardSections?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user