diff --git a/plm-api/src/controllers/app/common/productgroup.controller.js b/plm-api/src/controllers/app/common/productgroup.controller.js new file mode 100644 index 0000000..84e8cfd --- /dev/null +++ b/plm-api/src/controllers/app/common/productgroup.controller.js @@ -0,0 +1,112 @@ +// src/controllers/admin/users/users.controller.js +const express = require("express"); +const router = express.Router(); +const productService = require("../../../services/productgroup.service"); +const authMiddleware = require("../../../middleware/auth.middleware"); +const roleCheck = require("../../../middleware/roleCheck.middleware"); +const { body, param } = require("express-validator"); +const validate = require("../../../middleware/validator.middleware"); + +router.use(authMiddleware); +router.use(roleCheck(["super_admin", "company_admin"])); + +// 제품군 목록 조회 +router.get("/productgroupList", async (req, res, next) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + + const result = await productService.findAll(req.user, page, limit); + res.json(result); + } catch (error) { + next(error); + } +}); + +// Create productgroup +router.post( + "/productgroupCreate", + [ + body("product_group_name").notEmpty().withMessage("유효한 ID가 필요합니다"), + body("companyId").isUUID().withMessage("유효한 회사 ID가 필요합니다"), + validate, + ], + async (req, res, next) => { + try { + const productGroup = await productService.createProductGroup(req.body, req.user); + res.status(201).json(productGroup); + } catch (error) { + next(error); + } + } +); + +// Update productgroupUp +router.put( + "/productgroupUpdate/:id", + [ + param("id").isUUID().withMessage("유효한 ID가 필요합니다"), + body("product_group_name").optional().notEmpty().withMessage("제품군 이름이 필요합니다."), + body("companyId").optional().isUUID().withMessage("유효한 회사 ID가 필요합니다"), + validate, + ], + async (req, res, next) => { + try { + const productGroup = await productService.findById(req.params.id); + + if (!productGroup) { + return res.status(404).json({ message: "고객사를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 고객사만 수정 가능 + if ( + req.user.role === "company_admin" && + productGroup.companyId !== req.user.companyId + ) { + return res.status(403).json({ + message: "다른 회사의 고객사를 수정할 수 없습니다", + }); + } + + const updatedProductGroup = await productService.updateProductGroup(req.params.id, req.body, req.user); + res.json(updatedProductGroup); + } catch (error) { + next(error); + } + } +); + +// Delete productgroupUp +router.delete( + "/productgroupUpDelete/:id", + [ + param("id").isUUID().withMessage("유효한 ID가 필요합니다"), + validate, + ], + async (req, res, next) => { + try { + const productGroup = await productService.findById(req.params.id); + + if (!productGroup) { + return res.status(404).json({ message: "고객사를 찾을 수 없습니다" }); + } + + // company_admin은 자신의 회사 고객사만 삭제 가능 + if ( + req.user.role === "company_admin" && + productGroup.companyId !== req.user.companyId + ) { + return res.status(403).json({ + message: "다른 회사의 고객사를 삭제할 수 없습니다", + }); + } + + await productService.deleteProductGroup(req.params.id, req.user); + res.status(204).end(); + } catch (error) { + next(error); + } + } +); + +module.exports = router; diff --git a/plm-api/src/models/ProductGroup.js b/plm-api/src/models/ProductGroup.js new file mode 100644 index 0000000..d6c7ce3 --- /dev/null +++ b/plm-api/src/models/ProductGroup.js @@ -0,0 +1,62 @@ +// models/ProductGroup.js + +const { Model, DataTypes } = require("sequelize"); + +class ProductGroup extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + product_group_name: { + type: DataTypes.STRING(64), + allowNull: true, + }, + description: { + type: DataTypes.STRING(1000), + allowNull: true, + }, + writer: { + type: DataTypes.STRING(32), + allowNull: true, + }, + regdate: { + type: DataTypes.DATE, + allowNull: true, + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + // 새로 추가할 필드들 + companyId: { + type: DataTypes.UUID, + comment: "회사 ID", + }, + }, + { + sequelize, + modelName: 'ProductGroup', + tableName: 'product_group_mng1', + timestamps: true, + indexes: [ + { + fields: ['id'], + }, + ], + } + ); + return this; + } + + static associate(models) { + // OemMng이 Company에 속함 + this.belongsTo(models.Company, { foreignKey: "companyId" }); + } + +} + +module.exports = ProductGroup; \ No newline at end of file diff --git a/plm-api/src/routes/app.js b/plm-api/src/routes/app.js index afb3f5b..87c2517 100644 --- a/plm-api/src/routes/app.js +++ b/plm-api/src/routes/app.js @@ -18,6 +18,7 @@ const companiesController = require("../controllers/admin/companies/companies.co const deviceController = require("../controllers/app/device/device.controller"); const commonController = require("../controllers/app/common/common.controller"); const oemMngController = require("../controllers/app/common/oemmng.controller"); +const productgroupController = require("../controllers/app/common/productgroup.controller"); router.use("/health", healthController); router.use("/auth", authController); @@ -35,5 +36,6 @@ router.use("/companies", companiesController); router.use("/devices", deviceController); router.use("/common", commonController); router.use("/oemmng", oemMngController); +router.use("/productgroup", productgroupController); module.exports = router; diff --git a/plm-api/src/services/productgroup.service.js b/plm-api/src/services/productgroup.service.js new file mode 100644 index 0000000..5d1345d --- /dev/null +++ b/plm-api/src/services/productgroup.service.js @@ -0,0 +1,163 @@ +const { + ProductGroup, + Company, + Role, +} = require("../models"); +//const { Op } = require("sequelize"); +const alertService = require("./alert.service"); + +class productgroupService { + async findAll(currentUser, page = 1, limit = 10) { + try { + // Initialize the where clause + let where = {}; + + // company_admin은 자신의 회사 유저만 조회 가능 + if (currentUser.role === "company_admin") { + where.companyId = currentUser.companyId; + } + // isActive 필터 추가 + //where.isActive = { [Op.eq]: true }; + + const offset = (page - 1) * limit; + + // 전체 개수 조회 + const count = await ProductGroup.count({ where }); + + const productgroups = await ProductGroup.findAll({ + where, + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + ], + order: [["createdAt", "DESC"]], + offset, + limit, + distinct: true, + }); + + // 인덱스 추가 + const productGroupsWithIndex = productgroups.map((productgroup, index) => { + const productgroupJson = productgroup.toJSON(); + productgroupJson.index = offset + index + 1; + return productgroupJson; + }); + + return { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + productgroups: productGroupsWithIndex, + }; + } catch (error) { + console.error("Error in findAll:", error); + throw error; + } + } + + async findById(id) { + return await ProductGroup.findByPk(id, { + include: [ + { + model: Company, + attributes: ["id", "name"], + }, + ], + }); + } + + async createProductGroup(productgroupData, currentUser) { + const { roleId, ...productgroupFields } = productgroupData; + + // 등록자 정보 추가 + productgroupFields.writer = currentUser.name; + productgroupFields.regdate = new Date(); + + const productgroup = await ProductGroup.create(productgroupFields); + + if (roleId) { + const role = await Role.findOne({ + where: { + id: roleId, + companyId: productgroup.companyId, + }, + }); + + if (!role) { + throw new Error("Role not found or does not belong to the company"); + } + + await productgroup.addRole(role); + } + + await alertService.createAlert({ + type: "info", + message: `제품군 ${productgroup.name}이(가) ${currentUser.name}에 의해 생성되었습니다.`, + companyId: productgroup.companyId, + }); + + return productgroup; + } + + async updateProductGroup(id, updateData, currentUser) { + const { roleId, ...productgroupFields } = updateData; + + const productgroup = await ProductGroup.findByPk(id); + if (!productgroup) throw new Error("ProductGroup not found"); + + // company_admin은 특정 필드를 수정할 수 없음 (예: role을 super_admin으로 변경 불가) + if (currentUser.role === "company_admin") { + if (updateData.role && updateData.role === "super_admin") { + throw new Error("super_admin 역할을 부여할 수 없습니다"); + } + updateData.companyId = currentUser.companyId; + } + + const updatedProductGroup = await productgroup.update(productgroupFields); + + if (roleId) { + const role = await Role.findOne({ + where: { + id: roleId, + companyId: productgroup.companyId, + }, + }); + + if (!role) { + throw new Error("Role not found or does not belong to the company"); + } + + await productgroup.addRole(role); + } + + await alertService.createAlert({ + type: "info", + message: `제품군 ${productgroup.name}이(가) ${currentUser.name}에 의해 수정되었습니다.`, + companyId: productgroup.companyId, + }); + + return updatedProductGroup; + } + + async deleteProductGroup(id, currentUser) { + const productgroup = await ProductGroup.findByPk(id); + if (!productgroup) throw new Error("ProductGroup not found"); + + const productgroupName = productgroup.name; + const companyId = productgroup.companyId; + + await productgroup.destroy(); + + await alertService.createAlert({ + type: "info", + message: `제품군 ${productgroupName}이(가) ${currentUser.name}에 의해 삭제되었습니다.`, + companyId: companyId, + }); + + return true; + } +} + +module.exports = new productgroupService(); \ No newline at end of file diff --git a/plm-app/src/app/(admin)/common/oemmng/page.tsx b/plm-app/src/app/(admin)/common/oemmng/page.tsx index c7e82ba..dc918be 100644 --- a/plm-app/src/app/(admin)/common/oemmng/page.tsx +++ b/plm-app/src/app/(admin)/common/oemmng/page.tsx @@ -1,4 +1,3 @@ -// src/app/(admin)/users/accounts/page.tsx "use client"; import React, { useState, useCallback, useMemo } from "react"; diff --git a/plm-app/src/app/(admin)/common/productgroup/components/ProductGroupForm.tsx b/plm-app/src/app/(admin)/common/productgroup/components/ProductGroupForm.tsx new file mode 100644 index 0000000..3d53977 --- /dev/null +++ b/plm-app/src/app/(admin)/common/productgroup/components/ProductGroupForm.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { productgroup} from "@/types/common/productgroup/productgroup"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +interface ProductGroupFormProps { + initialData?: Partial<productgroup>; + onSubmit: (data: Partial<productgroup>) => void; + onCancel: () => void; +} + +export const ProductGroupForm: React.FC<ProductGroupFormProps> = ({ + initialData, + onSubmit, + onCancel, +}) => { + const form = useForm({ + defaultValues: { + product_group_name: initialData?.product_group_name || "", + description: initialData?.description || "", + isActive: initialData?.isActive || false, + }, + }); + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="product_group_name" + validation={{ + required: "제품군은 필수 입력값입니다", + maxLength: { value: 100, message: "최대 100자까지 입력 가능합니다" } + }} + render={({ field }) => ( + <FormItem> + <FormLabel>제품군</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem> + <div className="flex items-center gap-2"> + <FormLabel>활성화</FormLabel> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </div> + <FormMessage /> + </FormItem> + )} + /> + + <div className="flex justify-end gap-2"> + <Button type="button" variant="outline" onClick={onCancel}> + 취소 + </Button> + <Button type="submit"> + {initialData ? "수정" : "생성"} + </Button> + </div> + </form> + </Form> + ); +}; \ No newline at end of file diff --git a/plm-app/src/app/(admin)/common/productgroup/page.tsx b/plm-app/src/app/(admin)/common/productgroup/page.tsx new file mode 100644 index 0000000..70b6a53 --- /dev/null +++ b/plm-app/src/app/(admin)/common/productgroup/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import React, { useState, useCallback, useMemo } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DataTable } from "@/components/ui/data-table"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Plus, Edit, Trash2, Search } from "lucide-react"; +import { ColumnDef } from "@tanstack/react-table"; +import { useAuthStore } from "@/stores/auth"; +import { AxiosError } from "axios"; +import { ProductGroupForm } from "./components/ProductGroupForm"; +import { Switch } from "@/components/ui/switch"; +import { productgroup, PaginatedResponse } from "@/types/common/productgroup/productgroup"; +import { PLMTable } from "@/components/common/Table"; +import { Input } from "@/components/ui/input"; + +const ProductGroupPage = () => { + const { token, user } = useAuthStore(); + const [isOpen, setIsOpen] = React.useState(false); + const [editingUser, setEditingUser] = React.useState<productgroup | null>(null); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [currentPageSize, setCurrentPageSize] = useState(20); // 페이지 크기 상태 추가 + const [searchQuery, setSearchQuery] = useState(""); // 검색어 상태 추가 + + // Fetch product groups with pagination + const { data, isLoading } = useQuery<PaginatedResponse>({ + queryKey: ["productgroups", page, pageSize], + queryFn: async () => { + const { data } = await api.get("/api/v1/app/productgroup/productgroupList", { + params: { + page, + limit: pageSize, + }, + }); + return { + ...data, + productgroups: data.productgroups.map((productgroups: productgroup) => ({ + ...productgroups, + })), + }; + }, + enabled: !!token, + }); + + const handlePageSizeChange = useCallback((newPageSize: number) => { + setPageSize(newPageSize); + setPage(1); + }, []); + + // 필터링된 데이터 생성 + const filteredProductGroups = useMemo(() => { + if (!data?.productgroups || !searchQuery) return data?.productgroups; + + return data.productgroups.filter(group => + group.product_group_name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [data?.productgroups, searchQuery]); + + // Create product group mutation + const createMutation = useMutation({ + mutationFn: async (newGroup: Partial<productgroup>) => { + // Include companyId in the user data + const groupWithCompanyId = { + ...newGroup, + companyId: user?.companyId, + }; + + const { data } = await api.post<productgroup>( + "/api/v1/app/productgroup/productgroupCreate", + groupWithCompanyId + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["productgroups"] }); + setIsOpen(false); + toast({ + title: "제품군 생성", + description: "새로운 제품군이 생성되었습니다.", + }); + }, + onError: (error: AxiosError) => { + toast({ + title: "제품군 생성 실패", + description: (error.response?.data as { message: string }).message, + variant: "destructive", + }); + }, + }); + + // Update product group mutation + const updateMutation = useMutation({ + mutationFn: async (groupData: Partial<productgroup>) => { + const { data } = await api.put<productgroup>( + `/api/v1/app/productgroup/productgroupUpdate/${groupData.id}`, + groupData + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["productgroups"] }); + setIsOpen(false); + setEditingUser(null); + toast({ + title: "제품군 수정", + description: "제품군 정보가 수정되었습니다.", + }); + }, + onError: (error: AxiosError) => { + toast({ + title: "제품군 수정 실패", + description: (error.response?.data as { message: string }).message, + variant: "destructive", + }); + }, + }); + + // Delete product group mutation + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/api/v1/app/productgroup/productgroupDelete/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["productGroups"] }); + toast({ + title: "제품군 삭제", + description: "제품군이 삭제되었습니다.", + }); + }, + onError: (error: AxiosError) => { + toast({ + title: "제품군 삭제 실패", + description: (error.response?.data as { message: string }).message, + variant: "destructive", + }); + }, + }); + + // Table columns 수정 + const columns: ColumnDef<productgroup>[] = [ + { + id: "index", + header: "No", + cell: ({ row }) => row.original.index, + meta: { + width: "60px", + textAlign: "center", + }, + }, + { + accessorKey: "product_group_name", + header: "제품군명", + meta: { + width: "120px", + textAlign: "center", + }, + }, + { + accessorKey: "description", + header: "내용", + meta: { + width: "200px", + textAlign: "center", + }, + }, + { + accessorKey: "isActive", + header: "활성화", + meta: { + width: "100px", + textAlign: "center", + }, + cell: ({ row }) => ( + <div className="text-center"> + <Switch + checked={row.original.isActive} + onCheckedChange={(value) => { + updateMutation.mutate({ id: row.original.id, isActive: value }); + }} + /> + </div> + ), + }, + { + id: "actions", + header: "액션", + meta: { + width: "100px", + textAlign: "center", + }, + cell: ({ row }) => ( + <div className="flex items-center justify-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => { + setEditingUser(row.original); + setIsOpen(true); + }} + > + <Edit className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => { + if (confirm("정말 삭제하시겠습니까?")) { + deleteMutation.mutate(row.original.id); + } + }} + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + ), + }, + ]; + + if (isLoading) return <div>Loading...</div>; + + return ( + <div className="w-full h-full"> + <div className="bg-white rounded-lg shadow"> + <div className="p-2 border-b border-gray-200"> + <div className="flex justify-between items-center mb-3"> + <div> + <h1 className="text-2xl font-semibold text-gray-900">제품군 관리</h1> + <p className="text-sm text-gray-500"> + 제품군을 관리하고 권한을 설정합니다. + </p> + </div> + <Button onClick={() => setIsOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + 제품군 추가 + </Button> + </div> + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" /> + <Input + placeholder="제품군명 또는 코드 검색" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8 w-full" + /> + </div> + </div> + + <div className="p-2"> + <div className="h-[650px]"> + {data?.productgroups && data.productgroups.length > 0 ? ( + <PLMTable + columns={columns} + data={filteredProductGroups || []} // 필터링된 데이터 사용 + pageSize={currentPageSize} + onPageSizeChange={setCurrentPageSize} + onRowClick={(row: productgroup) => { + setEditingUser(row); + setIsOpen(true); + }} + enableCheckbox={true} + isTreeTable={false} + /> + ) : ( + <div className="text-center py-12 text-muted-foreground"> + 등록된 제품군이 없습니다. + </div> + )} + </div> + </div> + </div> + + {/* Product Group Create/Edit Dialog */} + <Dialog + open={isOpen} + onOpenChange={(open) => { + setIsOpen(open); + if (!open) setEditingUser(null); + }} + > + <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>{editingUser ? "제품군 수정" : "새 제품군"}</DialogTitle> + <DialogDescription> + {editingUser + ? "기존 제품군 정보를 수정합니다." + : "새로운 제품군을 생성합니다."} + </DialogDescription> + </DialogHeader> + <ProductGroupForm + initialData={editingUser || undefined} + onSubmit={(data) => { + if (editingUser) { + updateMutation.mutate({ id: editingUser.id, ...data }); + } else { + createMutation.mutate(data); + } + }} + onCancel={() => { + setIsOpen(false); + setEditingUser(null); + }} + /> + </DialogContent> + </Dialog> + </div> + ); +}; + +export default ProductGroupPage; diff --git a/plm-app/src/types/common/productgroup/productgroup.ts b/plm-app/src/types/common/productgroup/productgroup.ts new file mode 100644 index 0000000..97b1c4e --- /dev/null +++ b/plm-app/src/types/common/productgroup/productgroup.ts @@ -0,0 +1,23 @@ + + +export interface productgroup { + id: string; + index: number; + product_group_name?: string | null; + description?: string | null; + writer?: string | null; + regdate?: Date | null; + isActive: boolean; +} + +export interface productgroupResponse { + success: boolean; + data: productgroup[]; +} + +export interface PaginatedResponse { + total: number; + totalPages: number; + currentPage: number; + productgroups: productgroup[]; +} \ No newline at end of file