Enhance packaging and department management features
- Updated SQL queries in the packaging controller to include item details and packaging unit information through LEFT JOINs for improved data retrieval. - Enhanced the packaging page with a new search input for real-time item matching, allowing users to search by item code or name dynamically. - Refactored department management to use a more consistent state management approach, including renaming state variables for clarity and updating the user modal functionality for better user experience. - Improved the overall layout and responsiveness of the packaging and department pages, ensuring a more user-friendly interface.
This commit is contained in:
@@ -173,7 +173,11 @@ export async function getPkgUnitItems(
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
|
||||
FROM pkg_unit_item pui
|
||||
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
|
||||
WHERE pui.pkg_code=$1 AND pui.company_code=$2
|
||||
ORDER BY pui.created_date DESC`,
|
||||
[pkgCode, companyCode]
|
||||
);
|
||||
|
||||
@@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
`SELECT lup.*, pu.pkg_name, pu.pkg_type
|
||||
FROM loading_unit_pkg lup
|
||||
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
|
||||
WHERE lup.loading_code=$1 AND lup.company_code=$2
|
||||
ORDER BY lup.created_date DESC`,
|
||||
[loadingCode, companyCode]
|
||||
);
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function PackagingPage() {
|
||||
const [pkgMatchQty, setPkgMatchQty] = useState(1);
|
||||
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
|
||||
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
|
||||
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -313,7 +314,7 @@ export default function PackagingPage() {
|
||||
|
||||
// --- 포장단위 추가 모달 (적재함 구성) ---
|
||||
const openPkgMatchModal = () => {
|
||||
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod("");
|
||||
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
|
||||
setPkgMatchModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -790,11 +791,19 @@ export default function PackagingPage() {
|
||||
<DialogDescription>포장재에 매칭할 품목을 검색하여 추가합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="품목코드 / 품목명 검색" value={itemMatchKeyword} onChange={(e) => setItemMatchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItemsForMatch()} className="h-9 text-xs" />
|
||||
<Button size="sm" onClick={searchItemsForMatch} className="h-9"><Search className="mr-1 h-3 w-3" /> 검색</Button>
|
||||
</div>
|
||||
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
|
||||
onChange={(e) => {
|
||||
setItemMatchKeyword(e.target.value);
|
||||
const kw = e.target.value;
|
||||
clearTimeout((window as any).__itemMatchTimer);
|
||||
(window as any).__itemMatchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await getGeneralItems(kw || undefined);
|
||||
if (res.success) setItemMatchResults(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, 300);
|
||||
}}
|
||||
className="h-9 text-xs" />
|
||||
<div className="max-h-[300px] overflow-auto border rounded">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -808,9 +817,9 @@ export default function PackagingPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemMatchResults.length === 0 ? (
|
||||
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : itemMatchResults.map((item) => (
|
||||
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
|
||||
onClick={() => setItemMatchSelected(item)}>
|
||||
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
|
||||
@@ -830,8 +839,8 @@ export default function PackagingPage() {
|
||||
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs">포장수량(EA) <span className="text-destructive">*</span></Label>
|
||||
<Input type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||
<Label htmlFor="pkg-item-match-qty" className="text-xs">포장수량(EA) <span className="text-destructive">*</span></Label>
|
||||
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -844,41 +853,59 @@ export default function PackagingPage() {
|
||||
|
||||
{/* 포장단위 추가 모달 (적재함 구성) */}
|
||||
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
|
||||
<DialogContent className="max-w-[550px]">
|
||||
<DialogContent className="max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>포장단위 추가 — {selectedLoading?.loading_name}</DialogTitle>
|
||||
<DialogDescription>적재함에 적재할 포장단위를 선택합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-[200px] overflow-auto border rounded">
|
||||
<Input
|
||||
placeholder="포장코드 / 포장명 검색"
|
||||
value={pkgMatchSearchKw}
|
||||
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<div className="max-h-[300px] overflow-auto border rounded">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px]">
|
||||
<TableHead className="p-2 w-[30px]" />
|
||||
<TableHead className="p-2">포장코드</TableHead>
|
||||
<TableHead className="p-2 w-[120px]">포장코드</TableHead>
|
||||
<TableHead className="p-2">포장명</TableHead>
|
||||
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||
<TableHead className="p-2 w-[100px]">크기(mm)</TableHead>
|
||||
<TableHead className="p-2 w-[80px] text-right">최대중량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pkgUnits.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={4} className="text-center text-muted-foreground text-xs h-16">포장단위가 없습니다</TableCell></TableRow>
|
||||
) : pkgUnits.filter(p => p.status === "ACTIVE").map((p) => (
|
||||
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
|
||||
onClick={() => setPkgMatchSelected(p)}>
|
||||
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(() => {
|
||||
const kw = pkgMatchSearchKw.toLowerCase();
|
||||
const filtered = pkgUnits.filter(p =>
|
||||
p.status === "ACTIVE"
|
||||
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
|
||||
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
|
||||
);
|
||||
return filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">추가 가능한 포장단위가 없습니다</TableCell></TableRow>
|
||||
) : filtered.map((p) => (
|
||||
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
|
||||
onClick={() => setPkgMatchSelected(p)}>
|
||||
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="w-[150px]">
|
||||
<Label className="text-xs">최대적재수량 <span className="text-destructive">*</span></Label>
|
||||
<Input type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||
<Label htmlFor="loading-pkg-match-qty" className="text-xs">최대적재수량 <span className="text-destructive">*</span></Label>
|
||||
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">적재방향</Label>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function DepartmentPage() {
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [deptCount, setDeptCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 사원
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
@@ -77,6 +77,7 @@ export default function DepartmentPage() {
|
||||
|
||||
// 사원 모달
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userEditMode, setUserEditMode] = useState(false);
|
||||
const [userForm, setUserForm] = useState<Record<string, any>>({});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -93,7 +94,9 @@ export default function DepartmentPage() {
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const data = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
|
||||
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
|
||||
setDepts(data);
|
||||
setDeptCount(res.data?.data?.total || data.length);
|
||||
} catch (err) {
|
||||
@@ -107,25 +110,27 @@ export default function DepartmentPage() {
|
||||
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
||||
|
||||
// 선택된 부서
|
||||
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
|
||||
const selectedDept = depts.find((d) => d.id === selectedDeptId);
|
||||
const selectedDeptCode = selectedDept?.dept_code || null;
|
||||
|
||||
// 우측: 사원 조회
|
||||
useEffect(() => {
|
||||
if (!selectedDeptCode) { setMembers([]); return; }
|
||||
const fetchMembers = async () => {
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||
};
|
||||
fetchMembers();
|
||||
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
|
||||
const fetchMembers = useCallback(async () => {
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const filters = selectedDeptCode
|
||||
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
|
||||
: [];
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||
}, [selectedDeptCode]);
|
||||
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
// 부서 등록
|
||||
const openDeptRegister = () => {
|
||||
setDeptForm({});
|
||||
@@ -180,14 +185,20 @@ export default function DepartmentPage() {
|
||||
data: [{ dept_code: selectedDeptCode }],
|
||||
});
|
||||
toast.success("삭제되었습니다.");
|
||||
setSelectedDeptCode(null);
|
||||
setSelectedDeptId(null);
|
||||
fetchDepts();
|
||||
} catch { toast.error("삭제에 실패했습니다."); }
|
||||
};
|
||||
|
||||
// 사원 추가
|
||||
const openUserModal = () => {
|
||||
setUserForm({ dept_code: selectedDeptCode || "" });
|
||||
const openUserModal = (editData?: any) => {
|
||||
if (editData) {
|
||||
setUserEditMode(true);
|
||||
setUserForm({ ...editData, user_password: "" });
|
||||
} else {
|
||||
setUserEditMode(false);
|
||||
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
|
||||
}
|
||||
setFormErrors({});
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
@@ -208,14 +219,34 @@ export default function DepartmentPage() {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { created_date, updated_date, ...fields } = userForm;
|
||||
await apiClient.post(`/table-management/tables/${USER_TABLE}/add`, fields);
|
||||
toast.success("사원이 추가되었습니다.");
|
||||
// 비밀번호 미입력 시 기본값 (신규만)
|
||||
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
|
||||
|
||||
await apiClient.post("/admin/users/with-dept", {
|
||||
userInfo: {
|
||||
user_id: userForm.user_id,
|
||||
user_name: userForm.user_name,
|
||||
user_name_eng: userForm.user_name_eng || undefined,
|
||||
user_password: password || undefined,
|
||||
email: userForm.email || undefined,
|
||||
tel: userForm.tel || undefined,
|
||||
cell_phone: userForm.cell_phone || undefined,
|
||||
sabun: userForm.sabun || undefined,
|
||||
position_name: userForm.position_name || undefined,
|
||||
dept_code: userForm.dept_code || undefined,
|
||||
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
|
||||
status: userForm.status || "active",
|
||||
},
|
||||
mainDept: userForm.dept_code ? {
|
||||
dept_code: userForm.dept_code,
|
||||
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
|
||||
position_name: userForm.position_name || undefined,
|
||||
} : undefined,
|
||||
isUpdate: userEditMode,
|
||||
});
|
||||
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
|
||||
setUserModalOpen(false);
|
||||
// 우측 새로고침
|
||||
const code = selectedDeptCode;
|
||||
setSelectedDeptCode(null);
|
||||
setTimeout(() => setSelectedDeptCode(code), 50);
|
||||
fetchMembers();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
@@ -275,10 +306,9 @@ export default function DepartmentPage() {
|
||||
columns={LEFT_COLUMNS}
|
||||
data={depts}
|
||||
loading={deptLoading}
|
||||
selectedId={selectedDeptCode}
|
||||
selectedId={selectedDeptId}
|
||||
onSelect={(id) => {
|
||||
const dept = depts.find((d) => d.dept_code === id || d.id === id);
|
||||
setSelectedDeptCode(dept?.dept_code || id);
|
||||
setSelectedDeptId((prev) => (prev === id ? null : id));
|
||||
}}
|
||||
onRowDoubleClick={() => openDeptEdit()}
|
||||
emptyMessage="등록된 부서가 없습니다"
|
||||
@@ -293,29 +323,25 @@ export default function DepartmentPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4" /> 부서 인원
|
||||
<Users className="w-4 h-4" />
|
||||
{selectedDept ? "부서 인원" : "전체 사원"}
|
||||
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
|
||||
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}명</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openUserModal}>
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||
</Button>
|
||||
</div>
|
||||
{!selectedDeptCode ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
좌측에서 부서를 선택하세요
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
gridId="dept-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={members}
|
||||
loading={memberLoading}
|
||||
showRowNumber={false}
|
||||
tableName={USER_TABLE}
|
||||
emptyMessage="소속 사원이 없습니다"
|
||||
/>
|
||||
)}
|
||||
<DataGrid
|
||||
gridId="dept-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={members}
|
||||
loading={memberLoading}
|
||||
showRowNumber={false}
|
||||
tableName={USER_TABLE}
|
||||
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
|
||||
onRowDoubleClick={(row) => openUserModal(row)}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -365,14 +391,14 @@ export default function DepartmentPage() {
|
||||
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사원 추가</DialogTitle>
|
||||
<DialogDescription>{selectedDept?.dept_name} 부서에 사원을 추가합니다.</DialogDescription>
|
||||
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
|
||||
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||
placeholder="사용자 ID" className="h-9" />
|
||||
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
||||
@@ -387,7 +413,7 @@ export default function DepartmentPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">비밀번호</Label>
|
||||
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||
placeholder="비밀번호" className="h-9" type="password" />
|
||||
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">직급</Label>
|
||||
|
||||
Reference in New Issue
Block a user