Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/components/hr/SalaryManagement/index.tsx
#	src/components/production/WorkResults/WorkResultList.tsx
#	tsconfig.tsbuildinfo
This commit is contained in:
2026-01-16 15:47:13 +09:00
91 changed files with 21969 additions and 20128 deletions

View File

@@ -22,10 +22,10 @@ import {
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
} from '@/components/templates/UniversalListPage';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
@@ -231,8 +231,13 @@ export default function DynamicBoardListPage() {
// 테이블 행 렌더링
const renderTableRow = useCallback(
(item: BoardPost, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
(
item: BoardPost,
index: number,
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
return (
<TableRow
@@ -243,7 +248,7 @@ export default function DynamicBoardListPage() {
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
@@ -264,7 +269,7 @@ export default function DynamicBoardListPage() {
</TableRow>
);
},
[selectedItems, handleRowClick, handleToggleSelection]
[handleRowClick]
);
// 모바일 카드 렌더링
@@ -273,9 +278,9 @@ export default function DynamicBoardListPage() {
item: BoardPost,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
return (
<Card
className="cursor-pointer hover:bg-muted/50"
@@ -310,15 +315,6 @@ export default function DynamicBoardListPage() {
[handleRowClick]
);
// 페이지네이션 설정
const pagination: PaginationConfig = {
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage: ITEMS_PER_PAGE,
onPageChange: setCurrentPage,
};
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -327,72 +323,102 @@ export default function DynamicBoardListPage() {
);
}
// UniversalListPage 설정
const boardConfig: UniversalListConfig<BoardPost> = {
title: boardName,
description: boardDescription || `${boardName} 게시판입니다.`,
icon: MessageSquare,
basePath: `/boards/${boardCode}`,
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: filteredData,
totalCount: filteredData.length,
}),
},
columns: tableColumns,
headerActions: () => (
<>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<Button className="ml-auto" onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
),
tableHeaderActions: (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{filteredData.length}
</span>
<Select
value={statusFilter}
onValueChange={setStatusFilter}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortOption} onValueChange={setSortOption}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
searchPlaceholder: '제목, 작성자로 검색...',
itemsPerPage: ITEMS_PER_PAGE,
clientSideFiltering: true,
renderTableRow,
renderMobileCard,
};
return (
<IntegratedListTemplateV2
title={boardName}
description={boardDescription || `${boardName} 게시판입니다.`}
icon={MessageSquare}
headerActions={
<>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<Button className="ml-auto" onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="제목, 작성자로 검색..."
tableHeaderActions={
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{filteredData.length}
</span>
<Select
value={statusFilter}
onValueChange={setStatusFilter}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortOption} onValueChange={setSortOption}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
tableColumns={tableColumns}
data={paginatedData}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={pagination}
<UniversalListPage<BoardPost>
config={boardConfig}
initialData={filteredData}
initialTotalCount={filteredData.length}
externalSelection={{
selectedItems,
setSelectedItems,
toggleSelection: handleToggleSelection,
toggleSelectAll: handleToggleSelectAll,
}}
externalSearch={{
searchTerm: searchValue,
setSearchTerm: setSearchValue,
}}
externalPagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage: ITEMS_PER_PAGE,
onPageChange: setCurrentPage,
}}
/>
);
}

View File

