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:
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user