Преглед изворни кода

[퀘스트 어종관리] 완료

DESKTOP-T61HUSC\user пре 2 недеља
родитељ
комит
2918d7ca12

+ 7 - 7
app/pages/site-manager/species_quest/list.vue

@@ -81,11 +81,11 @@
             <th style="width: 100px;">구분</th>
             <th style="width: 68px;">최소금지</th>
             <th style="width: 68px;">최대길이</th>
-            <th style="width: 140px;">1라운드</th>
-            <th style="width: 140px;">2라운드</th>
-            <th style="width: 140px;">3라운드</th>
-            <th style="width: 140px;">4라운드</th>
-            <th style="width: 140px;">5라운드</th>
+            <th style="width: 140px;">1단계</th>
+            <th style="width: 140px;">2단계</th>
+            <th style="width: 140px;">3단계</th>
+            <th style="width: 140px;">4단계</th>
+            <th style="width: 140px;">5단계</th>
             <th style="width: 90px;">등록일</th>
             <th style="width: 60px;">관리</th>
           </tr>
@@ -364,7 +364,7 @@
     if (startDate.value) params.start_date = startDate.value;
     if (endDate.value) params.end_date = endDate.value;
 
-    const { data, error } = await get("/species-challenge/list", { params });
+    const { data, error } = await get("/species-quest/list", { params });
     if (error) {
       items.value = []; totalCount.value = 0; totalPages.value = 0;
     } else if (data?.success && data?.data) {
@@ -504,7 +504,7 @@
     const doSave = async () => {
       isSaving.value = true;
       try {
-        const { data, error } = await post("/species-challenge/bulk-save", { creates, updates, deletes });
+        const { data, error } = await post("/species-quest/bulk-save", { creates, updates, deletes });
         if (error || !data?.success) {
           showToast(error?.message || data?.message || "저장 실패", "error");
           return;

+ 4 - 0
backend/app/Config/Routes.php

@@ -54,6 +54,10 @@ $routes->post('api/species/bulk-delete', 'Api\SpeciesController::bulkDelete');
 $routes->get('api/species-challenge/list', 'Api\SpeciesChallengeController::index');
 $routes->post('api/species-challenge/bulk-save', 'Api\SpeciesChallengeController::bulkSave');
 
+// Species Quest (어종 퀘스트)
+$routes->get('api/species-quest/list', 'Api\SpeciesQuestController::index');
+$routes->post('api/species-quest/bulk-save', 'Api\SpeciesQuestController::bulkSave');
+
 // Item (아이템)
 $routes->get('api/item/list', 'Api\ItemController::index');
 $routes->get('api/item/(:num)', 'Api\ItemController::show/$1');

+ 244 - 0
backend/app/Controllers/Api/SpeciesQuestController.php

@@ -0,0 +1,244 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class SpeciesQuestController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'species_quest';
+
+    /**
+     * 어종 챌린지 목록 (구분 JOIN)
+     * GET /api/species-challenge/list
+     */
+    public function index()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $page = (int) ($this->request->getGet('page') ?? 1);
+            $perPage = (int) ($this->request->getGet('per_page') ?? 10);
+            if ($page < 1) $page = 1;
+            if ($perPage < 1) $perPage = 10;
+            $offset = ($page - 1) * $perPage;
+
+            $search = trim((string) $this->request->getGet('search'));
+            $typeIdRaw = $this->request->getGet('type_id');     // '', 'null', 또는 숫자
+            $startDate = trim((string) $this->request->getGet('start_date'));
+            $endDate   = trim((string) $this->request->getGet('end_date'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table . ' sc')
+                ->join('species_type st', 'st.id = sc.type_id', 'left')
+                ->where('sc.deleted_YN', 'N');
+
+            if ($search !== '') $builder->like('sc.name', $search);
+            if ($typeIdRaw === 'null') {
+                $builder->where('sc.type_id IS NULL', null, false);
+            } elseif (is_numeric($typeIdRaw) && (int) $typeIdRaw > 0) {
+                $builder->where('sc.type_id', (int) $typeIdRaw);
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
+                $builder->where('sc.created_at >=', $startDate . ' 00:00:00');
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+                $builder->where('sc.created_at <=', $endDate . ' 23:59:59');
+            }
+
+            $total = $builder->countAllResults(false);
+
+            $items = $builder
+                ->select('sc.*, st.name as type_name')
+                ->orderBy('sc.id', 'DESC')
+                ->limit($perPage, $offset)
+                ->get()
+                ->getResult();
+
+            return $this->respondSuccess([
+                'items'       => $items,
+                'total'       => $total,
+                'page'        => $page,
+                'per_page'    => $perPage,
+                'total_pages' => (int) ceil($total / $perPage),
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesQuestController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 어종 챌린지 일괄 저장 (creates + updates + deletes, 트랜잭션)
+     * POST /api/species-challenge/bulk-save
+     */
+    public function bulkSave()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getRawInput() ?? [];
+            }
+            $creates = is_array($payload['creates'] ?? null) ? $payload['creates'] : [];
+            $updates = is_array($payload['updates'] ?? null) ? $payload['updates'] : [];
+            $deletes = is_array($payload['deletes'] ?? null) ? $payload['deletes'] : [];
+
+            if (empty($creates) && empty($updates) && empty($deletes)) {
+                return $this->respondError('저장할 내용이 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+            $db->transBegin();
+
+            $createdCount = 0;
+            $updatedCount = 0;
+            $deletedCount = 0;
+
+            // creates
+            foreach ($creates as $i => $c) {
+                $err = $this->validateRow($c, '신규 ' . ($i + 1) . '행', $db);
+                if ($err) { $db->transRollback(); return $this->respondError($err, ResponseInterface::HTTP_BAD_REQUEST); }
+
+                $db->table($this->table)->insert([
+                    'type_id'    => ((int) $c['type_id']) > 0 ? (int) $c['type_id'] : null,
+                    'name'       => trim((string) $c['name']),
+                    'min'        => (int) $c['min'],
+                    'max'        => (int) $c['max'],
+                    'round1_min' => (int) $c['round1_min'],
+                    'round1_max' => (int) $c['round1_max'],
+                    'round2_min' => (int) $c['round2_min'],
+                    'round2_max' => (int) $c['round2_max'],
+                    'round3_min' => (int) $c['round3_min'],
+                    'round3_max' => (int) $c['round3_max'],
+                    'round4_min' => (int) $c['round4_min'],
+                    'round4_max' => (int) $c['round4_max'],
+                    'round5_min' => (int) $c['round5_min'],
+                    'round5_max' => (int) $c['round5_max'],
+                    'created_at' => date('Y-m-d H:i:s'),
+                ]);
+                $createdCount++;
+            }
+
+            // updates
+            foreach ($updates as $i => $u) {
+                $rowLabel = '수정 ' . ($i + 1) . '행';
+                $id = (int) ($u['id'] ?? 0);
+                if ($id <= 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: ID가 올바르지 않습니다.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                $exists = $db->table($this->table)->where('id', $id)->where('deleted_YN', 'N')->countAllResults();
+                if ($exists === 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 대상이 없습니다.", ResponseInterface::HTTP_NOT_FOUND);
+                }
+
+                $err = $this->validateRow($u, $rowLabel, $db);
+                if ($err) { $db->transRollback(); return $this->respondError($err, ResponseInterface::HTTP_BAD_REQUEST); }
+
+                $db->table($this->table)->where('id', $id)->update([
+                    'type_id'    => ((int) $u['type_id']) > 0 ? (int) $u['type_id'] : null,
+                    'name'       => trim((string) $u['name']),
+                    'min'        => (int) $u['min'],
+                    'max'        => (int) $u['max'],
+                    'round1_min' => (int) $u['round1_min'],
+                    'round1_max' => (int) $u['round1_max'],
+                    'round2_min' => (int) $u['round2_min'],
+                    'round2_max' => (int) $u['round2_max'],
+                    'round3_min' => (int) $u['round3_min'],
+                    'round3_max' => (int) $u['round3_max'],
+                    'round4_min' => (int) $u['round4_min'],
+                    'round4_max' => (int) $u['round4_max'],
+                    'round5_min' => (int) $u['round5_min'],
+                    'round5_max' => (int) $u['round5_max'],
+                ]);
+                $updatedCount++;
+            }
+
+            // deletes
+            $deleteIds = [];
+            foreach ($deletes as $d) {
+                $id = (int) (is_array($d) ? ($d['id'] ?? 0) : $d);
+                if ($id > 0) $deleteIds[] = $id;
+            }
+            if (!empty($deleteIds)) {
+                $db->table($this->table)
+                    ->whereIn('id', array_values(array_unique($deleteIds)))
+                    ->where('deleted_YN', 'N')
+                    ->update(['deleted_YN' => 'Y']);
+                $deletedCount = count(array_unique($deleteIds));
+            }
+
+            if ($db->transStatus() === false) {
+                $db->transRollback();
+                return $this->respondError('저장 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+            $db->transCommit();
+
+            $total = $createdCount + $updatedCount + $deletedCount;
+            return $this->respondSuccess(
+                ['created' => $createdCount, 'updated' => $updatedCount, 'deleted' => $deletedCount],
+                "{$total}건이 저장되었습니다. (신규 {$createdCount} / 수정 {$updatedCount} / 삭제 {$deletedCount})"
+            );
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesQuestController bulkSave error: ' . $e->getMessage());
+            return $this->respondError('저장 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 한 행 검증 — 에러 메시지 반환 (null이면 OK)
+     */
+    private function validateRow($row, string $label, $db)
+    {
+        $typeId = (int) ($row['type_id'] ?? 0);
+        $name = trim((string) ($row['name'] ?? ''));
+        $min = $row['min'] ?? null;
+        $max = $row['max'] ?? null;
+
+        if ($name === '') return "{$label}: 어종명을 입력하세요.";
+        if (mb_strlen($name) > 50) return "{$label}: 어종명은 50자 이내";
+
+        // type_id가 지정된 경우에만 존재 확인 (미선택 허용)
+        if ($typeId > 0) {
+            $typeExists = $db->table('species_type')
+                ->where('id', $typeId)->where('deleted_YN', 'N')->countAllResults();
+            if ($typeExists === 0) return "{$label}: 존재하지 않는 구분입니다.";
+        }
+
+        if ($min === null || $min === '' || !is_numeric($min)) return "{$label}: 최소금지를 입력하세요.";
+        if ($max === null || $max === '' || !is_numeric($max)) return "{$label}: 최대길이를 입력하세요.";
+        if ((int) $min < 0) return "{$label}: 최소금지는 0 이상";
+        if ((int) $max < (int) $min) return "{$label}: 최대길이는 최소금지 이상이어야 합니다.";
+
+        for ($r = 1; $r <= 5; $r++) {
+            $rmin = $row["round{$r}_min"] ?? null;
+            $rmax = $row["round{$r}_max"] ?? null;
+            if ($rmin === null || $rmin === '' || !is_numeric($rmin)) return "{$label}: {$r}라운드 최소를 입력하세요.";
+            if ($rmax === null || $rmax === '' || !is_numeric($rmax)) return "{$label}: {$r}라운드 최대를 입력하세요.";
+            $rmin = (int) $rmin;
+            $rmax = (int) $rmax;
+            if ($rmin > $rmax) return "{$label}: {$r}라운드 최소는 최대보다 작거나 같아야 합니다.";
+            // 각 라운드 max <= 최대길이
+            if ($rmax > (int) $max) {
+                return "{$label}: {$r}라운드 최대는 최대길이({$max})보다 클 수 없습니다.";
+            }
+        }
+
+        // 1라운드 min >= 최소금지
+        if ((int) $row['round1_min'] < (int) $min) {
+            return "{$label}: 1라운드 최소는 최소금지({$min})보다 작을 수 없습니다.";
+        }
+
+        return null;
+    }
+}

+ 19 - 19
db.vuerd.json

@@ -519,7 +519,7 @@
           "wrYVvwUvdLSL-rQaNsyfq"
         ],
         "ui": {
-          "x": 74.0074,
+          "x": 75.0383,
           "y": 1641.7407,
           "zIndex": 772,
           "widthName": 75,
@@ -527,7 +527,7 @@
           "color": ""
         },
         "meta": {
-          "updateAt": 1780901303203,
+          "updateAt": 1780983686203,
           "createAt": 1780899956621
         }
       },
@@ -3229,7 +3229,7 @@
       "1h5FmbC1kvc66CoEEKcPz": {
         "id": "1h5FmbC1kvc66CoEEKcPz",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_min",
+        "name": "round2_min",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3242,14 +3242,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901312697,
+          "updateAt": 1780983668266,
           "createAt": 1780901254266
         }
       },
       "6FgKenK-i9vE7H0ouTpPn": {
         "id": "6FgKenK-i9vE7H0ouTpPn",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_max",
+        "name": "round2_max",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3262,14 +3262,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901313208,
+          "updateAt": 1780983672436,
           "createAt": 1780901254760
         }
       },
       "moXSyZgXBkx7Wmn2kpwrc": {
         "id": "moXSyZgXBkx7Wmn2kpwrc",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_min",
+        "name": "round3_min",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3282,14 +3282,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901313747,
+          "updateAt": 1780983674925,
           "createAt": 1780901255330
         }
       },
       "crqyC1aNLwfZ5dxBBpxiJ": {
         "id": "crqyC1aNLwfZ5dxBBpxiJ",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_max",
+        "name": "round3_max",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3302,14 +3302,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901314234,
+          "updateAt": 1780983678221,
           "createAt": 1780901255690
         }
       },
       "1FEFaMWLQZNscou6-hnzu": {
         "id": "1FEFaMWLQZNscou6-hnzu",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_min",
+        "name": "round4_min",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3322,14 +3322,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901314685,
+          "updateAt": 1780983681003,
           "createAt": 1780901256148
         }
       },
       "6QBt2bf-rzrfob-cnYKn7": {
         "id": "6QBt2bf-rzrfob-cnYKn7",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_max",
+        "name": "round4_max",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3342,14 +3342,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901315128,
+          "updateAt": 1780983684418,
           "createAt": 1780901256448
         }
       },
       "_ERiYo35EfGl88KXj-St2": {
         "id": "_ERiYo35EfGl88KXj-St2",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_min",
+        "name": "round5_min",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3362,14 +3362,14 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901315705,
+          "updateAt": 1780983686988,
           "createAt": 1780901256770
         }
       },
       "4Pzr9nRS8Xs5tvCE4d_v8": {
         "id": "4Pzr9nRS8Xs5tvCE4d_v8",
         "tableId": "M0_u-aSCZODbw1yxM1xbr",
-        "name": "round1_max",
+        "name": "round5_max",
         "comment": "",
         "dataType": "INT",
         "default": "",
@@ -3382,7 +3382,7 @@
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1780901316170,
+          "updateAt": 1780983689933,
           "createAt": 1780901257083
         }
       },
@@ -3699,7 +3699,7 @@
           "columnIds": [
             "fgdsMrriRk3jn-w8yCWTD"
           ],
-          "x": 262.5074,
+          "x": 263.5383,
           "y": 1641.7407,
           "direction": 4
         },