@@ -33,10 +33,11 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
IntegratedListTemplateV2,
TabOption,
TableColumn,
} from "@/components/templates/IntegratedListTemplateV2";
UniversalListPage,
type UniversalListConfig,
type TabOption,
type TableColumn,
} from "@/components/templates/UniversalListPage";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/api/error-handler";
import {
@@ -447,10 +448,10 @@ export default function CustomerAccountManagementPage() {
const renderTableRow = (
customer: Client,
index: number,
globalIndex: number
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const itemId = customer.id;
const isSelected = selectedItems.has(itemId);
const { isSelected, onToggle } = handlers;
return (
<TableRow
@@ -461,7 +462,7 @@ export default function CustomerAccountManagementPage() {
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(itemId)}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell>{globalIndex}</TableCell>
@@ -511,9 +512,9 @@ export default function CustomerAccountManagementPage() {
customer: Client,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={customer.id}
@@ -591,101 +592,129 @@ export default function CustomerAccountManagementPage() {
);
}
// ===== UniversalListPage 설정 =====
const clientManagementConfig: UniversalListConfig<Client> = {
title: "거래처 목록",
description: "거래처 정보 및 계정을 관리합니다",
icon: Building2,
basePath: "/sales/client-management-sales-admin",
idField: "id",
actions: {
getList: async () => ({
success: true,
data: clients,
totalCount: pagination?.total || clients.length,
}),
},
columns: tableColumns,
tabs: tabs,
defaultTab: filterType,
stats: stats,
searchPlaceholder: "거래처명, 코드, 대표자, 전화번호, 사업자번호 검색...",
itemsPerPage,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const term = searchValue.toLowerCase();
return (
item.name.toLowerCase().includes(term) ||
item.code.toLowerCase().includes(term) ||
(item.representative?.toLowerCase().includes(term) ?? false) ||
(item.phone?.includes(term) ?? false) ||
(item.businessNo?.includes(term) ?? false)
);
},
tabFilter: (item, tabValue) => {
if (tabValue === "all") return true;
if (tabValue === "active") return item.status === "활성";
if (tabValue === "inactive") return item.status === "비활성";
if (tabValue === "purchase") return item.clientType === "매입" || item.clientType === "매입매출";
if (tabValue === "sales") return item.clientType === "매출" || item.clientType === "매입매출";
return true;
},
headerActions: () => (
<div className="flex gap-2 ml-auto">
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
<Bell className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleAddNew}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
),
tableTitle: `${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredClients.length}개)`,
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{deleteTargetId
? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
: ""}
<br />
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 확인 다이얼로그 */}
<AlertDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmBulkDelete}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
),
};
return (
<>
<IntegratedListTemplateV2
title="거래처 목록"
description="거래처 정보 및 계정을 관리합니다"
icon={Building2}
headerActions={
<div className="flex gap-2 ml-auto">
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
<Bell className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleAddNew}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
}
stats={stats}
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="거래처명, 코드, 대표자, 전화번호, 사업자번호 검색..."
tabs={tabs}
activeTab={filterType}
onTabChange={(value) => {
setFilterType(value);
setCurrentPage(1);
}}
tableColumns={tableColumns}
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredClients.length}개)`}
data={paginatedClients}
totalCount={filteredClients.length}
allData={mobileClients}
mobileDisplayCount={mobileDisplayCount}
infinityScrollSentinelRef={sentinelRef}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
onBulkDelete={handleBulkDelete}
getItemId={(customer) => customer.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: pagination?.total || filteredClients.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{deleteTargetId
? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
: ""}
<br />
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 확인 다이얼로그 */}
<AlertDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmBulkDelete}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<UniversalListPage<Client>
config={clientManagementConfig}
initialData={clients}
initialTotalCount={pagination?.total || clients.length}
/>
);
}

View File

@@ -28,10 +28,11 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import {
IntegratedListTemplateV2,
TabOption,
TableColumn,
} from "@/components/templates/IntegratedListTemplateV2";
UniversalListPage,
type UniversalListConfig,
type TabOption,
type TableColumn,
} from "@/components/templates/UniversalListPage";
import { toast } from "sonner";
import {
TableRow,
@@ -462,10 +463,11 @@ export default function OrderManagementSalesPage() {
const renderTableRow = (
order: Order,
index: number,
globalIndex: number
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
const itemId = order.id;
const isSelected = selectedItems.has(itemId);
return (
<TableRow
@@ -476,7 +478,7 @@ export default function OrderManagementSalesPage() {
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(itemId)}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell>{globalIndex}</TableCell>
@@ -534,9 +536,9 @@ export default function OrderManagementSalesPage() {
order: Order,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={order.id}
@@ -622,6 +624,156 @@ export default function OrderManagementSalesPage() {
);
};
// ===== UniversalListPage 설정 =====
const orderConfig: UniversalListConfig<Order> = {
title: "수주 목록",
description: "수주 관리 및 생산지시 연동",
icon: FileText,
basePath: "/sales/order-management-sales",
idField: "id",
actions: {
getList: async () => ({
success: true,
data: filteredOrders,
totalCount: filteredOrders.length,
}),
deleteBulk: async (ids) => {
const result = await deleteOrders(ids);
if (result.success) {
setOrders(orders.filter((o) => !ids.includes(o.id)));
setSelectedItems(new Set());
toast.success(`${ids.length}개의 수주가 삭제되었습니다.`);
const statsResult = await getOrderStats();
if (statsResult.success && statsResult.data) {
setApiStats(statsResult.data);
}
}
return result;
},
},
columns: tableColumns,
tabs: tabs,
defaultTab: filterType,
computeStats: () => stats,
searchPlaceholder: "로트번호, 견적번호, 발주처, 현장명 검색...",
itemsPerPage: itemsPerPage,
clientSideFiltering: true,
searchFilter: (order, searchValue) => {
const searchLower = searchValue.toLowerCase();
return (
order.lotNumber.toLowerCase().includes(searchLower) ||
order.quoteNumber.toLowerCase().includes(searchLower) ||
order.client.toLowerCase().includes(searchLower) ||
order.siteName.toLowerCase().includes(searchLower)
);
},
tabFilter: (order, activeTab) => {
if (activeTab === "all") return true;
if (activeTab === "registered") return order.status === "order_registered";
if (activeTab === "confirmed") return order.status === "order_confirmed";
if (activeTab === "production_ordered") return order.status === "production_ordered";
if (activeTab === "receivable") return order.hasReceivable === true;
return true;
},
headerActions: () => (
<div className="flex gap-2 ml-auto">
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
<Bell className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => router.push("/sales/order-management-sales/new")}>
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
),
renderTableRow: renderTableRow,
renderMobileCard: renderMobileCard,
renderDialogs: () => (
<>
{/* 수주 취소 확인 다이얼로그 */}
<AlertDialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{cancelTargetId
? `수주번호: ${orders.find((o) => o.id === cancelTargetId)?.lotNumber || cancelTargetId}`
: ""}
<br />
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmCancel} className="bg-orange-600 hover:bg-orange-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 수주 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<span className="text-yellow-600"></span>
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p className="text-foreground">
<strong>{deleteTargetIds.length}</strong> ?
</p>
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 mt-0.5"></span>
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground"></span>
<br />
. , .
</div>
</div>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isDeleting}
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
),
};
// 로딩 상태 표시
if (isLoading) {
return (
@@ -635,122 +787,19 @@ export default function OrderManagementSalesPage() {
}
return (
<>
<IntegratedListTemplateV2
title="수주 목록"
description="수주 관리 및 생산지시 연동"
icon={FileText}
headerActions={
<div className="flex gap-2 ml-auto">
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
<Bell className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => router.push("/sales/order-management-sales/new")}>
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
}
stats={stats}
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="로트번호, 견적번호, 발주처, 현장명 검색..."
tabs={tabs}
activeTab={filterType}
onTabChange={(value) => {
setFilterType(value);
setCurrentPage(1);
}}
tableColumns={tableColumns}
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredOrders.length}개)`}
data={paginatedOrders}
totalCount={filteredOrders.length}
allData={mobileOrders}
mobileDisplayCount={mobileDisplayCount}
infinityScrollSentinelRef={sentinelRef}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(order) => order.id}
onBulkDelete={handleBulkDelete}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredOrders.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 수주 취소 확인 다이얼로그 */}
<AlertDialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{cancelTargetId
? `수주번호: ${orders.find((o) => o.id === cancelTargetId)?.lotNumber || cancelTargetId}`
: ""}
<br />
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmCancel} className="bg-orange-600 hover:bg-orange-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 수주 삭제 확인 다이얼로그 - 스크린샷 디자인 적용 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<span className="text-yellow-600"></span>
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p className="text-foreground">
<strong>{deleteTargetIds.length}</strong> ?
</p>
{/* 주의 박스 */}
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 mt-0.5"></span>
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground"></span>
<br />
. , .
</div>
</div>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isDeleting}
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<UniversalListPage<Order>
config={orderConfig}
initialData={filteredOrders}
initialTotalCount={filteredOrders.length}
externalSelection={{
selectedItems,
onSelectionChange: setSelectedItems,
}}
onTabChange={(value) => {
setFilterType(value);
setCurrentPage(1);
}}
onSearchChange={setSearchTerm}
/>
);
}

