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')
+
+
+
+
공지사항 등록
+
+ 공지(상단노출) / 첨부파일 / 링크를 설정합니다.
+
+
+
+
+ ← 목록
+
+
+
+
+
+@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
+
+
+
+
+
+ ← 목록
+
+
+
+
+ {{-- 수정 폼 --}}
+
+
+ {{-- 하단 액션바 --}}
+
+
+ ← 뒤로가기
+
+
+
+
+
+
+
+
+@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 }}건
+
+
+
+
+
+ | NO |
+ 제목 |
+ 상태 |
+ 공지 |
+ 등록일시 |
+ hit |
+ 관리 |
+
+
+
+ @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
+
+ | {{ $no }} |
+
+
+
+ @if($isPinned)
+ ● 공지
+ @endif
+ @if($isHidden)
+ ● 숨김
+ @endif
+ {{ $it->subject ?? '-' }}
+
+ |
+
+
+ @if($isHidden)
+ ● 숨김
+ @else
+ ● 노출
+ @endif
+ |
+
+
+ @if($isPinned)
+ On
+ @else
+ Off
+ @endif
+ |
+
+ {{ $it->regdate ?? '-' }} |
+ {{ $it->hit ?? 0 }} |
+
+
+
+ 보기
+
+ |
+
+ @php $no--; @endphp
+ @empty
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{ $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 }}건
+
+
+
+
+
+ | NO |
+ SEQ |
+ 상태 |
+ 분류 |
+ 제목 |
+ 회신 |
+ 회원 |
+ 등록일시 |
+ 배정자 |
+ 관리 |
+
+
+
+ @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
+
+
+ | {{ $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
+ |
+
+ 보기
+ |
+
+
+ @php if ($no > 0) $no--; @endphp
+ @empty
+ {{-- ✅ 컬럼 수(10) 맞춰서 UI 깨짐 방지 --}}
+ | 데이터가 없습니다. |
+ @endforelse
+
+
+
+
+
+ {{-- ✅ 공지사항과 동일한 커스텀 페이징 + 쿼리 유지 --}}
+ {{ $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'])
+
+ @endif
+
+ @if($actions['can_start'])
+
+ @endif
+
+ @if($actions['can_return'])
+
+ @endif
+
+ @if($actions['can_complete'])
+
+ @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'])
+
+ @else
+
+ {{ $row->answer_content ?: '등록된 답변이 없습니다.' }}
+
+
+
+ SMS: {{ $row->answer_sms ?: '-' }}
+
+ @endif
+
+
+ @if($actions['can_postpone'])
+
+
업무 보류
+
+
+ @endif
+
+
+
+
최근 구매기록(10건)
+ @if($orders)
+
+
+
+
+ | 주문 |
+ 상품 |
+ 금액 |
+ 결제일시 |
+ 상태 |
+
+
+
+ @foreach($orders as $o)
+
+ | {{ $o->order_no ?? ($o->seq ?? '-') }} |
+ {{ $o->product_name ?? '-' }} |
+ {{ $o->price ?? '-' }} |
+ {{ $o->regdate ?? '-' }} |
+ {{ $o->stat_pay ?? '-' }} |
+
+ @endforeach
+
+
+
+ @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
+
+ |
+ {{ $a ? ($a['name'].' ('.$a['email'].')') : ('admin#'.$aid) }}
+ |
+ {{ $stateLabels[$before][0] ?? $before }} |
+ {{ $stateLabels[$after][0] ?? $after }} |
+ {{ $lg['why'] ?? '' }} |
+ {{ $lg['when'] ?? '' }} |
+
+ @endforeach
+
+
+
+ @else
+
변경이력이 없습니다.
+ @endif
+
+
+
+
내부 메모
+
+
+ @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
- {{-- ✅ 박스 안에서 하단 네비(이전/다음/목록) --}}
+ {{-- 박스 안에서 하단 네비(이전/다음/목록) --}}