From 754d6e24976b08ca4c85fb76a1ddeced446943ae Mon Sep 17 00:00:00 2001 From: sungro815 Date: Mon, 9 Feb 2026 19:47:58 +0900 Subject: [PATCH] =?UTF-8?q?notice=20,=20qna=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/DispatchDueAdminMailBatches.php | 18 + .../Admin/Notice/AdminNoticeController.php | 118 ++++ .../Admin/Qna/AdminQnaController.php | 171 +++++ .../Controllers/Web/Cs/NoticeController.php | 93 +-- app/Http/Middleware/NoStore.php | 18 +- app/Jobs/SendAdminQnaCreatedMailJob.php | 3 +- app/Jobs/SendUserQnaAnsweredMailJob.php | 62 ++ app/Providers/AppServiceProvider.php | 12 + .../Admin/Cs/AdminQnaRepository.php | 158 +++++ .../Admin/Notice/AdminNoticeRepository.php | 75 +++ app/Repositories/Cs/CsQnaRepository.php | 128 ++++ app/Repositories/Cs/NoticeRepository.php | 96 +++ app/Services/Admin/Mail/AdminMailService.php | 2 +- .../Admin/Notice/AdminNoticeService.php | 224 +++++++ app/Services/Admin/Qna/AdminQnaService.php | 601 ++++++++++++++++++ app/Services/CsNoticeService.php | 71 +++ app/Services/QnaService.php | 141 ++-- config/cs_faq.php | 12 +- config/mail.php | 18 +- config/purifier.php | 129 ++++ public/assets/css/cs-notice.css | 126 ++++ resources/css/admin.css | 10 + resources/views/admin/notice/create.blade.php | 99 +++ resources/views/admin/notice/edit.blade.php | 176 +++++ resources/views/admin/notice/index.blade.php | 170 +++++ .../views/admin/partials/sidebar.blade.php | 22 +- resources/views/admin/qna/index.blade.php | 209 ++++++ resources/views/admin/qna/show.blade.php | 271 ++++++++ .../mail/admin/qna_adminreturn.blade.php | 96 +++ resources/views/web/cs/notice/show.blade.php | 41 +- routes/admin.php | 46 ++ routes/web.php | 5 + 32 files changed, 3193 insertions(+), 228 deletions(-) create mode 100644 app/Console/Commands/DispatchDueAdminMailBatches.php create mode 100644 app/Http/Controllers/Admin/Notice/AdminNoticeController.php create mode 100644 app/Http/Controllers/Admin/Qna/AdminQnaController.php create mode 100644 app/Jobs/SendUserQnaAnsweredMailJob.php create mode 100644 app/Repositories/Admin/Cs/AdminQnaRepository.php create mode 100644 app/Repositories/Admin/Notice/AdminNoticeRepository.php create mode 100644 app/Repositories/Cs/CsQnaRepository.php create mode 100644 app/Repositories/Cs/NoticeRepository.php create mode 100644 app/Services/Admin/Notice/AdminNoticeService.php create mode 100644 app/Services/Admin/Qna/AdminQnaService.php create mode 100644 app/Services/CsNoticeService.php create mode 100644 config/purifier.php create mode 100644 resources/views/admin/notice/create.blade.php create mode 100644 resources/views/admin/notice/edit.blade.php create mode 100644 resources/views/admin/notice/index.blade.php create mode 100644 resources/views/admin/qna/index.blade.php create mode 100644 resources/views/admin/qna/show.blade.php create mode 100644 resources/views/mail/admin/qna_adminreturn.blade.php diff --git a/app/Console/Commands/DispatchDueAdminMailBatches.php b/app/Console/Commands/DispatchDueAdminMailBatches.php new file mode 100644 index 0000000..c4fba27 --- /dev/null +++ b/app/Console/Commands/DispatchDueAdminMailBatches.php @@ -0,0 +1,18 @@ +dispatchDueScheduledBatches((int)$this->option('limit')); + $this->info("dispatched={$n}"); + return 0; + } +} diff --git a/app/Http/Controllers/Admin/Notice/AdminNoticeController.php b/app/Http/Controllers/Admin/Notice/AdminNoticeController.php new file mode 100644 index 0000000..3c04afd --- /dev/null +++ b/app/Http/Controllers/Admin/Notice/AdminNoticeController.php @@ -0,0 +1,118 @@ +validate([ + 'field' => ['nullable', 'string', 'in:subject,content'], + 'q' => ['nullable', 'string', 'max:200'], + ]); + + $templates = $this->service->paginate($filters, 15); + + return view('admin.notice.index', [ + 'templates' => $templates, // ✅ blade 호환 + 'filters' => $filters, + ]); + } + + public function create() + { + return view('admin.notice.create'); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'subject' => ['required','string','max:80'], + 'content' => ['required','string'], + 'first_sign' => ['nullable'], // checkbox + + 'link_01' => ['nullable','string','max:200'], + 'link_02' => ['nullable','string','max:200'], + + 'file_01' => ['nullable','file','mimes:gif,jpg,jpeg,png,hwp,doc,docx,pdf,ppt,pptx,xls,xlsx,zip','max:102400'], + 'file_02' => ['nullable','file','mimes:gif,jpg,jpeg,png,hwp,doc,docx,pdf,ppt,pptx,xls,xlsx,zip','max:102400'], + ]); + + $adminId = (int) auth('admin')->id(); + + $id = $this->service->create( + $data, + $request->file('file_01'), + $request->file('file_02'), + $adminId + ); + + return redirect() + ->route('admin.notice.edit', ['id' => $id]) + ->with('ok', '공지사항이 등록되었습니다.'); + } + + public function edit(int $id, Request $request) + { + $row = $this->service->get($id); + + return view('admin.notice.edit', [ + 'row' => $row, + 'filters' => $request->query(), // 필요하면 화면에서 "목록" 링크에 그대로 붙여주기용 + ]); + } + + public function update(int $id, Request $request) + { + $data = $request->validate([ + 'subject' => ['required','string','max:80'], + 'content' => ['required','string'], + 'first_sign' => ['nullable'], // checkbox + 'hiding' => ['nullable'], // checkbox + + 'link_01' => ['nullable','string','max:200'], + 'link_02' => ['nullable','string','max:200'], + + 'file_01' => ['nullable','file','mimes:gif,jpg,jpeg,png,hwp,doc,docx,pdf,ppt,pptx,xls,xlsx,zip','max:102400'], + 'file_02' => ['nullable','file','mimes:gif,jpg,jpeg,png,hwp,doc,docx,pdf,ppt,pptx,xls,xlsx,zip','max:102400'], + ]); + + $this->service->update( + $id, + $data, + $request->file('file_01'), + $request->file('file_02'), + ); + + return redirect() + ->route('admin.notice.edit', ['id' => $id] + $request->query()) // ✅ 쿼리 유지 + ->with('ok', '공지사항이 저장되었습니다.'); + } + + public function destroy(int $id, Request $request) + { + $this->service->delete($id); + + return redirect() + ->route('admin.notice.index', $request->query()) // ✅ 쿼리 유지 + ->with('ok', '공지사항이 삭제되었습니다.'); + } + + public function download(int $id, int $slot): StreamedResponse + { + $r = $this->service->download($id, $slot); + abort_unless($r['ok'], 404); + + return Storage::disk('public')->download($r['path'], $r['name']); + } +} diff --git a/app/Http/Controllers/Admin/Qna/AdminQnaController.php b/app/Http/Controllers/Admin/Qna/AdminQnaController.php new file mode 100644 index 0000000..7c488ca --- /dev/null +++ b/app/Http/Controllers/Admin/Qna/AdminQnaController.php @@ -0,0 +1,171 @@ +only([ + 'year','enquiry_code','state','q','q_type','date_from','date_to','my_work', + ]); + + $adminId = (int)auth('admin')->id(); + + $data = $this->service->paginate($filters, 20, $adminId); + + return view('admin.qna.index', [ + 'rows' => $data['rows'], + 'year' => $data['year'], + 'filters' => $filters, + 'enquiryCodes' => $data['enquiryCodes'], + 'stateLabels' => $data['stateLabels'], + 'categories' => $data['categories'], + 'categoriesMap' => $data['categoriesMap'], + ]); + } + + public function show(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + if ($year <= 2000) $year = (int)date('Y'); + + $adminId = (int)auth('admin')->id(); + + $data = $this->service->detail($seq, $year, $adminId); + + return view('admin.qna.show', [ + 'row' => $data['row'], + 'year' => $year, + 'member' => $data['member'], + 'orders' => $data['orders'], + 'stateLabels' => $data['stateLabels'], + 'enquiryCodes' => $data['enquiryCodes'], + 'deferCodes' => $data['deferCodes'], + 'stateLog' => $data['stateLog'], + 'memoLog' => $data['memoLog'], + 'admins' => $data['admins'], + 'actions' => $data['actions'], + 'categories' => $data['categories'], + 'categoriesMap' => $data['categoriesMap'], + ]); + } + + public function assignToMe(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $this->service->assignToMe($seq, $year, $adminId); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '내 업무로 배정했습니다.'); + } + + public function startWork(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $this->service->startWork($seq, $year, $adminId); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '업무를 시작했습니다.'); + } + + public function returnWork(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $this->service->returnWork($seq, $year, $adminId); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '업무를 반납했습니다.'); + } + + public function postponeWork(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $validated = $request->validate([ + 'defer_code' => ['nullable','string','max:2'], + 'defer_comment' => ['required','string','max:2000'], + ]); + + $this->service->postponeWork( + $seq, + $year, + $adminId, + (string)($validated['defer_code'] ?? ''), + (string)$validated['defer_comment'] + ); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '업무를 보류했습니다.'); + } + + public function saveAnswer(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $validated = $request->validate([ + 'answer_content' => ['required','string','max:20000'], + 'answer_sms' => ['required','string','max:500'], + ]); + + $this->service->saveAnswer( + $seq, + $year, + $adminId, + (string)$validated['answer_content'], + (string)$validated['answer_sms'], + ); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '답변을 저장했습니다.'); + } + + public function completeWork(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $this->service->completeWork($seq, $year, $adminId); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '업무를 완료 처리했습니다. (요청 방식에 따라 발송)'); + } + + public function addMemo(int $seq, Request $request) + { + $year = (int)$request->query('year', date('Y')); + $adminId = (int)auth('admin')->id(); + + $validated = $request->validate([ + 'memo' => ['required','string','max:2000'], + ]); + + $this->service->addMemo($seq, $year, $adminId, (string)$validated['memo']); + + return redirect() + ->route('admin.qna.show', ['seq'=>$seq, 'year'=>$year] + $request->query()) + ->with('ok', '메모를 추가했습니다.'); + } +} diff --git a/app/Http/Controllers/Web/Cs/NoticeController.php b/app/Http/Controllers/Web/Cs/NoticeController.php index 0ab085c..669111f 100644 --- a/app/Http/Controllers/Web/Cs/NoticeController.php +++ b/app/Http/Controllers/Web/Cs/NoticeController.php @@ -3,90 +3,45 @@ namespace App\Http\Controllers\Web\Cs; use App\Http\Controllers\Controller; -use App\Models\GcBoard; +use App\Services\CsNoticeService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\StreamedResponse; -class NoticeController extends Controller +final class NoticeController extends Controller { + public function __construct( + private readonly CsNoticeService $service, + ) {} + public function index(Request $request) { - $q = trim((string)$request->query('q', '')); + $data = $request->validate([ + 'q' => ['nullable', 'string', 'max:200'], + ]); - $notices = GcBoard::query() - ->visibleNotice() - ->when($q !== '', function ($query) use ($q) { - $query->where(function ($w) use ($q) { - $w->where('subject', 'like', "%{$q}%") - ->orWhere('content', 'like', "%{$q}%"); - }); - }) - ->noticeOrder() - ->paginate(15) - ->withQueryString(); + $q = trim((string)($data['q'] ?? '')); + + $notices = $this->service->paginate($q, 15); return view('web.cs.notice.index', [ 'notices' => $notices, - 'q' => $q, + 'q' => $q, ]); } public function show(Request $request, int $seq) { - $notice = GcBoard::query() - ->visibleNotice() - ->where('seq', $seq) - ->firstOrFail(); + $res = $this->service->detail($seq, $request->session()); - // 조회수 (세션 기준 중복 방지) - $hitKey = "cs_notice_hit_{$seq}"; - if (!$request->session()->has($hitKey)) { - GcBoard::where('seq', $seq)->increment('hit'); - $request->session()->put($hitKey, 1); - $notice->hit = (int)$notice->hit + 1; - } - - // base - $base = GcBoard::query()->visibleNotice(); - - /** - * 전제: noticeOrder()가 "최신/우선 노출이 위" 정렬이라고 가정 - * 예) first_sign DESC, seq DESC (또는 regdate DESC 포함) - * - * - prev: 리스트에서 '아래' 글(더 오래된/더 낮은 우선순위) => 정렬상 "뒤" - * - next: 리스트에서 '위' 글(더 최신/더 높은 우선순위) => 정렬상 "앞" - */ - - // ✅ prev (뒤쪽: first_sign 더 낮거나, 같으면 seq 더 낮은 것) - $prev = (clone $base) - ->where(function ($w) use ($notice) { - $w->where('first_sign', '<', $notice->first_sign) - ->orWhere(function ($w2) use ($notice) { - $w2->where('first_sign', '=', $notice->first_sign) - ->where('seq', '<', $notice->seq); - }); - }) - ->noticeOrder() // DESC 정렬 그대로 (뒤쪽 중에서 가장 가까운 1개) - ->first(); - - // ✅ next (앞쪽: first_sign 더 높거나, 같으면 seq 더 높은 것) - // "가장 가까운 앞"을 얻기 위해 정렬을 ASC로 뒤집어서 first()로 뽑는다. - $next = (clone $base) - ->where(function ($w) use ($notice) { - $w->where('first_sign', '>', $notice->first_sign) - ->orWhere(function ($w2) use ($notice) { - $w2->where('first_sign', '=', $notice->first_sign) - ->where('seq', '>', $notice->seq); - }); - }) - ->orderBy('first_sign', 'asc') - ->orderBy('seq', 'asc') - ->first(); - - return view('web.cs.notice.show', [ - 'notice' => $notice, - 'prev' => $prev, - 'next' => $next, - ]); + return view('web.cs.notice.show', $res); } + public function download(int $seq, int $slot): StreamedResponse + { + $r = $this->service->download($seq, $slot); + abort_unless($r['ok'], 404); + + return Storage::disk('public')->download($r['path'], $r['name']); + } } diff --git a/app/Http/Middleware/NoStore.php b/app/Http/Middleware/NoStore.php index c9c256e..19f49f2 100644 --- a/app/Http/Middleware/NoStore.php +++ b/app/Http/Middleware/NoStore.php @@ -1,16 +1,20 @@ header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->header('Pragma', 'no-cache') - ->header('Expires', '0'); + $response = $next($request); + + // Symfony Response 계열은 headers bag 사용 + $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); + $response->headers->set('Pragma', 'no-cache'); + $response->headers->set('Expires', '0'); + + return $response; } } diff --git a/app/Jobs/SendAdminQnaCreatedMailJob.php b/app/Jobs/SendAdminQnaCreatedMailJob.php index 29c99f7..4a15ff6 100644 --- a/app/Jobs/SendAdminQnaCreatedMailJob.php +++ b/app/Jobs/SendAdminQnaCreatedMailJob.php @@ -27,7 +27,8 @@ class SendAdminQnaCreatedMailJob implements ShouldQueue public function handle(MailService $mail): void { //관리자 이메일 - $adminEmails = ['sungro815@syye.net', 'rudals1540@plusmaker.co.kr']; + //$adminEmails = ['sungro815@syye.net', 'rudals1540@plusmaker.co.kr']; + $adminEmails = ['sungro815@syye.net']; foreach ($adminEmails as $to) { try { $mail->sendTemplate( diff --git a/app/Jobs/SendUserQnaAnsweredMailJob.php b/app/Jobs/SendUserQnaAnsweredMailJob.php new file mode 100644 index 0000000..1ea8672 --- /dev/null +++ b/app/Jobs/SendUserQnaAnsweredMailJob.php @@ -0,0 +1,62 @@ +toEmail); + if ($to === '') return; + + try { + $mail->sendTemplate( + $to, + '[PIN FOR YOU] 1:1 문의 답변 안내', + 'mail.admin.qna_adminreturn', + [ + 'mem_no' => $this->memNo, + 'qna_id' => $this->qnaId, + 'year' => $this->data['year'] ?? null, + 'enquiry_code' => $this->data['enquiry_code'] ?? '', + 'enquiry_title' => $this->data['enquiry_title'] ?? '', + 'enquiry_content' => $this->data['enquiry_content'] ?? '', + 'answer_content' => $this->data['answer_content'] ?? '', + 'answer_sms' => $this->data['answer_sms'] ?? '', + 'regdate' => $this->data['regdate'] ?? '', + 'completed_at' => $this->data['completed_at'] ?? now()->toDateTimeString(), + 'siteUrl' => config('app.url'), + // 'qnaUrl' => ... (원하면 상세 링크) + ], + queue: false + ); + } catch (\Throwable $e) { + Log::error('[QNA] user answered mail failed', [ + 'mem_no' => $this->memNo, + 'qna_id' => $this->qnaId, + 'email' => $to, + 'err' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 964dda8..4d3200d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,18 @@ class AppServiceProvider extends ServiceProvider Fortify::ignoreRoutes(); } + if (app()->environment('local','development')) { + \DB::listen(function ($q) { + if ($q->time >= 50) { // 50ms 이상만 + \Log::info('[SQL SLOW]', [ + 'ms' => $q->time, + 'sql' => $q->sql, + 'bindings' => $q->bindings, + ]); + } + }); + } + $this->app->singleton(CiSeedCrypto::class, function () { $key = config('legacy.seed_user_key_default', ''); $iv = config('legacy.iv', []); diff --git a/app/Repositories/Admin/Cs/AdminQnaRepository.php b/app/Repositories/Admin/Cs/AdminQnaRepository.php new file mode 100644 index 0000000..87060a5 --- /dev/null +++ b/app/Repositories/Admin/Cs/AdminQnaRepository.php @@ -0,0 +1,158 @@ +table($year)); + } + + public function paginate(int $year, array $filters, int $perPage): LengthAwarePaginator + { + if (!$this->hasYearTable($year)) { + return new Paginator([], 0, $perPage, 1, [ + 'path' => request()->url(), + 'query' => request()->query(), + ]); + } + + $t = $this->table($year); + + $q = DB::table("{$t} as q") + ->select([ + 'q.seq','q.state','q.enquiry_code','q.enquiry_title','q.member_num','q.regdate','q.answer_admin_num', + 'q.return_type','q.completion_date', + ]) + ->when(($filters['enquiry_code'] ?? '') !== '', fn($w) => $w->where('q.enquiry_code', $filters['enquiry_code'])) + ->when(($filters['state'] ?? '') !== '', fn($w) => $w->where('q.state', $filters['state'])) + ->when(($filters['my_work'] ?? '') === '1' && (int)($filters['admin_id'] ?? 0) > 0, + fn($w) => $w->where('q.answer_admin_num', (int)$filters['admin_id']) + ) + ->when(($filters['date_from'] ?? '') !== '', fn($w) => $w->where('q.regdate', '>=', $filters['date_from'].' 00:00:00')) + ->when(($filters['date_to'] ?? '') !== '', fn($w) => $w->where('q.regdate', '<=', $filters['date_to'].' 23:59:59')) + ->orderByDesc('q.seq'); + + // 검색 + $qText = trim((string)($filters['q'] ?? '')); + $qType = (string)($filters['q_type'] ?? 'title'); + + if ($qText !== '') { + if ($qType === 'title') { + $q->where('q.enquiry_title', 'like', "%{$qText}%"); + } elseif ($qType === 'member_no' && ctype_digit($qText)) { + $q->where('q.member_num', (int)$qText); + } elseif ($qType === 'member_email') { + $memNo = $this->findMemberNoByEmail($qText); + $q->where('q.member_num', $memNo > 0 ? $memNo : -1); + } elseif ($qType === 'admin_email') { + $adminId = $this->findAdminIdByEmail($qText); + $q->where('q.answer_admin_num', $adminId > 0 ? $adminId : -1); + } + } + + return $q->paginate($perPage)->withQueryString(); + } + + public function findOrFail(int $year, int $seq): object + { + if (!$this->hasYearTable($year)) abort(404); + + $t = $this->table($year); + + $row = DB::table($t)->where('seq', $seq)->first(); + if (!$row) abort(404); + return $row; + } + + public function lockForUpdate(int $year, int $seq, array $cols = ['*']): object + { + if (!$this->hasYearTable($year)) abort(404); + $t = $this->table($year); + + $row = DB::table($t)->select($cols)->where('seq', $seq)->lockForUpdate()->first(); + if (!$row) abort(404); + return $row; + } + + public function update(int $year, int $seq, array $data): int + { + $t = $this->table($year); + return DB::table($t)->where('seq', $seq)->update($data); + } + + public function getEnquiryCodes(): array + { + return DB::table('counseling_code') + ->where('enquiry_type', 'e') + ->orderBy('enquiry_code', 'asc') + ->get(['enquiry_code','enquiry_title']) + ->map(fn($r) => ['code'=>(string)$r->enquiry_code, 'title'=>(string)$r->enquiry_title]) + ->all(); + } + + public function getDeferCodes(): array + { + return DB::table('counseling_code') + ->where('enquiry_type', 'd') + ->orderBy('enquiry_code', 'asc') + ->get(['enquiry_code','enquiry_title']) + ->map(fn($r) => ['code'=>(string)$r->enquiry_code, 'title'=>(string)$r->enquiry_title]) + ->all(); + } + + public function findMemberNoByEmail(string $email): int + { + $row = DB::table('mem_info')->where('email', $email)->first(['mem_no']); + return (int)($row->mem_no ?? 0); + } + + public function findAdminIdByEmail(string $email): int + { + $row = DB::table('admin_users')->where('email', $email)->first(['id']); + return (int)($row->id ?? 0); + } + + public function getMemberInfo(int $memNo): ?object + { + return DB::table('mem_info')->where('mem_no', $memNo)->first(['mem_no','email','name','cell_phone']); + } + + public function getRecentOrders(int $memNo, int $limit = 10): array + { + // 테이블/컬럼은 네 DB에 맞춰 조정 가능 (CI3 pin_order 기반) + if (!Schema::hasTable('pin_order')) return []; + + return DB::table('pin_order') + ->where('mem_no', $memNo) + ->whereIn('stat_pay', ['p','w','c']) + ->orderByDesc('seq') + ->limit($limit) + ->get() + ->all(); + } + + public function getAdminsByIds(array $ids): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + if (!$ids) return []; + + return DB::table('admin_users') + ->whereIn('id', $ids) + ->get(['id','email','name']) + ->keyBy('id') + ->map(fn($r) => ['id'=>(int)$r->id,'email'=>(string)$r->email,'name'=>(string)$r->name]) + ->all(); + } +} diff --git a/app/Repositories/Admin/Notice/AdminNoticeRepository.php b/app/Repositories/Admin/Notice/AdminNoticeRepository.php new file mode 100644 index 0000000..edbf355 --- /dev/null +++ b/app/Repositories/Admin/Notice/AdminNoticeRepository.php @@ -0,0 +1,75 @@ +select(['seq','gubun','subject','admin_num','regdate','hiding','first_sign','hit']) + ->where('gubun', self::GUBUN); + + if ($q !== '') { + $query->where($field, 'like', '%'.$q.'%'); + } + + // ✅ 상단공지 먼저 + 최신순 + $query->orderByDesc('first_sign') + ->orderByDesc('regdate') + ->orderByDesc('seq'); + + return $query->paginate($perPage); + } + + public function findOrFailForEdit(int $id): GcBoard + { + return GcBoard::query()->whereKey($id)->firstOrFail(); + } + + public function lockForUpdate(int $id): GcBoard + { + return GcBoard::query()->lockForUpdate()->whereKey($id)->firstOrFail(); + } + + public function maxFirstSignForUpdate(): int + { + // notice 대상 max(first_sign) (동시성 대비 lock) + $max = (int) (GcBoard::query() + ->where('gubun', self::GUBUN) + ->lockForUpdate() + ->max('first_sign') ?? 0); + + return $max; + } + + public function create(array $data): GcBoard + { + return GcBoard::query()->create($data); + } + + public function update(GcBoard $row, array $data): bool + { + return $row->fill($data)->save(); + } + + public function delete(GcBoard $row): bool + { + return (bool) $row->delete(); + } + + public function transaction(\Closure $fn) + { + return DB::connection((new GcBoard)->getConnectionName())->transaction($fn); + } +} diff --git a/app/Repositories/Cs/CsQnaRepository.php b/app/Repositories/Cs/CsQnaRepository.php new file mode 100644 index 0000000..12a5bb1 --- /dev/null +++ b/app/Repositories/Cs/CsQnaRepository.php @@ -0,0 +1,128 @@ + 0 ? $year : (int) date('Y'); + } + + private function table(int $year): string + { + return "counseling_one_on_one_{$year}"; + } + + private function emptyPaginator(int $perPage): LengthAwarePaginator + { + return new Paginator([], 0, $perPage, 1, [ + 'path' => request()->url(), + 'query' => request()->query(), + ]); + } + + public function paginateMyQna(int $memNo, int $perPage = 10, int $year = 0): LengthAwarePaginator + { + $year = $this->resolveYear($year); + $table = $this->table($year); + + if (!Schema::hasTable($table)) { + return $this->emptyPaginator($perPage); + } + + return CounselingOneOnOne::queryForYear($year) + ->from("{$table} as q") + ->leftJoin('counseling_code as cc', function ($join) { + $join->on('cc.enquiry_code', '=', 'q.enquiry_code') + ->where('cc.enquiry_type', '=', 'e'); + }) + ->where('q.member_num', $memNo) + ->select([ + 'q.seq', + 'q.state', + 'q.enquiry_code', + 'q.enquiry_title', + 'q.regdate', + 'cc.enquiry_title as enquiry_code_name', + ]) + ->orderByDesc('q.seq') + ->paginate($perPage) + ->withQueryString(); + } + + public function findMyQna(int $memNo, int $seq, int $year = 0) + { + $year = $this->resolveYear($year); + $table = $this->table($year); + + if (!Schema::hasTable($table)) { + abort(404); + } + + return CounselingOneOnOne::queryForYear($year) + ->from("{$table} as q") + ->leftJoin('counseling_code as cc', function ($join) { + $join->on('cc.enquiry_code', '=', 'q.enquiry_code') + ->where('cc.enquiry_type', '=', 'e'); + }) + ->where('q.member_num', $memNo) + ->where('q.seq', $seq) + ->select([ + 'q.seq', + 'q.state', + 'q.enquiry_code', + 'q.enquiry_title', + 'q.enquiry_content', + 'q.answer_content', + 'q.regdate', + 'cc.enquiry_title as enquiry_code_name', + ]) + ->firstOrFail(); + } + + public function getEnquiryCodes(): array + { + return DB::table('counseling_code') + ->where('enquiry_type', 'e') + ->orderBy('enquiry_code', 'asc') + ->get(['enquiry_code', 'enquiry_title']) + ->map(fn($r) => ['code' => (string)$r->enquiry_code, 'title' => (string)$r->enquiry_title]) + ->all(); + } + + /** + * QnA insert (연도 테이블) + */ + public function insertQna(int $memNo, array $data, int $year = 0): int + { + $year = $this->resolveYear($year); + $table = $this->table($year); + + if (!Schema::hasTable($table)) { + throw new \RuntimeException("qna_table_not_found: {$table}"); + } + + return (int) DB::transaction(function () use ($table, $memNo, $data) { + return DB::table($table)->insertGetId([ + 'state' => 'a', + 'member_num' => $memNo, + 'enquiry_code' => (string)($data['enquiry_code'] ?? ''), + 'enquiry_title' => (string)($data['enquiry_title'] ?? ''), + 'enquiry_content' => (string)($data['enquiry_content'] ?? ''), + 'return_type' => (string)($data['return_type'] ?? 'web'), + 'user_upload' => null, + 'regdate' => now()->format('Y-m-d H:i:s'), + 'receipt_date' => "1900-01-01 00:00:00", + 'defer_date' => "1900-01-01 00:00:00", + 'completion_date' => "1900-01-01 00:00:00", + ]); + }); + } +} diff --git a/app/Repositories/Cs/NoticeRepository.php b/app/Repositories/Cs/NoticeRepository.php new file mode 100644 index 0000000..dd01fb1 --- /dev/null +++ b/app/Repositories/Cs/NoticeRepository.php @@ -0,0 +1,96 @@ +visibleNotice(); + } + + public function paginate(string $q, int $perPage = 15): LengthAwarePaginator + { + $q = trim($q); + + $query = $this->baseVisible() + ->when($q !== '', function (Builder $query) use ($q) { + $query->where(function (Builder $w) use ($q) { + $w->where('subject', 'like', "%{$q}%") + ->orWhere('content', 'like', "%{$q}%"); + }); + }); + + // 기존 정렬 스코프 유지 + $query->noticeOrder(); + + return $query->paginate($perPage); + } + + public function findOrFail(int $seq): GcBoard + { + return $this->baseVisible() + ->where('seq', $seq) + ->firstOrFail(); + } + + public function incrementHit(int $seq): void + { + // 단순 hit 증가 + GcBoard::query() + ->where('seq', $seq) + ->increment('hit'); + } + + /** + * 리스트 정렬 기준(공지 우선 + 최신 우선)을 아래처럼 가정/고정: + * 1) first_sign DESC + * 2) seq DESC + * + * ⚠️ 기존 noticeOrder()가 regdate를 포함해도 무방하지만, + * prev/next는 "가장 가까운 글"을 잡아야 해서 여기서는 명시적으로 처리. + */ + public function findPrev(GcBoard $cur): ?GcBoard + { + $curFirst = (int)($cur->first_sign ?? 0); + $curSeq = (int)$cur->seq; + + return $this->baseVisible() + ->where(function (Builder $w) use ($curFirst, $curSeq) { + // COALESCE(first_sign,0) < curFirst + $w->whereRaw('COALESCE(first_sign,0) < ?', [$curFirst]) + ->orWhere(function (Builder $w2) use ($curFirst, $curSeq) { + // COALESCE(first_sign,0) = curFirst AND seq < curSeq + $w2->whereRaw('COALESCE(first_sign,0) = ?', [$curFirst]) + ->where('seq', '<', $curSeq); + }); + }) + ->orderByRaw('COALESCE(first_sign,0) desc') + ->orderBy('seq', 'desc') + ->first(); + } + + public function findNext(GcBoard $cur): ?GcBoard + { + $curFirst = (int)($cur->first_sign ?? 0); + $curSeq = (int)$cur->seq; + + // "가장 가까운 앞"을 위해 ASC로 뒤집어서 first() + return $this->baseVisible() + ->where(function (Builder $w) use ($curFirst, $curSeq) { + $w->whereRaw('COALESCE(first_sign,0) > ?', [$curFirst]) + ->orWhere(function (Builder $w2) use ($curFirst, $curSeq) { + $w2->whereRaw('COALESCE(first_sign,0) = ?', [$curFirst]) + ->where('seq', '>', $curSeq); + }); + }) + ->orderByRaw('COALESCE(first_sign,0) asc') + ->orderBy('seq', 'asc') + ->first(); + } +} diff --git a/app/Services/Admin/Mail/AdminMailService.php b/app/Services/Admin/Mail/AdminMailService.php index bf1cb6d..2676efc 100644 --- a/app/Services/Admin/Mail/AdminMailService.php +++ b/app/Services/Admin/Mail/AdminMailService.php @@ -127,7 +127,7 @@ final class AdminMailService $page->appends($clean); } - return $this->repo->listBatches($filters, $perPage); + return $page; } public function getBatchDetail(int $batchId, array $filters, int $perPage=50): array diff --git a/app/Services/Admin/Notice/AdminNoticeService.php b/app/Services/Admin/Notice/AdminNoticeService.php new file mode 100644 index 0000000..3bcb360 --- /dev/null +++ b/app/Services/Admin/Notice/AdminNoticeService.php @@ -0,0 +1,224 @@ +repo->paginate($filters, $perPage)->withQueryString(); + } + + public function get(int $id): GcBoard + { + return $this->repo->findOrFailForEdit($id); + } + + public function create(array $data, ?UploadedFile $file1, ?UploadedFile $file2, int $adminId): int + { + // 파일은 먼저 저장(성공 시 DB 반영), 예외 시 롤백+파일 삭제 + $new1 = $file1 ? $this->storeFile($file1) : null; + $new2 = $file2 ? $this->storeFile($file2) : null; + + try { + return $this->repo->transaction(function () use ($data, $adminId, $new1, $new2) { + + $wantPinned = !empty($data['first_sign']); + $firstSign = 0; + + if ($wantPinned) { + $max = $this->repo->maxFirstSignForUpdate(); // lock 포함 + $firstSign = $max + 1; // ✅ 상단공지 순번(큰 값이 먼저) + } + + $payload = [ + 'gubun' => self::GUBUN, + 'subject' => (string)($data['subject'] ?? ''), + 'content' => (string)($data['content'] ?? ''), + 'admin_num' => $adminId, + 'regdate' => now()->format('Y-m-d H:i:s'), + 'hiding' => 'N', // 기본 노출 + 'first_sign' => $firstSign, + 'link_01' => $this->nullIfBlank($data['link_01'] ?? null), + 'link_02' => $this->nullIfBlank($data['link_02'] ?? null), + 'hit' => 0, + ]; + + if ($new1) $payload['file_01'] = $new1; + if ($new2) $payload['file_02'] = $new2; + + $row = $this->repo->create($payload); + return (int) $row->getKey(); + }); + } catch (\Throwable $e) { + // DB 실패 시 새로 저장한 파일 제거 + $this->deletePhysicalFiles([$new1, $new2]); + throw $e; + } + } + + public function update(int $id, array $data, ?UploadedFile $file1, ?UploadedFile $file2): void + { + // 새 파일 먼저 저장 + $new1 = $file1 ? $this->storeFile($file1) : null; + $new2 = $file2 ? $this->storeFile($file2) : null; + + $oldToDelete = []; + + try { + $oldToDelete = (array) $this->repo->transaction(function () use ($id, $data, $new1, $new2) { + + $row = $this->repo->lockForUpdate($id); + + $hiding = !empty($data['hiding']) ? 'Y' : 'N'; + + // ✅ first_sign(상단공지) 정리된 로직 + // - 체크 해제: 0 + // - 체크 + 기존이 0: max+1 부여 + // - 체크 + 기존이 >0: 기존 값 유지 + $wantPinned = !empty($data['first_sign']); + $firstSign = (int)($row->first_sign ?? 0); + + if ($wantPinned) { + if ($firstSign <= 0) { + $firstSign = $this->repo->maxFirstSignForUpdate() + 1; + } + } else { + $firstSign = 0; + } + + $payload = [ + 'subject' => (string)($data['subject'] ?? ''), + 'content' => (string)($data['content'] ?? ''), + 'link_01' => $this->nullIfBlank($data['link_01'] ?? null), + 'link_02' => $this->nullIfBlank($data['link_02'] ?? null), + 'hiding' => $hiding, + 'first_sign' => $firstSign, + ]; + + $oldFiles = []; + + if ($new1) { + $oldFiles[] = (string)($row->file_01 ?? ''); + $payload['file_01'] = $new1; + } + + if ($new2) { + $oldFiles[] = (string)($row->file_02 ?? ''); + $payload['file_02'] = $new2; + } + + $this->repo->update($row, $payload); + + // 트랜잭션 밖에서 삭제할 이전 파일명 반환 + return $oldFiles; + }); + } catch (\Throwable $e) { + // DB 실패 시 새로 저장한 파일 제거 + $this->deletePhysicalFiles([$new1, $new2]); + throw $e; + } + + // ✅ 커밋 이후에만 기존 파일 삭제 + $this->deletePhysicalFiles($oldToDelete); + } + + public function delete(int $id): void + { + $files = (array) $this->repo->transaction(function () use ($id) { + $row = $this->repo->lockForUpdate($id); + + $files = [ + (string)($row->file_01 ?? ''), + (string)($row->file_02 ?? ''), + ]; + + $this->repo->delete($row); + return $files; + }); + + $this->deletePhysicalFiles($files); + } + + public function download(int $id, int $slot): array + { + $row = $this->repo->findOrFailForEdit($id); + + $file = $slot === 1 + ? (string)($row->file_01 ?? '') + : (string)($row->file_02 ?? ''); + + $file = trim($file); + if ($file === '') return ['ok' => false, 'path' => '', 'name' => '']; + + $path = self::DIR.'/'.basename($file); + if (!Storage::disk(self::DISK)->exists($path)) { + return ['ok' => false, 'path' => '', 'name' => '']; + } + + return ['ok' => true, 'path' => $path, 'name' => $file]; + } + + private function storeFile(UploadedFile $file): string + { + $orig = $file->getClientOriginalName(); + $safe = $this->safeFilename($orig); + + $base = pathinfo($safe, PATHINFO_FILENAME); + $ext = pathinfo($safe, PATHINFO_EXTENSION); + $name = $safe; + + // 충돌 방지 + $i = 0; + while (Storage::disk(self::DISK)->exists(self::DIR.'/'.$name)) { + $i++; + $suffix = date('YmdHis').'_'.mt_rand(1000, 9999).'_'.$i; + $name = $base.'_'.$suffix.($ext ? '.'.$ext : ''); + } + + Storage::disk(self::DISK)->putFileAs(self::DIR, $file, $name); + return $name; + } + + private function deletePhysicalFiles(array $files): void + { + foreach ($files as $f) { + $f = trim((string)$f); + if ($f === '') continue; + + $path = self::DIR.'/'.$f; + if (Storage::disk(self::DISK)->exists($path)) { + Storage::disk(self::DISK)->delete($path); + } + } + } + + private function safeFilename(string $name): string + { + $name = trim($name); + $name = preg_replace('/[^\pL\pN\.\-\_\s]/u', '', $name) ?: 'file'; + $name = preg_replace('/\s+/', '_', $name); + $name = preg_replace('/_+/', '_', $name); + return mb_substr($name, 0, 180); + } + + private function nullIfBlank($v): ?string + { + $v = trim((string)($v ?? '')); + return $v === '' ? null : $v; + } +} diff --git a/app/Services/Admin/Qna/AdminQnaService.php b/app/Services/Admin/Qna/AdminQnaService.php new file mode 100644 index 0000000..389d156 --- /dev/null +++ b/app/Services/Admin/Qna/AdminQnaService.php @@ -0,0 +1,601 @@ + ['접수', 'pill--muted'], // 회색(대기/접수) + 'b' => ['분배완료', 'pill--info'], // 파랑(분배/할당 완료) + 'c' => ['처리중', 'pill--warn'], // 주황(진행중) + 'd' => ['보류', 'pill--bad'], // 빨강(중단/보류) + 'e' => ['완료', 'pill--ok'], // 초록(완료) + ]; + } + + public function paginate(array $filters, int $perPage, int $adminId): array + { + $year = (int)($filters['year'] ?? date('Y')); + if ($year <= 2000) $year = (int)date('Y'); + + $filters['admin_id'] = $adminId; + + $rows = $this->repo->paginate($year, $filters, $perPage); + + if (is_object($rows) && method_exists($rows, 'withQueryString')) { + $rows->withQueryString(); + } + + + return [ + 'year' => $year, + 'rows' => $this->repo->paginate($year, $filters, $perPage), + 'enquiryCodes' => $this->repo->getEnquiryCodes(), + 'stateLabels' => $this->stateLabels(), + 'categories' => array_values($this->categoriesByNum()), + 'categoriesMap' => $this->categoriesByNum(), + ]; + } + + public function detail(int $seq, int $year, int $adminId): array + { + // 1) row + $row = $this->repo->findOrFail($year, $seq); + + // 2) logs decode (state/memo) + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $stateLog = array_reverse($log['state_log'] ?? []); + $memoLog = array_reverse($log['memo'] ?? []); + + // 3) adminIds build (stateLog + memoLog + answer_admin_num) : set(연관배열)로 중복 제거 + $adminSet = []; + + foreach ($stateLog as $x) { + $id = (int)($x['admin_num'] ?? 0); + if ($id > 0) $adminSet[$id] = true; + } + foreach ($memoLog as $x) { + $id = (int)($x['admin_num'] ?? 0); + if ($id > 0) $adminSet[$id] = true; + } + + $ans = (int)($row->answer_admin_num ?? 0); + if ($ans > 0) $adminSet[$ans] = true; + + $adminIds = array_keys($adminSet); + + // 4) admins + $admins = $adminIds ? $this->repo->getAdminsByIds($adminIds) : collect(); + + // 5) member + decrypt phone + $member = $this->repo->getMemberInfo((int)($row->member_num ?? 0)); + if ($member && !empty($member->cell_phone)) { + $seed = app(\App\Support\LegacyCrypto\CiSeedCrypto::class); + $member->cell_phone = (string) $seed->decrypt((string)$member->cell_phone); + } + + // 6) 최근 거래 10건: 당분간 무조건 0건 (쿼리 절대 X) + $orders = []; + + // 7) codes cache + $enquiryCodes = \Cache::remember('cs:enquiry_codes', 3600, fn () => $this->repo->getEnquiryCodes()); + $deferCodes = \Cache::remember('cs:defer_codes', 3600, fn () => $this->repo->getDeferCodes()); + + // 8) actions + 내부 메모 권한(배정 후부터, 내 업무만) + $actions = $this->allowedActions($row, $adminId); + $state = (string)($row->state ?? ''); + $assignedToMe = ((int)($row->answer_admin_num ?? 0) === $adminId); + $actions['canMemo'] = ($state !== 'a') && $assignedToMe; // a(접수) 금지, b/c/d/e OK + + // 9) categories (config/cs_faq.php 기반 num 매핑) + $categoriesMap = $this->categoriesByNum(); + + return [ + 'row' => $row, + 'member' => $member, + 'orders' => $orders, + 'stateLabels' => $this->stateLabels(), + 'enquiryCodes' => $enquiryCodes, + 'deferCodes' => $deferCodes, + 'stateLog' => $stateLog, + 'memoLog' => $memoLog, + 'admins' => $admins, + 'actions' => $actions, + 'categories' => array_values($categoriesMap), + 'categoriesMap' => $categoriesMap, + ]; + } + + + public function assignToMe(int $seq, int $year, int $adminId): void + { + DB::transaction(function () use ($seq, $year, $adminId) { + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','state','answer_admin_num','admin_change_memo' + ]); + + if ($row->state !== 'a'){ + throw ValidationException::withMessages([ + 'answer_content' => '현재 상태에서는 배정할 수 없습니다.', + ]); + } + + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushStateLog($log, $adminId, $row->state, 'b', '업무를 직접 배정'); + + $this->repo->update($year, $seq, [ + 'state' => 'b', + 'answer_admin_num' => $adminId, + 'receipt_date' => now()->format('Y-m-d H:i:s'), + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function startWork(int $seq, int $year, int $adminId): void + { + DB::transaction(function () use ($seq, $year, $adminId) { + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','state','answer_admin_num','admin_change_memo' + ]); + + $assigned = (int)($row->answer_admin_num ?? 0); + if ($assigned !== $adminId) abort(403); + + if (!in_array($row->state, ['b','d'], true)){ + throw ValidationException::withMessages([ + 'answer_content' => '현재 상태에서는 업무 시작이 불가합니다.', + ]); + } + + $msg = ($row->state === 'd') ? '업무를 재개합니다!' : '업무를 시작했습니다.'; + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushStateLog($log, $adminId, $row->state, 'c', $msg); + + $this->repo->update($year, $seq, [ + 'state' => 'c', + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function returnWork(int $seq, int $year, int $adminId): void + { + DB::transaction(function () use ($seq, $year, $adminId) { + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','state','answer_admin_num','admin_change_memo' + ]); + + $assigned = (int)($row->answer_admin_num ?? 0); + if ($assigned !== $adminId) abort(403); + + if (!in_array($row->state, ['b','c','d'], true)){ + throw ValidationException::withMessages([ + 'answer_content' => '현재 상태에서는 반납할 수 없습니다.', + ]); + } + + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushStateLog($log, $adminId, $row->state, 'a', '업무를 종료/반납했습니다.'); + + $this->repo->update($year, $seq, [ + 'state' => 'a', + 'answer_admin_num' => null, + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function postponeWork(int $seq, int $year, int $adminId, string $deferCode, string $comment): void + { + DB::transaction(function () use ($seq, $year, $adminId, $deferCode, $comment) { + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','state','answer_admin_num','admin_change_memo' + ]); + + $assigned = (int)($row->answer_admin_num ?? 0); + if ($assigned !== $adminId) abort(403); + + if (!in_array($row->state, ['b','c'], true)){ + throw ValidationException::withMessages([ + 'answer_content' => '현재 상태에서는 보류할 수 없습니다.', + ]); + } + + $comment = trim($comment); + if ($comment === ''){ + throw ValidationException::withMessages([ + 'answer_content' => '보류 사유를 입력해 주세요.', + ]); + } + + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushStateLog($log, $adminId, $row->state, 'd', $comment); + + $this->repo->update($year, $seq, [ + 'state' => 'd', + 'defer_date' => now()->format('Y-m-d H:i:s'), + 'defer_comment' => $comment, + 'defer_admin_num' => $adminId, + 'defer_code' => $deferCode ?: null, + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function saveAnswer(int $seq, int $year, int $adminId, string $answerContent, string $answerSms): void + { + DB::transaction(function () use ($seq, $year, $adminId, $answerContent, $answerSms) { + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','state','answer_admin_num','admin_change_memo' + ]); + $this->assertCanMemo($row, $adminId); + + $assigned = (int)($row->answer_admin_num ?? 0); + if ($assigned !== $adminId) abort(403); + + if (!in_array($row->state, ['c'], true)){ + throw ValidationException::withMessages([ + 'answer_content' => '처리중 상태에서만 답변 저장이 가능합니다.', + ]); + } + + $answerContent = trim($answerContent); + $answerSms = trim($answerSms); + + if ($answerContent === '') abort(422, '관리자 답변을 입력해 주세요.'); + if ($answerSms === '') abort(422, 'SMS 답변을 입력해 주세요.'); + + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushMemoLog($log, $adminId, '관리자 답변 수정'); + + $this->repo->update($year, $seq, [ + 'answer_content' => $answerContent, + 'answer_sms' => $answerSms, + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function completeWork(int $seq, int $year, int $adminId): void + { + $notify = null; + + DB::transaction(function () use ($seq, $year, $adminId, &$notify) { + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','state','answer_admin_num','admin_change_memo', + 'return_type','member_num','enquiry_title','enquiry_content', + 'answer_content','answer_sms','regdate', + ]); + + $assigned = (int)($row->answer_admin_num ?? 0); + if ($assigned !== $adminId) abort(403); + + if (!in_array($row->state, ['b','c'], true)){ + throw ValidationException::withMessages([ + 'answer_content' => '현재 상태에서는 완료 처리가 불가합니다.', + ]); + } + + $answerContent = trim((string)($row->answer_content ?? '')); + $answerSms = trim((string)($row->answer_sms ?? '')); + + if ($answerContent === '') { + throw ValidationException::withMessages([ + 'answer_content' => '답변 내용이 비어있습니다. 먼저 저장해 주세요.', + ]); + } + + $rt = (string)($row->return_type ?? 'web'); + if (in_array($rt, ['sms','phone'], true) && $answerSms === '') { + abort(422, 'SMS 답변이 비어있습니다.'); + } + + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushStateLog($log, $adminId, $row->state, 'e', '업무를 완료했습니다.'); + + $this->repo->update($year, $seq, [ + 'state' => 'e', + 'completion_date' => now()->format('Y-m-d H:i:s'), + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + + // 트랜잭션 밖에서 발송하도록 데이터만 준비 + $notify = [ + 'return_type' => $rt, + 'member_num' => (int)$row->member_num, + 'enquiry_title' => (string)$row->enquiry_title, + 'enquiry_content' => (string)$row->enquiry_content, + 'answer_content' => $answerContent, + 'answer_sms' => $answerSms, + 'regdate' => (string)$row->regdate, + 'year' => $year, + 'seq' => (int)$row->seq, + ]; + }); + + if (is_array($notify)) { + DB::afterCommit(function () use ($notify) { + $this->sendResultToUser($notify); + }); + } + } + + public function addMemo(int $seq, int $year, int $adminId, string $memo): void + { + DB::transaction(function () use ($seq, $year, $adminId, $memo) { + $memo = trim($memo); + if ($memo === '') abort(422); + + $row = $this->repo->lockForUpdate($year, $seq, [ + 'seq','answer_admin_num','admin_change_memo' + ]); + + // 메모는 배정자만 제한할지, 모든 관리자 가능할지 선택. 관리자 모두 + // if ((int)($row->answer_admin_num ?? 0) !== $adminId) abort(403); + + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); + $log = $this->pushMemoLog($log, $adminId, $memo); + + $this->repo->update($year, $seq, [ + 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + private function allowedActions(object $row, int $adminId): array + { + $state = (string)$row->state; + $assigned = (int)($row->answer_admin_num ?? 0); + + return [ + 'can_assign' => ($state === 'a'), + 'can_start' => ($assigned === $adminId && in_array($state, ['b','d'], true)), + 'can_return' => ($assigned === $adminId && in_array($state, ['b','c','d'], true)), + 'can_postpone' => ($assigned === $adminId && in_array($state, ['b','c'], true)), + 'can_answer' => ($assigned === $adminId && $state === 'c'), + 'can_complete' => ($assigned === $adminId && in_array($state, ['b','c'], true)), + ]; + } + + private function decodeJson(string $json): array + { + $json = trim($json); + if ($json === '') return []; + $arr = json_decode($json, true); + return is_array($arr) ? $arr : []; + } + + private function pushStateLog(array $log, int $adminId, string $before, string $after, string $why): array + { + $log['state_log'] ??= []; + $log['state_log'][] = [ + 'admin_num' => $adminId, + 'state_before' => $before, + 'state_after' => $after, + 'Who' => 'admin', + 'why' => $why, + 'when' => now()->format('y-m-d H:i:s'), + ]; + return $log; + } + + private function pushMemoLog(array $log, int $adminId, string $memo): array + { + $log['memo'] ??= []; + $log['memo'][] = [ + 'admin_num' => $adminId, + 'memo' => $memo, + 'when' => now()->format('y-m-d H:i:s'), + ]; + return $log; + } + + private function maskEmail(string $email): string + { + $email = trim($email); + if ($email === '' || !str_contains($email, '@')) return ''; + [$id, $dom] = explode('@', $email, 2); + $idLen = mb_strlen($id); + if ($idLen <= 2) return str_repeat('*', $idLen) . '@' . $dom; + return mb_substr($id, 0, 2) . str_repeat('*', max(1, $idLen - 2)) . '@' . $dom; + } + + private function maskPhone(string $digits): string + { + $digits = preg_replace('/\D+/', '', $digits) ?? ''; + $len = strlen($digits); + if ($len <= 4) return str_repeat('*', $len); + return substr($digits, 0, 3) . str_repeat('*', max(0, $len - 7)) . substr($digits, -4); + } + + private function sendResultToUser(array $data): void + { + $memberNum = (int)($data['member_num'] ?? 0); + $rt = (string)($data['return_type'] ?? 'web'); + $year = (int)($data['year'] ?? date('Y')); + $qnaId = (int)($data['seq'] ?? $data['qna_id'] ?? 0); + + Log::info('[QNA][result] enter', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + 'year' => $year, + 'return_type' => $rt, + 'has_answer_content' => (trim((string)($data['answer_content'] ?? '')) !== ''), + 'answer_sms_len' => mb_strlen((string)($data['answer_sms'] ?? '')), + ]); + + if ($memberNum <= 0 || $qnaId <= 0) { + Log::warning('[QNA][result] invalid keys', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + ]); + return; + } + + $member = $this->repo->getMemberInfo($memberNum); + if (!$member) { + Log::warning('[QNA][result] member not found', [ + 'member_num' => $memberNum, + ]); + return; + } + + // EMAIL + if ($rt === 'email') { + $to = trim((string)($member->email ?? '')); + if ($to === '') { + Log::warning('[QNA][result][email] no email', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + ]); + return; + } + + Log::info('[QNA][result][email] dispatching', [ + 'to' => $this->maskEmail($to), + 'connection' => 'redis', + 'queue' => 'mail', + 'after_commit' => true, + ]); + + SendUserQnaAnsweredMailJob::dispatch( + $to, + $memberNum, + $qnaId, + [ + 'year' => $year, + 'enquiry_code' => (string)($data['enquiry_code'] ?? ''), + 'enquiry_title' => (string)($data['enquiry_title'] ?? ''), + 'enquiry_content' => (string)($data['enquiry_content'] ?? ''), + 'answer_content' => (string)($data['answer_content'] ?? ''), + 'answer_sms' => (string)($data['answer_sms'] ?? ''), + 'regdate' => (string)($data['regdate'] ?? ''), + 'completed_at' => (string)($data['completed_at'] ?? now()->toDateTimeString()), + ] + ) + ->onConnection('redis') + ->onQueue('mail') + ->afterCommit(); + + Log::info('[QNA][result][email] dispatched', [ + 'to' => $this->maskEmail($to), + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + ]); + + return; + } + + // SMS + if (in_array($rt, ['sms', 'phone'], true)) { + $msg = trim((string)($data['answer_sms'] ?? '')); + if ($msg === '') { + Log::warning('[QNA][result][sms] empty answer_sms', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + ]); + return; + } + + $phoneEnc = (string)($member->cell_phone ?? ''); + if ($phoneEnc === '') { + Log::warning('[QNA][result][sms] empty cell_phone', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + ]); + return; + } + + try { + $seed = app(CiSeedCrypto::class); + $phonePlain = (string)$seed->decrypt($phoneEnc); + $digits = preg_replace('/\D+/', '', $phonePlain) ?? ''; + if ($digits === '') { + Log::warning('[QNA][result][sms] decrypt ok but no digits', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + ]); + return; + } + + Log::info('[QNA][result][sms] sending', [ + 'to' => $this->maskPhone($digits), + 'msg_len' => mb_strlen($msg), + ]); + + app(SmsService::class)->send([ + 'from_number' => '1833-4856', + 'to_number' => $digits, + 'message' => $msg, + 'sms_type' => 'sms', + 'country' => 82, + ]); + + Log::info('[QNA][result][sms] sent', [ + 'to' => $this->maskPhone($digits), + ]); + + } catch (\Throwable $e) { + Log::warning('[QNA][result][sms] failed', [ + 'member_num' => $memberNum, + 'qna_id' => $qnaId, + 'err' => $e->getMessage(), + ]); + } + } + } + + private function categoriesByNum(): array + { + $cats = (array) config('cs_faq.categories', []); + $map = []; + foreach ($cats as $c) { + $num = (int)($c['num'] ?? 0); + if ($num > 0) $map[$num] = $c; // ['key','label','num'] + } + return $map; + } + + private function categoryLabel($enquiryCode): string + { + $num = (int) $enquiryCode; + $map = $this->categoriesByNum(); + return $map[$num]['label'] ?? ((string)$enquiryCode ?: '-'); + } + + private function assertCanMemo(object $row, int $adminId): void + { + if ((string)($row->state ?? '') === 'a') { + throw ValidationException::withMessages([ + 'answer_content' => '업무 배정 후 메모를 남길 수 있습니다.', + ]); + } + if ((int)($row->answer_admin_num ?? 0) !== $adminId) { + throw ValidationException::withMessages([ + 'answer_content' => '내 업무로 배정된 건만 메모를 남길 수 있습니다.', + ]); + } + } + +} diff --git a/app/Services/CsNoticeService.php b/app/Services/CsNoticeService.php new file mode 100644 index 0000000..4e05415 --- /dev/null +++ b/app/Services/CsNoticeService.php @@ -0,0 +1,71 @@ +query() 붙일 필요 없음) + return $this->repo->paginate($q, $perPage)->withQueryString(); + } + + /** + * @return array{notice:GcBoard, prev:?GcBoard, next:?GcBoard} + */ + public function detail(int $seq, Session $session): array + { + $notice = $this->repo->findOrFail($seq); + + // 조회수 (세션 기준 중복 방지) + $hitKey = "cs_notice_hit_{$seq}"; + if (!$session->has($hitKey)) { + $this->repo->incrementHit($seq); + $session->put($hitKey, 1); + $notice->hit = (int)($notice->hit ?? 0) + 1; // 화면 즉시 반영용 + } + + $prev = $this->repo->findPrev($notice); + $next = $this->repo->findNext($notice); + + return [ + 'notice' => $notice, + 'prev' => $prev, + 'next' => $next, + ]; + } + + public function download(int $seq, int $slot): array + { + $notice = $this->repo->findOrFail($seq); // visibleNotice 보장 + + $file = $slot === 1 + ? (string)($notice->file_01 ?? '') + : (string)($notice->file_02 ?? ''); + + $file = trim($file); + if ($file === '') return ['ok' => false, 'path' => '', 'name' => '']; + + // DB에 URL/경로가 섞여 있어도 파일명만 뽑아서 안전하게 처리 + $name = basename(parse_url($file, PHP_URL_PATH) ?? $file); + $path = 'bbs/'.$name; + + if (!Storage::disk('public')->exists($path)) { + return ['ok' => false, 'path' => '', 'name' => '']; + } + + return ['ok' => true, 'path' => $path, 'name' => $name]; + } + + +} diff --git a/app/Services/QnaService.php b/app/Services/QnaService.php index 7b00d83..a7a88d3 100644 --- a/app/Services/QnaService.php +++ b/app/Services/QnaService.php @@ -2,147 +2,78 @@ namespace App\Services; -use App\Models\CounselingOneOnOne; use App\Jobs\SendAdminQnaCreatedMailJob; +use App\Repositories\Cs\CsQnaRepository; use App\Support\LegacyCrypto\CiSeedCrypto; -use Illuminate\Support\Str; use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Illuminate\Pagination\LengthAwarePaginator as Paginator; -use Illuminate\Support\Facades\Schema; -use Illuminate\Support\Facades\DB; final class QnaService { + public function __construct( + private readonly CsQnaRepository $repo, + private readonly CiSeedCrypto $seed, + ) {} + public function paginateMyQna(int $memNo, int $perPage = 10, int $year = 0): LengthAwarePaginator { - $year = $year > 0 ? $year : (int) date('Y'); - $table = "counseling_one_on_one_{$year}"; - - if (!Schema::hasTable($table)) { - return new Paginator([], 0, $perPage, 1, [ - 'path' => request()->url(), - 'query' => request()->query(), - ]); - } - - return CounselingOneOnOne::queryForYear($year) - ->from("{$table} as q") - ->leftJoin('counseling_code as cc', function ($join) { - $join->on('cc.enquiry_code', '=', 'q.enquiry_code') - ->where('cc.enquiry_type', '=', 'e'); // ✅ 접수분류 타입 - }) - ->where('q.member_num', $memNo) - ->select([ - 'q.seq', - 'q.state', - 'q.enquiry_code', - 'q.enquiry_title', - 'q.regdate', - // ✅ 코드 대신 보여줄 분류명 - 'cc.enquiry_title as enquiry_code_name', - ]) - ->orderByDesc('q.seq') - ->paginate($perPage) - ->withQueryString(); + return $this->repo->paginateMyQna($memNo, $perPage, $year); } public function findMyQna(int $memNo, int $seq, int $year = 0) { - $year = $year > 0 ? $year : (int) date('Y'); - $table = "counseling_one_on_one_{$year}"; - - if (!Schema::hasTable($table)) { - abort(404); - } - - return CounselingOneOnOne::queryForYear($year) - ->from("{$table} as q") - ->leftJoin('counseling_code as cc', function ($join) { - $join->on('cc.enquiry_code', '=', 'q.enquiry_code') - ->where('cc.enquiry_type', '=', 'e'); - }) - ->where('q.member_num', $memNo) - ->where('q.seq', $seq) - ->select([ - 'q.seq', - 'q.state', - 'q.enquiry_code', - 'q.enquiry_title', - 'q.enquiry_content', - 'q.answer_content', - 'q.regdate', - 'cc.enquiry_title as enquiry_code_name', - ]) - ->firstOrFail(); + return $this->repo->findMyQna($memNo, $seq, $year); } public function getEnquiryCodes(): array { - return DB::table('counseling_code') - ->where('enquiry_type', 'e') - ->orderBy('enquiry_code', 'asc') - ->get(['enquiry_code', 'enquiry_title']) - ->map(fn($r) => ['code' => (string)$r->enquiry_code, 'title' => (string)$r->enquiry_title]) - ->all(); + return $this->repo->getEnquiryCodes(); } /** - * 1:1 문의 등록 (연도 테이블: counseling_one_on_one_YYYY) - * 반환: insert seq(id) + * 1:1 문의 등록 */ public function createQna(int $memNo, array $payload, ?int $year = null): int { - $year = $year ?: (int)date('Y'); - $table = "counseling_one_on_one_{$year}"; - - // 테이블이 없으면(혹시 모를 경우) 생성은 다음 단계로. 지금은 실패 처리 - if (!Schema::hasTable($table)) { - throw new \RuntimeException("qna_table_not_found: {$table}"); - } + $year = $year ?: (int) date('Y'); + // 컨트롤러에서 이미 sanitize/검증하지만, 서비스에서도 최소 방어(이중 안전) $enquiryCode = (string)($payload['enquiry_code'] ?? ''); $enquiryTitle = strip_tags(trim((string)($payload['enquiry_title'] ?? ''))); $enquiryContent = strip_tags(trim((string)($payload['enquiry_content'] ?? ''))); $returnType = (string)($payload['return_type'] ?? 'web'); + $id = $this->repo->insertQna($memNo, [ + 'enquiry_code' => $enquiryCode, + 'enquiry_title' => $enquiryTitle, + 'enquiry_content' => $enquiryContent, + 'return_type' => $returnType, + ], $year); - - $id = (int) DB::transaction(function () use ($table, $memNo, $enquiryCode, $enquiryTitle, $enquiryContent, $returnType) { - return DB::table($table)->insertGetId([ - 'state' => 'a', - 'member_num' => $memNo, - 'enquiry_code' => $enquiryCode, - 'enquiry_title' => $enquiryTitle, - 'enquiry_content' => $enquiryContent, - 'return_type' => $returnType, - 'user_upload' => null, - 'regdate' => now()->format('Y-m-d H:i:s'), - 'receipt_date' => "1900-01-01 00:00:00", - 'defer_date' => "1900-01-01 00:00:00", - 'completion_date' => "1900-01-01 00:00:00", - ]); - }); - - + // 메일 발송 데이터 구성 $userEmail = (string)($payload['_user_email'] ?? ''); $userName = (string)($payload['_user_name'] ?? ''); $ip = (string)($payload['_ip'] ?? ''); $cellEnc = (string)($payload['_user_cell_enc'] ?? ''); - $seed = app(CiSeedCrypto::class); - $cellPlain = (string) $seed->decrypt($cellEnc); + + $cellPlain = ''; + try { + $cellPlain = (string) $this->seed->decrypt($cellEnc); + } catch (\Throwable $e) { + $cellPlain = ''; // 복호화 실패해도 문의 등록은 유지 + } SendAdminQnaCreatedMailJob::dispatch($memNo, $id, [ - 'name' => $userName, - 'email' => $userEmail, - 'cell' => $cellPlain, - 'ip' => $ip, - 'year' => $year, - 'enquiry_code' => $enquiryCode, - 'enquiry_title' => $enquiryTitle, + 'name' => $userName, + 'email' => $userEmail, + 'cell' => $cellPlain, + 'ip' => $ip, + 'year' => $year, + 'enquiry_code' => $enquiryCode, + 'enquiry_title' => $enquiryTitle, 'enquiry_content' => $enquiryContent, - 'return_type' => $returnType, - 'created_at' => now()->toDateTimeString(), - ])->onConnection('redis'); // 네 QUEUE_CONNECTION=redis일 때 명시해도 좋음 + 'return_type' => $returnType, + 'created_at' => now()->toDateTimeString(), + ])->onConnection('redis'); return $id; } diff --git a/config/cs_faq.php b/config/cs_faq.php index 6e14fa7..a728569 100644 --- a/config/cs_faq.php +++ b/config/cs_faq.php @@ -8,12 +8,12 @@ return [ |-------------------------------------------------------------------------- */ 'categories' => [ - ['key' => 'signup', 'label' => '회원가입 문의'], - ['key' => 'login', 'label' => '로그인 문의'], - ['key' => 'pay', 'label' => '결제 문의'], - ['key' => 'code', 'label' => '상품권 코드 문의'], - ['key' => 'event', 'label' => '이벤트 문의'], - ['key' => 'etc', 'label' => '기타문의'], + ['key' => 'signup', 'label' => '회원가입 문의', 'num'=>1], + ['key' => 'login', 'label' => '로그인 문의', 'num'=>2], + ['key' => 'pay', 'label' => '결제 문의', 'num'=>3], + ['key' => 'code', 'label' => '상품권 코드 문의', 'num'=>4], + ['key' => 'event', 'label' => '이벤트 문의', 'num'=>5], + ['key' => 'etc', 'label' => '기타문의', 'num'=>6], ], /* diff --git a/config/mail.php b/config/mail.php index 7d629cb..214a697 100644 --- a/config/mail.php +++ b/config/mail.php @@ -40,16 +40,18 @@ return [ 'mailers' => [ 'smtp' => [ 'transport' => 'smtp', - 'host' => env('MAIL_HOST', '127.0.0.1'), - 'port' => (int) env('MAIL_PORT', 587), - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), // STARTTLS + 'host' => env('MAIL_HOST'), + 'port' => env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), - 'timeout' => (int) env('MAIL_TIMEOUT', 60), //timeout 적용 - 'local_domain' => env( - 'MAIL_EHLO_DOMAIN', - parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST) - ), + + 'timeout' => env('MAIL_TIMEOUT', 120), + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL'), PHP_URL_HOST)), + + // ★ 핵심(SES+장수 워커에서 451 완화) + 'ping_threshold' => env('MAIL_PING_THRESHOLD', 10), + 'restart_threshold' => env('MAIL_RESTART_THRESHOLD', 1), ], ], diff --git a/config/purifier.php b/config/purifier.php new file mode 100644 index 0000000..e5dcbf2 --- /dev/null +++ b/config/purifier.php @@ -0,0 +1,129 @@ +set('Core.Encoding', $this->config->get('purifier.encoding')); + * $config->set('Cache.SerializerPath', $this->config->get('purifier.cachePath')); + * if ( ! $this->config->get('purifier.finalize')) { + * $config->autoFinalize = false; + * } + * $config->loadArray($this->getConfig()); + * + * You must NOT delete the default settings + * anything in settings should be compacted with params that needed to instance HTMLPurifier_Config. + * + * @link http://htmlpurifier.org/live/configdoc/plain.html + */ + +return [ + 'encoding' => 'UTF-8', + 'finalize' => true, + 'ignoreNonStrings' => false, + 'cachePath' => storage_path('app/purifier'), + 'cacheFileMode' => 0755, + 'settings' => [ + 'default' => [ + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src]', + 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align', + 'AutoFormat.AutoParagraph' => true, + 'AutoFormat.RemoveEmpty' => true, + ], + + 'mail' => [ + 'HTML.Doctype' => 'HTML 4.01 Transitional', + + // 에디터(Quill/Trix)에서 흔히 나오는 태그 + 메일에서 필요한 것만 + // (style은 span/p 정도만 제한적으로) + 'HTML.Allowed' => implode(',', [ + 'div','p','br','span[style]', + 'strong','b','em','i','u','s', + 'ul','ol','li', + 'blockquote','pre','code', + 'h1','h2','h3','h4', + 'a[href|title|target]', // rel은 purifier가 잘라낼 수 있어 우선 제외 권장 + 'img[src|alt|title|width|height]', + ]), + + 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align', + + // 에디터는 이미 p/div 만들어주므로 AutoParagraph 켜면 레이아웃 망가질 수 있음 + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => true, + ], + + 'test' => [ + 'Attr.EnableID' => 'true', + ], + "youtube" => [ + "HTML.SafeIframe" => 'true', + "URI.SafeIframeRegexp" => "%^(http://|https://|//)(www.youtube.com/embed/|player.vimeo.com/video/)%", + ], + 'custom_definition' => [ + 'id' => 'html5-definitions', + 'rev' => 1, + 'debug' => false, + 'elements' => [ + // http://developers.whatwg.org/sections.html + ['section', 'Block', 'Flow', 'Common'], + ['nav', 'Block', 'Flow', 'Common'], + ['article', 'Block', 'Flow', 'Common'], + ['aside', 'Block', 'Flow', 'Common'], + ['header', 'Block', 'Flow', 'Common'], + ['footer', 'Block', 'Flow', 'Common'], + + // Content model actually excludes several tags, not modelled here + ['address', 'Block', 'Flow', 'Common'], + ['hgroup', 'Block', 'Required: h1 | h2 | h3 | h4 | h5 | h6', 'Common'], + + // http://developers.whatwg.org/grouping-content.html + ['figure', 'Block', 'Optional: (figcaption, Flow) | (Flow, figcaption) | Flow', 'Common'], + ['figcaption', 'Inline', 'Flow', 'Common'], + + // http://developers.whatwg.org/the-video-element.html#the-video-element + ['video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [ + 'src' => 'URI', + 'type' => 'Text', + 'width' => 'Length', + 'height' => 'Length', + 'poster' => 'URI', + 'preload' => 'Enum#auto,metadata,none', + 'controls' => 'Bool', + ]], + ['source', 'Block', 'Flow', 'Common', [ + 'src' => 'URI', + 'type' => 'Text', + ]], + + // http://developers.whatwg.org/text-level-semantics.html + ['s', 'Inline', 'Inline', 'Common'], + ['var', 'Inline', 'Inline', 'Common'], + ['sub', 'Inline', 'Inline', 'Common'], + ['sup', 'Inline', 'Inline', 'Common'], + ['mark', 'Inline', 'Inline', 'Common'], + ['wbr', 'Inline', 'Empty', 'Core'], + + // http://developers.whatwg.org/edits.html + ['ins', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']], + ['del', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']], + ], + 'attributes' => [ + ['iframe', 'allowfullscreen', 'Bool'], + ['table', 'height', 'Text'], + ['td', 'border', 'Text'], + ['th', 'border', 'Text'], + ['tr', 'width', 'Text'], + ['tr', 'height', 'Text'], + ['tr', 'border', 'Text'], + ], + ], + 'custom_attributes' => [ + ['a', 'target', 'Enum#_blank,_self,_target,_top'], + ], + 'custom_elements' => [ + ['u', 'Inline', 'Inline', 'Common'], + ], + ], + +]; diff --git a/public/assets/css/cs-notice.css b/public/assets/css/cs-notice.css index da14f24..723a09b 100644 --- a/public/assets/css/cs-notice.css +++ b/public/assets/css/cs-notice.css @@ -369,4 +369,130 @@ } } +/* 본문 아래 숨 쉴 공간 */ +.nv-content-box{ + padding-bottom: 6px; /* 기존보다 살짝만 */ +} + +.nv-body{ + /* 본문과 아래 섹션 간격 늘리기 */ + margin-bottom: 28px; +} + +/* ✅ 첨부/링크: 큰 박스 -> 가벼운 섹션 */ +.nv-resources{ + margin-top: 18px; /* 본문에서 아래로 떨어짐 */ + padding-top: 14px; + border-top: 1px solid rgba(0,0,0,.08); +} + +.nv-resources__head{ + display:flex; + align-items:flex-end; + justify-content:space-between; + gap:12px; + margin-bottom: 10px; +} + +.nv-resources__title{ + font-weight:800; + font-size: 13px; /* 작게 */ + letter-spacing: -0.02em; +} + +.nv-resources__desc{ + font-size: 12px; + opacity: .65; + white-space:nowrap; +} + +/* 2열 카드 리스트(모바일은 1열) */ +.nv-resources__grid{ + display:grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap:10px; +} +@media (max-width: 720px){ + .nv-resources__grid{ grid-template-columns: 1fr; } + .nv-resources__desc{ display:none; } +} + +/* 아이템(요즘 스타일: 얇은 카드 + hover) */ +.nv-item{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding: 12px 12px; + border-radius: 14px; + border: 1px solid rgba(0,0,0,.08); + background: rgba(255,255,255,.60); + text-decoration:none; + transition: transform .12s ease, box-shadow .12s ease, background .12s ease; +} + +.nv-item:hover{ + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(0,0,0,.08); + background: rgba(255,255,255,.85); +} + +.nv-item__left{ + display:flex; + align-items:center; + gap:10px; + min-width:0; +} + +.nv-item__name{ + font-size: 13px; + font-weight: 700; + line-height: 1.2; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + max-width: 320px; +} + +.nv-item__right{ + font-size: 12px; + opacity: .75; + white-space:nowrap; +} + +/* 뱃지(칩) */ +.nv-badge{ + display:inline-flex; + align-items:center; + justify-content:center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: .02em; + border: 1px solid rgba(0,0,0,.10); + background: rgba(0,0,0,.04); +} + +.nv-badge--link{ + background: rgba(59,130,246,.10); + border-color: rgba(59,130,246,.18); +} + +/* ✅ 이전/다음: 아래로 더 내려서 답답함 제거 */ +.nv-actions{ + margin-top: 26px; /* 더 아래로 */ + padding-top: 18px; + border-top: 1px dashed rgba(0,0,0,.10); +} + +/* 기존 버튼이 크면 살짝 슬림하게 */ +.nv-action{ + border-radius: 16px; + padding: 14px 14px; +} +.nv-action-title{ + opacity:.92; +} diff --git a/resources/css/admin.css b/resources/css/admin.css index 3d80a2a..71cb808 100644 --- a/resources/css/admin.css +++ b/resources/css/admin.css @@ -1333,3 +1333,13 @@ tr.row-link{ cursor:pointer; } tr.row-link:hover td{ background: rgba(255,255,255,.03); } +.pill--info{ + border-color: rgba(59,130,246,.35); + background: rgba(59,130,246,.12); +} + +/* 만약 기존에 pill--bad가 없다면 이것도 추가 */ +.pill--bad{ + border-color: rgba(244,63,94,.35); + background: rgba(244,63,94,.10); +} diff --git a/resources/views/admin/notice/create.blade.php b/resources/views/admin/notice/create.blade.php new file mode 100644 index 0000000..fcbcec4 --- /dev/null +++ b/resources/views/admin/notice/create.blade.php @@ -0,0 +1,99 @@ +@extends('admin.layouts.app') + +@section('title', '공지사항 등록') +@section('page_title', '공지사항 등록') +@section('page_desc', '공지(상단노출) / 첨부파일 / 링크를 설정합니다.') + +@push('head') + +@endpush + +@section('content') +
+
+
+
공지사항 등록
+
+ 공지(상단노출) / 첨부파일 / 링크를 설정합니다. +
+
+ + + ← 목록 + +
+
+ +
+ @csrf + +
+
+ +
+ +
+
+ +
+ + + @error('subject')
{{ $message }}
@enderror +
+ +
+ + + @error('content')
{{ $message }}
@enderror +
+ +
+ + + @error('link_01')
{{ $message }}
@enderror +
+ +
+ + + @error('link_02')
{{ $message }}
@enderror +
+ +
+ + + @error('file_01')
{{ $message }}
@enderror +
+ +
+ + + @error('file_02')
{{ $message }}
@enderror +
+ +
+ + 취소 +
+
+
+@endsection diff --git a/resources/views/admin/notice/edit.blade.php b/resources/views/admin/notice/edit.blade.php new file mode 100644 index 0000000..fb8464e --- /dev/null +++ b/resources/views/admin/notice/edit.blade.php @@ -0,0 +1,176 @@ +@extends('admin.layouts.app') + +@section('title', '공지사항 수정') +@section('page_title', '공지사항 수정') +@section('page_desc', '공지/숨김/첨부파일/링크를 관리합니다.') + +@push('head') + +@endpush + +@section('content') + @php + $isHidden = (($row->hiding ?? 'N') === 'Y'); + $isPinned = ((int)($row->first_sign ?? 0) > 0); + $meta = '#'.((int)($row->seq ?? 0)).' / reg: '.($row->regdate ?? '-').' / hit: '.($row->hit ?? 0); + @endphp + + {{-- 상단 요약 --}} +
+
+
+
공지사항 수정
+
+ {{ $meta }} + + @if($isPinned) ● 공지 @endif + @if($isHidden) ● 숨김 @endif + +
+
+ + + ← 목록 + +
+
+ + {{-- 수정 폼 --}} +
+ @csrf + @method('PUT') + +
+
+ +
+ + + +
+
+ +
+ + + @error('subject')
{{ $message }}
@enderror +
+ +
+ + + @error('content')
{{ $message }}
@enderror +
+ +
+ + + @error('link_01')
{{ $message }}
@enderror +
+ +
+ + + @error('link_02')
{{ $message }}
@enderror +
+ +
+ + + @if(($row->file_01 ?? '') !== '') +
+ 현재: + + 다운로드 + + {{ $row->file_01 }} +
+ @endif + @error('file_01')
{{ $message }}
@enderror +
+ +
+ + + @if(($row->file_02 ?? '') !== '') +
+ 현재: + + 다운로드 + + {{ $row->file_02 }} +
+ @endif + @error('file_02')
{{ $message }}
@enderror +
+
+
+ + {{-- 하단 액션바 --}} +
+ + ← 뒤로가기 + + +
+
+ @csrf + @method('DELETE') + +
+ + +
+
+@endsection diff --git a/resources/views/admin/notice/index.blade.php b/resources/views/admin/notice/index.blade.php new file mode 100644 index 0000000..7a23197 --- /dev/null +++ b/resources/views/admin/notice/index.blade.php @@ -0,0 +1,170 @@ +@extends('admin.layouts.app') + +@section('title', '공지사항') +@section('page_title', '공지사항') +@section('page_desc', '공지사항(상단공지/숨김/첨부파일)을 관리합니다.') +@section('content_class', 'a-content--full') + +@push('head') + +@endpush + +@section('content') + @php + // 컨트롤러에서 templates 로 내려오든, page 로 내려오든 호환 + $page = $page ?? ($templates ?? null); + $filters = $filters ?? []; + + $field = (string)($filters['field'] ?? 'subject'); + $q = (string)($filters['q'] ?? ''); + @endphp + +
+
+
+
공지사항
+
공지사항(상단공지/숨김/첨부파일)을 관리합니다.
+
+ +
+ + + 공지 등록 + + +
+
+ +
+ +
+ +
+ +
+ + 초기화 +
+
+
+
+
+ +
+
{{ $page?->total() ?? 0 }}
+ +
+ + + + + + + + + + + + + + @php + $total = $page?->total() ?? 0; + $perPage = $page?->perPage() ?? 15; + $curPage = $page?->currentPage() ?? 1; + $no = $total - (($curPage - 1) * $perPage); + @endphp + + @forelse($page as $it) + @php + $isHidden = (($it->hiding ?? 'N') === 'Y'); // Y=숨김 + $isPinned = ((int)($it->first_sign ?? 0) > 0); // >0=공지 + @endphp + + + + + + + + + + + + + + + @php $no--; @endphp + @empty + + @endforelse + +
NO제목상태공지등록일시hit관리
{{ $no }} +
+ @if($isPinned) + ● 공지 + @endif + @if($isHidden) + ● 숨김 + @endif + {{ $it->subject ?? '-' }} +
+
+ @if($isHidden) + ● 숨김 + @else + ● 노출 + @endif + + @if($isPinned) + On + @else + Off + @endif + {{ $it->regdate ?? '-' }}{{ $it->hit ?? 0 }} + + 보기 + +
데이터가 없습니다.
+
+ +
+ {{ $templates->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+@endsection diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 355baa5..7ceaaf1 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -14,6 +14,15 @@ ['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']], ], ], + [ + 'title' => '회원/정책', + 'items' => [ + ['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']], + ['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], + ['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']], + ['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']], + ], + ], [ 'title' => 'SMS 관리', 'items' => [ @@ -34,9 +43,7 @@ 'title' => '고객지원', 'items' => [ ['label' => '공지사항', 'route' => 'admin.notice.index','roles' => ['super_admin','support']], - ['label' => '1:1 문의', 'route' => 'admin.inquiry.index','roles' => ['super_admin','support']], - ['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index','roles' => ['super_admin','support']], - ['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index','roles' => ['super_admin','support']], + ['label' => '1:1 문의', 'route' => 'admin.qna.index','roles' => ['super_admin','support']], ], ], [ @@ -66,15 +73,6 @@ ['label' => '환불/취소 내역', 'route' => 'admin.refunds.index','roles' => ['super_admin','finance']], ], ], - [ - 'title' => '회원/정책', - 'items' => [ - ['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']], - ['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], - ['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']], - ['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']], - ], - ], [ 'title' => '시스템 로그', 'items' => [ diff --git a/resources/views/admin/qna/index.blade.php b/resources/views/admin/qna/index.blade.php new file mode 100644 index 0000000..bad8976 --- /dev/null +++ b/resources/views/admin/qna/index.blade.php @@ -0,0 +1,209 @@ +{{-- resources/views/admin/qna/index.blade.php --}} + +@extends('admin.layouts.app') + +@section('title', '1:1 문의') +@section('page_title', '1:1 문의') +@section('page_desc', '접수/분배/처리/보류/완료 상태를 관리합니다.') +@section('content_class', 'a-content--full') + +@push('head') + +@endpush + +@section('content') + @php + $filters = $filters ?? []; + $curYear = (int)date('Y'); + $selectedYear = (int)($filters['year'] ?? $year ?? $curYear); + if ($selectedYear < 2018 || $selectedYear > $curYear) $selectedYear = $curYear; + + $qType = (string)($filters['q_type'] ?? 'title'); + $q = (string)($filters['q'] ?? ''); + $enquiryCode = (string)($filters['enquiry_code'] ?? ''); + $state = (string)($filters['state'] ?? ''); + $dateFrom = (string)($filters['date_from'] ?? ''); + $dateTo = (string)($filters['date_to'] ?? ''); + $myWork = !empty($filters['my_work']); + + $meAdminId = (int) auth('admin')->id(); + + $total = (is_object($rows ?? null) && method_exists($rows, 'total')) ? (int)$rows->total() : (int)($rows?->count() ?? 0); + $perPage = (int)($rows?->perPage() ?? 20); + $curPage = (int)($rows?->currentPage() ?? 1); + $no = $total > 0 ? ($total - (($curPage - 1) * $perPage)) : 0; + @endphp + +
+
+
+
1:1 문의
+
접수/분배/처리/보류/완료 상태를 관리합니다.
+
+ +
+
+ + + + + + + + + + + + + + + +
+ + 초기화 +
+
+
+
+
+ +
+
{{ $total }}
+ +
+ + + + + + + + + + + + + + + + + @forelse($rows as $r) + @php + $st = (string)($r->state ?? ''); + $stLabel = $stateLabels[$st][0] ?? ($st ?: '-'); + $stCls = $stateLabels[$st][1] ?? 'pill--muted'; + + $rt = (string)($r->return_type ?? ''); + $rtLabel = in_array($rt, ['phone','sms'], true) ? 'SMS' : ($rt === 'email' ? 'MAIL' : '-'); + $rtCls = $rtLabel === 'SMS' ? 'pill--warn' : ($rtLabel === 'MAIL' ? 'pill--ok' : 'pill--muted'); + + $catLabel = $categoriesMap[(int)($r->enquiry_code ?? 0)]['label'] ?? ((string)($r->enquiry_code ?? '-') ?: '-'); + + $assignedAdmin = (int)($r->answer_admin_num ?? 0); + $isMine = ($assignedAdmin > 0 && $assignedAdmin === $meAdminId); + + $showUrl = route('admin.qna.show', ['seq'=>(int)$r->seq, 'year'=>$selectedYear] + request()->query()); + @endphp + + + + + + + + + + + + + + + @php if ($no > 0) $no--; @endphp + @empty + {{-- ✅ 컬럼 수(10) 맞춰서 UI 깨짐 방지 --}} + + @endforelse + +
NOSEQ상태분류제목회신회원등록일시배정자관리
{{ $no > 0 ? $no : '-' }}#{{ (int)$r->seq }}● {{ $stLabel }}{{ $catLabel }} + +
+ {{ $r->enquiry_title ?? '-' }} +
+
+
{{ $rtLabel }}{{ (int)($r->member_num ?? 0) }}{{ $r->regdate ?? '-' }} + @if($assignedAdmin > 0) + {{ $assignedAdmin }} + @if($isMine) + ME + @endif + @else + - + @endif + + 보기 +
데이터가 없습니다.
+
+ +
+ {{-- ✅ 공지사항과 동일한 커스텀 페이징 + 쿼리 유지 --}} + {{ $rows->onEachSide(1)->links('vendor.pagination.admin') }} +
+
+@endsection diff --git a/resources/views/admin/qna/show.blade.php b/resources/views/admin/qna/show.blade.php new file mode 100644 index 0000000..e4a58a4 --- /dev/null +++ b/resources/views/admin/qna/show.blade.php @@ -0,0 +1,271 @@ +@extends('admin.layouts.app') + +@section('title', '1:1 문의 상세') +@section('page_title', '1:1 문의 상세') +@section('page_desc', '업무 배정/처리/보류/완료 및 답변 발송') + +@push('head') + +@endpush + +@section('content') + @php + $st = (string)$row->state; + $stLabel = $stateLabels[$st][0] ?? $st; + $stCls = $stateLabels[$st][1] ?? 'pill--muted'; + @endphp + +
+
+
+
접수분류 / 처리상태
+
+ {{ $row->enquiry_code ?? '-' }} + ● {{ $stLabel }} + #{{ (int)$row->seq }} / year {{ (int)$year }} +
+
+ +
+ ← 목록 + + @if($actions['can_assign']) +
+ @csrf + +
+ @endif + + @if($actions['can_start']) +
+ @csrf + +
+ @endif + + @if($actions['can_return']) +
+ @csrf + +
+ @endif + + @if($actions['can_complete']) +
+ @csrf + +
+ @endif +
+
+
+ +
+ +
+
제목
+
{{ $row->enquiry_title }}
+ +
+
회원정보
+ @if($member) +
+ 회원번호 : {{ (int)$member->mem_no }}
+ 이메일 : {{ $member->email }}
+ 성명 : {{ $member->name }}
+ 전화번호 : {{ $member->cell_phone }} +
+ @else +
회원 정보를 찾을 수 없습니다.
+ @endif +
+ +
+
내용
+
{{ $row->enquiry_content }}
+
+ +
+
관리자 답변
+ + @if($actions['can_answer']) +
+ @csrf + + @error('answer_content')
{{ $message }}
@enderror + +
+ SMS답변 (업무완료 시 SMS요청 회원에게 발송됩니다) +
+ + @error('answer_sms')
{{ $message }}
@enderror + +
+ +
+
+ @else +
+ {{ $row->answer_content ?: '등록된 답변이 없습니다.' }} +
+ +
+ SMS: {{ $row->answer_sms ?: '-' }} +
+ @endif +
+ + @if($actions['can_postpone']) +
+
업무 보류
+
+ @csrf +
+ + + +
+ @error('defer_comment')
{{ $message }}
@enderror +
+
+ @endif +
+ +
+
최근 구매기록(10건)
+ @if($orders) +
+ + + + + + + + + + + + @foreach($orders as $o) + + + + + + + + @endforeach + +
주문상품금액결제일시상태
{{ $o->order_no ?? ($o->seq ?? '-') }}{{ $o->product_name ?? '-' }}{{ $o->price ?? '-' }}{{ $o->regdate ?? '-' }}{{ $o->stat_pay ?? '-' }}
+
+ @else +
구매기록을 불러올 수 없습니다(테이블/컬럼 확인 필요).
+ @endif + +
+
변경이력
+ @if($stateLog) +
+ + + + + + + + + + + + @foreach($stateLog as $lg) + @php + $aid = (int)($lg['admin_num'] ?? 0); + $a = $admins[$aid] ?? null; + $before = (string)($lg['state_before'] ?? ''); + $after = (string)($lg['state_after'] ?? ''); + @endphp + + + + + + + + @endforeach + +
처리자내용일자
+ {{ $a ? ($a['name'].' ('.$a['email'].')') : ('admin#'.$aid) }} + {{ $stateLabels[$before][0] ?? $before }}{{ $stateLabels[$after][0] ?? $after }}{{ $lg['why'] ?? '' }}{{ $lg['when'] ?? '' }}
+
+ @else +
변경이력이 없습니다.
+ @endif +
+ +
+
내부 메모
+
+ @csrf + @php $canMemo = (bool)($actions['canMemo'] ?? false); @endphp + + + + +
+ + @if($memoLog) +
+ @foreach($memoLog as $m) + @php + $aid = (int)($m['admin_num'] ?? 0); + $a = $admins[$aid] ?? null; + @endphp +
+
+ {{ $a ? ($a['name'].' ('.$a['email'].')') : ('admin#'.$aid) }} + · {{ $m['when'] ?? '' }} +
+
{{ $m['memo'] ?? '' }}
+
+ @endforeach +
+ @endif +
+ +
+ +
+@endsection diff --git a/resources/views/mail/admin/qna_adminreturn.blade.php b/resources/views/mail/admin/qna_adminreturn.blade.php new file mode 100644 index 0000000..e753d60 --- /dev/null +++ b/resources/views/mail/admin/qna_adminreturn.blade.php @@ -0,0 +1,96 @@ +@extends('mail.layouts.base') + +@section('content') + @php + // 안전하게 기본값 처리 (undefined 방지) + $title = $title ?? '[PIN FOR YOU] 1:1 문의 답변 안내'; + + $memNo = $mem_no ?? ($member_num ?? '-'); + $qnaId = $qna_id ?? ($qna_seq ?? '-'); + $year = $year ?? null; + + $enquiryTitle = $enquiry_title ?? '-'; + $enquiryCode = $enquiry_code ?? '-'; + $returnType = $return_type ?? '-'; + $regdate = $regdate ?? '-'; + $completedAt = $completed_at ?? '-'; + + $enquiryContent = $enquiry_content ?? '-'; + $answerContent = $answer_content ?? '-'; + + // 버튼 링크(선택) + $siteUrl = $siteUrl ?? config('app.url'); + $accent = $accent ?? '#E4574B'; + + // 외부에서 userUrl/qnaUrl을 넘겨주면 우선 사용 + $qnaUrl = $qnaUrl ?? ($userUrl ?? ''); + + // 아무것도 안 넘어오면 최소한 사이트 홈이라도 + if ($qnaUrl === '' && $siteUrl !== '') { + $qnaUrl = $siteUrl; + } + @endphp + +
+
+ {{ $title }} +
+
+ +
+ 고객님의 1:1 문의에 대한 답변이 등록되었습니다.
+ 아래 내용을 확인해 주세요. +
+ +
+ +
+
+
문의번호 : {{ $qnaId }}
+
제목 : {{ $enquiryTitle }}
+
문의등록 : {{ $regdate }}
+
+
+ +
+ +
+ 문의 내용 +
+
+ +
+
+ {{ $enquiryContent }} +
+
+ +
+ +
+ 답변 내용 +
+
+ +
+
+ {{ $answerContent }} +
+
+ + @if(!empty($qnaUrl)) +
+ + 사이트에서 확인하기 + + @endif + +
+ +
+ 이 메일은 시스템 알림입니다. +
+
+@endsection diff --git a/resources/views/web/cs/notice/show.blade.php b/resources/views/web/cs/notice/show.blade.php index eb28f80..fa0e88e 100644 --- a/resources/views/web/cs/notice/show.blade.php +++ b/resources/views/web/cs/notice/show.blade.php @@ -61,15 +61,17 @@ @endphp @if($link1 || $link2 || $notice->file_01 || $notice->file_02) -
-
첨부 / 관련 링크
+
+
+
첨부 / 링크
+
필요한 자료를 빠르게 열어보세요.
+
- +
@endif - {{-- ✅ 박스 안에서 하단 네비(이전/다음/목록) --}} + {{-- 박스 안에서 하단 네비(이전/다음/목록) --}}