View File

@@ -41,10 +41,11 @@ import {
Trash2,
} from "lucide-react";
import {
IntegratedListTemplateV2,
TabOption,
TableColumn,
} from "@/components/templates/IntegratedListTemplateV2";
UniversalListPage,
type UniversalListConfig,
type TabOption,
type TableColumn,
} from "@/components/templates/UniversalListPage";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard";
@@ -389,9 +390,10 @@ export default function ProductionOrdersListPage() {
const renderTableRow = (
item: ProductionOrder,
index: number,
globalIndex: number
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const isSelected = selectedItems.has(item.id);
const { isSelected, onToggle } = handlers;
return (
<TableRow
key={item.id}
@@ -401,7 +403,7 @@ export default function ProductionOrdersListPage() {
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(item.id)}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">
@@ -453,9 +455,9 @@ export default function ProductionOrdersListPage() {
item: ProductionOrder,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={item.id}
@@ -522,57 +524,72 @@ export default function ProductionOrdersListPage() {
);
};
return (
<>
<IntegratedListTemplateV2
title="생산지시 목록"
icon={Factory}
headerActions={
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
}
// 진행 단계 표시
tabsContent={
<Card className="w-full mb-4">
<CardContent className="py-2">
<ProgressSteps />
</CardContent>
</Card>
}
// 검색
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="생산지시번호, 수주번호, 현장명 검색..."
// 탭
tabs={tabs}
activeTab={activeTab}
onTabChange={(value) => {
setActiveTab(value);
setCurrentPage(1);
}}
// 테이블
tableColumns={TABLE_COLUMNS}
data={paginatedData}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => item.id}
onBulkDelete={handleBulkDelete}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
// ===== UniversalListPage 설정 =====
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
title: "생산지시 목록",
icon: Factory,
basePath: "/sales/order-management-sales/production-orders",
{/* 삭제 확인 다이얼로그 */}
idField: "id",
actions: {
getList: async () => ({
success: true,
data: orders,
totalCount: orders.length,
}),
},
columns: TABLE_COLUMNS,
tabs: tabs,
defaultTab: activeTab,
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
itemsPerPage,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const term = searchValue.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
},
tabFilter: (item, tabValue) => {
if (tabValue === "all") return true;
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
return item.status === statusMap[tabValue];
},
headerActions: (
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
),
tabsContent: (
<Card className="w-full mb-4">
<CardContent className="py-2">
<ProgressSteps />
</CardContent>
</Card>
),
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
@@ -610,6 +627,33 @@ export default function ProductionOrdersListPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
),
};
return (
<UniversalListPage<ProductionOrder>
config={productionOrderConfig}
initialData={orders}
initialTotalCount={orders.length}
externalSelection={{
selectedItems,
setSelectedItems,
}}
externalTab={{
activeTab,
setActiveTab: (value) => {
setActiveTab(value);
setCurrentPage(1);
},
}}
externalSearch={{
searchValue: searchTerm,
setSearchValue: setSearchTerm,
}}
externalPagination={{
currentPage,
setCurrentPage,
}}
/>
);
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import localFont from 'next/font/local';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
@@ -52,6 +52,16 @@ export const metadata: Metadata = {
},
};
// 📱 Viewport 설정 - iOS safe-area 지원 + 확대 가능
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
minimumScale: 1, // 최소 100%
maximumScale: 5, // 최대 500%까지 확대 가능
userScalable: true, // 손가락 확대 허용
viewportFit: 'cover', // 아이폰 노치/다이나믹 아일랜드/하단 홈바 영역 커버
};
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}

