notice , qna 작업

This commit is contained in:
sungro815 2026-02-09 19:47:58 +09:00
parent 0db9e2bdc5
commit 754d6e2497
32 changed files with 3193 additions and 228 deletions

View File

@ -0,0 +1,18 @@
<?php
namespace App\Console\Commands;
use App\Services\Admin\Mail\AdminMailService;
use Illuminate\Console\Command;
final class DispatchDueAdminMailBatches extends Command
{
protected $signature = 'admin-mail:dispatch-due {--limit=20}';
protected $description = 'Dispatch due scheduled admin mail batches';
public function handle(AdminMailService $svc): int
{
$n = $svc->dispatchDueScheduledBatches((int)$this->option('limit'));
$this->info("dispatched={$n}");
return 0;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin\Notice;
use App\Http\Controllers\Controller;
use App\Services\Admin\Notice\AdminNoticeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class AdminNoticeController extends Controller
{
public function __construct(
private readonly AdminNoticeService $service,
) {}
public function index(Request $request)
{
$filters = $request->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']);
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Admin\Qna;
use App\Http\Controllers\Controller;
use App\Services\Admin\Qna\AdminQnaService;
use Illuminate\Http\Request;
final class AdminQnaController extends Controller
{
public function __construct(
private readonly AdminQnaService $service
) {}
public function index(Request $request)
{
$filters = $request->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', '메모를 추가했습니다.');
}
}

View File

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

View File

@ -1,16 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class NoStore
final class NoStore
{
public function handle(Request $request, Closure $next)
public function handle($request, Closure $next)
{
$res = $next($request);
return $res->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;
}
}

View File

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

View File

@ -0,0 +1,62 @@
<?php
namespace App\Jobs;
use App\Services\MailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendUserQnaAnsweredMailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(
public string $toEmail,
public int $memNo,
public int $qnaId,
public array $data = []
) {}
public function handle(MailService $mail): void
{
$to = trim($this->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(),
]);
}
}
}

View File

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

View File

@ -0,0 +1,158 @@
<?php
namespace App\Repositories\Admin\Cs;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class AdminQnaRepository
{
public function table(int $year): string
{
return "counseling_one_on_one_{$year}";
}
public function hasYearTable(int $year): bool
{
return Schema::hasTable($this->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();
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Repositories\Admin\Notice;
use App\Models\GcBoard;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class AdminNoticeRepository
{
private const GUBUN = 'notice';
public function paginate(array $filters, int $perPage = 15): LengthAwarePaginator
{
$q = trim((string)($filters['q'] ?? ''));
$field = (string)($filters['field'] ?? 'subject');
if (!in_array($field, ['subject', 'content'], true)) $field = 'subject';
$query = GcBoard::query()
->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);
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Repositories\Cs;
use App\Models\CounselingOneOnOne;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class CsQnaRepository
{
private function resolveYear(int $year = 0): int
{
return $year > 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",
]);
});
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Repositories\Cs;
use App\Models\GcBoard;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
final class NoticeRepository
{
private function baseVisible(): Builder
{
// 기존 스코프 유지 (gubun=notice + hiding=N 등)
return GcBoard::query()->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();
}
}

View File

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

View File

@ -0,0 +1,224 @@
<?php
namespace App\Services\Admin\Notice;
use App\Models\GcBoard;
use App\Repositories\Admin\Notice\AdminNoticeRepository;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
final class AdminNoticeService
{
private const GUBUN = 'notice';
private const DISK = 'public';
private const DIR = 'bbs';
public function __construct(
private readonly AdminNoticeRepository $repo,
) {}
public function paginate(array $filters, int $perPage = 15)
{
// paginator 링크 쿼리스트링 유지
return $this->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;
}
}

View File

@ -0,0 +1,601 @@
<?php
namespace App\Services\Admin\Qna;
use App\Repositories\Admin\Cs\AdminQnaRepository;
use App\Services\SmsService;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Jobs\SendUserQnaAnsweredMailJob;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
final class AdminQnaService
{
public function __construct(
private readonly AdminQnaRepository $repo,
) {}
private function t(array &$timers, string $key, float $start): void
{
$timers[$key] = round((microtime(true) - $start) * 1000, 1); // ms
}
public function stateLabels(): array
{
return [
'a' => ['접수', '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' => '내 업무로 배정된 건만 메모를 남길 수 있습니다.',
]);
}
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Services;
use App\Models\GcBoard;
use App\Repositories\Cs\NoticeRepository;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Storage;
final class CsNoticeService
{
public function __construct(
private readonly NoticeRepository $repo,
) {}
public function paginate(string $q, int $perPage = 15): LengthAwarePaginator
{
// querystring 유지 (컨트롤러에서 request()->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];
}
}

View File

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

View File

@ -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],
],
/*

View File

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

129
config/purifier.php Normal file
View File

@ -0,0 +1,129 @@
<?php
/**
* Ok, glad you are here
* first we get a config instance, and set the settings
* $config = HTMLPurifier_Config::createDefault();
* $config->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'],
],
],
];

View File

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

View File

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

View File

@ -0,0 +1,99 @@
@extends('admin.layouts.app')
@section('title', '공지사항 등록')
@section('page_title', '공지사항 등록')
@section('page_desc', '공지(상단노출) / 첨부파일 / 링크를 설정합니다.')
@push('head')
<style>
/* notice create only - match admins */
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--wide{padding:10px 14px;font-weight:800;}
.wrap{max-width:880px;}
.checks{display:flex;flex-wrap:wrap;gap:10px;}
</style>
@endpush
@section('content')
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">공지사항 등록</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">
공지(상단노출) / 첨부파일 / 링크를 설정합니다.
</div>
</div>
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.notice.index', request()->only(['field','q','page'])) }}">
목록
</a>
</div>
</div>
<form method="POST"
action="{{ route('admin.notice.store') }}"
enctype="multipart/form-data"
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
@csrf
<div class="a-card wrap" style="padding:16px;">
<div class="a-field">
<label class="a-label">옵션</label>
<div class="checks">
<label class="a-check" style="margin:0;">
<input type="checkbox" name="first_sign" value="1" {{ old('first_sign') ? 'checked' : '' }}>
<span>공지(게시물 상위노출)</span>
</label>
</div>
</div>
<div class="a-field">
<label class="a-label">제목</label>
<input class="a-input" name="subject" value="{{ old('subject') }}" maxlength="80" placeholder="제목을 입력하세요">
@error('subject') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label">내용</label>
<textarea class="a-input" name="content" rows="12" placeholder="내용을 입력하세요">{{ old('content') }}</textarea>
@error('content') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 링크1</label>
<input class="a-input" name="link_01" value="{{ old('link_01') }}" maxlength="200" placeholder="https://">
@error('link_01') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 링크2</label>
<input class="a-input" name="link_02" value="{{ old('link_02') }}" maxlength="200" placeholder="https://">
@error('link_02') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 첨부파일1</label>
<input class="a-input" type="file" name="file_01">
@error('file_01') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 첨부파일2</label>
<input class="a-input" type="file" name="file_02">
@error('file_02') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:14px;">
<button class="lbtn lbtn--primary lbtn--wide" type="submit">등록</button>
<a class="lbtn lbtn--ghost" href="{{ route('admin.notice.index', request()->only(['field','q','page'])) }}">취소</a>
</div>
</div>
</form>
@endsection

View File

@ -0,0 +1,176 @@
@extends('admin.layouts.app')
@section('title', '공지사항 수정')
@section('page_title', '공지사항 수정')
@section('page_desc', '공지/숨김/첨부파일/링크를 관리합니다.')
@push('head')
<style>
/* notice edit only - match admins */
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
.lbtn--danger:hover{background:rgba(244,63,94,.98);}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--wide{padding:10px 14px;font-weight:800;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.actions{position:sticky;bottom:10px;z-index:5;margin-top:12px;
display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center;
padding:12px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(0,0,0,.25);backdrop-filter:blur(10px);}
.actions__right{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.wrap{max-width:980px;}
.checks{display:flex;flex-wrap:wrap;gap:10px;}
</style>
@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
{{-- 상단 요약 --}}
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">공지사항 수정</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">
{{ $meta }}
<span style="margin-left:8px;">
@if($isPinned) <span class="pill pill--ok"> 공지</span> @endif
@if($isHidden) <span class="pill pill--bad"> 숨김</span> @endif
</span>
</div>
</div>
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.notice.index', request()->only(['field','q','page'])) }}">
목록
</a>
</div>
</div>
{{-- 수정 --}}
<form id="noticeEditForm"
method="POST"
action="{{ route('admin.notice.update', ['id'=>(int)$row->seq] + request()->only(['field','q','page'])) }}"
enctype="multipart/form-data"
onsubmit="this.querySelector('button[type=submit][data-submit=save]')?.setAttribute('disabled','disabled');">
@csrf
@method('PUT')
<div class="a-card wrap" style="padding:16px;">
<div class="a-field">
<label class="a-label">옵션</label>
<div class="checks">
<label class="a-check" style="margin:0;">
<input type="checkbox" name="first_sign" value="1" {{ (int)old('first_sign', $row->first_sign) > 0 ? 'checked' : '' }}>
<span>공지(게시물 상위노출)</span>
</label>
<label class="a-check" style="margin:0;">
<input type="checkbox" name="hiding" value="1" {{ old('hiding', $isHidden) ? 'checked' : '' }}>
<span>숨김</span>
</label>
</div>
</div>
<div class="a-field">
<label class="a-label">제목</label>
<input class="a-input" name="subject" value="{{ old('subject', $row->subject) }}" maxlength="80" placeholder="제목을 입력하세요">
@error('subject') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label">내용</label>
<textarea class="a-input" name="content" rows="12" placeholder="내용을 입력하세요">{{ old('content', $row->content) }}</textarea>
@error('content') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 링크1</label>
<input class="a-input" name="link_01" value="{{ old('link_01', $row->link_01) }}" maxlength="200" placeholder="https://">
@error('link_01') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 링크2</label>
<input class="a-input" name="link_02" value="{{ old('link_02', $row->link_02) }}" maxlength="200" placeholder="https://">
@error('link_02') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 첨부파일1</label>
<input class="a-input" type="file" name="file_01">
@if(($row->file_01 ?? '') !== '')
<div style="margin-top:8px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<span class="a-muted" style="font-size:12px;">현재:</span>
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.notice.file', ['id'=>(int)$row->seq, 'slot'=>1]) }}">
다운로드
</a>
<span class="mono">{{ $row->file_01 }}</span>
</div>
@endif
@error('file_01') <div class="a-error">{{ $message }}</div> @enderror
</div>
<div class="a-field">
<label class="a-label"># 첨부파일2</label>
<input class="a-input" type="file" name="file_02">
@if(($row->file_02 ?? '') !== '')
<div style="margin-top:8px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<span class="a-muted" style="font-size:12px;">현재:</span>
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.notice.file', ['id'=>(int)$row->seq, 'slot'=>2]) }}">
다운로드
</a>
<span class="mono">{{ $row->file_02 }}</span>
</div>
@endif
@error('file_02') <div class="a-error">{{ $message }}</div> @enderror
</div>
</div>
</form>
{{-- 하단 액션바 --}}
<div class="actions">
<a class="lbtn lbtn--ghost"
href="{{ route('admin.notice.index', request()->only(['field','q','page'])) }}">
뒤로가기
</a>
<div class="actions__right">
<form method="POST"
action="{{ route('admin.notice.destroy', ['id'=>(int)$row->seq]) }}"
style="display:inline;"
onsubmit="return confirm('이 공지사항을 삭제할까요?');">
@csrf
@method('DELETE')
<button class="lbtn lbtn--danger" type="submit">삭제</button>
</form>
<button class="lbtn lbtn--primary lbtn--wide"
form="noticeEditForm"
type="submit"
data-submit="save">
저장
</button>
</div>
</div>
@endsection

View File

@ -0,0 +1,170 @@
@extends('admin.layouts.app')
@section('title', '공지사항')
@section('page_title', '공지사항')
@section('page_desc', '공지사항(상단공지/숨김/첨부파일)을 관리합니다.')
@section('content_class', 'a-content--full')
@push('head')
<style>
/* notice index only - match admins */
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.bar__left .t{font-weight:900;font-size:16px;}
.bar__left .d{font-size:12px;margin-top:4px;}
.bar__right{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .q{width:230px;}
.filters .st{width:150px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--wide{padding:10px 14px;font-weight:800;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.row-disabled{opacity:.70;}
.table td{vertical-align:top;}
.clip{max-width:520px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;vertical-align:bottom;}
</style>
@endpush
@section('content')
@php
// 컨트롤러에서 templates 로 내려오든, page 로 내려오든 호환
$page = $page ?? ($templates ?? null);
$filters = $filters ?? [];
$field = (string)($filters['field'] ?? 'subject');
$q = (string)($filters['q'] ?? '');
@endphp
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div class="bar__left">
<div class="t">공지사항</div>
<div class="a-muted d">공지사항(상단공지/숨김/첨부파일) 관리합니다.</div>
</div>
<div class="bar__right">
<a class="lbtn lbtn--primary lbtn--wide"
href="{{ route('admin.notice.create', request()->query()) }}">
+ 공지 등록
</a>
<form method="GET" action="{{ route('admin.notice.index') }}" class="filters">
<div>
<select class="a-input st" name="field">
<option value="subject" {{ $field==='subject' ? 'selected' : '' }}>제목</option>
<option value="content" {{ $field==='content' ? 'selected' : '' }}>내용</option>
</select>
</div>
<div>
<input class="a-input q" name="q" value="{{ $q }}" placeholder="검색어 입력">
</div>
<div style="display:flex; gap:8px; align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ route('admin.notice.index') }}">초기화</a>
</div>
</form>
</div>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page?->total() ?? 0 }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1100px;">
<thead>
<tr>
<th style="width:80px;">NO</th>
<th>제목</th>
<th style="width:140px;">상태</th>
<th style="width:140px;">공지</th>
<th style="width:170px;">등록일시</th>
<th style="width:80px;">hit</th>
<th style="width:90px;">관리</th>
</tr>
</thead>
<tbody>
@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
<tr class="{{ $isHidden ? 'row-disabled' : '' }}">
<td class="a-muted">{{ $no }}</td>
<td>
<div style="display:flex; gap:8px; align-items:center;">
@if($isPinned)
<span class="pill pill--ok"> 공지</span>
@endif
@if($isHidden)
<span class="pill pill--bad"> 숨김</span>
@endif
<span class="clip">{{ $it->subject ?? '-' }}</span>
</div>
</td>
<td>
@if($isHidden)
<span class="pill pill--bad"> 숨김</span>
@else
<span class="pill pill--ok"> 노출</span>
@endif
</td>
<td>
@if($isPinned)
<span class="pill pill--ok">On</span>
@else
<span class="pill pill--muted">Off</span>
@endif
</td>
<td class="a-muted">{{ $it->regdate ?? '-' }}</td>
<td class="a-muted" style="text-align:center;">{{ $it->hit ?? 0 }}</td>
<td style="text-align:right;">
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.notice.edit', ['id'=>(int)$it->seq] + request()->query()) }}">
보기
</a>
</td>
</tr>
@php $no--; @endphp
@empty
<tr><td colspan="7" class="a-muted" style="padding:18px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $templates->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
@endsection

View File

@ -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' => [

View File

@ -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')
<style>
/* qna index only - match notice/admins */
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.bar__left .t{font-weight:900;font-size:16px;}
.bar__left .d{font-size:12px;margin-top:4px;}
.bar__right{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .w120{width:120px;}
.filters .w160{width:160px;}
.filters .w220{width:220px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.td-muted{opacity:.85;font-size:12px;}
.clip{max-width:520px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;vertical-align:bottom;}
.row-mine{background:rgba(59,130,246,.06);}
.table td{vertical-align:top;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div class="bar__left">
<div class="t">1:1 문의</div>
<div class="a-muted d">접수/분배/처리/보류/완료 상태를 관리합니다.</div>
</div>
<div class="bar__right">
<form method="GET" action="{{ route('admin.qna.index') }}" class="filters">
<select class="a-input w120" name="year">
@for($y = $curYear; $y >= 2018; $y--)
<option value="{{ $y }}" {{ $selectedYear === $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
<select class="a-input w160" name="enquiry_code">
<option value="">접수분류(전체)</option>
@foreach($categories as $c)
<option value="{{ $c['num'] }}" @selected((string)$enquiryCode === (string)$c['num'])>
{{ $c['label'] }}
</option>
@endforeach
</select>
<select class="a-input w160" name="state">
<option value="">처리상태(전체)</option>
@foreach($stateLabels as $k=>$v)
<option value="{{ $k }}" @selected((string)$state === (string)$k)>{{ $v[0] }}</option>
@endforeach
</select>
<select class="a-input w160" name="q_type">
<option value="title" @selected($qType==='title')>제목</option>
<option value="member_no" @selected($qType==='member_no')>회원번호</option>
<option value="member_email" @selected($qType==='member_email')>회원이메일</option>
<option value="admin_email" @selected($qType==='admin_email')>관리자이메일</option>
</select>
<input class="a-input w220" name="q" value="{{ $q }}" placeholder="검색어">
<input class="a-input w160" type="date" name="date_from" value="{{ $dateFrom }}">
<input class="a-input w160" type="date" name="date_to" value="{{ $dateTo }}">
<label class="a-check" style="margin:0;">
<input type="checkbox" name="my_work" value="1" {{ $myWork ? 'checked':'' }}>
<span>내업무</span>
</label>
<div style="display:flex; gap:8px; align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ route('admin.qna.index') }}">초기화</a>
</div>
</form>
</div>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $total }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1300px;">
<thead>
<tr>
<th style="width:80px;">NO</th>
<th style="width:110px;">SEQ</th>
<th style="width:140px;">상태</th>
<th style="width:160px;">분류</th>
<th>제목</th>
<th style="width:120px;">회신</th>
<th style="width:120px;">회원</th>
<th style="width:180px;">등록일시</th>
<th style="width:130px;">배정자</th>
<th style="width:90px;">관리</th>
</tr>
</thead>
<tbody>
@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
<tr class="{{ $isMine ? 'row-mine' : '' }}">
<td class="a-muted">{{ $no > 0 ? $no : '-' }}</td>
<td><span class="mono">#{{ (int)$r->seq }}</span></td>
<td><span class="pill {{ $stCls }}"> {{ $stLabel }}</span></td>
<td class="td-muted">{{ $catLabel }}</td>
<td>
<a href="{{ $showUrl }}" style="text-decoration:none; color:inherit;">
<div style="display:flex; gap:8px; align-items:center;">
<span class="clip" style="font-weight:900;">{{ $r->enquiry_title ?? '-' }}</span>
</div>
</a>
</td>
<td><span class="pill {{ $rtCls }}">{{ $rtLabel }}</span></td>
<td class="td-muted"><span class="mono">{{ (int)($r->member_num ?? 0) }}</span></td>
<td class="td-muted">{{ $r->regdate ?? '-' }}</td>
<td class="td-muted">
@if($assignedAdmin > 0)
<span class="mono">{{ $assignedAdmin }}</span>
@if($isMine)
<span class="pill pill--ok" style="margin-left:6px;">ME</span>
@endif
@else
-
@endif
</td>
<td style="text-align:right;">
<a class="lbtn lbtn--ghost lbtn--sm" href="{{ $showUrl }}">보기</a>
</td>
</tr>
@php if ($no > 0) $no--; @endphp
@empty
{{-- 컬럼 (10) 맞춰서 UI 깨짐 방지 --}}
<tr><td colspan="10" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{-- 공지사항과 동일한 커스텀 페이징 + 쿼리 유지 --}}
{{ $rows->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
@endsection

View File

@ -0,0 +1,271 @@
@extends('admin.layouts.app')
@section('title', '1:1 문의 상세')
@section('page_title', '1:1 문의 상세')
@section('page_desc', '업무 배정/처리/보류/완료 및 답변 발송')
@push('head')
<style>
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
.lbtn--danger:hover{background:rgba(244,63,94,.98);}
.lbtn--warn{background:rgba(245,158,11,.88);border-color:rgba(245,158,11,.95);color:#fff;}
.lbtn--warn:hover{background:rgba(245,158,11,.98);}
.lbtn--ghost{background:transparent;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.wrap{max-width:1200px;}
.grid{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 1100px){ .grid{grid-template-columns: 1fr 1fr;} }
.actionsTop{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center;}
.actionsRight{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-left:auto;}
.sectionTitle{font-weight:900;margin-bottom:8px;}
</style>
@endpush
@section('content')
@php
$st = (string)$row->state;
$stLabel = $stateLabels[$st][0] ?? $st;
$stCls = $stateLabels[$st][1] ?? 'pill--muted';
@endphp
<div class="a-card wrap" style="padding:16px; margin-bottom:16px;">
<div class="actionsTop">
<div>
<div class="a-muted" style="font-size:12px;">접수분류 / 처리상태</div>
<div style="display:flex; align-items:center; gap:10px; margin-top:6px; flex-wrap:wrap;">
<span class="pill pill--muted">{{ $row->enquiry_code ?? '-' }}</span>
<span class="pill {{ $stCls }}"> {{ $stLabel }}</span>
<span class="mono">#{{ (int)$row->seq }} / year {{ (int)$year }}</span>
</div>
</div>
<div class="actionsRight">
<a class="lbtn lbtn--ghost" href="{{ route('admin.qna.index', request()->only(['year','enquiry_code','state','q','q_type','date_from','date_to','my_work','page'])) }}"> 목록</a>
@if($actions['can_assign'])
<form method="POST" action="{{ route('admin.qna.assign',['seq'=>$row->seq,'year'=>$year]) }}">
@csrf
<button class="lbtn lbtn--primary" type="submit"> 업무로 배정</button>
</form>
@endif
@if($actions['can_start'])
<form method="POST" action="{{ route('admin.qna.start',['seq'=>$row->seq,'year'=>$year]) }}">
@csrf
<button class="lbtn lbtn--primary" type="submit">해당업무시작</button>
</form>
@endif
@if($actions['can_return'])
<form method="POST" action="{{ route('admin.qna.return',['seq'=>$row->seq,'year'=>$year]) }}" onsubmit="return confirm('업무를 반납할까요?');">
@csrf
<button class="lbtn lbtn--warn" type="submit">업무 반납하기</button>
</form>
@endif
@if($actions['can_complete'])
<form method="POST" action="{{ route('admin.qna.complete',['seq'=>$row->seq,'year'=>$year]) }}" onsubmit="return confirm('업무를 완료 처리할까요? (사용자에게 발송됩니다)');">
@csrf
<button class="lbtn lbtn--primary" type="submit">업무처리완료</button>
</form>
@endif
</div>
</div>
</div>
<div class="wrap grid">
<div class="a-card" style="padding:16px;">
<div class="sectionTitle">제목</div>
<div style="font-weight:900;">{{ $row->enquiry_title }}</div>
<div style="margin-top:14px;">
<div class="sectionTitle">회원정보</div>
@if($member)
<div class="a-muted" style="font-size:13px; line-height:1.7;">
회원번호 : <b>{{ (int)$member->mem_no }}</b><br>
이메일 : <b>{{ $member->email }}</b><br>
성명 : <b>{{ $member->name }}</b><br>
전화번호 : <b>{{ $member->cell_phone }}</b>
</div>
@else
<div class="a-muted">회원 정보를 찾을 없습니다.</div>
@endif
</div>
<div style="margin-top:18px;">
<div class="sectionTitle">내용</div>
<div class="a-input" style="min-height:110px; white-space:pre-wrap;">{{ $row->enquiry_content }}</div>
</div>
<div style="margin-top:18px;">
<div class="sectionTitle">관리자 답변</div>
@if($actions['can_answer'])
<form method="POST" action="{{ route('admin.qna.answer.save',['seq'=>$row->seq,'year'=>$year]) }}">
@csrf
<textarea class="a-input" name="answer_content" rows="10" placeholder="관리자 답변을 입력해 주세요">{{ old('answer_content', $row->answer_content) }}</textarea>
@error('answer_content') <div class="a-error">{{ $message }}</div> @enderror
<div class="a-muted" style="margin-top:10px; font-size:12px;">
SMS답변 (업무완료 SMS요청 회원에게 발송됩니다)
</div>
<textarea class="a-input" name="answer_sms" rows="3" maxlength="500">{{ old('answer_sms', $row->answer_sms ?? '[핀포유] 1:1 문의 답변이 등록되었습니다. 1:1문의내역에서 확인 가능합니다. 감사합니다') }}</textarea>
@error('answer_sms') <div class="a-error">{{ $message }}</div> @enderror
<div style="margin-top:10px; display:flex; gap:8px; justify-content:flex-end;">
<button class="lbtn lbtn--primary" type="submit">저장</button>
</div>
</form>
@else
<div class="a-input" style="min-height:110px; white-space:pre-wrap;">
{{ $row->answer_content ?: '등록된 답변이 없습니다.' }}
</div>
<div class="a-muted" style="margin-top:8px; font-size:12px;">
SMS: {{ $row->answer_sms ?: '-' }}
</div>
@endif
</div>
@if($actions['can_postpone'])
<div style="margin-top:18px;">
<div class="sectionTitle">업무 보류</div>
<form method="POST" action="{{ route('admin.qna.postpone',['seq'=>$row->seq,'year'=>$year]) }}">
@csrf
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<select class="a-input" name="defer_code" style="width:160px;">
<option value="">보류코드(선택)</option>
@foreach($deferCodes as $c)
<option value="{{ $c['code'] }}">{{ $c['code'] }} · {{ $c['title'] }}</option>
@endforeach
</select>
<input class="a-input" name="defer_comment" style="flex:1; min-width:240px;" placeholder="보류 사유를 입력해 주세요">
<button class="lbtn lbtn--danger" type="submit" onclick="return confirm('업무를 보류할까요?');">업무 보류하기</button>
</div>
@error('defer_comment') <div class="a-error">{{ $message }}</div> @enderror
</form>
</div>
@endif
</div>
<div class="a-card" style="padding:16px;">
<div class="sectionTitle">최근 구매기록(10)</div>
@if($orders)
<div style="overflow:auto;">
<table class="a-table">
<thead>
<tr>
<th>주문</th>
<th>상품</th>
<th>금액</th>
<th>결제일시</th>
<th>상태</th>
</tr>
</thead>
<tbody>
@foreach($orders as $o)
<tr>
<td class="a-muted">{{ $o->order_no ?? ($o->seq ?? '-') }}</td>
<td>{{ $o->product_name ?? '-' }}</td>
<td class="a-muted">{{ $o->price ?? '-' }}</td>
<td class="a-muted">{{ $o->regdate ?? '-' }}</td>
<td class="a-muted">{{ $o->stat_pay ?? '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="a-muted">구매기록을 불러올 없습니다(테이블/컬럼 확인 필요).</div>
@endif
<div style="margin-top:18px;">
<div class="sectionTitle">변경이력</div>
@if($stateLog)
<div style="overflow:auto;">
<table class="a-table">
<thead>
<tr>
<th style="width:140px;">처리자</th>
<th style="width:80px;"></th>
<th style="width:80px;"></th>
<th>내용</th>
<th style="width:160px;">일자</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">
{{ $a ? ($a['name'].' ('.$a['email'].')') : ('admin#'.$aid) }}
</td>
<td class="a-muted">{{ $stateLabels[$before][0] ?? $before }}</td>
<td class="a-muted">{{ $stateLabels[$after][0] ?? $after }}</td>
<td>{{ $lg['why'] ?? '' }}</td>
<td class="a-muted">{{ $lg['when'] ?? '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="a-muted">변경이력이 없습니다.</div>
@endif
</div>
<div style="margin-top:18px;">
<div class="sectionTitle">내부 메모</div>
<form method="POST" action="{{ route('admin.qna.memo.add',['seq'=>$row->seq,'year'=>$year]) }}">
@csrf
@php $canMemo = (bool)($actions['canMemo'] ?? false); @endphp
<textarea class="a-input" name="memo" rows="3"
{{ $canMemo ? '' : 'disabled' }}
placeholder="{{ $canMemo ? '내부 메모를 입력하세요' : '업무 배정 후(내 업무일 때) 메모 작성이 가능합니다.' }}"></textarea>
<button class="lbtn lbtn--primary" type="submit" {{ $canMemo ? '' : 'disabled' }}>
메모 저장
</button>
</form>
@if($memoLog)
<div style="margin-top:10px; display:flex; flex-direction:column; gap:8px;">
@foreach($memoLog as $m)
@php
$aid = (int)($m['admin_num'] ?? 0);
$a = $admins[$aid] ?? null;
@endphp
<div class="a-card" style="padding:10px;">
<div class="a-muted" style="font-size:12px;">
{{ $a ? ($a['name'].' ('.$a['email'].')') : ('admin#'.$aid) }}
· {{ $m['when'] ?? '' }}
</div>
<div style="margin-top:4px; white-space:pre-wrap;">{{ $m['memo'] ?? '' }}</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@ -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
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;color:#101828;">
<div style="font-size:18px;font-weight:800;letter-spacing:-.2px;">
{{ $title }}
</div>
<div style="height:10px"></div>
<div style="font-size:13px;line-height:1.8;color:#344054;">
고객님의 1:1 문의에 대한 답변이 등록되었습니다.<br>
아래 내용을 확인해 주세요.
</div>
<div style="height:12px"></div>
<div style="background:#F2F4F7;border:1px solid #EAECF0;border-radius:14px;padding:14px 14px;">
<div style="font-size:13px;line-height:1.9;color:#101828;">
<div><span style="color:#667085;">문의번호</span> : <strong>{{ $qnaId }}</strong></div>
<div><span style="color:#667085;">제목</span> : <strong>{{ $enquiryTitle }}</strong></div>
<div><span style="color:#667085;">문의등록</span> : {{ $regdate }}</div>
</div>
</div>
<div style="height:14px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
문의 내용
</div>
<div style="height:8px"></div>
<div style="background:#FFFFFF;border:1px solid #EAECF0;border-radius:14px;padding:14px 14px;">
<div style="font-size:13px;line-height:1.8;color:#101828;white-space:pre-wrap;word-break:break-word;">
{{ $enquiryContent }}
</div>
</div>
<div style="height:14px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
답변 내용
</div>
<div style="height:8px"></div>
<div style="background:#FFFFFF;border:1px solid #EAECF0;border-radius:14px;padding:14px 14px;">
<div style="font-size:13px;line-height:1.8;color:#101828;white-space:pre-wrap;word-break:break-word;">
{{ $answerContent }}
</div>
</div>
@if(!empty($qnaUrl))
<div style="height:16px"></div>
<a href="{{ $qnaUrl }}"
style="display:inline-block;background:{{ $accent }};color:#fff;text-decoration:none;
font-size:13px;font-weight:700;padding:10px 14px;border-radius:12px;">
사이트에서 확인하기
</a>
@endif
<div style="height:14px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
메일은 시스템 알림입니다.
</div>
</div>
@endsection

View File

@ -61,15 +61,17 @@
@endphp
@if($link1 || $link2 || $notice->file_01 || $notice->file_02)
<div class="nv-attach nv-attach--inbox">
<div class="nv-attach-title">첨부 / 관련 링크</div>
<section class="nv-resources" aria-label="첨부 및 관련 링크">
<div class="nv-resources__head">
<div class="nv-resources__title">첨부 / 링크</div>
<div class="nv-resources__desc">필요한 자료를 빠르게 열어보세요.</div>
</div>
<div class="nv-attach-list">
<div class="nv-resources__grid">
@if($notice->file_01)
<a class="nv-attach-item"
href="{{ $notice->file_01 }}"
download="{{ $file1 ?? '' }}">
href="{{ route('web.cs.notice.file', ['seq' => $notice->seq, 'slot' => 1]) }}">
<span class="nv-attach-name">{{ $file1 }}</span>
<span class="nv-attach-action">다운로드</span>
</a>
@ -77,36 +79,37 @@
@if($notice->file_02)
<a class="nv-attach-item"
href="{{ $notice->file_02 }}"
download="{{ $file2 ?? '' }}">
href="{{ route('web.cs.notice.file', ['seq' => $notice->seq, 'slot' => 2]) }}">
<span class="nv-attach-name">{{ $file2 }}</span>
<span class="nv-attach-action">다운로드</span>
</a>
@endif
@if($link1)
<a class="nv-attach-item"
href="{{ $link1 }}"
target="_blank" rel="noopener">
<span class="nv-attach-name">관련 링크 1</span>
<span class="nv-attach-action">열기</span>
<a class="nv-item" href="{{ $link1 }}" target="_blank" rel="noopener">
<div class="nv-item__left">
<span class="nv-badge nv-badge--link">LINK</span>
<span class="nv-item__name">관련 링크 1</span>
</div>
<span class="nv-item__right">열기 </span>
</a>
@endif
@if($link2)
<a class="nv-attach-item"
href="{{ $link2 }}"
target="_blank" rel="noopener">
<span class="nv-attach-name">관련 링크 2</span>
<span class="nv-attach-action">열기</span>
<a class="nv-item" href="{{ $link2 }}" target="_blank" rel="noopener">
<div class="nv-item__left">
<span class="nv-badge nv-badge--link">LINK</span>
<span class="nv-item__name">관련 링크 2</span>
</div>
<span class="nv-item__right">열기 </span>
</a>
@endif
</div>
</div>
</section>
@endif
{{-- 박스 안에서 하단 네비(이전/다음/목록) --}}
{{-- 박스 안에서 하단 네비(이전/다음/목록) --}}
<nav class="nv-actions nv-actions--webonly" aria-label="공지 네비게이션">
@if($prev)
<a class="nv-action nv-action--prev"

View File

@ -9,6 +9,8 @@ use App\Http\Controllers\Admin\Sms\AdminSmsTemplateController;
use App\Http\Controllers\Admin\Mail\AdminMailController;
use App\Http\Controllers\Admin\Mail\AdminMailLogController;
use App\Http\Controllers\Admin\Mail\AdminMailTemplateController;
use App\Http\Controllers\Admin\Notice\AdminNoticeController;
use App\Http\Controllers\Admin\Qna\AdminQnaController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () {
@ -136,6 +138,50 @@ Route::middleware(['web'])->group(function () {
Route::post('/preview', [AdminMailController::class, 'preview'])->name('admin.mail.preview');
});
Route::prefix('/notice')
->name('admin.notice.')
->middleware('admin.role:super_admin,support')
->group(function () {
Route::get('/', [AdminNoticeController::class, 'index'])->name('index');
Route::get('/create', [AdminNoticeController::class, 'create'])->name('create');
Route::post('/', [AdminNoticeController::class, 'store'])->name('store');
Route::get('/{id}', [AdminNoticeController::class, 'edit'])
->whereNumber('id')
->name('edit');
Route::put('/{id}', [AdminNoticeController::class, 'update'])
->whereNumber('id')
->name('update');
Route::delete('/{id}', [AdminNoticeController::class, 'destroy'])
->whereNumber('id')
->name('destroy');
// 첨부파일 다운로드 (slot: 1|2)
Route::get('/{id}/file/{slot}', [AdminNoticeController::class, 'download'])
->whereNumber('id')
->whereIn('slot', ['1', '2'])
->name('file');
});
Route::prefix('qna')->name('admin.qna.')->group(function () {
Route::get('/', [AdminQnaController::class, 'index'])->name('index');
Route::get('/{seq}', [AdminQnaController::class, 'show'])->name('show');
// 업무 액션
Route::post('/{seq}/assign', [AdminQnaController::class, 'assignToMe'])->name('assign');
Route::post('/{seq}/start', [AdminQnaController::class, 'startWork'])->name('start');
Route::post('/{seq}/return', [AdminQnaController::class, 'returnWork'])->name('return');
Route::post('/{seq}/postpone', [AdminQnaController::class, 'postponeWork'])->name('postpone');
Route::post('/{seq}/answer', [AdminQnaController::class, 'saveAnswer'])->name('answer.save');
Route::post('/{seq}/complete', [AdminQnaController::class, 'completeWork'])->name('complete');
// 내부 메모(선택)
Route::post('/{seq}/memo', [AdminQnaController::class, 'addMemo'])->name('memo.add');
});
/**
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니,
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 붙이면 .

View File

@ -23,6 +23,11 @@ Route::view('/', 'web.home')->name('web.home');
Route::prefix('cs')->name('web.cs.')->group(function () {
Route::get('/notice', [NoticeController::class, 'index'])->name('notice.index');
Route::get('/notice/{seq}', [NoticeController::class, 'show'])->whereNumber('seq')->name('notice.show');
Route::get('/notice/{seq}/file/{slot}', [NoticeController::class, 'download'])
->whereNumber('seq')
->whereIn('slot', ['1','2'])
->name('notice.file');
Route::view('faq', 'web.cs.faq.index')->name('faq.index');
Route::view('kakao', 'web.cs.kakao.index')->name('kakao.index');
Route::middleware('legacy.auth')->group(function () {