notice , qna 작업
This commit is contained in:
parent
0db9e2bdc5
commit
754d6e2497
18
app/Console/Commands/DispatchDueAdminMailBatches.php
Normal file
18
app/Console/Commands/DispatchDueAdminMailBatches.php
Normal 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;
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Admin/Notice/AdminNoticeController.php
Normal file
118
app/Http/Controllers/Admin/Notice/AdminNoticeController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/Admin/Qna/AdminQnaController.php
Normal file
171
app/Http/Controllers/Admin/Qna/AdminQnaController.php
Normal 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', '메모를 추가했습니다.');
|
||||
}
|
||||
}
|
||||
@ -3,26 +3,26 @@
|
||||
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,
|
||||
@ -32,61 +32,16 @@ class NoticeController extends Controller
|
||||
|
||||
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;
|
||||
return view('web.cs.notice.show', $res);
|
||||
}
|
||||
|
||||
// base
|
||||
$base = GcBoard::query()->visibleNotice();
|
||||
public function download(int $seq, int $slot): StreamedResponse
|
||||
{
|
||||
$r = $this->service->download($seq, $slot);
|
||||
abort_unless($r['ok'], 404);
|
||||
|
||||
/**
|
||||
* 전제: 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 Storage::disk('public')->download($r['path'], $r['name']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
62
app/Jobs/SendUserQnaAnsweredMailJob.php
Normal file
62
app/Jobs/SendUserQnaAnsweredMailJob.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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', []);
|
||||
|
||||
158
app/Repositories/Admin/Cs/AdminQnaRepository.php
Normal file
158
app/Repositories/Admin/Cs/AdminQnaRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
75
app/Repositories/Admin/Notice/AdminNoticeRepository.php
Normal file
75
app/Repositories/Admin/Notice/AdminNoticeRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
128
app/Repositories/Cs/CsQnaRepository.php
Normal file
128
app/Repositories/Cs/CsQnaRepository.php
Normal 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",
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
96
app/Repositories/Cs/NoticeRepository.php
Normal file
96
app/Repositories/Cs/NoticeRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
224
app/Services/Admin/Notice/AdminNoticeService.php
Normal file
224
app/Services/Admin/Notice/AdminNoticeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
601
app/Services/Admin/Qna/AdminQnaService.php
Normal file
601
app/Services/Admin/Qna/AdminQnaService.php
Normal 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' => '내 업무로 배정된 건만 메모를 남길 수 있습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
71
app/Services/CsNoticeService.php
Normal file
71
app/Services/CsNoticeService.php
Normal 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];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -2,134 +2,65 @@
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
// 컨트롤러에서 이미 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 = (int) DB::transaction(function () use ($table, $memNo, $enquiryCode, $enquiryTitle, $enquiryContent, $returnType) {
|
||||
return DB::table($table)->insertGetId([
|
||||
'state' => 'a',
|
||||
'member_num' => $memNo,
|
||||
$id = $this->repo->insertQna($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",
|
||||
]);
|
||||
});
|
||||
|
||||
], $year);
|
||||
|
||||
// 메일 발송 데이터 구성
|
||||
$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,
|
||||
@ -142,7 +73,7 @@ final class QnaService
|
||||
'enquiry_content' => $enquiryContent,
|
||||
'return_type' => $returnType,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
])->onConnection('redis'); // 네 QUEUE_CONNECTION=redis일 때 명시해도 좋음
|
||||
])->onConnection('redis');
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
@ -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],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -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
129
config/purifier.php
Normal 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'],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
99
resources/views/admin/notice/create.blade.php
Normal file
99
resources/views/admin/notice/create.blade.php
Normal 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
|
||||
176
resources/views/admin/notice/edit.blade.php
Normal file
176
resources/views/admin/notice/edit.blade.php
Normal 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
|
||||
170
resources/views/admin/notice/index.blade.php
Normal file
170
resources/views/admin/notice/index.blade.php
Normal 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
|
||||
@ -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' => [
|
||||
|
||||
209
resources/views/admin/qna/index.blade.php
Normal file
209
resources/views/admin/qna/index.blade.php
Normal 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
|
||||
271
resources/views/admin/qna/show.blade.php
Normal file
271
resources/views/admin/qna/show.blade.php
Normal 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
|
||||
96
resources/views/mail/admin/qna_adminreturn.blade.php
Normal file
96
resources/views/mail/admin/qna_adminreturn.blade.php
Normal 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
|
||||
@ -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"
|
||||
|
||||
@ -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 을 붙이면 됨.
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user