View File

@@ -1,65 +0,0 @@
/**
* 파일 다운로드 프록시 API
*
* 백엔드 파일 다운로드 API는 인증이 필요하므로,
* Next.js API 라우트를 통해 인증된 요청을 프록시합니다.
*
* GET /api/files/[id]/download
*/
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: '인증이 필요합니다.' },
{ status: 401 }
);
}
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${id}/download`;
const response = await fetch(backendUrl, {
headers: {
'Authorization': `Bearer ${token}`,
'X-API-KEY': process.env.API_KEY || '',
},
});
if (!response.ok) {
return NextResponse.json(
{ success: false, message: '파일을 찾을 수 없습니다.' },
{ status: response.status }
);
}
// 파일 데이터와 헤더 전달
const blob = await response.blob();
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = response.headers.get('content-disposition');
const headers: HeadersInit = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000', // 1년 캐시
};
if (contentDisposition) {
headers['Content-Disposition'] = contentDisposition;
}
return new NextResponse(blob, { headers });
} catch (error) {
console.error('[FileDownload] Error:', error);
return NextResponse.json(
{ success: false, message: '파일 다운로드 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -1,92 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* 🔵 Next.js 내부 API - 메뉴 조회 프록시 (PHP 백엔드로 전달)
*
* ⚡ 설계 목적:
* - 동적 메뉴 갱신: 재로그인 없이 메뉴 목록 갱신
* - 보안: HttpOnly 쿠키에서 토큰을 읽어 백엔드로 전달
*
* 🔄 동작 흐름:
* 1. 클라이언트 → Next.js /api/menus
* 2. Next.js: HttpOnly 쿠키에서 access_token 읽기
* 3. Next.js → PHP /api/v1/menus (메뉴 조회 요청)
* 4. Next.js → 클라이언트 (메뉴 목록 응답)
*
* 📌 백엔드 API 요청 사항:
* - 엔드포인트: GET /api/v1/menus
* - 인증: Bearer 토큰 필요
* - 응답: { menus: [...] } (로그인 응답의 menus와 동일 구조)
*
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
*/
export async function GET(request: NextRequest) {
try {
// HttpOnly 쿠키에서 access_token 읽기
const accessToken = request.cookies.get('access_token')?.value;
if (!accessToken) {
return NextResponse.json(
{ error: 'Unauthorized', message: '인증 토큰이 없습니다' },
{ status: 401 }
);
}
// PHP 백엔드 메뉴 API 호출
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/menus`;
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-API-KEY': process.env.API_KEY || '',
},
});
if (!response.ok) {
// 백엔드 에러 응답 전달
const errorData = await response.json().catch(() => ({}));
console.error('[Menu API] Backend error:', response.status, errorData);
return NextResponse.json(
{
error: 'Backend Error',
message: errorData.message || '메뉴 조회에 실패했습니다',
status: response.status
},
{ status: response.status }
);
}
const data = await response.json();
// 백엔드 응답 구조: { data: [...] } (ApiResponse::handle 표준)
// 또는 로그인 응답과 동일한 { menus: [...] } 형태일 수 있음
const menus = data.data || data.menus || (Array.isArray(data) ? data : null);
// 메뉴 데이터 검증
if (!menus || !Array.isArray(menus)) {
console.error('[Menu API] Invalid response format:', data);
return NextResponse.json(
{ error: 'Invalid Response', message: '메뉴 데이터 형식이 올바르지 않습니다' },
{ status: 500 }
);
}
// 응답 구조 통일: { menus: [...] }
return NextResponse.json({ menus }, { status: 200 });
} catch (error) {
console.error('[Menu API] Proxy error:', error);
return NextResponse.json(
{
error: 'Internal Server Error',
message: error instanceof Error ? error.message : '서버 오류가 발생했습니다'
},
{ status: 500 }
);
}
}

