디자인 ui/ux 변경 및 sms 발송관리, 템플릿, 로그 완료

This commit is contained in:
sungro815 2026-02-06 09:45:11 +09:00
parent af3b2e7534
commit 7e04708d79
23 changed files with 3099 additions and 547 deletions

View 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']}건을 등록했습니다."
]);
}
}

View 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,
]);
}
}

View 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' => '저장되었습니다.',
]);
}
}

View 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();
}
}

View 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();
}
}

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

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

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

View File

@ -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 로직과 동일한 우선순위 유지

View File

@ -127,3 +127,6 @@
window.__adminFlash = []; window.__adminFlash = [];
}); });
})(); })();

View File

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

View File

@ -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="이 계정의 잠금을 해제할까요?&#10;(locked_until 초기화 + 실패횟수 0)" data-confirm="이 계정의 잠금을 해제할까요?&#10;(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="비밀번호를 초기화할까요?&#10;임시 비밀번호는 이메일로 설정됩니다.&#10;(다음 로그인 시 변경 강제)" data-confirm="비밀번호를 초기화할까요?&#10;임시 비밀번호는 이메일로 설정됩니다.&#10;(다음 로그인 시 변경 강제)"
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>

View File

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

View File

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

View File

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

View File

@ -4,168 +4,192 @@
@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

View File

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

View 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') !== '')
&nbsp;/ 검색어: <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

View 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

View 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&#10;또는 줄바꿈으로 붙여넣기">{{ 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('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'",'&#039;');
}
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

View 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_} 회원님&#10;오늘 날짜는 {_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

View 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') !== '')
&nbsp; / 검색: <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

View File

@ -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 붙이면 .