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:
kjs
2026-03-25 11:44:49 +09:00
parent d5650c5797
commit 69c5a78753
3 changed files with 141 additions and 80 deletions

View File

@@ -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]
);

View File

@@ -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>

View File

@@ -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>