View File

@@ -1,60 +0,0 @@
import { NextRequest } from 'next/server';
import { proxyToPhpBackend } from '@/lib/api/php-proxy';
/**
* 특정 페이지 조회 API
*
* 엔드포인트: GET /api/tenants/{tenantId}/item-master-config/pages/{pageId}
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string; pageId: string }> }
) {
const { tenantId, pageId } = await params;
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`,
{ method: 'GET' }
);
}
/**
* 특정 페이지 업데이트 API
*
* 엔드포인트: PUT /api/tenants/{tenantId}/item-master-config/pages/{pageId}
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string; pageId: string }> }
) {
const { tenantId, pageId } = await params;
const body = await request.json();
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`,
{
method: 'PUT',
body: JSON.stringify(body),
}
);
}
/**
* 특정 페이지 삭제 API
*
* 엔드포인트: DELETE /api/tenants/{tenantId}/item-master-config/pages/{pageId}
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string; pageId: string }> }
) {
const { tenantId, pageId } = await params;
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`,
{ method: 'DELETE' }
);
}

View File

@@ -1,74 +0,0 @@
import { NextRequest } from 'next/server';
import { proxyToPhpBackend, appendQueryParams } from '@/lib/api/php-proxy';
/**
* 품목기준관리 전체 설정 조회 API
*
* 엔드포인트: GET /api/tenants/{tenantId}/item-master-config
*
* 역할:
* - PHP 백엔드로 단순 프록시
* - tenant.id 검증은 PHP에서 수행
* - PHP가 403 반환하면 그대로 전달
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
const { searchParams } = new URL(request.url);
// PHP 엔드포인트 생성 (query params 포함)
const phpEndpoint = appendQueryParams(
`/api/v1/tenants/${tenantId}/item-master-config`,
searchParams
);
return proxyToPhpBackend(request, phpEndpoint, {
method: 'GET',
});
}
/**
* 품목기준관리 전체 설정 저장 API
*
* 엔드포인트: POST /api/tenants/{tenantId}/item-master-config
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
const body = await request.json();
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
}
/**
* 품목기준관리 전체 설정 업데이트 API
*
* 엔드포인트: PUT /api/tenants/{tenantId}/item-master-config
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
const body = await request.json();
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config`,
{
method: 'PUT',
body: JSON.stringify(body),
}
);
}

View File

@@ -5,6 +5,18 @@
@variant dark (&:is(.dark *));
@variant senior (&:is(.senior *));
/* 📱 iOS Safe Area CSS 변수
- 아이폰 노치/다이나믹 아일랜드/홈바 영역
- viewportFit: cover와 함께 사용
- 레이아웃 컴포넌트에서 필요 시 사용
*/
:root {
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
}
:root {
--font-size: 16px;
/* Clean minimalist background */