디자인 ui/ux 변경 및 sms 발송관리, 템플릿, 로그 완료
This commit is contained in:
parent
af3b2e7534
commit
7e04708d79
69
app/Http/Controllers/Admin/Sms/AdminSmsController.php
Normal file
69
app/Http/Controllers/Admin/Sms/AdminSmsController.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers\Admin\Sms;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Sms\AdminSmsService;
|
||||||
|
use App\Services\Admin\Sms\AdminSmsTemplateService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminSmsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminSmsService $service,
|
||||||
|
private readonly AdminSmsTemplateService $templateService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$templates = $this->templateService->activeForSend(200);
|
||||||
|
return view('admin.sms.send', [
|
||||||
|
'templates' => $templates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'from_number' => ['required','string','max:30'],
|
||||||
|
'send_mode' => ['required','in:one,many,template'],
|
||||||
|
'message' => ['required','string','max:2000'],
|
||||||
|
'sms_type_hint' => ['nullable','in:auto,sms,mms'],
|
||||||
|
'scheduled_at' => ['nullable','date_format:Y-m-d H:i'],
|
||||||
|
|
||||||
|
// one
|
||||||
|
'to_number' => ['nullable','string','max:30'],
|
||||||
|
|
||||||
|
// many
|
||||||
|
'to_numbers_text'=> ['nullable','string','max:500000'],
|
||||||
|
'to_numbers_csv' => ['nullable','file','mimes:csv,txt','max:5120'],
|
||||||
|
|
||||||
|
// template
|
||||||
|
'template_csv' => ['nullable','file','mimes:csv,txt','max:5120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// template은 super_admin만 (서버 강제)
|
||||||
|
$roleNames = (array) data_get(session('admin_ctx', []), 'role_names', []);
|
||||||
|
if (($data['send_mode'] ?? '') === 'template' && !in_array('super_admin', $roleNames, true)) {
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type' => 'danger', 'title' => '권한 없음', 'message' => '템플릿 발송은 super_admin만 가능합니다.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = (int) auth('admin')->id();
|
||||||
|
|
||||||
|
$res = $this->service->createBatch($adminId, $data, $request);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
return back()->with('toast', [
|
||||||
|
'type' => 'danger', 'title' => '실패', 'message' => $res['message'] ?? '처리에 실패했습니다.'
|
||||||
|
])->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.sms.logs.show', ['batchId' => $res['batch_id']])
|
||||||
|
->with('toast', [
|
||||||
|
'type' => 'success',
|
||||||
|
'title' => '접수 완료',
|
||||||
|
'message' => "총 {$res['total']}건 중 유효 {$res['valid']}건을 등록했습니다."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Http/Controllers/Admin/Sms/AdminSmsLogController.php
Normal file
53
app/Http/Controllers/Admin/Sms/AdminSmsLogController.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Sms;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Sms\AdminSmsLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminSmsLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminSmsLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET admin.sms.logs
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$filters = $request->only([
|
||||||
|
'status', 'send_mode', 'q', 'date_from', 'date_to',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$batches = $this->service->paginateBatches($filters, 30);
|
||||||
|
|
||||||
|
return view('admin.sms.logs.index', [
|
||||||
|
'batches' => $batches,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET admin.sms.logs.show
|
||||||
|
*/
|
||||||
|
public function show(int $batchId, Request $request)
|
||||||
|
{
|
||||||
|
$batch = $this->service->getBatch($batchId);
|
||||||
|
if (!$batch) {
|
||||||
|
return redirect()->route('admin.sms.logs')->with('toast', [
|
||||||
|
'type' => 'danger',
|
||||||
|
'title' => '없음',
|
||||||
|
'message' => '해당 발송 이력을 찾을 수 없습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = $request->only(['status', 'to', 'q']);
|
||||||
|
$items = $this->service->paginateItems($batchId, $filters, 50);
|
||||||
|
|
||||||
|
return view('admin.sms.logs.show', [
|
||||||
|
'batch' => $batch,
|
||||||
|
'items' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/Http/Controllers/Admin/Sms/AdminSmsTemplateController.php
Normal file
145
app/Http/Controllers/Admin/Sms/AdminSmsTemplateController.php
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Sms;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Sms\AdminSmsTemplateService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminSmsTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminSmsTemplateService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET admin.templates.index
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$filters = $request->only(['active', 'q']);
|
||||||
|
$templates = $this->service->list($filters, 30);
|
||||||
|
|
||||||
|
return view('admin.templates.index', [
|
||||||
|
'templates' => $templates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET admin.templates.create
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('admin.templates.form', [
|
||||||
|
'mode' => 'create',
|
||||||
|
'tpl' => (object)[
|
||||||
|
'id' => null,
|
||||||
|
'code' => '',
|
||||||
|
'title' => '',
|
||||||
|
'body' => '',
|
||||||
|
'description' => '',
|
||||||
|
'is_active' => 1,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST admin.templates.store
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'code' => ['required', 'string', 'max:60', 'regex:/^[a-zA-Z0-9\-_]{3,60}$/'],
|
||||||
|
'title' => ['required', 'string', 'max:120'],
|
||||||
|
'body' => ['required', 'string', 'max:5000'],
|
||||||
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
|
// checkbox는 존재 여부로 처리
|
||||||
|
]);
|
||||||
|
|
||||||
|
$adminId = (int) auth('admin')->id();
|
||||||
|
|
||||||
|
$res = $this->service->create($adminId, [
|
||||||
|
'code' => $data['code'],
|
||||||
|
'title' => $data['title'],
|
||||||
|
'body' => $data['body'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'is_active' => $request->has('is_active') ? 1 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
return back()->withInput()->with('toast', [
|
||||||
|
'type' => 'danger',
|
||||||
|
'title' => '실패',
|
||||||
|
'message' => $res['message'] ?? '템플릿 생성에 실패했습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.templates.edit', ['id' => $res['id']])->with('toast', [
|
||||||
|
'type' => 'success',
|
||||||
|
'title' => '완료',
|
||||||
|
'message' => '템플릿이 생성되었습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET admin.templates.edit
|
||||||
|
*/
|
||||||
|
public function edit(int $id)
|
||||||
|
{
|
||||||
|
$tpl = $this->service->get($id);
|
||||||
|
if (!$tpl) {
|
||||||
|
return redirect()->route('admin.templates.index')->with('toast', [
|
||||||
|
'type' => 'danger',
|
||||||
|
'title' => '없음',
|
||||||
|
'message' => '템플릿을 찾을 수 없습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin.templates.form', [
|
||||||
|
'mode' => 'edit',
|
||||||
|
'tpl' => $tpl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT admin.templates.update
|
||||||
|
*/
|
||||||
|
public function update(int $id, Request $request)
|
||||||
|
{
|
||||||
|
$tpl = $this->service->get($id);
|
||||||
|
if (!$tpl) {
|
||||||
|
return redirect()->route('admin.templates.index')->with('toast', [
|
||||||
|
'type' => 'danger',
|
||||||
|
'title' => '없음',
|
||||||
|
'message' => '템플릿을 찾을 수 없습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:120'],
|
||||||
|
'body' => ['required', 'string', 'max:5000'],
|
||||||
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $this->service->update($id, [
|
||||||
|
'title' => $data['title'],
|
||||||
|
'body' => $data['body'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'is_active' => $request->has('is_active') ? 1 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
return back()->withInput()->with('toast', [
|
||||||
|
'type' => 'danger',
|
||||||
|
'title' => '실패',
|
||||||
|
'message' => '템플릿 수정에 실패했습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.templates.edit', ['id' => $id])->with('toast', [
|
||||||
|
'type' => 'success',
|
||||||
|
'title' => '완료',
|
||||||
|
'message' => '저장되었습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Repositories/Admin/Sms/AdminSmsBatchRepository.php
Normal file
152
app/Repositories/Admin/Sms/AdminSmsBatchRepository.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Sms;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminSmsBatchRepository
|
||||||
|
{
|
||||||
|
public function insertBatch(array $data): int
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
return (int) DB::table('admin_sms_batches')->insertGetId([
|
||||||
|
'admin_user_id' => (int)($data['admin_user_id'] ?? 0),
|
||||||
|
'from_number' => (string)($data['from_number'] ?? ''),
|
||||||
|
'send_mode' => (string)($data['send_mode'] ?? 'one'),
|
||||||
|
'schedule_type' => (string)($data['schedule_type'] ?? 'now'),
|
||||||
|
'scheduled_at' => $data['scheduled_at'] ?? null,
|
||||||
|
|
||||||
|
'message_raw' => (string)($data['message_raw'] ?? ''),
|
||||||
|
'byte_len' => (int)($data['byte_len'] ?? 0),
|
||||||
|
'sms_type_hint' => (string)($data['sms_type_hint'] ?? 'auto'),
|
||||||
|
|
||||||
|
'total_count' => (int)($data['total_count'] ?? 0),
|
||||||
|
'valid_count' => (int)($data['valid_count'] ?? 0),
|
||||||
|
'duplicate_count' => (int)($data['duplicate_count'] ?? 0),
|
||||||
|
'invalid_count' => (int)($data['invalid_count'] ?? 0),
|
||||||
|
|
||||||
|
'status' => (string)($data['status'] ?? 'queued'),
|
||||||
|
|
||||||
|
'request_ip' => $data['request_ip'] ?? null,
|
||||||
|
'user_agent' => $data['user_agent'] ?? null,
|
||||||
|
'error_message' => $data['error_message'] ?? null,
|
||||||
|
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* items: [['seq'=>1,'to_number'=>'010...','message_final'=>'...','sms_type'=>'sms','status'=>'queued', ...], ...]
|
||||||
|
*/
|
||||||
|
public function insertItemsChunked(int $batchId, array $items, int $chunkSize = 1000): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$buf = [];
|
||||||
|
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$buf[] = [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'seq' => (int)($it['seq'] ?? 0),
|
||||||
|
'to_number' => (string)($it['to_number'] ?? ''),
|
||||||
|
'message_final' => (string)($it['message_final'] ?? ''),
|
||||||
|
'sms_type' => (string)($it['sms_type'] ?? 'sms'),
|
||||||
|
'status' => (string)($it['status'] ?? 'queued'),
|
||||||
|
'provider' => (string)($it['provider'] ?? 'lguplus'),
|
||||||
|
'provider_msg_id' => $it['provider_msg_id'] ?? null,
|
||||||
|
'provider_code' => $it['provider_code'] ?? null,
|
||||||
|
'provider_message' => $it['provider_message'] ?? null,
|
||||||
|
'submitted_at' => $it['submitted_at'] ?? null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($buf) >= $chunkSize) {
|
||||||
|
DB::table('admin_sms_batch_items')->insert($buf);
|
||||||
|
$buf = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($buf)) {
|
||||||
|
DB::table('admin_sms_batch_items')->insert($buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBatch(int $batchId, array $data): int
|
||||||
|
{
|
||||||
|
$data['updated_at'] = now();
|
||||||
|
return (int) DB::table('admin_sms_batches')->where('id', $batchId)->update($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItemBySeq(int $batchId, int $seq, array $data): int
|
||||||
|
{
|
||||||
|
$data['updated_at'] = now();
|
||||||
|
return (int) DB::table('admin_sms_batch_items')
|
||||||
|
->where('batch_id', $batchId)
|
||||||
|
->where('seq', $seq)
|
||||||
|
->update($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findBatch(int $batchId): ?object
|
||||||
|
{
|
||||||
|
return DB::table('admin_sms_batches')->where('id', $batchId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateBatches(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table('admin_sms_batches as b')
|
||||||
|
->leftJoin('admin_users as au', 'au.id', '=', 'b.admin_user_id')
|
||||||
|
->select([
|
||||||
|
'b.*',
|
||||||
|
DB::raw('COALESCE(au.nickname, au.name, au.email) as admin_name'),
|
||||||
|
DB::raw('au.email as admin_email'),
|
||||||
|
])
|
||||||
|
->orderByDesc('b.id');
|
||||||
|
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$q->where('b.status', (string)$filters['status']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['send_mode'])) {
|
||||||
|
$q->where('b.send_mode', (string)$filters['send_mode']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$kw = '%' . (string)$filters['q'] . '%';
|
||||||
|
$q->where(function ($w) use ($kw) {
|
||||||
|
$w->where('b.message_raw', 'like', $kw)
|
||||||
|
->orWhere('b.from_number', 'like', $kw)
|
||||||
|
->orWhere('b.request_ip', 'like', $kw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$q->where('b.created_at', '>=', $filters['date_from'] . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$q->where('b.created_at', '<=', $filters['date_to'] . ' 23:59:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table('admin_sms_batch_items')
|
||||||
|
->where('batch_id', $batchId)
|
||||||
|
->orderBy('seq');
|
||||||
|
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$q->where('status', (string)$filters['status']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['to'])) {
|
||||||
|
$to = preg_replace('/\D+/', '', (string)$filters['to']);
|
||||||
|
if ($to !== '') $q->where('to_number', 'like', '%' . $to . '%');
|
||||||
|
}
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$kw = '%' . (string)$filters['q'] . '%';
|
||||||
|
$q->where('message_final', 'like', $kw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Repositories/Admin/Sms/AdminSmsTemplateRepository.php
Normal file
66
app/Repositories/Admin/Sms/AdminSmsTemplateRepository.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Sms;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminSmsTemplateRepository
|
||||||
|
{
|
||||||
|
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table('admin_sms_templates')->orderByDesc('id');
|
||||||
|
|
||||||
|
if (isset($filters['active']) && $filters['active'] !== '') {
|
||||||
|
$q->where('is_active', (int)$filters['active']);
|
||||||
|
}
|
||||||
|
if (!empty($filters['q'])) {
|
||||||
|
$kw = '%' . (string)$filters['q'] . '%';
|
||||||
|
$q->where(function ($w) use ($kw) {
|
||||||
|
$w->where('code', 'like', $kw)
|
||||||
|
->orWhere('title', 'like', $kw)
|
||||||
|
->orWhere('body', 'like', $kw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?object
|
||||||
|
{
|
||||||
|
return DB::table('admin_sms_templates')->where('id', $id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(array $data): int
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
return (int) DB::table('admin_sms_templates')->insertGetId([
|
||||||
|
'code' => (string)($data['code'] ?? ''),
|
||||||
|
'title' => (string)($data['title'] ?? ''),
|
||||||
|
'body' => (string)($data['body'] ?? ''),
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'is_active' => (int)($data['is_active'] ?? 1),
|
||||||
|
'created_by' => $data['created_by'] ?? null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $data): int
|
||||||
|
{
|
||||||
|
$data['updated_at'] = now();
|
||||||
|
return (int) DB::table('admin_sms_templates')->where('id', $id)->update($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listActive(int $limit = 200): array
|
||||||
|
{
|
||||||
|
return DB::table('admin_sms_templates')
|
||||||
|
->select(['id','code','title','body'])
|
||||||
|
->where('is_active', 1)
|
||||||
|
->orderBy('title')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
28
app/Services/Admin/Sms/AdminSmsLogService.php
Normal file
28
app/Services/Admin/Sms/AdminSmsLogService.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Sms;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Sms\AdminSmsBatchRepository;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
final class AdminSmsLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminSmsBatchRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function paginateBatches(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->repo->paginateBatches($filters, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBatch(int $batchId): ?object
|
||||||
|
{
|
||||||
|
return $this->repo->findBatch($batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->repo->paginateItems($batchId, $filters, $perPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
401
app/Services/Admin/Sms/AdminSmsService.php
Normal file
401
app/Services/Admin/Sms/AdminSmsService.php
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Sms;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Sms\AdminSmsBatchRepository;
|
||||||
|
use App\Services\SmsService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
final class AdminSmsService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminSmsBatchRepository $repo,
|
||||||
|
private readonly SmsService $smsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $adminId
|
||||||
|
* @param array $data 컨트롤러 validate 결과
|
||||||
|
* @return array ['ok'=>bool, 'batch_id'=>int, 'total'=>int, 'valid'=>int, 'invalid'=>int, 'duplicate'=>int, 'message'=>string]
|
||||||
|
*/
|
||||||
|
public function createBatch(int $adminId, array $data, Request $request): array
|
||||||
|
{
|
||||||
|
$mode = (string)($data['send_mode'] ?? 'one');
|
||||||
|
$from = (string)($data['from_number'] ?? '');
|
||||||
|
$msgRaw = (string)($data['message'] ?? '');
|
||||||
|
$smsTypeHint = (string)($data['sms_type_hint'] ?? 'auto');
|
||||||
|
|
||||||
|
$scheduleType = (string)($data['schedule_type'] ?? 'now');
|
||||||
|
$scheduledAt = null;
|
||||||
|
|
||||||
|
if ($scheduleType === 'schedule') {
|
||||||
|
$scheduledAt = !empty($data['scheduled_at']) ? $data['scheduled_at'] : null;
|
||||||
|
if ($scheduledAt === null) {
|
||||||
|
return ['ok' => false, 'message' => '예약 시간을 입력해 주세요.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 대상 생성
|
||||||
|
try {
|
||||||
|
$targets = match ($mode) {
|
||||||
|
'one' => $this->buildTargetsOne((string)($data['to_number'] ?? ''), $msgRaw),
|
||||||
|
'many' => $this->buildTargetsMany(
|
||||||
|
(string)($data['to_numbers_text'] ?? ''),
|
||||||
|
$request->file('to_numbers_csv'),
|
||||||
|
$msgRaw
|
||||||
|
),
|
||||||
|
'template' => $this->buildTargetsTemplate(
|
||||||
|
$request->file('template_csv'),
|
||||||
|
$msgRaw
|
||||||
|
),
|
||||||
|
default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'),
|
||||||
|
};
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $targets['total'];
|
||||||
|
$valid = $targets['valid'];
|
||||||
|
$invalid = $targets['invalid'];
|
||||||
|
$dup = $targets['duplicate'];
|
||||||
|
$items = $targets['items']; // seq 포함된 배열
|
||||||
|
|
||||||
|
if ($valid < 1) {
|
||||||
|
return ['ok' => false, 'message' => '유효한 수신번호가 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) batch insert
|
||||||
|
$byteLen = $this->calcBytesCiLike($msgRaw);
|
||||||
|
|
||||||
|
$batchId = $this->repo->insertBatch([
|
||||||
|
'admin_user_id' => $adminId,
|
||||||
|
'from_number' => $from,
|
||||||
|
'send_mode' => $mode,
|
||||||
|
'schedule_type' => $scheduleType,
|
||||||
|
'scheduled_at' => $scheduledAt,
|
||||||
|
|
||||||
|
'message_raw' => $msgRaw,
|
||||||
|
'byte_len' => $byteLen,
|
||||||
|
'sms_type_hint' => in_array($smsTypeHint, ['auto','sms','mms'], true) ? $smsTypeHint : 'auto',
|
||||||
|
|
||||||
|
'total_count' => $total,
|
||||||
|
'valid_count' => $valid,
|
||||||
|
'duplicate_count' => $dup,
|
||||||
|
'invalid_count' => $invalid,
|
||||||
|
|
||||||
|
'status' => ($scheduleType === 'schedule') ? 'scheduled' : 'queued',
|
||||||
|
|
||||||
|
'request_ip' => $request->ip(),
|
||||||
|
'user_agent' => substr((string)$request->userAgent(), 0, 255),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3) items bulk insert (예약이면 queued, 즉시도 일단 queued로 넣고 이후 submitted/failed로 업데이트)
|
||||||
|
$this->repo->insertItemsChunked($batchId, $items);
|
||||||
|
|
||||||
|
// 4) 즉시 발송이면 실제 provider queue(DB)에 insert (SmsService가 sms_server에 넣음)
|
||||||
|
if ($scheduleType === 'now') {
|
||||||
|
$this->repo->updateBatch($batchId, ['status' => 'submitting']);
|
||||||
|
|
||||||
|
$submitted = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$payload = [
|
||||||
|
'from_number' => $from,
|
||||||
|
'to_number' => $it['to_number'],
|
||||||
|
'message' => $it['message_final'],
|
||||||
|
'sms_type' => $it['sms_type'], // 힌트(최종은 SmsService 내부 기준)
|
||||||
|
];
|
||||||
|
|
||||||
|
$ok = $this->smsService->send($payload, 'lguplus');
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
$submitted++;
|
||||||
|
$this->repo->updateItemBySeq($batchId, (int)$it['seq'], [
|
||||||
|
'status' => 'submitted',
|
||||||
|
'submitted_at' => now()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
$this->repo->updateItemBySeq($batchId, (int)$it['seq'], [
|
||||||
|
'status' => 'failed',
|
||||||
|
'provider_message' => 'SmsService send() returned false',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = 'submitted';
|
||||||
|
if ($submitted < 1) $status = 'failed';
|
||||||
|
else if ($failed > 0) $status = 'partial';
|
||||||
|
|
||||||
|
$this->repo->updateBatch($batchId, [
|
||||||
|
'status' => $status,
|
||||||
|
'error_message' => $failed > 0 ? "failed={$failed}" : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'total' => $total,
|
||||||
|
'valid' => $valid,
|
||||||
|
'invalid' => $invalid,
|
||||||
|
'duplicate' => $dup,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTargetsOne(string $to, string $message): array
|
||||||
|
{
|
||||||
|
$digits = $this->normalizePhone($to);
|
||||||
|
if (!$this->isValidKrMobile($digits)) {
|
||||||
|
return ['total'=>1,'valid'=>0,'invalid'=>1,'duplicate'=>0,'items'=>[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => 1,
|
||||||
|
'valid' => 1,
|
||||||
|
'invalid' => 0,
|
||||||
|
'duplicate' => 0,
|
||||||
|
'items' => [[
|
||||||
|
'seq' => 1,
|
||||||
|
'to_number' => $digits,
|
||||||
|
'message_final' => $message,
|
||||||
|
'sms_type' => $this->guessSmsType($message),
|
||||||
|
'status' => 'queued',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTargetsMany(string $text, ?UploadedFile $csv, string $message): array
|
||||||
|
{
|
||||||
|
$phones = [];
|
||||||
|
|
||||||
|
// 1) textarea
|
||||||
|
if (trim($text) !== '') {
|
||||||
|
$parts = preg_split('/[\s,]+/', $text) ?: [];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
$d = $this->normalizePhone((string)$p);
|
||||||
|
if ($d !== '') $phones[] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) csv (첫 컬럼만 phone으로 사용)
|
||||||
|
if ($csv instanceof UploadedFile) {
|
||||||
|
foreach ($this->readCsvLines($csv) as $row) {
|
||||||
|
if (empty($row)) continue;
|
||||||
|
$d = $this->normalizePhone((string)($row[0] ?? ''));
|
||||||
|
if ($d !== '') $phones[] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($phones);
|
||||||
|
$validList = [];
|
||||||
|
$invalid = 0;
|
||||||
|
|
||||||
|
foreach ($phones as $p) {
|
||||||
|
if ($this->isValidKrMobile($p)) $validList[] = $p;
|
||||||
|
else $invalid++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uniq = array_values(array_unique($validList));
|
||||||
|
$dup = count($validList) - count($uniq);
|
||||||
|
|
||||||
|
$smsType = $this->guessSmsType($message);
|
||||||
|
$items = [];
|
||||||
|
$seq = 1;
|
||||||
|
foreach ($uniq as $p) {
|
||||||
|
$items[] = [
|
||||||
|
'seq' => $seq++,
|
||||||
|
'to_number' => $p,
|
||||||
|
'message_final' => $message,
|
||||||
|
'sms_type' => $smsType,
|
||||||
|
'status' => 'queued',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'valid' => count($uniq),
|
||||||
|
'invalid' => $invalid,
|
||||||
|
'duplicate' => $dup,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTargetsTemplate(?UploadedFile $csv, string $message): array
|
||||||
|
{
|
||||||
|
if (!$csv instanceof UploadedFile) {
|
||||||
|
throw new \RuntimeException('템플릿 CSV 파일을 업로드해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholder 검증: {_text_02_}부터 연속인지
|
||||||
|
$idxs = $this->extractTemplateIndexes($message); // [2,3,4...]
|
||||||
|
if (empty($idxs)) {
|
||||||
|
throw new \RuntimeException('문구에 템플릿 토큰({_text_02_}~)이 없습니다.');
|
||||||
|
}
|
||||||
|
if ($idxs[0] !== 2) {
|
||||||
|
throw new \RuntimeException('첫 템플릿 토큰은 {_text_02_}로 시작해야 합니다.');
|
||||||
|
}
|
||||||
|
for ($i=0; $i<count($idxs); $i++) {
|
||||||
|
if ($idxs[$i] !== (2 + $i)) {
|
||||||
|
$need = 2 + $i;
|
||||||
|
throw new \RuntimeException("템플릿 토큰 {_text_0{$need}_} 이(가) 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$needArgs = count($idxs); // 02~ => args 개수
|
||||||
|
$lines = $this->readCsvLines($csv);
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
$invalid = 0;
|
||||||
|
$validPhones = [];
|
||||||
|
$rowsToBuild = [];
|
||||||
|
|
||||||
|
$lineNo = 0;
|
||||||
|
foreach ($lines as $row) {
|
||||||
|
$lineNo++;
|
||||||
|
if (empty($row)) continue;
|
||||||
|
|
||||||
|
$total++;
|
||||||
|
|
||||||
|
$phone = $this->normalizePhone((string)($row[0] ?? ''));
|
||||||
|
if (!$this->isValidKrMobile($phone)) {
|
||||||
|
$invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// args 검증: row[1..needArgs] must exist and not empty
|
||||||
|
for ($i=0; $i<$needArgs; $i++) {
|
||||||
|
$v = (string)($row[$i+1] ?? '');
|
||||||
|
if (trim($v) === '') {
|
||||||
|
throw new \RuntimeException("CSV {$lineNo}라인: {_text_0".(2+$i)."_} 매칭 값이 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsToBuild[] = [$phone, $row]; // 나중에 치환
|
||||||
|
$validPhones[] = $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uniqPhones = array_values(array_unique($validPhones));
|
||||||
|
$dup = count($validPhones) - count($uniqPhones);
|
||||||
|
|
||||||
|
// phone별 첫 등장 row만 사용(중복 번호는 제거)
|
||||||
|
$firstRowByPhone = [];
|
||||||
|
foreach ($rowsToBuild as [$phone, $row]) {
|
||||||
|
if (!isset($firstRowByPhone[$phone])) {
|
||||||
|
$firstRowByPhone[$phone] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$seq = 1;
|
||||||
|
|
||||||
|
foreach ($uniqPhones as $phone) {
|
||||||
|
$row = $firstRowByPhone[$phone];
|
||||||
|
|
||||||
|
$msgFinal = $message;
|
||||||
|
for ($i=0; $i<$needArgs; $i++) {
|
||||||
|
$token = '{_text_0'.(2+$i).'_}';
|
||||||
|
$msgFinal = str_replace($token, (string)$row[$i+1], $msgFinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'seq' => $seq++,
|
||||||
|
'to_number' => $phone,
|
||||||
|
'message_final' => $msgFinal,
|
||||||
|
'sms_type' => $this->guessSmsType($msgFinal),
|
||||||
|
'status' => 'queued',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'valid' => count($items),
|
||||||
|
'invalid' => $invalid,
|
||||||
|
'duplicate' => $dup,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePhone(string $v): string
|
||||||
|
{
|
||||||
|
return preg_replace('/\D+/', '', $v) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isValidKrMobile(string $digits): bool
|
||||||
|
{
|
||||||
|
return (bool) preg_match('/^01\d{8,9}$/', $digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractTemplateIndexes(string $message): array
|
||||||
|
{
|
||||||
|
preg_match_all('/\{_text_(0[2-9])_\}/', $message, $m);
|
||||||
|
$raw = array_unique($m[1] ?? []);
|
||||||
|
$idxs = array_map(fn($s) => (int)$s, $raw);
|
||||||
|
sort($idxs);
|
||||||
|
return $idxs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guessSmsType(string $message): string
|
||||||
|
{
|
||||||
|
// SmsService의 기존 기준을 “대충 맞춰”주는 용도(최종 판정은 provider/서비스)
|
||||||
|
$len = @mb_strlen($message, 'EUC-KR');
|
||||||
|
return ($len !== false && $len > 90) ? 'mms' : 'sms';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcBytesCiLike(string $str): int
|
||||||
|
{
|
||||||
|
// CI/JS 방식: ASCII 1, 그 외 2
|
||||||
|
$bytes = 0;
|
||||||
|
$len = mb_strlen($str, 'UTF-8');
|
||||||
|
for ($i=0; $i<$len; $i++) {
|
||||||
|
$ch = mb_substr($str, $i, 1, 'UTF-8');
|
||||||
|
$ord = unpack('N', mb_convert_encoding($ch, 'UCS-4BE', 'UTF-8'))[1] ?? 0;
|
||||||
|
$bytes += ($ord > 127) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV를 "라인 단위"로 읽어서 배열(row)로 반환
|
||||||
|
* - UTF-8/CP949/EUC-KR 어느 정도 허용
|
||||||
|
*/
|
||||||
|
private function readCsvLines(UploadedFile $csv): \Generator
|
||||||
|
{
|
||||||
|
$path = $csv->getRealPath();
|
||||||
|
if ($path === false) return;
|
||||||
|
|
||||||
|
$fh = fopen($path, 'rb');
|
||||||
|
if (!$fh) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (($row = fgetcsv($fh)) !== false) {
|
||||||
|
if ($row === [null] || $row === false) continue;
|
||||||
|
|
||||||
|
// encoding normalize
|
||||||
|
$out = [];
|
||||||
|
foreach ($row as $cell) {
|
||||||
|
$cell = (string)$cell;
|
||||||
|
$out[] = $this->toUtf8($cell);
|
||||||
|
}
|
||||||
|
yield $out;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toUtf8(string $s): string
|
||||||
|
{
|
||||||
|
// BOM 제거
|
||||||
|
$s = preg_replace('/^\xEF\xBB\xBF/', '', $s) ?? $s;
|
||||||
|
|
||||||
|
// 이미 UTF-8이면 그대로
|
||||||
|
if (mb_check_encoding($s, 'UTF-8')) return $s;
|
||||||
|
|
||||||
|
// CP949/EUC-KR 가능성
|
||||||
|
$converted = @mb_convert_encoding($s, 'UTF-8', 'CP949,EUC-KR,ISO-8859-1');
|
||||||
|
return is_string($converted) ? $converted : $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Services/Admin/Sms/AdminSmsTemplateService.php
Normal file
59
app/Services/Admin/Sms/AdminSmsTemplateService.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Sms;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Sms\AdminSmsTemplateRepository;
|
||||||
|
|
||||||
|
final class AdminSmsTemplateService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminSmsTemplateRepository $repo
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function list(array $filters, int $perPage = 30)
|
||||||
|
{
|
||||||
|
return $this->repo->paginate($filters, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(int $id): ?object
|
||||||
|
{
|
||||||
|
return $this->repo->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(int $adminId, array $data): array
|
||||||
|
{
|
||||||
|
$code = strtolower(trim((string)($data['code'] ?? '')));
|
||||||
|
if ($code === '' || !preg_match('/^[a-z0-9\-_]{3,60}$/', $code)) {
|
||||||
|
return ['ok'=>false, 'message'=>'code는 영문/숫자/대시/언더바 3~60자로 입력하세요.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->repo->insert([
|
||||||
|
'code' => $code,
|
||||||
|
'title' => trim((string)($data['title'] ?? '')),
|
||||||
|
'body' => (string)($data['body'] ?? ''),
|
||||||
|
'description' => trim((string)($data['description'] ?? '')) ?: null,
|
||||||
|
'is_active' => (int)($data['is_active'] ?? 1),
|
||||||
|
'created_by' => $adminId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['ok'=>true, 'id'=>$id];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $data): array
|
||||||
|
{
|
||||||
|
$affected = $this->repo->update($id, [
|
||||||
|
'title' => trim((string)($data['title'] ?? '')),
|
||||||
|
'body' => (string)($data['body'] ?? ''),
|
||||||
|
'description' => trim((string)($data['description'] ?? '')) ?: null,
|
||||||
|
'is_active' => (int)($data['is_active'] ?? 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['ok'=>($affected >= 0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeForSend(int $limit = 200): array
|
||||||
|
{
|
||||||
|
return $this->repo->listActive($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -91,32 +91,24 @@ class SmsService
|
|||||||
private function lguplusSend(array $data): bool
|
private function lguplusSend(array $data): bool
|
||||||
{
|
{
|
||||||
$conn = DB::connection('sms_server');
|
$conn = DB::connection('sms_server');
|
||||||
|
|
||||||
// sms/mms 결정
|
|
||||||
$smsSendType = $this->resolveSendType($data);
|
$smsSendType = $this->resolveSendType($data);
|
||||||
|
|
||||||
return $conn->transaction(function () use ($smsSendType, $data) {
|
return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data) {
|
||||||
|
|
||||||
if ($smsSendType === 'sms') {
|
if ($smsSendType === 'sms') {
|
||||||
// CI의 SC_TRAN insert
|
return $conn->table('SC_TRAN')->insert([
|
||||||
$insert = [
|
|
||||||
'TR_SENDDATE' => now()->format('Y-m-d H:i:s'),
|
'TR_SENDDATE' => now()->format('Y-m-d H:i:s'),
|
||||||
'TR_SENDSTAT' => '0',
|
'TR_SENDSTAT' => '0',
|
||||||
'TR_MSGTYPE' => '0',
|
'TR_MSGTYPE' => '0',
|
||||||
'TR_PHONE' => $data['to_number'],
|
'TR_PHONE' => $data['to_number'],
|
||||||
'TR_CALLBACK' => $data['from_number'],
|
'TR_CALLBACK' => $data['from_number'],
|
||||||
'TR_MSG' => $data['message'],
|
'TR_MSG' => $data['message'],
|
||||||
];
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Eloquent 사용
|
|
||||||
return (bool) ScTran::create($insert);
|
|
||||||
// 또는 Query Builder:
|
|
||||||
// return $conn->table('SC_TRAN')->insert($insert);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// CI의 MMS_MSG insert
|
|
||||||
$subject = $data['subject'] ?? mb_substr($data['message'], 0, 22, 'UTF-8');
|
$subject = $data['subject'] ?? mb_substr($data['message'], 0, 22, 'UTF-8');
|
||||||
|
|
||||||
$insert = [
|
return $conn->table('MMS_MSG')->insert([
|
||||||
'SUBJECT' => $subject,
|
'SUBJECT' => $subject,
|
||||||
'PHONE' => $data['to_number'],
|
'PHONE' => $data['to_number'],
|
||||||
'CALLBACK' => $data['from_number'],
|
'CALLBACK' => $data['from_number'],
|
||||||
@ -126,15 +118,11 @@ class SmsService
|
|||||||
'FILE_CNT' => 0,
|
'FILE_CNT' => 0,
|
||||||
'FILE_PATH1' => '',
|
'FILE_PATH1' => '',
|
||||||
'TYPE' => '0',
|
'TYPE' => '0',
|
||||||
];
|
]);
|
||||||
|
|
||||||
return (bool) MmsMsg::create($insert);
|
|
||||||
// 또는 Query Builder:
|
|
||||||
// return $conn->table('MMS_MSG')->insert($insert);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private function resolveSendType(array $data): string
|
private function resolveSendType(array $data): string
|
||||||
{
|
{
|
||||||
// CI 로직과 동일한 우선순위 유지
|
// CI 로직과 동일한 우선순위 유지
|
||||||
|
|||||||
@ -127,3 +127,6 @@
|
|||||||
window.__adminFlash = [];
|
window.__adminFlash = [];
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +1,46 @@
|
|||||||
@extends('admin.layouts.app')
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
@section('title', '관리자 계정 등록')
|
@section('title', '관리자 계정 등록')
|
||||||
|
@section('page_title', '관리자 계정 등록')
|
||||||
|
@section('page_desc', '임시 비밀번호 생성 후 다음 로그인 시 변경이 강제됩니다.')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* admins create only */
|
||||||
|
.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:760px;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<form method="POST" action="{{ route('admin.admins.store') }}"
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
|
|
||||||
@csrf
|
|
||||||
|
|
||||||
<div class="a-panel">
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:900; font-size:16px;">관리자 계정 등록</div>
|
<div style="font-weight:900; font-size:16px;">관리자 계정 등록</div>
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:4px;">
|
<div class="a-muted" style="font-size:12px; margin-top:4px;">
|
||||||
비밀번호는 입력하지 않습니다. 서버에서 임시 비밀번호를 생성하며, 다음 로그인 시 변경이 강제됩니다.
|
비밀번호는 입력하지 않습니다. 서버에서 임시 비밀번호 생성 → 다음 로그인 시 변경 강제
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="a-btn a-btn--ghost a-btn--sm"
|
<a class="lbtn lbtn--ghost lbtn--sm"
|
||||||
href="{{ route('admin.admins.index', $filters ?? []) }}"
|
href="{{ route('admin.admins.index', $filters ?? []) }}">
|
||||||
style="width:auto;">
|
← 목록
|
||||||
목록
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height:12px;"></div>
|
<form method="POST" action="{{ route('admin.admins.store') }}"
|
||||||
|
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<div class="a-panel">
|
<div class="a-card wrap" style="padding:16px;">
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">이메일</label>
|
<label class="a-label">이메일</label>
|
||||||
<input class="a-input" name="email" value="{{ old('email') }}" placeholder="admin@example.com" autocomplete="off">
|
<input class="a-input" name="email" value="{{ old('email') }}" placeholder="admin@example.com" autocomplete="off">
|
||||||
@ -64,9 +78,10 @@
|
|||||||
@error('role') <div class="a-error">{{ $message }}</div> @enderror
|
@error('role') <div class="a-error">{{ $message }}</div> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="margin-top:14px;">
|
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:14px;">
|
||||||
등록
|
<button class="lbtn lbtn--primary lbtn--wide" type="submit">등록</button>
|
||||||
</button>
|
<a class="lbtn lbtn--ghost" href="{{ route('admin.admins.index', $filters ?? []) }}">취소</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -1,6 +1,43 @@
|
|||||||
@extends('admin.layouts.app')
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
@section('title', '관리자 정보 수정')
|
@section('title', '관리자 정보 수정')
|
||||||
|
@section('page_title', '관리자 정보 수정')
|
||||||
|
@section('page_desc', '기본정보/상태/2FA/역할을 관리합니다.')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* admins edit only */
|
||||||
|
.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;}
|
||||||
|
.kvgrid{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||||
|
@media (min-width: 980px){ .kvgrid{grid-template-columns:1fr 1fr 1fr;} }
|
||||||
|
.kv{padding:14px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);}
|
||||||
|
.kv .k{font-size:12px;opacity:.8;margin-bottom:6px;}
|
||||||
|
.kv .v{font-weight:900;}
|
||||||
|
.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;}
|
||||||
|
.checks{display:flex;flex-wrap:wrap;gap:10px;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php
|
@php
|
||||||
@ -8,35 +45,108 @@
|
|||||||
|
|
||||||
$lockedUntil = $admin->locked_until ?? null;
|
$lockedUntil = $admin->locked_until ?? null;
|
||||||
$isLocked = false;
|
$isLocked = false;
|
||||||
|
|
||||||
if (!empty($lockedUntil)) {
|
if (!empty($lockedUntil)) {
|
||||||
try {
|
try { $isLocked = \Carbon\Carbon::parse($lockedUntil)->isFuture(); }
|
||||||
$isLocked = \Carbon\Carbon::parse($lockedUntil)->isFuture();
|
catch (\Throwable $e) { $isLocked = true; }
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$isLocked = true; // 파싱 실패 시 보수적으로 잠김 처리
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$lockLabel = (string)($admin->locked_until ?? '');
|
|
||||||
$lockLabelLabel = $lockLabel === "" ? '계정정상' : '계정잠금';
|
|
||||||
$lockLabelColor = $lockLabel === "" ? '#2b7fff' : '#ff4d4f';
|
|
||||||
|
|
||||||
$st = (string)($admin->status ?? 'active');
|
$st = (string)($admin->status ?? 'active');
|
||||||
$statusLabel = $st === 'active' ? '활성' : '비활성';
|
$statusLabel = $st === 'active' ? '활성' : '비활성';
|
||||||
$statusColor = $st === 'active' ? '#2b7fff' : '#ff4d4f';
|
$statusPill = $st === 'active' ? 'pill--ok' : 'pill--bad';
|
||||||
|
|
||||||
|
$hasSecret = !empty($admin->totp_secret_enc);
|
||||||
|
$isModeOtp = (int)($admin->totp_enabled ?? 0) === 1;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
{{-- ===== 상단 정보 패널 ===== --}}
|
{{-- 요약 카드 --}}
|
||||||
<div class="a-kvgrid">
|
<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 class="a-kv">
|
<div>
|
||||||
<div class="a-kv__k">관리자번호/이메일</div>
|
<div style="font-weight:900; font-size:16px;">관리자 정보 수정</div>
|
||||||
<div class="a-kv__v a-mono">{{ $admin->id ?? '-' }} / {{ $admin->email ?? '-' }}</div>
|
<div class="a-muted" style="font-size:12px; margin-top:4px;">
|
||||||
|
#{{ $admin->id ?? '-' }} / {{ $admin->email ?? '-' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-kv">
|
<a class="lbtn lbtn--ghost lbtn--sm"
|
||||||
<div class="a-kv__k">현재 역할</div>
|
href="{{ route('admin.admins.index', request()->only(['q','status','page'])) }}">
|
||||||
<div class="a-kv__v">
|
← 목록
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- KV 그리드 --}}
|
||||||
|
<div class="kvgrid" style="margin-bottom:16px;">
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">상태</div>
|
||||||
|
<div class="v">
|
||||||
|
<span class="pill {{ $statusPill }}">● {{ $statusLabel }}</span>
|
||||||
|
@if($st !== 'active')
|
||||||
|
<span class="a-muted" style="margin-left:6px; font-size:12px;">(로그인 불가)</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">계정 잠금</div>
|
||||||
|
<div class="v">
|
||||||
|
@if($isLocked)
|
||||||
|
<span class="pill pill--bad">● 잠김</span>
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-top:6px;">{{ $admin->locked_until }}</div>
|
||||||
|
@else
|
||||||
|
<span class="pill pill--ok">● 정상</span>
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-top:6px;">※ 3회 실패 시 잠김</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">로그인 실패 횟수</div>
|
||||||
|
<div class="v">{{ (int)($admin->failed_login_count ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">최근 로그인 IP</div>
|
||||||
|
<div class="v"><span class="mono">{{ $ip }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">최근 로그인 시간</div>
|
||||||
|
<div class="v">{{ $admin->last_login_at ?? '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">2FA</div>
|
||||||
|
<div class="v">
|
||||||
|
@if($hasSecret)
|
||||||
|
<span class="pill pill--ok">OTP 등록됨</span>
|
||||||
|
@else
|
||||||
|
<span class="pill pill--muted">OTP 미등록</span>
|
||||||
|
@endif
|
||||||
|
<span class="a-muted" style="margin-left:6px; font-size:12px;">
|
||||||
|
현재모드: <b>{{ $isModeOtp ? 'OTP' : 'SMS' }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">생성</div>
|
||||||
|
<div class="v">{{ $admin->created_at ?? '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">최근 수정</div>
|
||||||
|
<div class="v">{{ $admin->updated_at ?? '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">비활성 처리자</div>
|
||||||
|
<div class="v">{{ $admin->deleted_by ?? '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div class="k">현재 역할</div>
|
||||||
|
<div class="v">
|
||||||
@forelse($roles as $rr)
|
@forelse($roles as $rr)
|
||||||
<span class="a-chip">{{ $rr['name'] ?? ($rr['code'] ?? '-') }}</span>
|
<span class="a-chip">{{ $rr['name'] ?? ($rr['code'] ?? '-') }}</span>
|
||||||
@empty
|
@empty
|
||||||
@ -44,68 +154,17 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">상태</div>
|
|
||||||
<div class="a-kv__v">
|
|
||||||
<span style="font-weight:900; color:{{ $statusColor }};">{{ $statusLabel }}</span>
|
|
||||||
@if($st !== 'active')
|
|
||||||
<span class="a-muted" style="margin-left:6px;">(관리자 로그인 불가)</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-kv">
|
{{-- 수정 폼 --}}
|
||||||
<div class="a-kv__k">계정상태</div>
|
|
||||||
<div class="a-kv__v">
|
|
||||||
<span style="font-weight:900; color:{{ $lockLabelColor }};">{{ $lockLabelLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:10px;">
|
|
||||||
※ 로그인 비밀번호 3회 연속 실패 시 계정이 잠깁니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">로그인 실패 횟수</div>
|
|
||||||
<div class="a-kv__v">{{ (int)($admin->failed_login_count ?? 0) }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">마지막 로그인 아이피</div>
|
|
||||||
<div class="a-kv__v a-mono">{{ $ip }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">마지막 로그인 시간</div>
|
|
||||||
<div class="a-kv__v">{{ $admin->last_login_at ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">관리자 생성 일시</div>
|
|
||||||
<div class="a-kv__v">{{ $admin->created_at ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">최근 정보수정일</div>
|
|
||||||
<div class="a-kv__v">{{ $admin->updated_at ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="a-kv">
|
|
||||||
<div class="a-kv__k">비활성화 처리자</div>
|
|
||||||
<div class="a-kv__v">{{ $admin->deleted_by ?? '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height:12px;"></div>
|
|
||||||
|
|
||||||
{{-- ===== 수정 폼(단 하나) ===== --}}
|
|
||||||
<form id="adminEditForm"
|
<form id="adminEditForm"
|
||||||
method="POST"
|
method="POST"
|
||||||
action="{{ route('admin.admins.update', ['id'=>$admin->id]) }}"
|
action="{{ route('admin.admins.update', ['id'=>$admin->id]) }}"
|
||||||
onsubmit="this.querySelector('button[type=submit][data-submit=save]')?.setAttribute('disabled','disabled');">
|
onsubmit="this.querySelector('button[type=submit][data-submit=save]')?.setAttribute('disabled','disabled');">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="a-panel">
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="display:grid; grid-template-columns:1fr; gap:12px; max-width:880px;">
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">닉네임</label>
|
<label class="a-label">닉네임</label>
|
||||||
<input class="a-input" name="nickname" value="{{ old('nickname', $admin->nickname ?? '') }}">
|
<input class="a-input" name="nickname" value="{{ old('nickname', $admin->nickname ?? '') }}">
|
||||||
@ -117,7 +176,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">휴대폰(01055558888 숫자만 등록하세요)</label>
|
<label class="a-label">휴대폰 (숫자만 10~11자리)</label>
|
||||||
<input class="a-input" name="phone" value="{{ old('phone', $phone ?? '') }}" placeholder="01012345678">
|
<input class="a-input" name="phone" value="{{ old('phone', $phone ?? '') }}" placeholder="01012345678">
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:6px;">
|
<div class="a-muted" style="font-size:12px; margin-top:6px;">
|
||||||
※ 저장 시 phone_hash + phone_enc 갱신
|
※ 저장 시 phone_hash + phone_enc 갱신
|
||||||
@ -127,19 +186,19 @@
|
|||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">상태</label>
|
<label class="a-label">상태</label>
|
||||||
<select class="a-input" name="status">
|
<select class="a-input" name="status">
|
||||||
@php $st = old('status', $admin->status ?? 'active'); @endphp
|
@php $stSel = old('status', $admin->status ?? 'active'); @endphp
|
||||||
<option value="active" {{ $st==='active'?'selected':'' }}>활성</option>
|
<option value="active" {{ $stSel==='active'?'selected':'' }}>활성</option>
|
||||||
<option value="blocked" {{ $st==='blocked'?'selected':'' }}>비활성</option>
|
<option value="blocked" {{ $stSel==='blocked'?'selected':'' }}>비활성</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label">
|
<label class="a-label">
|
||||||
2차 인증방법
|
2차 인증방법
|
||||||
@if(!empty($admin->totp_secret_enc))
|
@if($hasSecret)
|
||||||
<span class="a-pill a-pill--ok">Google OTP 등록</span>
|
<span class="pill pill--ok" style="margin-left:6px;">OTP 등록</span>
|
||||||
@else
|
@else
|
||||||
<span class="a-pill a-pill--muted">Google OTP 미등록</span>
|
<span class="pill pill--muted" style="margin-left:6px;">OTP 미등록</span>
|
||||||
@endif
|
@endif
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -147,13 +206,13 @@
|
|||||||
<option value="0" {{ (int)old('totp_enabled', $admin->totp_enabled ?? 0)===0 ? 'selected' : '' }}>SMS 인증</option>
|
<option value="0" {{ (int)old('totp_enabled', $admin->totp_enabled ?? 0)===0 ? 'selected' : '' }}>SMS 인증</option>
|
||||||
<option value="1"
|
<option value="1"
|
||||||
{{ (int)old('totp_enabled', $admin->totp_enabled ?? 0)===1 ? 'selected' : '' }}
|
{{ (int)old('totp_enabled', $admin->totp_enabled ?? 0)===1 ? 'selected' : '' }}
|
||||||
{{ empty($admin->totp_secret_enc) ? 'disabled' : '' }}
|
{{ !$hasSecret ? 'disabled' : '' }}
|
||||||
>Google OTP 인증</option>
|
>Google OTP 인증</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@if(empty($admin->totp_secret_enc))
|
@if(!$hasSecret)
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:6px;">
|
<div class="a-muted" style="font-size:12px; margin-top:6px;">
|
||||||
※ Google OTP 미등록 상태라 선택할 수 없습니다. (등록은 ‘내 정보’에서만 가능)
|
※ OTP 미등록 상태라 선택할 수 없습니다. (등록은 ‘내 정보’에서만 가능)
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@ -162,7 +221,7 @@
|
|||||||
<label class="a-label">역할(Role)</label>
|
<label class="a-label">역할(Role)</label>
|
||||||
<div class="a-muted" style="font-size:12px; margin-bottom:8px;">여러 개 선택 가능</div>
|
<div class="a-muted" style="font-size:12px; margin-bottom:8px;">여러 개 선택 가능</div>
|
||||||
|
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:10px;">
|
<div class="checks">
|
||||||
@foreach($allRoles as $r)
|
@foreach($allRoles as $r)
|
||||||
@php $rid = (int)$r['id']; @endphp
|
@php $rid = (int)$r['id']; @endphp
|
||||||
<label class="a-check" style="margin:0;">
|
<label class="a-check" style="margin:0;">
|
||||||
@ -174,50 +233,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{-- ===== 하단 액션바(폼 밖) ===== --}}
|
{{-- 하단 액션바 --}}
|
||||||
<div class="a-actions">
|
<div class="actions">
|
||||||
<a class="a-btn a-btn--white a-btn--sm"
|
<a class="lbtn lbtn--ghost"
|
||||||
href="{{ route('admin.admins.index', request()->only(['q','status','page'])) }}"
|
href="{{ route('admin.admins.index', request()->only(['q','status','page'])) }}">
|
||||||
style="width:auto;">
|
← 뒤로가기
|
||||||
뒤로가기
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="a-actions__right">
|
<div class="actions__right">
|
||||||
@if($lockLabel)
|
@if(!empty($admin->locked_until))
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ route('admin.admins.unlock', $admin->id) }}"
|
action="{{ route('admin.admins.unlock', $admin->id) }}"
|
||||||
style="display:inline;"
|
style="display:inline;"
|
||||||
data-confirm="이 계정의 잠금을 해제할까요? (locked_until 초기화 + 실패횟수 0)"
|
data-confirm="이 계정의 잠금을 해제할까요? (locked_until 초기화 + 실패횟수 0)"
|
||||||
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');"
|
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
|
||||||
>
|
|
||||||
@csrf
|
@csrf
|
||||||
<button class="a-btn a-btn--dangerSolid a-btn--sm" type="submit" style="width:auto;">
|
<button class="lbtn lbtn--danger" type="submit">잠금해제</button>
|
||||||
잠금해제
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ route('admin.admins.reset_password', $admin->id) }}"
|
action="{{ route('admin.admins.reset_password', $admin->id) }}"
|
||||||
style="display:inline;"
|
style="display:inline;"
|
||||||
data-confirm="비밀번호를 초기화할까요? 임시 비밀번호는 이메일로 설정됩니다. (다음 로그인 시 변경 강제)"
|
data-confirm="비밀번호를 초기화할까요? 임시 비밀번호는 이메일로 설정됩니다. (다음 로그인 시 변경 강제)"
|
||||||
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');"
|
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
|
||||||
>
|
|
||||||
@csrf
|
@csrf
|
||||||
<button class="a-btn a-btn--dangerSolid a-btn--sm" type="submit" style="width:auto;">
|
<button class="lbtn lbtn--danger" type="submit">비밀번호 초기화</button>
|
||||||
비밀번호 초기화
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button class="a-btn a-btn--primary a-btn--sm"
|
<button class="lbtn lbtn--primary lbtn--wide"
|
||||||
form="adminEditForm"
|
form="adminEditForm"
|
||||||
type="submit"
|
type="submit"
|
||||||
data-submit="save"
|
data-submit="save">
|
||||||
style="width:auto;
|
|
||||||
">
|
|
||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,47 +1,98 @@
|
|||||||
@extends('admin.layouts.app')
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
@section('title', '관리자 계정 관리')
|
@section('title', '관리자 계정 관리')
|
||||||
|
@section('page_title', '관리자 계정 관리')
|
||||||
|
@section('page_desc', '계정/2FA/최근로그인/역할 정보를 관리합니다.')
|
||||||
@section('content_class', 'a-content--full')
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* admins index only */
|
||||||
|
.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:210px;}
|
||||||
|
.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;}
|
||||||
|
.clip{max-width:230px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.muted12{font-size:12px;}
|
||||||
|
.row-disabled{opacity:.65;}
|
||||||
|
.table td{vertical-align:top;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="a-panel">
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
|
<div class="bar">
|
||||||
<div>
|
<div class="bar__left">
|
||||||
<div style="font-weight:900; font-size:16px;">관리자 계정 관리</div>
|
<div class="t">관리자 계정 관리</div>
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:4px;">계정/2FA/최근로그인/역할 정보를 관리합니다.</div>
|
<div class="a-muted d">계정/2FA/최근로그인/역할 정보를 관리합니다.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
<div class="bar__right">
|
||||||
<a class="a-btn a-btn--ghost a-btn--sm"
|
<a class="lbtn lbtn--primary lbtn--wide"
|
||||||
href="{{ route('admin.admins.create', request()->only(['q','status','page'])) }}"
|
href="{{ route('admin.admins.create', request()->only(['q','status','page'])) }}">
|
||||||
style="width:auto; padding:12px 14px;">
|
+ 관리자 등록
|
||||||
관리자 등록
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<form method="GET" action="{{ route('admin.admins.index') }}" style="display:flex; gap:8px; flex-wrap:wrap;">
|
<form method="GET" action="{{ route('admin.admins.index') }}" class="filters">
|
||||||
<input class="a-input" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="이메일/성명/닉네임 검색" style="width:240px;">
|
<div>
|
||||||
<select class="a-input" name="status" style="width:160px;">
|
<input class="a-input q" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="이메일/성명/닉네임">
|
||||||
<option value="">상태(전체)</option>
|
</div>
|
||||||
<option value="active" {{ (($filters['status'] ?? '')==='active')?'selected':'' }}>활성 관리자</option>
|
|
||||||
<option value="blocked" {{ (($filters['status'] ?? '')==='blocked')?'selected':'' }}>비활성 관리자</option>
|
<div>
|
||||||
|
<select class="a-input st" name="status">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="active" {{ (($filters['status'] ?? '')==='active')?'selected':'' }}>활성</option>
|
||||||
|
<option value="blocked" {{ (($filters['status'] ?? '')==='blocked')?'selected':'' }}>비활성</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="width:auto; padding:12px 14px;">검색</button>
|
</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.admins.index') }}">초기화</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="height:12px;"></div>
|
|
||||||
|
|
||||||
<table class="a-table a-table--admins">
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:10px;">총 <b>{{ $page->total() }}</b>명</div>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table table" style="width:100%; min-width:1200px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:80px;">ID</th>
|
<th style="width:70px;">ID</th>
|
||||||
<th>닉네임</th>
|
<th style="width:150px;">닉네임</th>
|
||||||
<th>성명</th>
|
<th style="width:120px;">성명</th>
|
||||||
<th>이메일</th>
|
<th style="width:230px;">이메일</th>
|
||||||
<th style="width:120px;">상태</th>
|
<th style="width:110px;">상태</th>
|
||||||
<th style="width:120px;">잠금</th>
|
<th style="width:120px;">잠금</th>
|
||||||
<th style="width:120px;">2FA 모드</th>
|
<th style="width:120px;">2FA 모드</th>
|
||||||
<th style="width:110px;">TOTP</th>
|
<th style="width:120px;">TOTP</th>
|
||||||
<th>역할</th>
|
<th>역할</th>
|
||||||
<th style="width:170px;">최근 로그인</th>
|
<th style="width:170px;">최근 로그인</th>
|
||||||
<th style="width:90px;">관리</th>
|
<th style="width:90px;">관리</th>
|
||||||
@ -52,33 +103,46 @@
|
|||||||
@php
|
@php
|
||||||
$uid = (int)$u->id;
|
$uid = (int)$u->id;
|
||||||
$roles = $roleMap[$uid] ?? [];
|
$roles = $roleMap[$uid] ?? [];
|
||||||
$pc = $permCnt[$uid] ?? 0;
|
$st = (string)($u->status ?? '');
|
||||||
|
$isLocked = !empty($u->locked_until);
|
||||||
|
|
||||||
|
$stLabel = $st === 'active' ? '활성' : ($st === 'blocked' ? '비활성' : ($st ?: '-'));
|
||||||
|
$stPill = $st === 'active' ? 'pill--ok' : ($st === 'blocked' ? 'pill--bad' : 'pill--muted');
|
||||||
|
|
||||||
|
$twofa = (string)($u->two_factor_mode ?? 'sms');
|
||||||
|
$twofaLabel = ($twofa === 'totp' || $twofa === 'otp') ? 'OTP' : 'SMS';
|
||||||
|
|
||||||
|
$totpOn = (int)($u->totp_enabled ?? 0) === 1;
|
||||||
@endphp
|
@endphp
|
||||||
<tr class="{{ (($u->status ?? '') === 'blocked') ? 'is-disabled' : '' }}">
|
|
||||||
<td class="a-td--muted">{{ $uid }}</td>
|
<tr class="{{ $st === 'blocked' ? 'row-disabled' : '' }}">
|
||||||
|
<td class="a-muted">{{ $uid }}</td>
|
||||||
<td>{{ $u->nickname ?? '-' }}</td>
|
<td>{{ $u->nickname ?? '-' }}</td>
|
||||||
<td>{{ $u->name ?? '-' }}</td>
|
<td>{{ $u->name ?? '-' }}</td>
|
||||||
<td>{{ $u->email ?? '-' }}</td>
|
<td><span class="mono">{{ $u->email ?? '-' }}</span></td>
|
||||||
@php
|
|
||||||
$st = (string)($u->status ?? '');
|
<td>
|
||||||
$stLabel = $st === 'active' ? '활성' : ($st === 'blocked' ? '비활성' : ($st ?: '-'));
|
<span class="pill {{ $stPill }}">● {{ $stLabel }}</span>
|
||||||
@endphp
|
|
||||||
<td class="a-td--status {{ $st === 'blocked' ? 'is-bad' : '' }}">
|
|
||||||
{{ $stLabel }}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@php
|
|
||||||
$isLocked = !empty($u->locked_until);
|
|
||||||
@endphp
|
|
||||||
<td style="font-weight:800;">
|
<td style="font-weight:800;">
|
||||||
@if($isLocked)
|
@if($isLocked)
|
||||||
<span style="color:#ff4d4f;">계정잠금</span>
|
<span class="pill pill--bad">● 잠김</span>
|
||||||
@else
|
@else
|
||||||
<span style="color:rgba(255,255,255,.80);">계정정상</span>
|
<span class="pill pill--ok">● 정상</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>{{ $u->two_factor_mode ?? 'sms' }}</td>
|
|
||||||
<td>{{ (int)($u->totp_enabled ?? 0) === 1 ? 'On' : 'Off' }}</td>
|
<td><span class="mono">{{ $twofaLabel }}</span></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($totpOn)
|
||||||
|
<span class="pill pill--ok">On</span>
|
||||||
|
@else
|
||||||
|
<span class="pill pill--muted">Off</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
@forelse($roles as $r)
|
@forelse($roles as $r)
|
||||||
<span class="a-chip">{{ $r['name'] ?? $r['code'] }}</span>
|
<span class="a-chip">{{ $r['name'] ?? $r['code'] }}</span>
|
||||||
@ -86,10 +150,14 @@
|
|||||||
<span class="a-muted">-</span>
|
<span class="a-muted">-</span>
|
||||||
@endforelse
|
@endforelse
|
||||||
</td>
|
</td>
|
||||||
<td>{{ $u->last_login_at ?? '-' }}</td>
|
|
||||||
<td>
|
<td class="a-muted">{{ $u->last_login_at ?? '-' }}</td>
|
||||||
<a class="a-btn a-btn--ghost a-btn--sm" style="width:auto; padding:8px 10px;"
|
|
||||||
href="{{ route('admin.admins.edit', ['id'=>$uid]) }}">보기</a>
|
<td style="text-align:right;">
|
||||||
|
<a class="lbtn lbtn--ghost lbtn--sm"
|
||||||
|
href="{{ route('admin.admins.edit', ['id'=>$uid]) }}">
|
||||||
|
보기
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
@ -97,8 +165,10 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:14px;">
|
<div style="margin-top:14px;">
|
||||||
{{ $page->links() }}
|
{{ $page->links() }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -4,43 +4,81 @@
|
|||||||
@section('page_title', '비밀번호 변경')
|
@section('page_title', '비밀번호 변경')
|
||||||
@section('page_desc', '현재 비밀번호 확인 후 변경')
|
@section('page_desc', '현재 비밀번호 확인 후 변경')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* password page only */
|
||||||
|
.pw-wrap{max-width:560px;}
|
||||||
|
.pw-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:12px;}
|
||||||
|
.pw-head__title{font-weight:900;font-size:16px;}
|
||||||
|
.pw-head__desc{margin-top:4px;font-size:12px;}
|
||||||
|
|
||||||
|
.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--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--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
|
||||||
|
|
||||||
|
.hint{margin-top:10px;font-size:12px;line-height:1.6;}
|
||||||
|
.divider{margin:14px 0;opacity:.15;}
|
||||||
|
.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<section class="a-page">
|
<section class="a-page">
|
||||||
<article class="a-card" style="max-width:560px;">
|
<article class="a-card pw-wrap" style="padding:16px;">
|
||||||
<div class="a-card__head">
|
<div class="pw-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="a-card__title">비밀번호 변경</div>
|
<div class="pw-head__title">비밀번호 변경</div>
|
||||||
<div class="a-card__desc a-muted">변경 시 감사로그가 기록됩니다.</div>
|
<div class="pw-head__desc a-muted">변경 시 감사로그가 기록됩니다.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="pill pill--warn">보안</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.me.password.update') }}" class="a-form" onsubmit="this.querySelector('button[type=submit]').disabled=true;">
|
<form method="POST"
|
||||||
|
action="{{ route('admin.me.password.update') }}"
|
||||||
|
class="a-form"
|
||||||
|
onsubmit="this.querySelector('button[type=submit]').disabled=true;">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label" for="current_password">현재 비밀번호</label>
|
<label class="a-label" for="current_password">현재 비밀번호</label>
|
||||||
<input class="a-input" id="current_password" name="current_password" type="password" autocomplete="current-password">
|
<input class="a-input" id="current_password" name="current_password"
|
||||||
|
type="password" autocomplete="current-password" autofocus>
|
||||||
@error('current_password')<div class="a-error">{{ $message }}</div>@enderror
|
@error('current_password')<div class="a-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label" for="password">새 비밀번호</label>
|
<label class="a-label" for="password">새 비밀번호</label>
|
||||||
<input class="a-input" id="password" name="password" type="password" autocomplete="new-password">
|
<input class="a-input" id="password" name="password"
|
||||||
|
type="password" autocomplete="new-password">
|
||||||
@error('password')<div class="a-error">{{ $message }}</div>@enderror
|
@error('password')<div class="a-error">{{ $message }}</div>@enderror
|
||||||
|
<div class="a-muted hint">* 영문/숫자/특수문자 조합 권장</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label" for="password_confirmation">새 비밀번호 확인</label>
|
<label class="a-label" for="password_confirmation">새 비밀번호 확인</label>
|
||||||
<input class="a-input" id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password">
|
<input class="a-input" id="password_confirmation" name="password_confirmation"
|
||||||
|
type="password" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="margin-top:12px;">
|
<hr class="divider">
|
||||||
변경
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a class="a-link" href="{{ route('admin.me') }}" style="display:inline-block; margin-top:12px;">
|
<div class="actions">
|
||||||
← 내정보로
|
<button class="lbtn lbtn--primary lbtn--wide" type="submit">변경</button>
|
||||||
</a>
|
<a class="lbtn lbtn--ghost" href="{{ route('admin.me') }}">← 내 정보로</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted hint">
|
||||||
|
* 비밀번호 변경 후 다음 로그인부터 새 비밀번호가 적용됩니다.<br>
|
||||||
|
* OTP/SMS 2FA와 별개로 비밀번호 자체는 주기적으로 변경하는 것을 권장합니다.
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,67 +1,131 @@
|
|||||||
@extends('admin.layouts.app')
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
@section('title', '보안 설정 (2차 인증)')
|
@section('title', '보안 설정 (2차 인증)')
|
||||||
|
@section('page_title', '보안 설정')
|
||||||
|
@section('page_desc', '2차 인증(SMS / Google OTP) 관리')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* security page only (match sms/logs tone) */
|
||||||
|
.sec-wrap{display:flex;flex-direction:column;gap:12px;}
|
||||||
|
.sec-card{padding:16px;}
|
||||||
|
.sec-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;}
|
||||||
|
.sec-head__t{font-weight:900;font-size:16px;}
|
||||||
|
.sec-head__d{margin-top:4px;font-size:12px;line-height:1.55;}
|
||||||
|
|
||||||
|
.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--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
|
||||||
|
.pill--muted{opacity:.9}
|
||||||
|
|
||||||
|
.mono{padding:6px 10px;border-radius:12px;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:13px;}
|
||||||
|
.muted12{font-size:12px;}
|
||||||
|
.divider{margin:14px 0;opacity:.15;}
|
||||||
|
|
||||||
|
.sec-grid{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||||
|
@media (min-width: 980px){ .sec-grid{grid-template-columns:1fr 1fr;} }
|
||||||
|
|
||||||
|
.help{font-size:13px;line-height:1.65;}
|
||||||
|
.help ol{margin:0;padding-left:18px;}
|
||||||
|
.help li{margin:4px 0;}
|
||||||
|
.help code{padding:2px 6px;border-radius:8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);}
|
||||||
|
|
||||||
|
.mode-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
|
||||||
|
.mode-row__label{font-size:13px;}
|
||||||
|
.mode-row select{width:200px;}
|
||||||
|
.hintline{margin-top:8px;font-size:12px;line-height:1.5;}
|
||||||
|
.qrbox{background:#fff;border-radius:14px;padding:12px;display:inline-block;}
|
||||||
|
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
|
||||||
|
.otp{max-width:220px;}
|
||||||
|
.otp input{font-size:16px;font-weight:900;letter-spacing:.08em;text-align:center;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="a-panel">
|
<section class="sec-wrap">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
|
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="a-card sec-card">
|
||||||
|
<div class="sec-head">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:900; font-size:16px;">2차 인증 (Google OTP)</div>
|
<div class="sec-head__t">2차 인증 (SMS / Google OTP)</div>
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:4px;">
|
<div class="a-muted sec-head__d">
|
||||||
SMS 또는 Google OTP(TOTP)로 2차 인증을 진행합니다.
|
SMS 또는 Google OTP(TOTP)로 2차 인증을 진행합니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="a-btn a-btn--ghost a-btn--sm" href="{{ route('admin.me') }}" style="width:auto;">뒤로가기</a>
|
<a class="lbtn lbtn--ghost lbtn--sm" href="{{ route('admin.me') }}">← 뒤로가기</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height:12px;"></div>
|
|
||||||
|
|
||||||
{{-- 이용 안내 --}}
|
{{-- 이용 안내 --}}
|
||||||
<div class="a-panel">
|
<div class="a-card sec-card">
|
||||||
<div style="font-weight:900; margin-bottom:6px;">이용 안내</div>
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||||
<div class="a-muted" style="font-size:13px; line-height:1.55;">
|
<div style="font-weight:900;">이용 안내</div>
|
||||||
1) Google Authenticator / Microsoft Authenticator 등 앱 설치<br>
|
<span class="pill pill--muted">도움말</span>
|
||||||
2) 등록 시작 → QR 스캔(또는 시크릿 수동 입력)<br>
|
|
||||||
3) 앱에 표시되는 6자리 코드를 입력해 등록 완료<br>
|
|
||||||
4) 필요 시 재등록(새 시크릿 발급) 또는 삭제 가능
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height:12px;"></div>
|
<div class="a-muted help" style="margin-top:10px;">
|
||||||
|
<ol>
|
||||||
|
<li>Google Authenticator / Microsoft Authenticator 등 OTP 앱을 설치합니다.</li>
|
||||||
|
<li><b>등록 시작</b> 버튼을 눌러 QR을 생성합니다.</li>
|
||||||
|
<li>앱에서 QR을 스캔하거나, 시크릿을 수동으로 입력합니다.</li>
|
||||||
|
<li>앱에 표시되는 <b>6자리 코드</b>를 입력하면 등록이 완료됩니다.</li>
|
||||||
|
<li>등록 완료 후에만 <b>Google OTP 인증</b>으로 전환할 수 있습니다.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="hintline">
|
||||||
|
* OTP는 <b>30초</b>마다 바뀌므로, 시간이 지나면 새 코드로 다시 입력하세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- 상태/모드 --}}
|
{{-- 상태/모드 --}}
|
||||||
<div class="a-panel">
|
<div class="a-card sec-card">
|
||||||
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
|
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:flex-start;">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:900;">현재 상태</div>
|
<div style="font-weight:900;">현재 상태</div>
|
||||||
<div style="margin-top:6px;">
|
<div style="margin-top:8px;">
|
||||||
@if($isRegistered)
|
@if($isRegistered)
|
||||||
<span class="a-pill a-pill--ok">Google OTP 등록됨</span>
|
<span class="pill pill--ok">Google OTP 등록됨</span>
|
||||||
<span class="a-muted" style="font-size:12px; margin-left:6px;">({{ $admin->totp_verified_at }})</span>
|
<span class="a-muted muted12" style="margin-left:6px;">({{ $admin->totp_verified_at }})</span>
|
||||||
@elseif($isPending)
|
@elseif($isPending)
|
||||||
<span class="a-pill a-pill--warn">등록 진행 중</span>
|
<span class="pill pill--warn">등록 진행 중</span>
|
||||||
|
<span class="a-muted muted12" style="margin-left:6px;">(QR 스캔 후 코드 확인)</span>
|
||||||
@else
|
@else
|
||||||
<span class="a-pill a-pill--muted">미등록</span>
|
<span class="pill pill--muted">미등록</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.totp.mode') }}" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
<form method="POST" action="{{ route('admin.totp.mode') }}" class="mode-row">
|
||||||
@csrf
|
@csrf
|
||||||
<label class="a-muted" style="font-size:13px;">2차 인증방법</label>
|
<span class="a-muted mode-row__label">2차 인증방법</span>
|
||||||
<select class="a-input a-input--sm" name="totp_enabled" style="width:180px;">
|
|
||||||
|
<select class="a-input a-input--sm" name="totp_enabled">
|
||||||
<option value="0" {{ (int)($admin->totp_enabled ?? 0) === 0 ? 'selected' : '' }}>SMS 인증</option>
|
<option value="0" {{ (int)($admin->totp_enabled ?? 0) === 0 ? 'selected' : '' }}>SMS 인증</option>
|
||||||
<option value="1"
|
<option value="1"
|
||||||
{{ (int)($admin->totp_enabled ?? 0) === 1 ? 'selected' : '' }}
|
{{ (int)($admin->totp_enabled ?? 0) === 1 ? 'selected' : '' }}
|
||||||
{{ !$isRegistered ? 'disabled' : '' }}
|
{{ !$isRegistered ? 'disabled' : '' }}
|
||||||
>Google OTP 인증</option>
|
>Google OTP 인증</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="a-btn a-btn--primary a-btn--sm" type="submit" style="width:auto;">저장</button>
|
|
||||||
|
<button class="lbtn lbtn--primary lbtn--sm" type="submit">저장</button>
|
||||||
|
|
||||||
@if(!$isRegistered)
|
@if(!$isRegistered)
|
||||||
<div class="a-muted" style="font-size:12px; width:100%;">
|
<div class="a-muted muted12" style="width:100%;">
|
||||||
※ Google OTP 미등록 상태에서는 OTP 인증으로 전환할 수 없습니다.
|
※ Google OTP 미등록 상태에서는 OTP 인증으로 전환할 수 없습니다.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -69,72 +133,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height:12px;"></div>
|
|
||||||
|
|
||||||
{{-- 등록/확인 UI --}}
|
{{-- 등록/확인 UI --}}
|
||||||
@if($isPending)
|
@if($isPending)
|
||||||
<div class="a-panel">
|
<div class="sec-grid">
|
||||||
<div style="font-weight:900; margin-bottom:10px;">등록 진행</div>
|
|
||||||
|
|
||||||
<div class="a-grid2">
|
{{-- QR --}}
|
||||||
<div class="a-card" style="padding:14px;">
|
<div class="a-card sec-card">
|
||||||
<div class="a-muted" style="font-size:12px; margin-bottom:8px;">QR 코드 스캔</div>
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||||
<div style="background:#fff; border-radius:14px; padding:12px; display:inline-block;">
|
<div style="font-weight:900;">QR 코드 스캔</div>
|
||||||
|
<span class="pill pill--warn">진행중</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<div class="a-muted muted12" style="margin-bottom:8px;">OTP 앱에서 QR을 스캔하세요</div>
|
||||||
|
<div class="qrbox">
|
||||||
{!! $qrSvg !!}
|
{!! $qrSvg !!}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-card" style="padding:14px;">
|
|
||||||
<div class="a-muted" style="font-size:12px; margin-bottom:6px;">시크릿(수동 입력용)</div>
|
|
||||||
<div class="a-mono" style="font-weight:900; font-size:16px; letter-spacing:.08em;">
|
|
||||||
{{ $secret }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="height:10px;"></div>
|
{{-- 시크릿 + 코드 입력 --}}
|
||||||
|
<div class="a-card sec-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||||
|
<div style="font-weight:900;">시크릿 / 등록 완료</div>
|
||||||
|
<span class="pill pill--muted">수동 입력 가능</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.totp.confirm') }}">
|
<div style="margin-top:12px;">
|
||||||
|
<div class="a-muted muted12" style="margin-bottom:6px;">시크릿(수동 입력용)</div>
|
||||||
|
<div class="mono" style="font-weight:900; font-size:16px; letter-spacing:.08em;">
|
||||||
|
{{ $secret }}
|
||||||
|
</div>
|
||||||
|
<div class="a-muted muted12" style="margin-top:8px;">
|
||||||
|
* 앱에서 “수동 입력”을 선택하고 위 시크릿을 등록할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('admin.totp.confirm') }}" class="otp">
|
||||||
@csrf
|
@csrf
|
||||||
<label class="a-label">앱에 표시된 6자리 인증코드</label>
|
<label class="a-label">앱에 표시된 6자리 인증코드</label>
|
||||||
<input class="a-input a-otp-input" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="123456">
|
<input class="a-input a-otp-input" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="123456">
|
||||||
@error('code') <div class="a-error">{{ $message }}</div> @enderror
|
@error('code') <div class="a-error">{{ $message }}</div> @enderror
|
||||||
|
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="margin-top:12px;">
|
<div class="actions" style="margin-top:12px;">
|
||||||
등록 완료
|
<button class="lbtn lbtn--primary lbtn--wide" type="submit">등록 완료</button>
|
||||||
</button>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.totp.disable') }}" style="margin-top:10px;"
|
<form method="POST" action="{{ route('admin.totp.disable') }}"
|
||||||
|
style="margin-top:10px;"
|
||||||
data-confirm="등록을 취소하고 OTP 정보를 삭제할까요?">
|
data-confirm="등록을 취소하고 OTP 정보를 삭제할까요?">
|
||||||
@csrf
|
@csrf
|
||||||
<button class="a-btn a-btn--danger" type="submit">등록 취소(삭제)</button>
|
<button class="lbtn lbtn--danger" type="submit">등록 취소(삭제)</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
{{-- 미등록 / 등록됨 --}}
|
{{-- 미등록 / 등록됨 --}}
|
||||||
<div class="a-panel">
|
<div class="a-card sec-card">
|
||||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;">관리</div>
|
||||||
|
<div class="a-muted muted12" style="margin-top:4px;">
|
||||||
|
등록/재등록/삭제를 통해 OTP 정보를 관리할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
@if(!$isRegistered)
|
@if(!$isRegistered)
|
||||||
<form method="POST" action="{{ route('admin.totp.start') }}"
|
<form method="POST" action="{{ route('admin.totp.start') }}"
|
||||||
data-confirm="Google OTP 등록을 시작할까요?\nQR 스캔 후 6자리 코드를 입력해야 완료됩니다.">
|
data-confirm="Google OTP 등록을 시작할까요?\nQR 스캔 후 6자리 코드를 입력해야 완료됩니다.">
|
||||||
@csrf
|
@csrf
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="width:auto;">등록 시작</button>
|
<button class="lbtn lbtn--primary" type="submit">등록 시작</button>
|
||||||
</form>
|
</form>
|
||||||
@else
|
@else
|
||||||
<form method="POST" action="{{ route('admin.totp.reset') }}"
|
<form method="POST" action="{{ route('admin.totp.reset') }}"
|
||||||
data-confirm="Google OTP를 재등록할까요?\n새 시크릿이 발급되며 기존 등록은 무효화됩니다.">
|
data-confirm="Google OTP를 재등록할까요?\n새 시크릿이 발급되며 기존 등록은 무효화됩니다.">
|
||||||
@csrf
|
@csrf
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="width:auto;">재등록(수정)</button>
|
<button class="lbtn lbtn--primary" type="submit">재등록(수정)</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.totp.disable') }}"
|
<form method="POST" action="{{ route('admin.totp.disable') }}"
|
||||||
data-confirm="Google OTP를 삭제할까요?\n삭제 후에는 SMS 인증을 사용합니다.">
|
data-confirm="Google OTP를 삭제할까요?\n삭제 후에는 SMS 인증을 사용합니다.">
|
||||||
@csrf
|
@csrf
|
||||||
<button class="a-btn a-btn--danger" type="submit" style="width:auto;">삭제</button>
|
<button class="lbtn lbtn--danger" type="submit">삭제</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($isRegistered)
|
||||||
|
<hr class="divider">
|
||||||
|
<div class="a-muted help">
|
||||||
|
<div>✅ 등록 완료 상태입니다. “2차 인증방법”에서 <b>Google OTP 인증</b>으로 전환할 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</section>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -2,170 +2,194 @@
|
|||||||
|
|
||||||
@section('title', '내 정보')
|
@section('title', '내 정보')
|
||||||
@section('page_title', '내 정보')
|
@section('page_title', '내 정보')
|
||||||
@section('page_desc', '프로필/연락처/보안 상태')
|
@section('page_desc', '프로필 / 연락처 / 보안 상태')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* me page only */
|
||||||
|
.me-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||||
|
@media (max-width: 980px){ .me-grid{grid-template-columns:1fr;} }
|
||||||
|
|
||||||
|
.me-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:12px;}
|
||||||
|
.me-head__title{font-weight:900;font-size:16px;}
|
||||||
|
.me-head__desc{margin-top:4px;font-size:12px;}
|
||||||
|
|
||||||
|
.me-formgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
|
||||||
|
@media (max-width: 720px){ .me-formgrid{grid-template-columns:1fr;} }
|
||||||
|
|
||||||
|
.me-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px;}
|
||||||
|
|
||||||
|
.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,.18);border-color:rgba(244,63,94,.35);}
|
||||||
|
.lbtn--ghost{background:transparent;}
|
||||||
|
.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--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
|
||||||
|
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
.pill--muted{opacity:.9}
|
||||||
|
|
||||||
|
.kv{display:grid;grid-template-columns:140px 1fr;gap:10px 14px;}
|
||||||
|
@media (max-width: 720px){ .kv{grid-template-columns:1fr;} }
|
||||||
|
.kv__k{color:rgba(255,255,255,.65);font-size:12px;}
|
||||||
|
.kv__v{font-weight:700;}
|
||||||
|
.kv__sub{font-weight:500;color:rgba(255,255,255,.65);font-size:12px;margin-top:4px;}
|
||||||
|
|
||||||
|
.chipwrap{display:flex;flex-wrap:wrap;gap:8px;}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:8px;padding:8px 10px;border-radius:999px;font-size:12px;
|
||||||
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
|
||||||
|
.chip__sub{opacity:.7;font-size:11px;padding:3px 8px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);}
|
||||||
|
|
||||||
|
.divider{margin:14px 0;opacity:.15;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@php
|
||||||
|
$hasSecret = !empty($me->totp_secret_enc);
|
||||||
|
$isVerified = !empty($me->totp_verified_at);
|
||||||
|
$isEnabled = (int)($me->totp_enabled ?? 0) === 1;
|
||||||
|
|
||||||
|
$modeLabel = $isEnabled ? 'Google OTP(TOTP)' : 'SMS 인증';
|
||||||
|
|
||||||
|
$totpLabel = '미등록';
|
||||||
|
$totpPill = 'pill--muted';
|
||||||
|
if ($hasSecret && $isVerified) { $totpLabel='등록됨'; $totpPill='pill--ok'; }
|
||||||
|
elseif ($hasSecret && !$isVerified) { $totpLabel='등록 진행중'; $totpPill='pill--warn'; }
|
||||||
|
@endphp
|
||||||
|
|
||||||
<section class="a-page">
|
<section class="a-page">
|
||||||
|
<div class="me-grid">
|
||||||
|
|
||||||
<div class="a-grid a-grid--me">
|
{{-- 기본 정보 --}}
|
||||||
<article class="a-card">
|
<article class="a-card" style="padding:16px;">
|
||||||
<div class="a-card__head">
|
<div class="me-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="a-card__title">기본 정보</div>
|
<div class="me-head__title">기본 정보</div>
|
||||||
<div class="a-card__desc a-muted">이메일은 변경 불가</div>
|
<div class="me-head__desc a-muted">이메일은 변경할 수 없습니다.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pill pill--muted">프로필</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.me.update') }}" class="a-form" onsubmit="this.querySelector('button[type=submit]').disabled=true;">
|
<form method="POST"
|
||||||
|
action="{{ route('admin.me.update') }}"
|
||||||
|
class="a-form"
|
||||||
|
onsubmit="this.querySelector('button[type=submit]').disabled=true;">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field" style="margin-bottom:12px;">
|
||||||
<label class="a-label">이메일</label>
|
<label class="a-label">이메일</label>
|
||||||
<input class="a-input" value="{{ $me->email }}" disabled>
|
<input class="a-input" value="{{ $me->email }}" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="me-formgrid">
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label" for="nickname">닉네임</label>
|
<label class="a-label" for="nickname">닉네임</label>
|
||||||
<input
|
<input class="a-input" id="nickname" name="nickname"
|
||||||
class="a-input"
|
|
||||||
id="nickname"
|
|
||||||
name="nickname"
|
|
||||||
placeholder="예: super admin"
|
placeholder="예: super admin"
|
||||||
value="{{ old('nickname', $me->nickname ?? '') }}"
|
value="{{ old('nickname', $me->nickname ?? '') }}">
|
||||||
>
|
|
||||||
@error('nickname')<div class="a-error">{{ $message }}</div>@enderror
|
@error('nickname')<div class="a-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field">
|
||||||
<label class="a-label" for="name">성명(본명)</label>
|
<label class="a-label" for="name">성명(본명)</label>
|
||||||
<input
|
<input class="a-input" id="name" name="name"
|
||||||
class="a-input"
|
value="{{ old('name', $me->name ?? '') }}">
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
value="{{ old('name', $me->name ?? '') }}"
|
|
||||||
>
|
|
||||||
@error('name')<div class="a-error">{{ $message }}</div>@enderror
|
@error('name')<div class="a-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-field">
|
<div class="a-field" style="grid-column: 1 / -1;">
|
||||||
<label class="a-label" for="phone">휴대폰</label>
|
<label class="a-label" for="phone">휴대폰</label>
|
||||||
<input class="a-input" id="phone" name="phone" placeholder="01012345678" value="{{ old('phone', $phone_plain ?? '') }}">
|
<input class="a-input" id="phone" name="phone"
|
||||||
|
placeholder="01012345678"
|
||||||
|
value="{{ old('phone', $phone_plain ?? '') }}">
|
||||||
|
<div class="a-muted" style="margin-top:6px;font-size:12px;">숫자만 입력 권장</div>
|
||||||
@error('phone')<div class="a-error">{{ $message }}</div>@enderror
|
@error('phone')<div class="a-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="a-btn a-btn--primary" type="submit" style="margin-top:12px;">
|
<div class="me-actions">
|
||||||
저장
|
<button class="lbtn lbtn--primary lbtn--wide" type="submit">저장</button>
|
||||||
</button>
|
<span class="a-muted" style="font-size:12px;">* 저장 시 즉시 반영됩니다.</span>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="a-card">
|
{{-- 보안 --}}
|
||||||
<div class="a-card__head">
|
<article class="a-card" style="padding:16px;">
|
||||||
|
<div class="me-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="a-card__title">보안</div>
|
<div class="me-head__title">보안</div>
|
||||||
<div class="a-card__desc a-muted">비밀번호 변경 및 2FA 상태</div>
|
<div class="me-head__desc a-muted">비밀번호 변경 및 2FA 상태</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pill {{ $isEnabled ? 'pill--ok' : 'pill--muted' }}">2FA</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php
|
<div class="kv">
|
||||||
$hasSecret = !empty($me->totp_secret_enc);
|
<div class="kv__k">2FA 모드</div>
|
||||||
$isVerified = !empty($me->totp_verified_at);
|
<div class="kv__v">
|
||||||
$isEnabled = (int)($me->totp_enabled ?? 0) === 1;
|
<span class="pill {{ $isEnabled ? 'pill--ok' : 'pill--muted' }}">● {{ $modeLabel }}</span>
|
||||||
@endphp
|
<div class="kv__sub">로그인 시 추가 인증 방식입니다.</div>
|
||||||
|
|
||||||
<div class="a-meinfo">
|
|
||||||
<div class="a-meinfo__row">
|
|
||||||
<div class="a-meinfo__k">2FA 모드</div>
|
|
||||||
<div class="a-meinfo__v">
|
|
||||||
<span class="a-pill">{{ ($isEnabled=='1') ? 'google TOPT' : 'SMS' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="a-meinfo__row">
|
<div class="kv__k">TOTP 상태</div>
|
||||||
<div class="a-meinfo__k">TOTP</div>
|
<div class="kv__v">
|
||||||
<div class="a-meinfo__v">
|
<span class="pill {{ $totpPill }}">● {{ $totpLabel }}</span>
|
||||||
@php
|
|
||||||
$hasSecret = !empty($me->totp_secret_enc); // 버튼 기준 (등록 플로우 진입 여부)
|
|
||||||
$isVerified = !empty($me->totp_verified_at); // 등록 완료 여부
|
|
||||||
$isModeOtp = (int)($me->totp_enabled ?? 0) === 1; // 현재 로그인 인증 방식 표시용(조건 X)
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
{{-- 1) 등록 상태 표시 (등록/미등록/진행중) --}}
|
|
||||||
@if($hasSecret && $isVerified)
|
@if($hasSecret && $isVerified)
|
||||||
<span class="a-pill a-pill--ok">TOTP 등록됨</span>
|
<div class="kv__sub">등록일: {{ $me->totp_verified_at }}</div>
|
||||||
<span class="a-muted" style="margin-left:8px; font-size:12px;">
|
|
||||||
<BR>({{ $me->totp_verified_at }})
|
|
||||||
</span>
|
|
||||||
@elseif($hasSecret && !$isVerified)
|
@elseif($hasSecret && !$isVerified)
|
||||||
<span class="a-pill a-pill--warn">등록 진행중</span>
|
<div class="kv__sub">QR 등록 후, 인증코드 확인이 필요합니다.</div>
|
||||||
<span class="a-muted" style="margin-left:8px; font-size:12px;">
|
|
||||||
(인증코드 확인 필요)
|
|
||||||
</span>
|
|
||||||
@else
|
@else
|
||||||
<span class="a-pill a-pill--muted">미등록</span>
|
<div class="kv__sub">Google Authenticator(또는 호환 앱)로 등록할 수 있습니다.</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="a-meinfo__row">
|
<div class="kv__k">내 역할</div>
|
||||||
<div class="a-meinfo__k">내 역할</div>
|
<div class="kv__v">
|
||||||
<div class="a-meinfo__v">
|
<div class="chipwrap">
|
||||||
<div class="a-chips">
|
|
||||||
@forelse(($roles ?? []) as $r)
|
@forelse(($roles ?? []) as $r)
|
||||||
<span class="a-chip">
|
<span class="chip">
|
||||||
{{ $r['name'] }}
|
{{ $r['name'] }}
|
||||||
<span class="a-chip__sub">{{ $r['code'] }}</span>
|
<span class="chip__sub">{{ $r['code'] }}</span>
|
||||||
</span>
|
</span>
|
||||||
@empty
|
@empty
|
||||||
<span class="a-muted">부여된 역할이 없습니다.</span>
|
<span class="a-muted">부여된 역할이 없습니다.</span>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- <div class="a-meinfo__row">--}}
|
<div class="kv__k">최근 로그인</div>
|
||||||
{{-- <div class="a-meinfo__k">내 권한</div>--}}
|
<div class="kv__v">
|
||||||
{{-- <div class="a-meinfo__v">--}}
|
|
||||||
{{-- <div class="a-chips">--}}
|
|
||||||
{{-- @forelse(($perms ?? []) as $p)--}}
|
|
||||||
{{-- <span class="a-chip">{{ $p['code'] }}</span>--}}
|
|
||||||
{{-- @empty--}}
|
|
||||||
{{-- <span class="a-muted">권한 정보가 없습니다.</span>--}}
|
|
||||||
{{-- @endforelse--}}
|
|
||||||
{{-- </div>--}}
|
|
||||||
{{-- </div>--}}
|
|
||||||
{{-- </div>--}}
|
|
||||||
|
|
||||||
<div class="a-meinfo__row">
|
|
||||||
<div class="a-meinfo__k">최근 로그인</div>
|
|
||||||
<div class="a-meinfo__v">
|
|
||||||
{{ $me->last_login_at ? $me->last_login_at : '-' }}
|
{{ $me->last_login_at ? $me->last_login_at : '-' }}
|
||||||
</div>
|
<div class="kv__sub">최근 로그인 시간 기준입니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="a-btn a-btn--highlight" href="{{ route('admin.me.password.form') }}" style="margin-top:12px;">
|
<hr class="divider">
|
||||||
|
|
||||||
|
<div class="me-actions">
|
||||||
|
<a class="lbtn lbtn--primary lbtn--wide" href="{{ route('admin.me.password.form') }}">
|
||||||
비밀번호 변경
|
비밀번호 변경
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
<div class="a-muted" style="font-size:12px; margin-top:10px;">
|
|
||||||
<div style="margin-top:10px;">
|
|
||||||
@if(!$hasSecret)
|
@if(!$hasSecret)
|
||||||
<a class="a-btn a-btn--primary a-btn--sm" style="width:auto;"
|
<a class="lbtn" href="{{ route('admin.security') }}">Google OTP 등록</a>
|
||||||
href="{{ route('admin.security') }}">
|
|
||||||
Google OTP 등록
|
|
||||||
</a>
|
|
||||||
@else
|
@else
|
||||||
<a class="a-btn a-btn--ghost a-btn--sm" style="width:auto;"
|
<a class="lbtn lbtn--ghost" href="{{ route('admin.security') }}">2FA 방식 변경 / OTP 관리</a>
|
||||||
href="{{ route('admin.security') }}">
|
|
||||||
2FA 인증방법 변경 / Google OTP 관리
|
|
||||||
</a>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="a-muted" style="font-size:12px; margin-top:10px; line-height:1.6;">
|
||||||
|
* OTP를 등록해도, 최종 로그인 방식(OTP/SMS)은 “2FA 방식 변경”에서 선택할 수 있습니다.
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -17,9 +17,9 @@
|
|||||||
[
|
[
|
||||||
'title' => '알림/메시지',
|
'title' => '알림/메시지',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']],
|
['label' => 'SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']],
|
['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '알림 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']],
|
['label' => 'SMS 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
224
resources/views/admin/sms/logs/index.blade.php
Normal file
224
resources/views/admin/sms/logs/index.blade.php
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'SMS 발송 이력')
|
||||||
|
@section('page_title', 'SMS 발송 이력')
|
||||||
|
@section('page_desc', '배치 단위로 조회합니다.')
|
||||||
|
@php
|
||||||
|
$STATUS_LABELS = [
|
||||||
|
'scheduled' => '예약대기',
|
||||||
|
'queued' => '대기',
|
||||||
|
'submitting' => '전송중',
|
||||||
|
'submitted' => '전송완료',
|
||||||
|
'partial' => '일부실패',
|
||||||
|
'failed' => '실패',
|
||||||
|
'canceled' => '취소',
|
||||||
|
];
|
||||||
|
|
||||||
|
$MODE_LABELS = [
|
||||||
|
'one' => '단건',
|
||||||
|
'many' => '대량',
|
||||||
|
'template' => '템플릿',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* logs list page only */
|
||||||
|
.logbar{display:flex; gap:10px; flex-wrap:wrap; align-items:end;}
|
||||||
|
.logbar__grow{flex:1; min-width:260px;}
|
||||||
|
.logbar__actions{display:flex; gap:8px; align-items:center;}
|
||||||
|
|
||||||
|
.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);}
|
||||||
|
.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;}
|
||||||
|
|
||||||
|
.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--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
.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;}
|
||||||
|
|
||||||
|
.msg-clip{max-width:520px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:.95;}
|
||||||
|
.table td{vertical-align:top;}
|
||||||
|
.a-input[type="date"]{
|
||||||
|
-webkit-appearance: auto !important;
|
||||||
|
appearance: auto !important;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (다크톤 UI에서 달력 아이콘이 안 보이면) */
|
||||||
|
.a-input[type="date"]::-webkit-calendar-picker-indicator{
|
||||||
|
opacity: .85;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.a-input[type="date"]::-webkit-calendar-picker-indicator:hover{
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#logFilterForm .a-input[type="date"]{
|
||||||
|
width: 140px; /* 날짜만 보일 정도 */
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logFilterForm input[name="q"]{
|
||||||
|
max-width: 320px; /* 검색창 줄이기 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (선택) 셀렉트도 조금만 슬림하게 */
|
||||||
|
#logFilterForm select.a-input{
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<form method="GET" action="{{ route('admin.sms.logs') }}" class="logbar" id="logFilterForm">
|
||||||
|
|
||||||
|
<div style="min-width:170px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">상태</div>
|
||||||
|
<select class="a-input" name="status">
|
||||||
|
<option value="">전체</option>
|
||||||
|
@foreach(['scheduled','queued','submitting','submitted','partial','failed','canceled'] as $st)
|
||||||
|
<option value="{{ $st }}" @selected(request('status')===$st)>
|
||||||
|
{{ $STATUS_LABELS[$st] ?? $st }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:140px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">모드</div>
|
||||||
|
<select class="a-input" name="send_mode">
|
||||||
|
<option value="">전체</option>
|
||||||
|
@foreach(['one','many','template'] as $m)
|
||||||
|
<option value="{{ $m }}" @selected(request('send_mode')===$m)>
|
||||||
|
{{ $MODE_LABELS[$m] ?? $m }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 기간: 달력 선택 (from/to) --}}
|
||||||
|
<div style="min-width:260px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">기간</div>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||||
|
<input class="a-input" type="date" name="date_from" id="dateFrom" value="{{ request('date_from') }}">
|
||||||
|
<span class="a-muted">~</span>
|
||||||
|
<input class="a-input" type="date" name="date_to" id="dateTo" value="{{ request('date_to') }}">
|
||||||
|
<button type="button" class="lbtn lbtn--ghost" id="dateClear">초기화</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:260px; max-width:340px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">검색</div>
|
||||||
|
<input class="a-input" name="q" value="{{ request('q') }}" placeholder="문구/발신번호/IP/작성자 등">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logbar__actions">
|
||||||
|
<button class="lbtn lbtn--primary" type="submit">조회</button>
|
||||||
|
<a class="lbtn" href="{{ route('admin.sms.send') }}">발송</a>
|
||||||
|
<a class="lbtn lbtn--ghost" href="{{ route('admin.sms.logs') }}">필터 초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px;">
|
||||||
|
<div class="a-muted">총 <b>{{ $batches->total() }}</b>건</div>
|
||||||
|
<div class="a-muted">
|
||||||
|
@if((string)request('date_from') !== '' || (string)request('date_to') !== '')
|
||||||
|
기간: <b>{{ request('date_from') ?: '무제한' }}</b> ~ <b>{{ request('date_to') ?: '무제한' }}</b>
|
||||||
|
@endif
|
||||||
|
@if((string)request('q') !== '')
|
||||||
|
/ 검색어: <b>{{ request('q') }}</b>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table table" style="width:100%; min-width:1100px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:80px;">Batch</th>
|
||||||
|
<th style="width:160px;">생성일시</th>
|
||||||
|
<th style="width:160px;">작성자</th>
|
||||||
|
<th style="width:90px;">모드</th>
|
||||||
|
<th style="width:170px;">예약</th>
|
||||||
|
<th style="width:110px;">건수</th>
|
||||||
|
<th style="width:140px;">상태</th>
|
||||||
|
<th>문구</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($batches as $b)
|
||||||
|
@php
|
||||||
|
$st = (string)$b->status;
|
||||||
|
$pillClass = match ($st) {
|
||||||
|
'submitted' => 'pill--ok',
|
||||||
|
'partial','submitting','queued','scheduled' => 'pill--warn',
|
||||||
|
'failed','canceled' => 'pill--bad',
|
||||||
|
default => 'pill--muted',
|
||||||
|
};
|
||||||
|
$stLabel = $STATUS_LABELS[$st] ?? $st;
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="mono" style="text-decoration:none;"
|
||||||
|
href="{{ route('admin.sms.logs.show', ['batchId'=>$b->id]) }}">#{{ $b->id }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="a-muted">{{ $b->created_at }}</td>
|
||||||
|
<td style="font-weight:700;">{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}</td>
|
||||||
|
<td><span class="mono">{{ $MODE_LABELS[$b->send_mode] ?? $b->send_mode }}</span></td>
|
||||||
|
<td class="a-muted">{{ $b->scheduled_at ?? '-' }}</td>
|
||||||
|
<td><b>{{ $b->valid_count }}</b>/{{ $b->total_count }}</td>
|
||||||
|
<td><span class="pill {{ $pillClass }}">● {{ $stLabel }}</span></td>
|
||||||
|
<td><div class="msg-clip">{{ $b->message_raw }}</div></td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" class="a-muted">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $batches->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const from = document.getElementById('dateFrom');
|
||||||
|
const to = document.getElementById('dateTo');
|
||||||
|
const clear= document.getElementById('dateClear');
|
||||||
|
if (!from || !to) return;
|
||||||
|
|
||||||
|
// ✅ 클릭해도 안 뜨는 환경에서 강제 오픈
|
||||||
|
[from, to].forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
if (typeof el.showPicker === 'function') el.showPicker();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// To가 비어있고 From만 찍으면 To를 From으로 자동 세팅(편의)
|
||||||
|
from.addEventListener('change', () => {
|
||||||
|
if (from.value && !to.value) to.value = from.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
clear?.addEventListener('click', () => {
|
||||||
|
from.value = '';
|
||||||
|
to.value = '';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
158
resources/views/admin/sms/logs/show.blade.php
Normal file
158
resources/views/admin/sms/logs/show.blade.php
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'SMS 이력 상세')
|
||||||
|
@section('page_title', 'SMS 이력 상세')
|
||||||
|
@section('page_desc', '배치 및 수신자별 상세')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* logs show page only */
|
||||||
|
.sbtn{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;}
|
||||||
|
.sbtn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.sbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.sbtn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.sbtn--ghost{background:transparent;}
|
||||||
|
|
||||||
|
.kv{display:grid;grid-template-columns:repeat(5, minmax(160px,1fr));gap:14px;align-items:start;}
|
||||||
|
@media (max-width: 1100px){.kv{grid-template-columns:repeat(2, minmax(160px,1fr));}}
|
||||||
|
.kv .k{font-size:12px;opacity:.75;}
|
||||||
|
.kv .v{font-weight:800;font-size:14px;margin-top:4px;}
|
||||||
|
.kv .v.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-weight:700;}
|
||||||
|
|
||||||
|
.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--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
|
||||||
|
.msgbox{white-space:pre-wrap;background:rgba(255,255,255,.03);padding:12px;border-radius:12px;border:1px solid rgba(255,255,255,.08);}
|
||||||
|
.clip{max-width:560px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
||||||
|
.table td{vertical-align:top;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$st = (string)$batch->status;
|
||||||
|
$pillClass = match ($st) {
|
||||||
|
'submitted' => 'pill--ok',
|
||||||
|
'partial','submitting','queued','scheduled' => 'pill--warn',
|
||||||
|
'failed','canceled' => 'pill--bad',
|
||||||
|
default => 'pill--warn',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="kv">
|
||||||
|
<div>
|
||||||
|
<div class="k">Batch ID</div>
|
||||||
|
<div class="v mono">#{{ $batch->id }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="k">상태</div>
|
||||||
|
<div class="v"><span class="pill {{ $pillClass }}">● {{ $batch->status }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="k">모드</div>
|
||||||
|
<div class="v mono">{{ $batch->send_mode }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="k">예약</div>
|
||||||
|
<div class="v">{{ $batch->scheduled_at ?? '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="k">건수</div>
|
||||||
|
<div class="v">
|
||||||
|
유효 <b>{{ $batch->valid_count }}</b> /
|
||||||
|
전체 {{ $batch->total_count }} /
|
||||||
|
중복 {{ $batch->duplicate_count }} /
|
||||||
|
오류 {{ $batch->invalid_count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin:14px 0; opacity:.15;">
|
||||||
|
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">문구</div>
|
||||||
|
<div class="msgbox">{{ $batch->message_raw }}</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end;">
|
||||||
|
<a class="sbtn" href="{{ route('admin.sms.logs') }}">목록</a>
|
||||||
|
<a class="sbtn sbtn--primary" href="{{ route('admin.sms.send') }}">새 발송</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<form method="GET"
|
||||||
|
action="{{ route('admin.sms.logs.show', ['batchId'=>$batch->id]) }}"
|
||||||
|
style="display:flex; gap:10px; flex-wrap:wrap; align-items:end; margin-bottom:12px;">
|
||||||
|
|
||||||
|
<div style="min-width:160px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">상태</div>
|
||||||
|
<select class="a-input" name="status">
|
||||||
|
<option value="">전체</option>
|
||||||
|
@foreach(['queued','submitted','failed'] as $st)
|
||||||
|
<option value="{{ $st }}" @selected(request('status')===$st)>{{ $st }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:200px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">수신번호</div>
|
||||||
|
<input class="a-input" name="to" value="{{ request('to') }}" placeholder="010...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1; min-width:240px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">메시지 검색</div>
|
||||||
|
<input class="a-input" name="q" value="{{ request('q') }}" placeholder="치환된 메시지 내용">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="sbtn sbtn--primary" type="submit">필터</button>
|
||||||
|
<a class="sbtn sbtn--ghost" href="{{ route('admin.sms.logs.show', ['batchId'=>$batch->id]) }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table table" style="width:100%; min-width:1100px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">Seq</th>
|
||||||
|
<th style="width:150px;">수신번호</th>
|
||||||
|
<th style="width:90px;">타입</th>
|
||||||
|
<th style="width:120px;">상태</th>
|
||||||
|
<th style="width:190px;">제출시간</th>
|
||||||
|
<th>메시지</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($items as $it)
|
||||||
|
@php
|
||||||
|
$ist = (string)$it->status;
|
||||||
|
$ic = match ($ist) {
|
||||||
|
'submitted' => 'pill--ok',
|
||||||
|
'failed' => 'pill--bad',
|
||||||
|
default => 'pill--warn',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $it->seq }}</td>
|
||||||
|
<td style="font-weight:800;">{{ $it->to_number }}</td>
|
||||||
|
<td><span class="mono">{{ $it->sms_type }}</span></td>
|
||||||
|
<td><span class="pill {{ $ic }}">● {{ $it->status }}</span></td>
|
||||||
|
<td class="a-muted">{{ $it->submitted_at ?? '-' }}</td>
|
||||||
|
<td><div class="clip">{{ $it->message_final }}</div></td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="6" class="a-muted">상세 데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $items->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
594
resources/views/admin/sms/send.blade.php
Normal file
594
resources/views/admin/sms/send.blade.php
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '관리자 SMS 발송')
|
||||||
|
@section('page_title', '관리자 SMS 발송')
|
||||||
|
@section('page_desc', '단건 / 대량 / 템플릿 발송')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* 이 페이지에서만 쓰는 로컬 스타일 */
|
||||||
|
.sms-grid{display:grid;grid-template-columns:1fr 1.15fr;gap:16px;align-items:start;}
|
||||||
|
@media (max-width: 980px){.sms-grid{grid-template-columns:1fr;}}
|
||||||
|
|
||||||
|
.sms-toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:end;}
|
||||||
|
.sms-badges{margin-left:auto;display:flex;gap:10px;align-items:center;}
|
||||||
|
|
||||||
|
.sms-seg{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);}
|
||||||
|
.sms-tab{padding:8px 12px;font-size:13px;border-radius:999px;line-height:1;border:0;background:transparent;color:inherit;cursor:pointer;}
|
||||||
|
.sms-tab.is-active{background:rgba(255,255,255,.14);}
|
||||||
|
|
||||||
|
.sms-btn{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;}
|
||||||
|
.sms-btn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.sms-btn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.sms-btn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.sms-btn--ghost{background:transparent;}
|
||||||
|
|
||||||
|
.sms-badge{padding:6px 10px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);font-size:12px;}
|
||||||
|
.sms-badge--type{font-weight:800;}
|
||||||
|
|
||||||
|
.sms-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
|
||||||
|
.sms-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
|
||||||
|
.sms-select{min-width:280px;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<form method="POST" action="{{ route('admin.sms.send.store') }}" enctype="multipart/form-data" id="smsSendForm">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<input type="hidden" name="send_mode" id="sendMode" value="one">
|
||||||
|
<input type="hidden" name="sms_type_hint" id="smsTypeHint" value="auto">
|
||||||
|
|
||||||
|
{{-- 상단 바 --}}
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="sms-toolbar">
|
||||||
|
<div style="min-width:280px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">발신번호</div>
|
||||||
|
<input class="a-input" name="from_number" value="{{ config('services.sms.from','1833-4856') }}" readonly>
|
||||||
|
<div class="a-muted" style="margin-top:6px;">고정 (수정불가)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="min-width:340px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">발송 시점</div>
|
||||||
|
<div class="sms-row">
|
||||||
|
<label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시</label>
|
||||||
|
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약</label>
|
||||||
|
<input class="a-input" type="text" name="scheduled_at" id="scheduledAt"
|
||||||
|
placeholder="YYYY-MM-DD HH:mm" style="width:180px" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="a-muted" style="margin-top:6px;">예약은 5분 단위 권장</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-badges">
|
||||||
|
<div class="sms-badge sms-badge--type" id="smsTypeBadge">SMS</div>
|
||||||
|
<div class="sms-badge" id="byteBadge">0 Bytes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-grid">
|
||||||
|
{{-- 좌측: 수신/모드 --}}
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:8px;">발송 유형</div>
|
||||||
|
|
||||||
|
<div class="sms-seg" style="margin-bottom:12px;">
|
||||||
|
<button type="button" class="sms-tab is-active" data-tab="one">단건</button>
|
||||||
|
<button type="button" class="sms-tab" data-tab="many">대량</button>
|
||||||
|
<button type="button" class="sms-tab" data-tab="template">템플릿</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section data-panel="one">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">수신번호 (1건)</div>
|
||||||
|
<input class="a-input" name="to_number" id="toNumber" placeholder="01012345678" value="{{ old('to_number') }}">
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-panel="many" style="display:none;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||||
|
<div class="a-muted">수신번호 (여러건)</div>
|
||||||
|
<div class="a-muted" id="manyCountText">0건</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="a-input" name="to_numbers_text" id="toNumbersText" rows="10"
|
||||||
|
placeholder="01011112222, 0103336666 또는 줄바꿈으로 붙여넣기">{{ old('to_numbers_text') }}</textarea>
|
||||||
|
|
||||||
|
<div class="sms-row" style="margin-top:10px;">
|
||||||
|
<label class="sms-btn">
|
||||||
|
CSV 업로드
|
||||||
|
<input type="file" name="to_numbers_csv" id="toNumbersCsv" accept=".csv,.txt" style="display:none;">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="sms-btn sms-btn--ghost" id="clearMany">비우기</button>
|
||||||
|
<div class="a-muted" id="manyStats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" style="margin-top:8px;">* 서버에서 최종 정규화/중복/오류 제거합니다.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-panel="template" style="display:none;">
|
||||||
|
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
||||||
|
<label class="sms-btn">
|
||||||
|
CSV 업로드
|
||||||
|
<input type="file" name="template_csv" id="templateCsv" accept=".csv,.txt" style="display:none;">
|
||||||
|
</label>
|
||||||
|
<span class="a-muted">* 첫 번째 컬럼은 <b>수신번호</b>, 이후 컬럼은 <b>치환값</b>입니다.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CSV 미리보기 --}}
|
||||||
|
<div id="tplCsvPreviewWrap"
|
||||||
|
style="display:none; margin-top:12px; padding:12px; border-radius:14px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08);">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||||
|
<div style="font-weight:800;">CSV 미리보기 (상위 5줄)</div>
|
||||||
|
<button type="button" class="sms-btn" id="tplCsvClearBtn">파일 비우기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" id="tplCsvMeta" style="margin-top:8px; line-height:1.6;"></div>
|
||||||
|
|
||||||
|
<div style="overflow:auto; margin-top:10px;">
|
||||||
|
<table class="a-table" style="width:100%; min-width:720px;">
|
||||||
|
<thead>
|
||||||
|
<tr id="tplCsvHeadRow"></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tplCsvBodyRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" id="tplCsvHint" style="margin-top:10px; line-height:1.7;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" id="tplCsvError" style="display:none; margin-top:10px; line-height:1.6; color:#ff6b6b;"></div>
|
||||||
|
|
||||||
|
|
||||||
|
{{-- 이용 안내 --}}
|
||||||
|
<div style="margin-top:12px; padding:12px; border-radius:14px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08);">
|
||||||
|
<div style="font-weight:800; margin-bottom:8px;">템플릿 CSV 이용 안내</div>
|
||||||
|
|
||||||
|
<div class="a-muted" style="line-height:1.7;">
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<b>1) CSV 파일 작성 요령</b><br>
|
||||||
|
<span class="a-muted">각 줄이 “1명”이며, 쉼표(<code>,</code>)로 컬럼을 구분합니다.</span>
|
||||||
|
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">예시</div>
|
||||||
|
<div style="padding:10px; border-radius:12px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.06); font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size:12px; white-space:pre;">
|
||||||
|
01036828958,홍길동,20260111
|
||||||
|
01036828901,이순신,20260112
|
||||||
|
01036828902,김개똥,20260113
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul style="margin:10px 0 0 18px;">
|
||||||
|
<li><b>1번째 컬럼</b>: 수신 전화번호(고정)</li>
|
||||||
|
<li><b>2번째 컬럼</b>: 사용자 문구 1 (→ <code>{_text_02_}</code>)</li>
|
||||||
|
<li><b>3번째 컬럼</b>: 사용자 문구 2 (→ <code>{_text_03_}</code>)</li>
|
||||||
|
<li>… 이런 식으로 뒤 컬럼이 계속 이어집니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<b>2) 발송 문구와 매칭(치환) 규칙</b><br>
|
||||||
|
<span class="a-muted">발송 문구에 토큰을 넣으면 CSV의 값으로 자동 치환됩니다.</span>
|
||||||
|
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">발송 문구 예시</div>
|
||||||
|
<div style="padding:10px; border-radius:12px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.06); font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size:12px; white-space:pre;">
|
||||||
|
안녕하세요 {_text_02_} 회원님
|
||||||
|
오늘 날짜는 {_text_03_} 입니다
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">치환 결과 예시(첫 줄 기준)</div>
|
||||||
|
<ul style="margin:0 0 0 18px;">
|
||||||
|
<li><code>{_text_02_}</code> → <b>홍길동</b></li>
|
||||||
|
<li><code>{_text_03_}</code> → <b>20210111</b></li>
|
||||||
|
</ul>
|
||||||
|
<div class="a-muted" style="margin-top:6px;">
|
||||||
|
즉, 첫 줄의 값(홍길동/20210111)이 문구에 들어가서 “개인화 메시지”가 됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>3) 사용 가능한 토큰 범위</b><br>
|
||||||
|
<span class="a-muted">
|
||||||
|
사용자 문구는 <code>{_text_02_}</code>부터 최대 8개까지 지원합니다.
|
||||||
|
(<code>{_text_02_} ~ {_text_09_}</code>)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 우측: 문구/프리셋/액션 --}}
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">문구 템플릿(프리셋) 선택</div>
|
||||||
|
|
||||||
|
<div class="sms-row">
|
||||||
|
<select class="a-input sms-select" id="tplSelect">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
@foreach(($templates ?? []) as $t)
|
||||||
|
<option value="{{ $t->id ?? $t['id'] }}">
|
||||||
|
{{ ($t->title ?? $t['title']) }} ({{ $t->code ?? $t['code'] }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="button" class="sms-btn" id="tplApplyReplace">덮어쓰기</button>
|
||||||
|
<button type="button" class="sms-btn sms-btn--ghost" id="tplApplyAppend">뒤에붙이기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" style="margin-top:8px;">* “덮어쓰기”는 현재 문구를 교체합니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">발송 문구</div>
|
||||||
|
<textarea class="a-input" name="message" id="messageText" rows="10" placeholder="메시지를 입력하세요">{{ old('message') }}</textarea>
|
||||||
|
|
||||||
|
<div class="sms-row" style="margin-top:10px;">
|
||||||
|
<button type="button" class="sms-btn sms-btn--ghost" data-insert="{_text_02_}">{_text_02_}</button>
|
||||||
|
<button type="button" class="sms-btn sms-btn--ghost" data-insert="{_text_03_}">{_text_03_}</button>
|
||||||
|
<button type="button" class="sms-btn sms-btn--ghost" data-insert="{_text_04_}">{_text_04_}</button>
|
||||||
|
<span class="a-muted">토큰 빠른 삽입</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin:16px 0; opacity:.15;">
|
||||||
|
|
||||||
|
<div class="sms-actions">
|
||||||
|
<button type="submit" class="sms-btn sms-btn--primary">발송</button>
|
||||||
|
<a class="sms-btn" href="{{ route('admin.sms.logs') }}">발송 이력</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-muted" style="margin-top:8px;">* 90 bytes 초과는 MMS 표시(최종은 서버 기준)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const form = document.getElementById('smsSendForm');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// 서버에서 내려온 템플릿(본문 포함)
|
||||||
|
const templates = @json($templates ?? []);
|
||||||
|
|
||||||
|
// tabs
|
||||||
|
const tabBtns = Array.from(form.querySelectorAll('[data-tab]'));
|
||||||
|
const panels = Array.from(form.querySelectorAll('[data-panel]'));
|
||||||
|
const sendModeEl = document.getElementById('sendMode');
|
||||||
|
|
||||||
|
function setTab(tab){
|
||||||
|
tabBtns.forEach(b => b.classList.toggle('is-active', b.dataset.tab === tab));
|
||||||
|
panels.forEach(p => p.style.display = (p.dataset.panel === tab) ? '' : 'none');
|
||||||
|
sendModeEl.value = tab;
|
||||||
|
}
|
||||||
|
tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab)));
|
||||||
|
|
||||||
|
// schedule
|
||||||
|
const scheduledAt = document.getElementById('scheduledAt');
|
||||||
|
form.querySelectorAll('input[name="schedule_type"]').forEach(r => {
|
||||||
|
r.addEventListener('change', () => {
|
||||||
|
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]').checked;
|
||||||
|
scheduledAt.disabled = !isSch;
|
||||||
|
if(!isSch) scheduledAt.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// byte count
|
||||||
|
const msg = document.getElementById('messageText');
|
||||||
|
const byteBadge = document.getElementById('byteBadge');
|
||||||
|
const typeBadge = document.getElementById('smsTypeBadge');
|
||||||
|
const smsTypeHint = document.getElementById('smsTypeHint');
|
||||||
|
|
||||||
|
function calcBytes(str){
|
||||||
|
let byte = 0;
|
||||||
|
for (let i=0;i<str.length;i++){
|
||||||
|
const c = str.charCodeAt(i);
|
||||||
|
byte += (c > 127) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return byte;
|
||||||
|
}
|
||||||
|
function refreshBytes(){
|
||||||
|
const b = calcBytes(msg?.value || '');
|
||||||
|
if (byteBadge) byteBadge.textContent = b + ' Bytes';
|
||||||
|
const type = (b > 90) ? 'mms' : 'sms';
|
||||||
|
if (typeBadge) typeBadge.textContent = type.toUpperCase();
|
||||||
|
if (smsTypeHint) smsTypeHint.value = 'auto';
|
||||||
|
}
|
||||||
|
msg?.addEventListener('input', refreshBytes);
|
||||||
|
refreshBytes();
|
||||||
|
|
||||||
|
// token insert
|
||||||
|
form.querySelectorAll('[data-insert]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if(!msg) return;
|
||||||
|
const token = btn.getAttribute('data-insert') || '';
|
||||||
|
const start = msg.selectionStart ?? msg.value.length;
|
||||||
|
const end = msg.selectionEnd ?? msg.value.length;
|
||||||
|
msg.value = msg.value.slice(0,start) + token + msg.value.slice(end);
|
||||||
|
msg.focus();
|
||||||
|
msg.selectionStart = msg.selectionEnd = start + token.length;
|
||||||
|
refreshBytes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// many stats
|
||||||
|
const toNumbersText = document.getElementById('toNumbersText');
|
||||||
|
const manyCountText = document.getElementById('manyCountText');
|
||||||
|
const manyStats = document.getElementById('manyStats');
|
||||||
|
const clearMany = document.getElementById('clearMany');
|
||||||
|
|
||||||
|
function parsePhonesRough(text){
|
||||||
|
const raw = (text||'').split(/[\s,]+/).map(s=>s.trim()).filter(Boolean);
|
||||||
|
const digits = raw.map(v=>v.replace(/\D+/g,'')).filter(Boolean);
|
||||||
|
const valid = digits.filter(v=>/^01\d{8,9}$/.test(v));
|
||||||
|
const invalid = digits.length - valid.length;
|
||||||
|
const uniq = Array.from(new Set(valid));
|
||||||
|
return {total:digits.length, valid:valid.length, uniq:uniq.length, invalid};
|
||||||
|
}
|
||||||
|
function refreshMany(){
|
||||||
|
if(!toNumbersText) return;
|
||||||
|
const r = parsePhonesRough(toNumbersText.value);
|
||||||
|
if (manyCountText) manyCountText.textContent = r.uniq + '건';
|
||||||
|
if (manyStats) manyStats.textContent = `입력 ${r.total} / 유효 ${r.valid} / 중복제거 ${r.uniq} / 오류 ${r.invalid}`;
|
||||||
|
}
|
||||||
|
toNumbersText?.addEventListener('input', refreshMany);
|
||||||
|
clearMany?.addEventListener('click', () => { if(toNumbersText){ toNumbersText.value=''; refreshMany(); } });
|
||||||
|
refreshMany();
|
||||||
|
|
||||||
|
// template apply (프리셋)
|
||||||
|
const sel = document.getElementById('tplSelect');
|
||||||
|
const btnR = document.getElementById('tplApplyReplace');
|
||||||
|
const btnA = document.getElementById('tplApplyAppend');
|
||||||
|
|
||||||
|
function getSelectedBody(){
|
||||||
|
const id = Number(sel?.value || 0);
|
||||||
|
if (!id) return '';
|
||||||
|
const t = templates.find(x => Number(x.id) === id);
|
||||||
|
if (!t) return '';
|
||||||
|
return String(t.body ?? t.content ?? t.message ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(mode){
|
||||||
|
if (!msg) return;
|
||||||
|
const body = getSelectedBody();
|
||||||
|
if (!body) { alert('템플릿을 선택하세요.'); return; }
|
||||||
|
|
||||||
|
if (mode === 'replace') msg.value = body;
|
||||||
|
else msg.value = msg.value ? (msg.value + "\n" + body) : body;
|
||||||
|
|
||||||
|
msg.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
msg.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
btnR?.addEventListener('click', () => apply('replace'));
|
||||||
|
btnA?.addEventListener('click', () => apply('append'));
|
||||||
|
|
||||||
|
// submit check (최종 검증은 서버)
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
const mode = sendModeEl?.value || 'one';
|
||||||
|
const m = (msg?.value || '').trim();
|
||||||
|
if(!m){ e.preventDefault(); alert('발송 문구를 입력하세요.'); return; }
|
||||||
|
|
||||||
|
if(mode === 'one'){
|
||||||
|
const v = (document.getElementById('toNumber')?.value || '').replace(/\D+/g,'');
|
||||||
|
if(!/^01\d{8,9}$/.test(v)){ e.preventDefault(); alert('수신번호(단건)를 올바르게 입력하세요.'); return; }
|
||||||
|
}
|
||||||
|
if(mode === 'many'){
|
||||||
|
const r = parsePhonesRough(toNumbersText?.value || '');
|
||||||
|
const hasCsv = (document.getElementById('toNumbersCsv')?.files || []).length > 0;
|
||||||
|
if(r.uniq < 1 && !hasCsv){ e.preventDefault(); alert('대량 수신번호를 입력하거나 CSV 업로드하세요.'); return; }
|
||||||
|
}
|
||||||
|
if(mode === 'template'){
|
||||||
|
const hasT = (document.getElementById('templateCsv')?.files || []).length > 0;
|
||||||
|
if(!hasT){ e.preventDefault(); alert('템플릿 CSV를 업로드하세요.'); return; }
|
||||||
|
}
|
||||||
|
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
|
||||||
|
if(isSch && !(scheduledAt?.value || '').trim()){ e.preventDefault(); alert('예약 시간을 입력하세요.'); return; }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const input = document.getElementById('templateCsv');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const wrap = document.getElementById('tplCsvPreviewWrap');
|
||||||
|
const meta = document.getElementById('tplCsvMeta');
|
||||||
|
const headTr = document.getElementById('tplCsvHeadRow');
|
||||||
|
const bodyTb = document.getElementById('tplCsvBodyRows');
|
||||||
|
const hint = document.getElementById('tplCsvHint');
|
||||||
|
const errBox = document.getElementById('tplCsvError');
|
||||||
|
const clearBtn = document.getElementById('tplCsvClearBtn');
|
||||||
|
|
||||||
|
function showErr(msg){
|
||||||
|
if (!errBox) return;
|
||||||
|
errBox.style.display = msg ? '' : 'none';
|
||||||
|
errBox.textContent = msg || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s){
|
||||||
|
return String(s ?? '')
|
||||||
|
.replaceAll('&','&')
|
||||||
|
.replaceAll('<','<')
|
||||||
|
.replaceAll('>','>')
|
||||||
|
.replaceAll('"','"')
|
||||||
|
.replaceAll("'",''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLoose(text){
|
||||||
|
// “간단/안전” 파서: 기본은 콤마 분리, 따옴표 포함 라인은 최소한 지원
|
||||||
|
// (완전한 CSV RFC 파서는 아니지만 관리자 도구용으로 실사용 충분)
|
||||||
|
const lines = (text || '')
|
||||||
|
.replace(/^\uFEFF/, '') // BOM 제거
|
||||||
|
.split(/\r\n|\n|\r/)
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const line of lines){
|
||||||
|
const cells = [];
|
||||||
|
let cur = '';
|
||||||
|
let inQ = false;
|
||||||
|
|
||||||
|
for (let i=0;i<line.length;i++){
|
||||||
|
const ch = line[i];
|
||||||
|
if (ch === '"'){
|
||||||
|
// "" 이스케이프 처리
|
||||||
|
if (inQ && line[i+1] === '"'){ cur += '"'; i++; continue; }
|
||||||
|
inQ = !inQ;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ',' && !inQ){
|
||||||
|
cells.push(cur.trim());
|
||||||
|
cur = '';
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(cur.trim());
|
||||||
|
rows.push(cells);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(v){
|
||||||
|
const d = String(v||'').replace(/\D+/g,'');
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreview(rows){
|
||||||
|
// 최대 5줄 표시
|
||||||
|
const sample = rows.slice(0, 5);
|
||||||
|
const maxCols = sample.reduce((m, r) => Math.max(m, r.length), 0);
|
||||||
|
|
||||||
|
// 헤더(Col1..)
|
||||||
|
headTr.innerHTML = '';
|
||||||
|
for (let c=0;c<Math.max(1,maxCols);c++){
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = (c===0) ? 'phone' : `val${c+1}`;
|
||||||
|
headTr.appendChild(th);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 바디
|
||||||
|
bodyTb.innerHTML = '';
|
||||||
|
sample.forEach((r, idx) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
for (let c=0;c<Math.max(1,maxCols);c++){
|
||||||
|
const td = document.createElement('td');
|
||||||
|
const val = (r[c] ?? '');
|
||||||
|
td.innerHTML = esc(val);
|
||||||
|
if (c===0){
|
||||||
|
const p = normalizePhone(val);
|
||||||
|
if (!/^01\d{8,9}$/.test(p)) {
|
||||||
|
td.style.color = '#ff6b6b';
|
||||||
|
td.style.fontWeight = '800';
|
||||||
|
td.title = '휴대폰 형식이 올바르지 않습니다 (010/011/016/017/018/019 10~11자리)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr.appendChild(td);
|
||||||
|
}
|
||||||
|
bodyTb.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메타/힌트
|
||||||
|
const totalRows = rows.length;
|
||||||
|
const sampleCols = maxCols;
|
||||||
|
const tokenFrom = 2;
|
||||||
|
const tokenTo = Math.min(9, tokenFrom + (sampleCols - 2)); // phone(1) + val2.. -> 토큰 02부터
|
||||||
|
const tokenInfo = (sampleCols <= 1)
|
||||||
|
? '치환값 컬럼이 없습니다. (phone만 존재)'
|
||||||
|
: `치환 가능 토큰: {_text_02_} ~ {_text_0${tokenTo}_} (샘플 기준)`;
|
||||||
|
|
||||||
|
meta.innerHTML = `
|
||||||
|
<div>총 <b>${totalRows}</b>줄 · 샘플 컬럼수 <b>${sampleCols}</b>개</div>
|
||||||
|
<div>${tokenInfo}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let hintHtml = '';
|
||||||
|
if (sampleCols <= 1){
|
||||||
|
hintHtml += `<div>• 현재 CSV는 수신번호만 있어 “개인화 치환”은 불가능합니다.</div>`;
|
||||||
|
} else {
|
||||||
|
hintHtml += `<div>• 1번째 컬럼(phone) → 수신번호</div>`;
|
||||||
|
hintHtml += `<div>• 2번째 컬럼(val2) → <code>{_text_02_}</code></div>`;
|
||||||
|
if (sampleCols >= 3) hintHtml += `<div>• 3번째 컬럼(val3) → <code>{_text_03_}</code></div>`;
|
||||||
|
if (sampleCols >= 4) hintHtml += `<div>• 4번째 컬럼(val4) → <code>{_text_04_}</code></div>`;
|
||||||
|
if (sampleCols > 4) hintHtml += `<div>• 이후 컬럼은 순서대로 <code>{_text_05_}</code> …</div>`;
|
||||||
|
}
|
||||||
|
hintHtml += `<div class="a-muted" style="margin-top:6px;">* 최종 유효성/중복 제거/치환은 서버에서 한 번 더 처리합니다.</div>`;
|
||||||
|
hint.innerHTML = hintHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFile(file){
|
||||||
|
showErr('');
|
||||||
|
if (!file){
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (file.name || '').toLowerCase();
|
||||||
|
if (!name.endsWith('.csv') && !name.endsWith('.txt')){
|
||||||
|
showErr('CSV/TXT 파일만 업로드 가능합니다.');
|
||||||
|
input.value = '';
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024){
|
||||||
|
showErr('파일 용량은 5MB 이하만 가능합니다.');
|
||||||
|
input.value = '';
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try{
|
||||||
|
const text = String(reader.result || '');
|
||||||
|
const rows = parseCsvLoose(text);
|
||||||
|
|
||||||
|
if (!rows.length){
|
||||||
|
showErr('CSV 내용이 비어있습니다.');
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// phone 컬럼 존재 체크
|
||||||
|
const badPhone = rows.slice(0, 200).some(r => !/^01\d{8,9}$/.test(normalizePhone(r[0] ?? '')));
|
||||||
|
if (badPhone){
|
||||||
|
showErr('미리보기 범위 내에 휴대폰 형식이 올바르지 않은 줄이 있습니다. (빨간색 표시)');
|
||||||
|
} else {
|
||||||
|
showErr('');
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPreview(rows);
|
||||||
|
wrap.style.display = '';
|
||||||
|
}catch(e){
|
||||||
|
showErr('CSV 파싱 중 오류가 발생했습니다. 파일 형식을 확인해 주세요.');
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
showErr('파일을 읽을 수 없습니다. 다시 시도해 주세요.');
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const file = (input.files || [])[0];
|
||||||
|
handleFile(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn?.addEventListener('click', () => {
|
||||||
|
input.value = '';
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
showErr('');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
159
resources/views/admin/templates/form.blade.php
Normal file
159
resources/views/admin/templates/form.blade.php
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', $mode === 'create' ? '템플릿 생성' : '템플릿 수정')
|
||||||
|
@section('page_title', $mode === 'create' ? '템플릿 생성' : '템플릿 수정')
|
||||||
|
@section('page_desc', 'SMS 발송 화면에서 선택해 빠르게 삽입할 수 있습니다.')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.tpl-wrap{max-width:980px}
|
||||||
|
.tpl-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
|
||||||
|
@media (max-width: 980px){.tpl-grid{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
.tpl-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||||
|
.tpl-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}
|
||||||
|
|
||||||
|
.tpl-btn{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;}
|
||||||
|
.tpl-btn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.tpl-btn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.tpl-btn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.tpl-btn--ghost{background:transparent;}
|
||||||
|
|
||||||
|
.tpl-badge{padding:6px 10px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);font-size:12px;}
|
||||||
|
.tpl-help{line-height:1.6}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ $mode === 'create' ? route('admin.templates.store') : route('admin.templates.update', ['id'=>$tpl->id]) }}"
|
||||||
|
id="tplForm">
|
||||||
|
@csrf
|
||||||
|
@if($mode !== 'create')
|
||||||
|
@method('PUT')
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="tpl-wrap">
|
||||||
|
{{-- 상단 헤더 카드 --}}
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="tpl-row" style="justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:800; font-size:16px;">
|
||||||
|
{{ $mode === 'create' ? '새 템플릿 만들기' : '템플릿 수정' }}
|
||||||
|
</div>
|
||||||
|
<div class="a-muted" style="margin-top:6px;">
|
||||||
|
발송 화면에서 선택해 문구를 빠르게 삽입할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tpl-row">
|
||||||
|
<div class="tpl-badge">
|
||||||
|
상태: <b>{{ (int)old('is_active', $tpl->is_active ?? 1) === 1 ? '활성' : '비활성' }}</b>
|
||||||
|
</div>
|
||||||
|
@if($mode !== 'create')
|
||||||
|
<div class="tpl-badge">ID: <b>{{ $tpl->id }}</b></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 본문 그리드 --}}
|
||||||
|
<div class="tpl-grid">
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
{{-- code --}}
|
||||||
|
@if($mode === 'create')
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">Code (unique)</div>
|
||||||
|
<input class="a-input" name="code" value="{{ old('code') }}" placeholder="ex) welcome_01">
|
||||||
|
<div class="a-muted" style="margin-top:6px;">영문/숫자/대시/언더바 3~60</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">Code</div>
|
||||||
|
<div><code>{{ $tpl->code }}</code></div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- title --}}
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">제목</div>
|
||||||
|
<input class="a-input" name="title" value="{{ old('title', $tpl->title ?? '') }}" placeholder="예) 설날 인사">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- description --}}
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">설명 (선택)</div>
|
||||||
|
<input class="a-input" name="description" value="{{ old('description', $tpl->description ?? '') }}" placeholder="예) 2026 설 연휴 공지용">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- active --}}
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<label class="a-pill">
|
||||||
|
<input type="checkbox" name="is_active" value="1"
|
||||||
|
@checked((int)old('is_active', $tpl->is_active ?? 1) === 1)>
|
||||||
|
활성
|
||||||
|
</label>
|
||||||
|
<div class="a-muted" style="margin-top:6px;">비활성 템플릿은 발송 화면 목록에 표시되지 않습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
{{-- body --}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:end; gap:10px; flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">본문</div>
|
||||||
|
<div class="a-muted">토큰을 넣어 템플릿 발송/개인화에 활용할 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-row">
|
||||||
|
<button type="button" class="tpl-btn tpl-btn--ghost" data-insert="{_text_02_}">{_text_02_}</button>
|
||||||
|
<button type="button" class="tpl-btn tpl-btn--ghost" data-insert="{_text_03_}">{_text_03_}</button>
|
||||||
|
<button type="button" class="tpl-btn tpl-btn--ghost" data-insert="{_text_04_}">{_text_04_}</button>
|
||||||
|
<span class="a-muted">토큰 빠른 삽입</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="a-input" name="body" id="tplBody" rows="10"
|
||||||
|
placeholder="예) 안녕하세요 {_text_02_} 회원님 오늘 날짜는 {_text_03_} 입니다.">{{ old('body', $tpl->body ?? '') }}</textarea>
|
||||||
|
|
||||||
|
<div class="a-muted tpl-help" style="margin-top:10px;">
|
||||||
|
<b>템플릿 발송 CSV 예시</b><br>
|
||||||
|
<code>01036828958,홍길동,20210111</code><br>
|
||||||
|
<span class="a-muted">({_text_02_}=홍길동, {_text_03_}=20210111)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 하단 액션 --}}
|
||||||
|
<div class="a-card" style="padding:16px; margin-top:16px;">
|
||||||
|
<div class="tpl-actions">
|
||||||
|
<button class="tpl-btn tpl-btn--primary" type="submit">저장</button>
|
||||||
|
<a class="tpl-btn" href="{{ route('admin.templates.index') }}">목록</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const form = document.getElementById('tplForm');
|
||||||
|
const body = document.getElementById('tplBody');
|
||||||
|
if (!form || !body) return;
|
||||||
|
|
||||||
|
// token insert
|
||||||
|
form.querySelectorAll('[data-insert]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const token = btn.getAttribute('data-insert') || '';
|
||||||
|
const start = body.selectionStart ?? body.value.length;
|
||||||
|
const end = body.selectionEnd ?? body.value.length;
|
||||||
|
|
||||||
|
body.value = body.value.slice(0,start) + token + body.value.slice(end);
|
||||||
|
body.focus();
|
||||||
|
body.selectionStart = body.selectionEnd = start + token.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
127
resources/views/admin/templates/index.blade.php
Normal file
127
resources/views/admin/templates/index.blade.php
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'SMS 템플릿')
|
||||||
|
@section('page_title', 'SMS 템플릿')
|
||||||
|
@section('page_desc', '자주 쓰는 메시지 템플릿 관리')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
/* 이 페이지 전용 */
|
||||||
|
.tplbar{display:flex; gap:10px; flex-wrap:wrap; align-items:end;}
|
||||||
|
.tplbar__grow{flex:1; min-width:260px;}
|
||||||
|
|
||||||
|
.tplbtn{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;}
|
||||||
|
.tplbtn:hover{background:rgba(255,255,255,.10);}
|
||||||
|
.tplbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
||||||
|
.tplbtn--primary:hover{background:rgba(59,130,246,.98);}
|
||||||
|
.tplbtn--ghost{background:transparent;}
|
||||||
|
|
||||||
|
.tplpill{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);}
|
||||||
|
.tplpill--on{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
|
||||||
|
.tplpill--off{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
|
||||||
|
.tplcode{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;}
|
||||||
|
|
||||||
|
.tplbody{
|
||||||
|
max-width:520px;
|
||||||
|
display:-webkit-box;
|
||||||
|
-webkit-line-clamp:2;
|
||||||
|
-webkit-box-orient:vertical;
|
||||||
|
overflow:hidden;
|
||||||
|
white-space:normal;
|
||||||
|
opacity:.95;
|
||||||
|
}
|
||||||
|
.tpltable td{vertical-align:top;}
|
||||||
|
.tplright{display:flex; justify-content:flex-end;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
{{-- 필터/검색 --}}
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<form method="GET" action="{{ route('admin.templates.index') }}" class="tplbar">
|
||||||
|
<div style="min-width:140px;">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">활성</div>
|
||||||
|
<select class="a-input" name="active">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="1" @selected(request('active')==='1')>활성</option>
|
||||||
|
<option value="0" @selected(request('active')==='0')>비활성</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tplbar__grow">
|
||||||
|
<div class="a-muted" style="margin-bottom:6px;">검색</div>
|
||||||
|
<input class="a-input" name="q" value="{{ request('q') }}" placeholder="code / title / body">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tplright" style="gap:8px;">
|
||||||
|
<button class="tplbtn tplbtn--primary" type="submit">조회</button>
|
||||||
|
<a class="tplbtn" href="{{ route('admin.templates.create') }}">새 템플릿</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(request('active') !== null || request('q') !== null)
|
||||||
|
<div class="tplright" style="width:100%; margin-top:8px;">
|
||||||
|
<a class="tplbtn tplbtn--ghost" href="{{ route('admin.templates.index') }}">필터 초기화</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 리스트 --}}
|
||||||
|
<div class="a-card" style="padding:16px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px;">
|
||||||
|
<div class="a-muted">총 <b>{{ $templates->total() }}</b>건</div>
|
||||||
|
<div class="a-muted">
|
||||||
|
@if(request('active') !== null && request('active') !== '')
|
||||||
|
상태: <b>{{ request('active')==='1' ? '활성' : '비활성' }}</b>
|
||||||
|
@endif
|
||||||
|
@if((string)request('q') !== '')
|
||||||
|
/ 검색: <b>{{ request('q') }}</b>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table class="a-table tpltable" style="width:100%; min-width:980px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:70px;">ID</th>
|
||||||
|
<th style="width:110px;">상태</th>
|
||||||
|
<th style="width:180px;">Code</th>
|
||||||
|
<th style="width:220px;">Title</th>
|
||||||
|
<th>Body</th>
|
||||||
|
<th style="width:90px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($templates as $t)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $t->id }}</td>
|
||||||
|
<td>
|
||||||
|
@if((int)$t->is_active === 1)
|
||||||
|
<span class="tplpill tplpill--on">● 활성</span>
|
||||||
|
@else
|
||||||
|
<span class="tplpill tplpill--off">● 비활성</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td><span class="tplcode">{{ $t->code }}</span></td>
|
||||||
|
<td style="font-weight:700;">{{ $t->title }}</td>
|
||||||
|
<td><div class="tplbody">{{ $t->body }}</div></td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<a class="tplbtn" href="{{ route('admin.templates.edit', ['id'=>$t->id]) }}">수정</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="6" class="a-muted">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $templates->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\Auth\AdminAuthController;
|
|
||||||
use App\Http\Controllers\Admin\AdminAdminsController;
|
use App\Http\Controllers\Admin\AdminAdminsController;
|
||||||
use App\Http\Controllers\Admin\MeController;
|
use App\Http\Controllers\Admin\MeController;
|
||||||
|
use App\Http\Controllers\Admin\Auth\AdminAuthController;
|
||||||
|
use App\Http\Controllers\Admin\Sms\AdminSmsController;
|
||||||
|
use App\Http\Controllers\Admin\Sms\AdminSmsLogController;
|
||||||
|
use App\Http\Controllers\Admin\Sms\AdminSmsTemplateController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['web'])->group(function () {
|
Route::middleware(['web'])->group(function () {
|
||||||
@ -80,6 +83,35 @@ Route::middleware(['web'])->group(function () {
|
|||||||
Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock');
|
Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('/sms')->group(function () {
|
||||||
|
// 발송
|
||||||
|
Route::get('/send', [AdminSmsController::class, 'create'])->name('admin.sms.send');
|
||||||
|
Route::post('/send', [AdminSmsController::class, 'store'])->name('admin.sms.send.store');
|
||||||
|
|
||||||
|
// 이력
|
||||||
|
Route::get('/logs', [AdminSmsLogController::class, 'index'])->name('admin.sms.logs');
|
||||||
|
Route::get('/logs/{batchId}', [AdminSmsLogController::class, 'show'])->name('admin.sms.logs.show');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::prefix('templates')->name('admin.templates.')->group(function () {
|
||||||
|
Route::get('/', [AdminSmsTemplateController::class, 'index'])
|
||||||
|
->name('index');
|
||||||
|
|
||||||
|
Route::get('/create', [AdminSmsTemplateController::class, 'create'])
|
||||||
|
->name('create');
|
||||||
|
|
||||||
|
Route::post('/', [AdminSmsTemplateController::class, 'store'])
|
||||||
|
->name('store');
|
||||||
|
|
||||||
|
Route::get('/{id}', [AdminSmsTemplateController::class, 'edit'])
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('edit');
|
||||||
|
|
||||||
|
Route::put('/{id}', [AdminSmsTemplateController::class, 'update'])
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('update');
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니,
|
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니,
|
||||||
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨.
|
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user