메일, sms 예약발송 완료

This commit is contained in:
sungro815 2026-02-07 22:51:27 +09:00
parent 2b6e097fc1
commit 0db9e2bdc5
15 changed files with 1299 additions and 426 deletions

View File

@ -25,32 +25,19 @@ final class AdminSmsController extends Controller
{ {
$data = $request->validate([ $data = $request->validate([
'from_number' => ['required','string','max:30'], 'from_number' => ['required','string','max:30'],
'send_mode' => ['required','in:one,many,template'], 'send_mode' => ['required','in:one,many,template'], // template == CSV 업로드
'message' => ['required','string','max:2000'], 'message' => ['required','string','max:2000'],
'sms_type_hint' => ['nullable','in:auto,sms,mms'], 'sms_type_hint' => ['nullable','in:auto,sms,mms'],
'schedule_type' => ['required','in:now,schedule'],
'scheduled_at' => ['nullable','date_format:Y-m-d H:i'], 'scheduled_at' => ['nullable','date_format:Y-m-d H:i'],
// one 'to_number' => ['nullable','string','max:500'],
'to_number' => ['nullable','string','max:30'],
// many
'to_numbers_text'=> ['nullable','string','max:500000'], '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_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(); $adminId = (int) auth('admin')->id();
$res = $this->service->createBatch($adminId, $data, $request); $res = $this->service->createBatch($adminId, $data, $request);
if (!$res['ok']) { if (!$res['ok']) {

View File

@ -12,25 +12,29 @@ final class AdminSmsLogController extends Controller
private readonly AdminSmsLogService $service, private readonly AdminSmsLogService $service,
) {} ) {}
/**
* GET admin.sms.logs
*/
public function index(Request $request) public function index(Request $request)
{ {
$filters = $request->only([ $statusKeys = array_keys($this->service->getStatusLabels());
'status', 'send_mode', 'q', 'date_from', 'date_to', $modeKeys = array_keys($this->service->getModeLabels());
$filters = $request->validate([
'status' => ['nullable', 'in:'.implode(',', $statusKeys)],
'send_mode' => ['nullable', 'in:'.implode(',', $modeKeys)],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'q' => ['nullable', 'string', 'max:120'],
]); ]);
$batches = $this->service->paginateBatches($filters, 30); $batches = $this->service->paginateBatches($filters, 20);
return view('admin.sms.logs.index', [ return view('admin.sms.logs.index', [
'batches' => $batches, 'batches' => $batches,
'filters' => $filters,
'labels' => $this->service->getStatusLabels(),
'modeLabels' => $this->service->getModeLabels(),
]); ]);
} }
/**
* GET admin.sms.logs.show
*/
public function show(int $batchId, Request $request) public function show(int $batchId, Request $request)
{ {
$batch = $this->service->getBatch($batchId); $batch = $this->service->getBatch($batchId);
@ -42,12 +46,23 @@ final class AdminSmsLogController extends Controller
]); ]);
} }
$filters = $request->only(['status', 'to', 'q']); $itemStatusKeys = array_keys($this->service->getItemStatusLabels());
$filters = $request->validate([
'status' => ['nullable', 'in:'.implode(',', $itemStatusKeys)],
'to' => ['nullable', 'string', 'max:40'],
'q' => ['nullable', 'string', 'max:120'],
]);
$items = $this->service->paginateItems($batchId, $filters, 50); $items = $this->service->paginateItems($batchId, $filters, 50);
return view('admin.sms.logs.show', [ return view('admin.sms.logs.show', [
'batch' => $batch, 'batch' => $batch,
'items' => $items, 'items' => $items,
'filters' => $filters,
'labels' => $this->service->getStatusLabels(),
'modeLabels' => $this->service->getModeLabels(),
'itemLabels' => $this->service->getItemStatusLabels(),
]); ]);
} }
} }

View File

@ -54,7 +54,7 @@ final class AdminMailService
{ {
return [ return [
'one'=>'단건', 'one'=>'단건',
'many'=>'직접입력(여러건)', 'many'=>'여러건',
'csv'=>'CSV 업로드', 'csv'=>'CSV 업로드',
'db'=>'DB 검색', 'db'=>'DB 검색',
]; ];
@ -120,6 +120,13 @@ final class AdminMailService
public function listBatches(array $filters, int $perPage=20) public function listBatches(array $filters, int $perPage=20)
{ {
$page = $this->repo->listBatches($filters, $perPage);
if (is_object($page) && method_exists($page, 'appends')) {
$clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$page->appends($clean);
}
return $this->repo->listBatches($filters, $perPage); return $this->repo->listBatches($filters, $perPage);
} }
@ -129,6 +136,12 @@ final class AdminMailService
if (!$batch) return ['ok'=>false]; if (!$batch) return ['ok'=>false];
$items = $this->repo->listItems($batchId, $filters, $perPage); $items = $this->repo->listItems($batchId, $filters, $perPage);
if (is_object($items) && method_exists($items, 'appends')) {
$clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$items->appends($clean);
}
return ['ok'=>true,'batch'=>$batch,'items'=>$items]; return ['ok'=>true,'batch'=>$batch,'items'=>$items];
} }

View File

@ -11,9 +11,48 @@ final class AdminSmsLogService
private readonly AdminSmsBatchRepository $repo, private readonly AdminSmsBatchRepository $repo,
) {} ) {}
public function getStatusLabels(): array
{
return [
'scheduled' => '예약대기',
'queued' => '대기',
'submitting' => '전송중',
'submitted' => '전송완료',
'partial' => '일부실패',
'failed' => '실패',
'canceled' => '취소',
];
}
public function getItemStatusLabels(): array
{
return [
'queued' => '대기',
'submitted' => '성공',
'failed' => '실패',
'canceled' => '취소',
'skipped' => '스킵',
];
}
public function getModeLabels(): array
{
return [
'one' => '단건',
'many' => '대량',
'template' => '템플릿',
];
}
public function paginateBatches(array $filters, int $perPage = 30): LengthAwarePaginator public function paginateBatches(array $filters, int $perPage = 30): LengthAwarePaginator
{ {
return $this->repo->paginateBatches($filters, $perPage); $page = $this->repo->paginateBatches($filters, $perPage);
// ✅ 페이지 이동 시 필터 유지
$clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$page->appends($clean);
return $page;
} }
public function getBatch(int $batchId): ?object public function getBatch(int $batchId): ?object
@ -23,6 +62,12 @@ final class AdminSmsLogService
public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator
{ {
return $this->repo->paginateItems($batchId, $filters, $perPage); $page = $this->repo->paginateItems($batchId, $filters, $perPage);
// ✅ 상세 페이지에서도 필터 유지
$clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$page->appends($clean);
return $page;
} }
} }

View File

@ -15,19 +15,20 @@ final class AdminSmsService
) {} ) {}
/** /**
* @param int $adminId * send_mode:
* @param array $data 컨트롤러 validate 결과 * - one : 단건 (전화번호만 or "전화번호,치환값..." 가능)
* @return array ['ok'=>bool, 'batch_id'=>int, 'total'=>int, 'valid'=>int, 'invalid'=>int, 'duplicate'=>int, 'message'=>string] * - many: 대량 붙여넣기 (1=1, "전화번호" 또는 "전화번호,치환값..." 가능) / 최대 100(중복 제거 )
* - csv : CSV 업로드 (1=1, phone + 치환값...)
*/ */
public function createBatch(int $adminId, array $data, Request $request): array public function createBatch(int $adminId, array $data, Request $request): array
{ {
$mode = (string)($data['send_mode'] ?? 'one'); $mode = (string)($data['send_mode'] ?? 'one');
$from = (string)($data['from_number'] ?? ''); $from = (string)($data['from_number'] ?? '');
$msgRaw = (string)($data['message'] ?? ''); $msgRaw = (string)($data['message'] ?? '');
$smsTypeHint = (string)($data['sms_type_hint'] ?? 'auto'); $smsTypeHint = (string)($data['sms_type_hint'] ?? 'auto');
$scheduleType = (string)($data['schedule_type'] ?? 'now'); $scheduleType = (string)($data['schedule_type'] ?? 'now');
$scheduledAt = null; $scheduledAt = null;
if ($scheduleType === 'schedule') { if ($scheduleType === 'schedule') {
$scheduledAt = !empty($data['scheduled_at']) ? $data['scheduled_at'] : null; $scheduledAt = !empty($data['scheduled_at']) ? $data['scheduled_at'] : null;
@ -36,36 +37,27 @@ final class AdminSmsService
} }
} }
// 1) 대상 생성
try { try {
$targets = match ($mode) { $targets = match ($mode) {
'one' => $this->buildTargetsOne((string)($data['to_number'] ?? ''), $msgRaw), 'one' => $this->buildTargetsOne((string)($data['to_number'] ?? ''), $msgRaw),
'many' => $this->buildTargetsMany( 'many' => $this->buildTargetsMany((string)($data['to_numbers_text'] ?? ''), $msgRaw),
(string)($data['to_numbers_text'] ?? ''), 'csv' => $this->buildTargetsCsv($request->file('template_csv'), $msgRaw),
$request->file('to_numbers_csv'), default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'),
$msgRaw
),
'template' => $this->buildTargetsTemplate(
$request->file('template_csv'),
$msgRaw
),
default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'),
}; };
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['ok' => false, 'message' => $e->getMessage()]; return ['ok' => false, 'message' => $e->getMessage()];
} }
$total = $targets['total']; $total = $targets['total'];
$valid = $targets['valid']; $valid = $targets['valid'];
$invalid = $targets['invalid']; $invalid = $targets['invalid'];
$dup = $targets['duplicate']; $dup = $targets['duplicate'];
$items = $targets['items']; // seq 포함된 배열 $items = $targets['items'];
if ($valid < 1) { if ($valid < 1) {
return ['ok' => false, 'message' => '유효한 수신번호가 없습니다.']; return ['ok' => false, 'message' => '유효한 수신번호가 없습니다.'];
} }
// 2) batch insert
$byteLen = $this->calcBytesCiLike($msgRaw); $byteLen = $this->calcBytesCiLike($msgRaw);
$batchId = $this->repo->insertBatch([ $batchId = $this->repo->insertBatch([
@ -85,46 +77,43 @@ final class AdminSmsService
'invalid_count' => $invalid, 'invalid_count' => $invalid,
'status' => ($scheduleType === 'schedule') ? 'scheduled' : 'queued', 'status' => ($scheduleType === 'schedule') ? 'scheduled' : 'queued',
'request_ip' => $request->ip(), 'request_ip' => $request->ip(),
'user_agent' => substr((string)$request->userAgent(), 0, 255), 'user_agent' => substr((string)$request->userAgent(), 0, 255),
]); ]);
// 3) items bulk insert (예약이면 queued, 즉시도 일단 queued로 넣고 이후 submitted/failed로 업데이트) //sms mms 발송
$this->repo->insertItemsChunked($batchId, $items); $this->repo->insertItemsChunked($batchId, $items);
$this->repo->updateBatch($batchId, ['status' => 'submitting']);
// 4) 즉시 발송이면 실제 provider queue(DB)에 insert (SmsService가 sms_server에 넣음) $submitted = 0;
if ($scheduleType === 'now') { $failed = 0;
$this->repo->updateBatch($batchId, ['status' => 'submitting']);
$submitted = 0; foreach ($items as $it) {
$failed = 0; $payload = [
'from_number' => $from,
'to_number' => $it['to_number'],
'message' => $it['message_final'],
'sms_type' => $it['sms_type'],
'scheduled_at' => $scheduledAt,
];
foreach ($items as $it) { $ok = $this->smsService->send($payload, 'lguplus');
$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++;
if ($ok) { $this->repo->updateItemBySeq($batchId, (int)$it['seq'], [
$submitted++; 'status' => 'submitted',
$this->repo->updateItemBySeq($batchId, (int)$it['seq'], [ 'submitted_at' => now()->format('Y-m-d H:i:s'),
'status' => 'submitted', ]);
'submitted_at' => now()->format('Y-m-d H:i:s'), } else {
]); $failed++;
} else { $this->repo->updateItemBySeq($batchId, (int)$it['seq'], [
$failed++; 'status' => 'failed',
$this->repo->updateItemBySeq($batchId, (int)$it['seq'], [ 'provider_message' => 'SmsService send() returned false',
'status' => 'failed', ]);
'provider_message' => 'SmsService send() returned false',
]);
}
} }
$status = 'submitted'; $status = 'submitted';
if ($submitted < 1) $status = 'failed'; if ($submitted < 1) $status = 'failed';
else if ($failed > 0) $status = 'partial'; else if ($failed > 0) $status = 'partial';
@ -145,13 +134,19 @@ final class AdminSmsService
]; ];
} }
private function buildTargetsOne(string $to, string $message): array /**
* 단건: "010..." 또는 "010...,값,값,값..."
*/
private function buildTargetsOne(string $toRaw, string $message): array
{ {
$digits = $this->normalizePhone($to); [$digits, $args] = $this->parseInlineRow($toRaw);
if (!$this->isValidKrMobile($digits)) { if (!$this->isValidKrMobile($digits)) {
return ['total'=>1,'valid'=>0,'invalid'=>1,'duplicate'=>0,'items'=>[]]; return ['total'=>1,'valid'=>0,'invalid'=>1,'duplicate'=>0,'items'=>[]];
} }
$msgFinal = $this->applyTokensIfNeeded($message, $args, '단건 입력');
return [ return [
'total' => 1, 'total' => 1,
'valid' => 1, 'valid' => 1,
@ -160,146 +155,73 @@ final class AdminSmsService
'items' => [[ 'items' => [[
'seq' => 1, 'seq' => 1,
'to_number' => $digits, 'to_number' => $digits,
'message_final' => $message, 'message_final' => $msgFinal,
'sms_type' => $this->guessSmsType($message), 'sms_type' => $this->guessSmsType($msgFinal),
'status' => 'queued', 'status' => 'queued',
]], ]],
]; ];
} }
private function buildTargetsMany(string $text, ?UploadedFile $csv, string $message): array /**
* 대량(붙여넣기): 1=1
* - 전화번호만 가능
* - 또는 "전화번호,치환값..." 가능 (토큰 치환 지원)
* - 최대 100 (중복 제거 )
*/
private function buildTargetsMany(string $text, string $message): array
{ {
$phones = []; $lines = preg_split('/\r\n|\n|\r/', (string)$text) ?: [];
$lines = array_values(array_filter(array_map('trim', $lines), fn($v) => $v !== ''));
// 1) textarea $total = count($lines);
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; $invalid = 0;
foreach ($phones as $p) { // 메시지에 토큰이 있으면 필요 args 수 계산
if ($this->isValidKrMobile($p)) $validList[] = $p; $idxs = $this->extractTemplateIndexes($message);
else $invalid++; $needArgs = count($idxs);
}
$uniq = array_values(array_unique($validList)); // phone별 첫 줄만 사용(중복 제거)
$dup = count($validList) - count($uniq); $firstByPhone = []; // phone => [args, lineNo]
$dup = 0;
$smsType = $this->guessSmsType($message); foreach ($lines as $i => $line) {
$items = []; $lineNo = $i + 1;
$seq = 1; [$phone, $args] = $this->parseInlineRow($line);
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)) { if (!$this->isValidKrMobile($phone)) {
$invalid++; $invalid++;
continue; continue;
} }
// args 검증: row[1..needArgs] must exist and not empty // 토큰 사용중이면 이 줄에 치환값이 충분해야 함
for ($i=0; $i<$needArgs; $i++) { if ($needArgs > 0) {
$v = (string)($row[$i+1] ?? ''); $this->assertTokensContiguousFrom2($idxs, "대량 {$lineNo}라인");
if (trim($v) === '') { if (count($args) < $needArgs) {
throw new \RuntimeException("CSV {$lineNo}라인: {_text_0".(2+$i)."_} 매칭 값이 없습니다."); throw new \RuntimeException("대량 {$lineNo}라인: 토큰 치환값이 부족합니다. 필요 {$needArgs}개인데 입력은 ".count($args)."개입니다. (형식: 전화번호,값,값,...)");
} }
} }
$rowsToBuild[] = [$phone, $row]; // 나중에 치환 if (!isset($firstByPhone[$phone])) {
$validPhones[] = $phone; $firstByPhone[$phone] = [$args, $lineNo];
} else {
$dup++;
}
} }
$uniqPhones = array_values(array_unique($validPhones)); $uniqPhones = array_keys($firstByPhone);
$dup = count($validPhones) - count($uniqPhones);
// phone별 첫 등장 row만 사용(중복 번호는 제거) // 100건 제한(중복 제거 후)
$firstRowByPhone = []; if (count($uniqPhones) > 100) {
foreach ($rowsToBuild as [$phone, $row]) { throw new \RuntimeException('대량(붙여넣기)은 최대 100건입니다. 100건 초과는 CSV 업로드를 이용해 주세요.');
if (!isset($firstRowByPhone[$phone])) {
$firstRowByPhone[$phone] = $row;
}
} }
$items = []; $items = [];
$seq = 1; $seq = 1;
foreach ($uniqPhones as $phone) { foreach ($uniqPhones as $phone) {
$row = $firstRowByPhone[$phone]; [$args] = $firstByPhone[$phone];
$msgFinal = $message;
for ($i=0; $i<$needArgs; $i++) {
$token = '{_text_0'.(2+$i).'_}';
$msgFinal = str_replace($token, (string)$row[$i+1], $msgFinal);
}
$msgFinal = $this->applyTokensIfNeeded($message, $args, '대량 붙여넣기');
$items[] = [ $items[] = [
'seq' => $seq++, 'seq' => $seq++,
'to_number' => $phone, 'to_number' => $phone,
@ -318,6 +240,140 @@ final class AdminSmsService
]; ];
} }
/**
* CSV 업로드: 1=1 (phone + 치환값...)
* - 토큰 없으면 phone만 있어도 OK
* - 토큰 있으면 컬럼이 부족하면 에러
*/
private function buildTargetsCsv(?UploadedFile $csv, string $message): array
{
if (!$csv instanceof UploadedFile) {
throw new \RuntimeException('CSV 파일을 업로드해 주세요.');
}
$idxs = $this->extractTemplateIndexes($message);
$needArgs = count($idxs);
if ($needArgs > 0) {
$this->assertTokensContiguousFrom2($idxs, 'CSV');
}
$total = 0;
$invalid = 0;
$firstByPhone = []; // phone => row
$dup = 0;
$lineNo = 0;
foreach ($this->readCsvLines($csv) as $row) {
$lineNo++;
if (empty($row)) continue;
$total++;
$phone = $this->normalizePhone((string)($row[0] ?? ''));
if (!$this->isValidKrMobile($phone)) {
$invalid++;
continue;
}
if ($needArgs > 0) {
for ($i=0; $i<$needArgs; $i++) {
$v = trim((string)($row[$i+1] ?? ''));
if ($v === '') {
throw new \RuntimeException("CSV {$lineNo}라인: {_text_0".(2+$i)."_} 매칭 값이 없습니다.");
}
}
}
if (!isset($firstByPhone[$phone])) {
$firstByPhone[$phone] = $row;
} else {
$dup++;
}
}
$items = [];
$seq = 1;
foreach (array_keys($firstByPhone) as $phone) {
$row = $firstByPhone[$phone];
$args = [];
if ($needArgs > 0) {
for ($i=0; $i<$needArgs; $i++) $args[] = trim((string)$row[$i+1]);
}
$msgFinal = $this->applyTokensIfNeeded($message, $args, 'CSV');
$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 parseInlineRow(string $line): array
{
$line = trim((string)$line);
if ($line === '') return ['', []];
$parts = array_map('trim', explode(',', $line));
// 빈값 제거는 args의 공백도 날릴 수 있으니 "앞쪽만" 최소 정리
$parts = array_values($parts);
$phone = $this->normalizePhone($parts[0] ?? '');
$args = array_slice($parts, 1); // {_text_02_}부터 순서대로
// args는 trim만
$args = array_map(fn($v) => trim((string)$v), $args);
return [$phone, $args];
}
private function applyTokensIfNeeded(string $message, array $args, string $label): string
{
$idxs = $this->extractTemplateIndexes($message);
if (empty($idxs)) return $message;
$this->assertTokensContiguousFrom2($idxs, $label);
$needArgs = count($idxs);
if (count($args) < $needArgs) {
throw new \RuntimeException("{$label}: 토큰 치환값이 부족합니다. 필요 {$needArgs}개인데 입력은 ".count($args)."개입니다.");
}
$out = $message;
for ($i=0; $i<$needArgs; $i++) {
$token = '{_text_0'.(2+$i).'_}';
$out = str_replace($token, (string)$args[$i], $out);
}
return $out;
}
private function assertTokensContiguousFrom2(array $idxs, string $label): void
{
$idxs = array_values($idxs);
sort($idxs);
if ($idxs[0] !== 2) {
throw new \RuntimeException("{$label}: 첫 토큰은 {_text_02_}로 시작해야 합니다.");
}
for ($i=0; $i<count($idxs); $i++) {
if ($idxs[$i] !== (2 + $i)) {
$need = 2 + $i;
throw new \RuntimeException("{$label}: {_text_0{$need}_} 토큰이 누락되었습니다.");
}
}
}
private function normalizePhone(string $v): string private function normalizePhone(string $v): string
{ {
return preg_replace('/\D+/', '', $v) ?? ''; return preg_replace('/\D+/', '', $v) ?? '';
@ -339,14 +395,12 @@ final class AdminSmsService
private function guessSmsType(string $message): string private function guessSmsType(string $message): string
{ {
// SmsService의 기존 기준을 “대충 맞춰”주는 용도(최종 판정은 provider/서비스)
$len = @mb_strlen($message, 'EUC-KR'); $len = @mb_strlen($message, 'EUC-KR');
return ($len !== false && $len > 90) ? 'mms' : 'sms'; return ($len !== false && $len > 90) ? 'mms' : 'sms';
} }
private function calcBytesCiLike(string $str): int private function calcBytesCiLike(string $str): int
{ {
// CI/JS 방식: ASCII 1, 그 외 2
$bytes = 0; $bytes = 0;
$len = mb_strlen($str, 'UTF-8'); $len = mb_strlen($str, 'UTF-8');
for ($i=0; $i<$len; $i++) { for ($i=0; $i<$len; $i++) {
@ -357,10 +411,6 @@ final class AdminSmsService
return $bytes; return $bytes;
} }
/**
* CSV를 "라인 단위" 읽어서 배열(row) 반환
* - UTF-8/CP949/EUC-KR 어느 정도 허용
*/
private function readCsvLines(UploadedFile $csv): \Generator private function readCsvLines(UploadedFile $csv): \Generator
{ {
$path = $csv->getRealPath(); $path = $csv->getRealPath();
@ -373,11 +423,9 @@ final class AdminSmsService
while (($row = fgetcsv($fh)) !== false) { while (($row = fgetcsv($fh)) !== false) {
if ($row === [null] || $row === false) continue; if ($row === [null] || $row === false) continue;
// encoding normalize
$out = []; $out = [];
foreach ($row as $cell) { foreach ($row as $cell) {
$cell = (string)$cell; $out[] = $this->toUtf8((string)$cell);
$out[] = $this->toUtf8($cell);
} }
yield $out; yield $out;
} }
@ -388,13 +436,8 @@ final class AdminSmsService
private function toUtf8(string $s): string private function toUtf8(string $s): string
{ {
// BOM 제거
$s = preg_replace('/^\xEF\xBB\xBF/', '', $s) ?? $s; $s = preg_replace('/^\xEF\xBB\xBF/', '', $s) ?? $s;
// 이미 UTF-8이면 그대로
if (mb_check_encoding($s, 'UTF-8')) return $s; 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'); $converted = @mb_convert_encoding($s, 'UTF-8', 'CP949,EUC-KR,ISO-8859-1');
return is_string($converted) ? $converted : $s; return is_string($converted) ? $converted : $s;
} }

View File

@ -2,8 +2,6 @@
namespace App\Services; namespace App\Services;
use App\Models\Sms\ScTran;
use App\Models\Sms\MmsMsg;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
@ -19,6 +17,7 @@ class SmsService
* - subject (mms일 없으면 자동 생성) * - subject (mms일 없으면 자동 생성)
* - sms_type (sms|mms) (없으면 길이로 판단) * - sms_type (sms|mms) (없으면 길이로 판단)
* - country (없으면 세션/기본 82) * - country (없으면 세션/기본 82)
* - scheduled_at (옵션) : 'Y-m-d H:i' 또는 'Y-m-d H:i:s' (미래면 예약, 아니면 즉시)
* *
* @param string|null $companyName * @param string|null $companyName
* @return bool * @return bool
@ -51,15 +50,13 @@ class SmsService
return false; return false;
} }
// 4) 업체별 발송 // 4) 업체별 발송 (실제는 업체 DB insert)
return match ($this->companyName) { return match ($this->companyName) {
'lguplus' => $this->lguplusSend($payload), 'lguplus' => $this->lguplusSend($payload),
'sms2' => $this->sms2Send($payload), 'sms2' => $this->sms2Send($payload),
default => false, default => false,
}; };
} catch (\Throwable $e) { } catch (\Throwable $e) {
// 운영에서는 로깅 권장
// logger()->error('SmsService send failed', ['e' => $e]);
return false; return false;
} }
} }
@ -83,21 +80,54 @@ class SmsService
return true; return true;
} }
/**
* 예약시간 처리(CI3 로직 그대로)
* - scheduled_at이 없으면 now()
* - 'Y-m-d H:i' ':00' 붙임
* - 미래면 시간, 과거/형식오류면 now()
*/
private function resolveProviderSendDate(?string $scheduledAt): string
{
$now = now()->format('Y-m-d H:i:s');
if (!$scheduledAt) return $now;
$scheduled = trim($scheduledAt);
// 폼은 Y-m-d H:i 로 오니 :00 붙임
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $scheduled)) {
$scheduled .= ':00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $scheduled)) {
if (strtotime($now) < strtotime($scheduled)) {
return $scheduled; // 미래면 예약
}
}
return $now; // 과거/형식오류면 즉시
}
/** /**
* CI: lguplus_send 이식 * CI: lguplus_send 이식
* - 메시지 길이(EUC-KR) 기준 90 이하 sms, 초과 mms * - 메시지 길이(EUC-KR) 기준 90 이하 sms, 초과 mms
* - sms_type이 명시되면 그걸 우선 * - sms_type이 명시되면 그걸 우선
* - scheduled_at이 미래면 업체 예약 컬럼에 해당 시간으로 insert
* - SMS: SC_TRAN.TR_SENDDATE
* - MMS: MMS_MSG.REQDATE
*/ */
private function lguplusSend(array $data): bool private function lguplusSend(array $data): bool
{ {
$conn = DB::connection('sms_server'); $conn = DB::connection('sms_server');
$smsSendType = $this->resolveSendType($data); $smsSendType = $this->resolveSendType($data);
return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data) { // ✅ 예약/즉시 결정
$sendDate = $this->resolveProviderSendDate($data['scheduled_at'] ?? null);
return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data, $sendDate) {
if ($smsSendType === 'sms') { if ($smsSendType === 'sms') {
return $conn->table('SC_TRAN')->insert([ return $conn->table('SC_TRAN')->insert([
'TR_SENDDATE' => now()->format('Y-m-d H:i:s'), 'TR_SENDDATE' => $sendDate,
'TR_SENDSTAT' => '0', 'TR_SENDSTAT' => '0',
'TR_MSGTYPE' => '0', 'TR_MSGTYPE' => '0',
'TR_PHONE' => $data['to_number'], 'TR_PHONE' => $data['to_number'],
@ -113,7 +143,7 @@ class SmsService
'PHONE' => $data['to_number'], 'PHONE' => $data['to_number'],
'CALLBACK' => $data['from_number'], 'CALLBACK' => $data['from_number'],
'STATUS' => '0', 'STATUS' => '0',
'REQDATE' => now()->format('Y-m-d H:i:s'), 'REQDATE' => $sendDate,
'MSG' => $data['message'], 'MSG' => $data['message'],
'FILE_CNT' => 0, 'FILE_CNT' => 0,
'FILE_PATH1' => '', 'FILE_PATH1' => '',
@ -122,7 +152,6 @@ class SmsService
}); });
} }
private function resolveSendType(array $data): string private function resolveSendType(array $data): string
{ {
// CI 로직과 동일한 우선순위 유지 // CI 로직과 동일한 우선순위 유지

View File

@ -1296,3 +1296,40 @@ html,body{ height:100%; }
} }
} }
.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;}
.batch-link-btn{
display:inline-flex;
align-items:center;
justify-content:center;
padding:6px 10px;
border-radius:10px;
font-size:12px;
font-weight:800;
text-decoration:none !important;
color:#fff !important; /* 글씨 흰색 */
background:rgba(59,130,246,.88);
border:1px solid rgba(59,130,246,.95);
box-shadow: 0 1px 0 rgba(0,0,0,.25);
}
.batch-link-btn:hover{
background:rgba(59,130,246,.98);
transform: translateY(-1px);
}
.batch-link-btn:active{
transform: translateY(0);
}
tr.row-link{ cursor:pointer; }
tr.row-link:hover td{
background: rgba(255,255,255,.03);
}

View File

@ -34,21 +34,8 @@
@section('content') @section('content')
@php @php
$statusLabel = [ $statusLabel = $labels ?? [];
'scheduled' => '예약', $modeLabel = $modeLabels ?? [];
'queued' => '대기',
'submitting' => '발송중',
'submitted' => '완료',
'partial' => '부분성공',
'failed' => '실패',
'canceled' => '취소',
];
$modeLabel = [
'one' => '단건',
'many' => '여러건',
'template' => '템플릿CSV',
'db' => 'DB검색',
];
@endphp @endphp
<div class="a-card" style="padding:16px; margin-bottom:16px;"> <div class="a-card" style="padding:16px; margin-bottom:16px;">
@ -121,8 +108,8 @@
@php @php
$st = (string)$b->status; $st = (string)$b->status;
$pillClass = match ($st) { $pillClass = match ($st) {
'submitted' => 'pill--ok', 'sent' => 'pill--ok',
'partial','submitting','queued','scheduled' => 'pill--warn', 'partial','sending','queued','scheduled' => 'pill--warn',
'failed','canceled' => 'pill--bad', 'failed','canceled' => 'pill--bad',
default => 'pill--muted', default => 'pill--muted',
}; };
@ -131,10 +118,13 @@
$sent = (int)($b->sent_count ?? 0); $sent = (int)($b->sent_count ?? 0);
$total = (int)($b->valid_count ?? $b->total_count ?? 0); $total = (int)($b->valid_count ?? $b->total_count ?? 0);
@endphp @endphp
<tr> <tr class="row-link" data-url="{{ route('admin.mail.logs.show', ['batchId'=>$b->id]) }}">
<td> <td>
<a class="mono" style="text-decoration:none;" <a href="{{ route('admin.mail.logs.show', ['batchId'=>$b->id]) }}"
href="{{ route('admin.mail.logs.show', ['batchId'=>$b->id]) }}">#{{ $b->id }}</a> class="batch-link-btn mono">
#{{ $b->id }}
</a>
</td>
</td> </td>
<td class="a-muted">{{ $b->created_at }}</td> <td class="a-muted">{{ $b->created_at }}</td>
<td style="font-weight:700;">{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}</td> <td style="font-weight:700;">{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}</td>
@ -152,7 +142,7 @@
</div> </div>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
{{ $batches->links() }} {{ $batches->onEachSide(1)->links('vendor.pagination.admin') }}
</div> </div>
</div> </div>
@ -173,6 +163,16 @@
to.value = ''; to.value = '';
}); });
})(); })();
document.addEventListener('click', (e) => {
const tr = e.target.closest('tr.row-link');
if (!tr) return;
// a, button, input 등 "클릭 가능한 요소"를 눌렀으면 기본 동작 존중
if (e.target.closest('a,button,input,select,textarea,label')) return;
const url = tr.getAttribute('data-url');
if (url) window.location.href = url;
});
</script> </script>
@endpush @endpush
@endsection @endsection

View File

@ -22,21 +22,9 @@
@section('content') @section('content')
@php @php
$statusLabel = [ $statusLabel = $labels ?? [];
'scheduled' => '예약', $modeLabel = $modeLabels ?? [];
'queued' => '대기',
'submitting' => '발송중',
'submitted' => '완료',
'partial' => '부분성공',
'failed' => '실패',
'canceled' => '취소',
];
$modeLabel = [
'one' => '단건',
'many' => '여러건',
'template' => '템플릿CSV',
'db' => 'DB검색',
];
$sent = (int)($batch->sent_count ?? 0); $sent = (int)($batch->sent_count ?? 0);
$total = (int)($batch->valid_count ?? $batch->total_count ?? 0); $total = (int)($batch->valid_count ?? $batch->total_count ?? 0);
@ -149,9 +137,8 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
{{ $items->links() }} {{ $items->onEachSide(1)->links('vendor.pagination.admin') }}
</div> </div>
</div> </div>
@endsection @endsection

View File

@ -120,8 +120,18 @@
<div class="mrow"> <div class="mrow">
<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="now" checked> 즉시발송</label>
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약발송</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>
<span class="a-muted" style="font-size:12px;">(5 단위 권장)</span> {{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}}
<input class="a-input"
type="text"
name="scheduled_at"
id="scheduledAt"
placeholder="YYYY-MM-DD HH:mm"
style="width:180px"
disabled
readonly>
<button type="button" class="mbtn" id="openSchedulePicker" disabled>선택</button>
</div> </div>
</div> </div>
@ -327,6 +337,50 @@
</div> </div>
</form> </form>
{{-- ===== 예약발송 날짜/시간/(5분단위) 선택 모달 ===== --}}
<div id="schedulePickerModal" class="mailModal" aria-hidden="true">
<div id="schedulePickerBackdrop" class="mailModal__back"></div>
<div class="mailModal__box" role="dialog" aria-modal="true" aria-label="예약 발송 시간 선택"
style="width:520px; height:auto; max-height:calc(100vh - 24px);">
<div class="mailModal__head">
<div>
<div style="font-weight:900;">예약 발송 시간 선택</div>
<div class="mailModal__hint">날짜 · 시간 · (5 단위)</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button type="button" class="mbtn" id="closeSchedulePicker">닫기</button>
</div>
</div>
<div class="mailModal__body" style="height:auto;">
<div class="a-card" style="padding:14px;">
<div class="mrow" style="align-items:end;">
<div>
<div class="a-muted" style="margin-bottom:6px;">날짜</div>
<input class="a-input" type="date" id="schDate" style="width:180px;">
</div>
<div>
<div class="a-muted" style="margin-bottom:6px;">시간</div>
<select class="a-input" id="schHour" style="width:120px;"></select>
</div>
<div>
<div class="a-muted" style="margin-bottom:6px;"></div>
<select class="a-input" id="schMin" style="width:120px;"></select>
</div>
</div>
<div class="mrow" style="margin-top:12px; justify-content:flex-end;">
<button type="button" class="mbtn mbtn--ghost" id="resetSchedulePicker">초기화</button>
<button type="button" class="mbtn mbtn--primary" id="applySchedulePicker">적용</button>
</div>
</div>
</div>
</div>
</div>
{{-- ===== 전체 메일 형태 미리보기: 레이어 팝업 + 내부 스크롤 + iframe ===== --}} {{-- ===== 전체 메일 형태 미리보기: 레이어 팝업 + 내부 스크롤 + iframe ===== --}}
<div id="mailPreviewModal" class="mailModal" aria-hidden="true"> <div id="mailPreviewModal" class="mailModal" aria-hidden="true">
<div id="mailPreviewBackdrop" class="mailModal__back"></div> <div id="mailPreviewBackdrop" class="mailModal__back"></div>
@ -369,6 +423,7 @@
const sendModeEl = document.getElementById('sendMode'); const sendModeEl = document.getElementById('sendMode');
const scheduledAtEl = document.getElementById('scheduledAt'); const scheduledAtEl = document.getElementById('scheduledAt');
const openSchBtn = document.getElementById('openSchedulePicker'); // ✅ 추가
const toEmailOneEl = document.getElementById('toEmail'); const toEmailOneEl = document.getElementById('toEmail');
const toEmailsTextEl = document.getElementById('toEmailsText'); const toEmailsTextEl = document.getElementById('toEmailsText');
@ -412,6 +467,18 @@
const tplSelect = document.getElementById('tplSelect'); const tplSelect = document.getElementById('tplSelect');
const tplApplyBtn = document.getElementById('tplApply'); const tplApplyBtn = document.getElementById('tplApply');
// ✅ 예약발송 Picker modal elements (추가)
const schModalEl = document.getElementById('schedulePickerModal');
const schBackEl = document.getElementById('schedulePickerBackdrop');
const schCloseBtn = document.getElementById('closeSchedulePicker');
const schDateEl = document.getElementById('schDate');
const schHourEl = document.getElementById('schHour');
const schMinEl = document.getElementById('schMin');
const schApplyBtn = document.getElementById('applySchedulePicker');
const schResetBtn = document.getElementById('resetSchedulePicker');
// ===== helpers // ===== helpers
function escapeHtml(s){ function escapeHtml(s){
return String(s).replace(/[&<>"']/g, m => ({ return String(s).replace(/[&<>"']/g, m => ({
@ -419,6 +486,42 @@
}[m])); }[m]));
} }
function pad2(n){ return String(n).padStart(2,'0'); }
function formatScheduledAt(dateStr, hh, mm){
return `${dateStr} ${pad2(hh)}:${pad2(mm)}`;
}
function parseScheduledAt(v){
const s = String(v || '').trim();
const m = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})$/);
if(!m) return null;
return { date: m[1], hh: Number(m[2]), mm: Number(m[3]) };
}
function next5min(){
const d = new Date();
d.setSeconds(0,0);
const m = d.getMinutes();
const add = (5 - (m % 5)) % 5;
d.setMinutes(m + add);
return d;
}
function toDateInputValue(d){
const y = d.getFullYear();
const m = pad2(d.getMonth()+1);
const day = pad2(d.getDate());
return `${y}-${m}-${day}`;
}
// ✅ scheduled_at 직접 입력 차단(실수 방지)
if (scheduledAtEl){
scheduledAtEl.readOnly = true;
scheduledAtEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') return;
e.preventDefault();
});
scheduledAtEl.addEventListener('paste', (e) => e.preventDefault());
scheduledAtEl.addEventListener('drop', (e) => e.preventDefault());
}
// ✅ subject/body mirror sync (백엔드 키 불일치 대비) // ✅ subject/body mirror sync (백엔드 키 불일치 대비)
function syncMirrors(){ function syncMirrors(){
const s = subjectEl?.value ?? ''; const s = subjectEl?.value ?? '';
@ -445,8 +548,6 @@
if (skinTplHintEl) skinTplHintEl.style.display = ''; if (skinTplHintEl) skinTplHintEl.style.display = '';
} else { } else {
// skinEl.disabled = false;
skinEl.classList.remove('is-disabled'); skinEl.classList.remove('is-disabled');
skinEl.removeAttribute('aria-disabled'); skinEl.removeAttribute('aria-disabled');
@ -476,12 +577,17 @@
} }
tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab))); tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab)));
// ===== schedule // ===== schedule (기존 로직 유지 + 버튼 토글만 추가)
form.querySelectorAll('input[name="schedule_type"]').forEach(r => { form.querySelectorAll('input[name="schedule_type"]').forEach(r => {
r.addEventListener('change', () => { r.addEventListener('change', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked; const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (!scheduledAtEl) return; if (!scheduledAtEl) return;
scheduledAtEl.disabled = !isSch; scheduledAtEl.disabled = !isSch;
// ✅ 추가: 선택 버튼도 같이 토글
if (openSchBtn) openSchBtn.disabled = !isSch;
if (!isSch) scheduledAtEl.value = ''; if (!isSch) scheduledAtEl.value = '';
}); });
}); });
@ -526,7 +632,6 @@
// ✅ 핵심: tokens[0]은 더미로 비워두고 // ✅ 핵심: tokens[0]은 더미로 비워두고
// tokens[1]이 2열(= {_text_02_})이 되게 맞춤 // tokens[1]이 2열(= {_text_02_})이 되게 맞춤
// (백엔드가 보통 N-1 인덱스로 접근하는 케이스 방어)
const tokens = [''].concat(tokenCols); const tokens = [''].concat(tokenCols);
// ✅ 안전용: placeholder 그대로 key map도 함께 전송 // ✅ 안전용: placeholder 그대로 key map도 함께 전송
@ -701,18 +806,11 @@
tplSelect?.addEventListener('change', () => { tplSelect?.addEventListener('change', () => {
const chosen = isTemplateSelected(); const chosen = isTemplateSelected();
setTemplateLock(chosen); setTemplateLock(chosen);
// 템플릿을 비웠으면(선택 해제) 스킨을 다시 변경 가능
if (!chosen){
// 아무 것도 안 함 (skin 선택은 자유)
}
}); });
// 스킨을 바꾸려고 하면, 템플릿이 선택된 상태라면 템플릿을 해제하고 스킨 모드로 전환 // 스킨을 바꾸려고 하면, 템플릿이 선택된 상태라면 템플릿을 해제하고 스킨 모드로 전환
skinEl?.addEventListener('change', () => { skinEl?.addEventListener('change', () => {
if (isTemplateSelected()){ if (isTemplateSelected()){
// 템플릿이 선택되어 있으면 스킨 변경 불가 상태인데,
// 혹시 브라우저/스크립트로 변경되는 경우 방어: 템플릿 해제
if (tplSelect) tplSelect.value = ''; if (tplSelect) tplSelect.value = '';
setTemplateLock(false); setTemplateLock(false);
} }
@ -797,7 +895,7 @@
iframeTimer = setTimeout(updateIframePreview, 200); iframeTimer = setTimeout(updateIframePreview, 200);
} }
// ===== modal open/close // ===== modal open/close (메일 미리보기)
function openModal(){ function openModal(){
if(!modalEl) return; if(!modalEl) return;
modalEl.style.display = 'block'; modalEl.style.display = 'block';
@ -817,10 +915,114 @@
backEl?.addEventListener('click', closeModal); backEl?.addEventListener('click', closeModal);
refreshBtn?.addEventListener('click', () => { previewDirty = true; updateIframePreview(); }); refreshBtn?.addEventListener('click', () => { previewDirty = true; updateIframePreview(); });
document.addEventListener('keydown', (e) => { // ===== 예약발송 Picker modal (추가)
if(e.key === 'Escape' && modalEl?.style.display === 'block') closeModal(); function buildSelectOptions(){
if (schHourEl && schHourEl.options.length === 0){
for(let h=0; h<24; h++){
const opt = document.createElement('option');
opt.value = String(h);
opt.textContent = pad2(h);
schHourEl.appendChild(opt);
}
}
if (schMinEl && schMinEl.options.length === 0){
for(let m=0; m<60; m+=5){
const opt = document.createElement('option');
opt.value = String(m);
opt.textContent = pad2(m);
schMinEl.appendChild(opt);
}
}
}
function fillPickerFromCurrent(){
buildSelectOptions();
const cur = parseScheduledAt(scheduledAtEl?.value || '');
if (cur){
if (schDateEl) schDateEl.value = cur.date;
if (schHourEl) schHourEl.value = String(cur.hh);
if (schMinEl) schMinEl.value = String(cur.mm - (cur.mm % 5));
return;
}
const d = next5min();
if (schDateEl) schDateEl.value = toDateInputValue(d);
if (schHourEl) schHourEl.value = String(d.getHours());
if (schMinEl) schMinEl.value = String(d.getMinutes() - (d.getMinutes()%5));
}
let schModalOpen = false;
function openScheduleModal(){
if(!schModalEl) return;
fillPickerFromCurrent();
schModalEl.style.display = 'block';
document.documentElement.style.overflow = 'hidden';
schModalOpen = true;
}
function closeScheduleModal(){
if(!schModalEl) return;
schModalEl.style.display = 'none';
document.documentElement.style.overflow = '';
schModalOpen = false;
}
openSchBtn?.addEventListener('click', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (!isSch) return;
openScheduleModal();
}); });
schCloseBtn?.addEventListener('click', closeScheduleModal);
schBackEl?.addEventListener('click', closeScheduleModal);
schResetBtn?.addEventListener('click', () => {
if (scheduledAtEl) scheduledAtEl.value = '';
fillPickerFromCurrent();
refreshCounts();
syncMirrors();
refreshInlinePreview();
debouncedIframePreview();
});
schApplyBtn?.addEventListener('click', () => {
const dateStr = (schDateEl?.value || '').trim();
const hh = Number(schHourEl?.value || 0);
const mm = Number(schMinEl?.value || 0);
if (!dateStr){
alert('날짜를 선택하세요.');
return;
}
if (scheduledAtEl){
scheduledAtEl.value = formatScheduledAt(dateStr, hh, mm); // "YYYY-MM-DD HH:mm"
}
refreshCounts();
syncMirrors();
refreshInlinePreview();
debouncedIframePreview();
closeScheduleModal();
});
// Esc 처리: 두 모달 모두 닫기
document.addEventListener('keydown', (e) => {
if(e.key !== 'Escape') return;
if (modalEl?.style.display === 'block') closeModal();
if (schModalOpen) closeScheduleModal();
});
// 초기 schedule 상태 반영(버튼 disable 동기화)
(() => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (openSchBtn) openSchBtn.disabled = !isSch;
})();
// ===== submit guard // ===== submit guard
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
// ✅ submit 직전 한번 더 동기화 (주제/내용 누락 방어) // ✅ submit 직전 한번 더 동기화 (주제/내용 누락 방어)
@ -864,7 +1066,7 @@
} }
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked; const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if(isSch && !(scheduledAtEl?.value||'').trim()){ e.preventDefault(); alert('예약 시간을 입력하세요.'); return; } if(isSch && !(scheduledAtEl?.value||'').trim()){ e.preventDefault(); alert('예약 시간을 선택하세요.'); return; }
document.getElementById('sendBtn')?.setAttribute('disabled','disabled'); document.getElementById('sendBtn')?.setAttribute('disabled','disabled');
}); });

View File

@ -4,21 +4,8 @@
@section('page_title', 'SMS 발송 이력') @section('page_title', 'SMS 발송 이력')
@section('page_desc', '배치 단위로 조회합니다.') @section('page_desc', '배치 단위로 조회합니다.')
@php @php
$STATUS_LABELS = [ $STATUS_LABELS = $labels ?? [];
'scheduled' => '예약대기', $MODE_LABELS = $modeLabels ?? [];
'queued' => '대기',
'submitting' => '전송중',
'submitted' => '전송완료',
'partial' => '일부실패',
'failed' => '실패',
'canceled' => '취소',
];
$MODE_LABELS = [
'one' => '단건',
'many' => '대량',
'template' => '템플릿',
];
@endphp @endphp
@push('head') @push('head')
<style> <style>
@ -85,10 +72,8 @@
<div class="a-muted" style="margin-bottom:6px;">상태</div> <div class="a-muted" style="margin-bottom:6px;">상태</div>
<select class="a-input" name="status"> <select class="a-input" name="status">
<option value="">전체</option> <option value="">전체</option>
@foreach(['scheduled','queued','submitting','submitted','partial','failed','canceled'] as $st) @foreach(array_keys($STATUS_LABELS) as $st)
<option value="{{ $st }}" @selected(request('status')===$st)> <option value="{{ $st }}" @selected(request('status')===$st)>{{ $STATUS_LABELS[$st] }}</option>
{{ $STATUS_LABELS[$st] ?? $st }}
</option>
@endforeach @endforeach
</select> </select>
@ -98,10 +83,8 @@
<div class="a-muted" style="margin-bottom:6px;">모드</div> <div class="a-muted" style="margin-bottom:6px;">모드</div>
<select class="a-input" name="send_mode"> <select class="a-input" name="send_mode">
<option value="">전체</option> <option value="">전체</option>
@foreach(['one','many','template'] as $m) @foreach(array_keys($MODE_LABELS) as $m)
<option value="{{ $m }}" @selected(request('send_mode')===$m)> <option value="{{ $m }}" @selected(request('send_mode')===$m)>{{ $MODE_LABELS[$m] }}</option>
{{ $MODE_LABELS[$m] ?? $m }}
</option>
@endforeach @endforeach
</select> </select>
</div> </div>
@ -169,10 +152,12 @@
}; };
$stLabel = $STATUS_LABELS[$st] ?? $st; $stLabel = $STATUS_LABELS[$st] ?? $st;
@endphp @endphp
<tr> <tr class="row-link" data-url="{{ route('admin.sms.logs.show', ['batchId'=>$b->id]) }}">
<td> <td>
<a class="mono" style="text-decoration:none;" <a href="{{ route('admin.sms.logs.show', ['batchId'=>$b->id]) }}"
href="{{ route('admin.sms.logs.show', ['batchId'=>$b->id]) }}">#{{ $b->id }}</a> class="batch-link-btn mono">
#{{ $b->id }}
</a>
</td> </td>
<td class="a-muted">{{ $b->created_at }}</td> <td class="a-muted">{{ $b->created_at }}</td>
<td style="font-weight:700;">{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}</td> <td style="font-weight:700;">{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}</td>
@ -190,7 +175,7 @@
</div> </div>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
{{ $batches->links() }} {{ $batches->onEachSide(1)->links('vendor.pagination.admin') }}
</div> </div>
</div> </div>
@push('scripts') @push('scripts')
@ -219,6 +204,16 @@
}); });
})(); })();
document.addEventListener('click', (e) => {
const tr = e.target.closest('tr.row-link');
if (!tr) return;
// a, button, input 등 "클릭 가능한 요소"를 눌렀으면 기본 동작 존중
if (e.target.closest('a,button,input,select,textarea,label')) return;
const url = tr.getAttribute('data-url');
if (url) window.location.href = url;
});
</script> </script>
@endpush @endpush
@endsection @endsection

View File

@ -34,6 +34,9 @@
@section('content') @section('content')
@php @php
$STATUS_LABELS = $labels ?? [];
$MODE_LABELS = $modeLabels ?? [];
$ITEM_LABELS = $itemLabels ?? [];
$st = (string)$batch->status; $st = (string)$batch->status;
$pillClass = match ($st) { $pillClass = match ($st) {
'submitted' => 'pill--ok', 'submitted' => 'pill--ok',
@ -51,7 +54,7 @@
</div> </div>
<div> <div>
<div class="k">상태</div> <div class="k">상태</div>
<div class="v"><span class="pill {{ $pillClass }}"> {{ $batch->status }}</span></div> <span class="pill {{ $pillClass }}"> {{ $STATUS_LABELS[$st] ?? $st }}</span>
</div> </div>
<div> <div>
<div class="k">모드</div> <div class="k">모드</div>
@ -92,8 +95,8 @@
<div class="a-muted" style="margin-bottom:6px;">상태</div> <div class="a-muted" style="margin-bottom:6px;">상태</div>
<select class="a-input" name="status"> <select class="a-input" name="status">
<option value="">전체</option> <option value="">전체</option>
@foreach(['queued','submitted','failed'] as $st) @foreach(array_keys($ITEM_LABELS) as $st)
<option value="{{ $st }}" @selected(request('status')===$st)>{{ $st }}</option> <option value="{{ $st }}" @selected(request('status')===$st)>{{ $ITEM_LABELS[$st] }}</option>
@endforeach @endforeach
</select> </select>
</div> </div>
@ -133,6 +136,7 @@
$ic = match ($ist) { $ic = match ($ist) {
'submitted' => 'pill--ok', 'submitted' => 'pill--ok',
'failed' => 'pill--bad', 'failed' => 'pill--bad',
'canceled','skipped' => 'pill--bad',
default => 'pill--warn', default => 'pill--warn',
}; };
@endphp @endphp
@ -140,7 +144,7 @@
<td class="a-muted">{{ $it->seq }}</td> <td class="a-muted">{{ $it->seq }}</td>
<td style="font-weight:800;">{{ $it->to_number }}</td> <td style="font-weight:800;">{{ $it->to_number }}</td>
<td><span class="mono">{{ $it->sms_type }}</span></td> <td><span class="mono">{{ $it->sms_type }}</span></td>
<td><span class="pill {{ $ic }}"> {{ $it->status }}</span></td> <td><span class="pill {{ $ic }}"> {{ $ITEM_LABELS[$ist] ?? $ist }}</span></td>
<td class="a-muted">{{ $it->submitted_at ?? '-' }}</td> <td class="a-muted">{{ $it->submitted_at ?? '-' }}</td>
<td><div class="clip">{{ $it->message_final }}</div></td> <td><div class="clip">{{ $it->message_final }}</div></td>
</tr> </tr>
@ -152,7 +156,7 @@
</div> </div>
<div style="margin-top:12px;"> <div style="margin-top:12px;">
{{ $items->links() }} {{ $items->onEachSide(1)->links('vendor.pagination.admin') }}
</div> </div>
</div> </div>
@endsection @endsection

View File

@ -2,7 +2,7 @@
@section('title', '관리자 SMS 발송') @section('title', '관리자 SMS 발송')
@section('page_title', '관리자 SMS 발송') @section('page_title', '관리자 SMS 발송')
@section('page_desc', '단건 / 대량 / 템플릿 발송') @section('page_desc', '단건 / 대량(붙여넣기) / CSV 업로드 발송')
@push('head') @push('head')
<style> <style>
@ -14,11 +14,11 @@
.sms-badges{margin-left:auto;display:flex;gap:10px;align-items:center;} .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-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{padding:8px 12px;font-size:13px;border-radius:999px;line-height:1;border:0;background:transparent;color:inherit;cursor:pointer;white-space:nowrap;}
.sms-tab.is-active{background:rgba(255,255,255,.14);} .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; .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;} border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;white-space:nowrap;}
.sms-btn:hover{background:rgba(255,255,255,.10);} .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{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--primary:hover{background:rgba(59,130,246,.98);}
@ -30,6 +30,38 @@
.sms-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;} .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-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
.sms-select{min-width:280px;} .sms-select{min-width:280px;}
.warn{color:#ff6b6b;font-weight:800;}
.hintBox{
margin-top:10px;padding:10px 12px;border-radius:12px;
background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);
font-size:12px;line-height:1.6;
}
.hintBox code{background:rgba(0,0,0,.25);padding:2px 6px;border-radius:8px;}
.mmono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;}
/* ===== 예약발송 Picker(메일과 동일 패턴) ===== */
.smsModal{display:none; position:fixed; inset:0; z-index:9999;}
.smsModal__back{position:absolute; inset:0; background:rgba(0,0,0,.55);}
.smsModal__box{
position:relative;
width:520px; max-width:calc(100vw - 24px);
height:auto; max-height:calc(100vh - 24px);
margin:12px auto;
top:50%; transform:translateY(-50%);
border:1px solid rgba(255,255,255,.12);
background:rgba(20,20,20,.94);
border-radius:16px;
overflow:hidden;
box-shadow:0 18px 60px rgba(0,0,0,.45);
}
.smsModal__head{
display:flex; justify-content:space-between; align-items:center;
padding:10px 12px;
border-bottom:1px solid rgba(255,255,255,.10);
}
.smsModal__body{padding:12px;}
.smsModal__hint{font-size:12px; opacity:.75; margin-top:6px;}
</style> </style>
@endpush @endpush
@ -37,6 +69,7 @@
<form method="POST" action="{{ route('admin.sms.send.store') }}" enctype="multipart/form-data" id="smsSendForm"> <form method="POST" action="{{ route('admin.sms.send.store') }}" enctype="multipart/form-data" id="smsSendForm">
@csrf @csrf
{{-- send_mode: one | many | template(CSV 업로드) --}}
<input type="hidden" name="send_mode" id="sendMode" value="one"> <input type="hidden" name="send_mode" id="sendMode" value="one">
<input type="hidden" name="sms_type_hint" id="smsTypeHint" value="auto"> <input type="hidden" name="sms_type_hint" id="smsTypeHint" value="auto">
@ -46,7 +79,6 @@
<div style="min-width:280px;"> <div style="min-width:280px;">
<div class="a-muted" style="margin-bottom:6px;">발신번호</div> <div class="a-muted" style="margin-bottom:6px;">발신번호</div>
<input class="a-input" name="from_number" value="{{ config('services.sms.from','1833-4856') }}" readonly> <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>
<div style="min-width:340px;"> <div style="min-width:340px;">
@ -54,10 +86,12 @@
<div class="sms-row"> <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="now" checked> 즉시</label>
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약</label> <label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약</label>
{{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}}
<input class="a-input" type="text" name="scheduled_at" id="scheduledAt" <input class="a-input" type="text" name="scheduled_at" id="scheduledAt"
placeholder="YYYY-MM-DD HH:mm" style="width:180px" disabled> placeholder="YYYY-MM-DD HH:mm" style="width:180px" disabled readonly>
<button type="button" class="sms-btn" id="openSchedulePicker" disabled>선택</button>
</div> </div>
<div class="a-muted" style="margin-top:6px;">예약은 5 단위 권장</div>
</div> </div>
<div class="sms-badges"> <div class="sms-badges">
@ -74,43 +108,68 @@
<div class="sms-seg" style="margin-bottom:12px;"> <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 is-active" data-tab="one">단건</button>
<button type="button" class="sms-tab" data-tab="many">대량</button> <button type="button" class="sms-tab" data-tab="many">대량(붙여넣기)</button>
<button type="button" class="sms-tab" data-tab="template">템플릿</button> <button type="button" class="sms-tab" data-tab="template">CSV 업로드</button>
</div> </div>
{{-- 단건 --}}
<section data-panel="one"> <section data-panel="one">
<div class="a-muted" style="margin-bottom:6px;">수신번호 (1)</div> <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') }}"> <input class="a-input" name="to_number" id="toNumber"
placeholder="01012345678 또는 01012345678,이름,금지어,18000,생존템,발송"
value="{{ old('to_number') }}">
<div class="a-muted" style="margin-top:8px; line-height:1.6;">
<b>전화번호만</b> 입력하면 그대로 1 발송합니다.<br>
<b>콤마(,)</b> 뒤에 값을 붙이면 토큰이 치환됩니다.<br>
&nbsp;&nbsp;) <span class="mmono">010...,홍길동,쿠폰,18000,다날,발송</span><br>
&nbsp;&nbsp; <span class="mmono">{_text_02_}=홍길동</span>, <span class="mmono">{_text_03_}=쿠폰</span>, <span class="mmono">{_text_04_}=18000</span>, <span class="mmono">{_text_05_}=다날</span>, <span class="mmono">{_text_06_}=발송</span>
</div>
</section> </section>
{{-- 대량(붙여넣기) --}}
<section data-panel="many" style="display:none;"> <section data-panel="many" style="display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div class="a-muted">수신번호 (여러건)</div> <div class="a-muted">수신번호 (여러건 · 붙여넣기)</div>
<div class="a-muted" id="manyCountText">0</div> <div class="a-muted" id="manyCountText">0</div>
</div> </div>
<textarea class="a-input" name="to_numbers_text" id="toNumbersText" rows="10" <textarea class="a-input" name="to_numbers_text" id="toNumbersText" rows="10"
placeholder="01011112222, 0103336666&#10;또는 줄바꿈으로 붙여넣기">{{ old('to_numbers_text') }}</textarea> placeholder="전화번호만 1줄에 1개씩 입력하세요.
)
01011112222
01033366660
<div class="sms-row" style="margin-top:10px;"> 최대 100(중복 제거 )
<label class="sms-btn"> 100 초과 또는 개인화(토큰) 발송은 CSV 업로드를 사용하세요.">{{ old('to_numbers_text') }}</textarea>
CSV 업로드
<input type="file" name="to_numbers_csv" id="toNumbersCsv" accept=".csv,.txt" style="display:none;"> <div class="sms-row" style="margin-top:10px; justify-content:space-between;">
</label>
<button type="button" class="sms-btn sms-btn--ghost" id="clearMany">비우기</button>
<div class="a-muted" id="manyStats"></div> <div class="a-muted" id="manyStats"></div>
<button type="button" class="sms-btn sms-btn--ghost" id="clearMany">비우기</button>
</div> </div>
<div class="a-muted" style="margin-top:8px;">* 서버에서 최종 정규화/중복/오류 제거합니다.</div> <div class="a-muted" style="margin-top:8px; line-height:1.6;">
붙여넣기 대량 발송은 <b>전화번호만</b> 지원합니다. (<b>토큰 치환 불가</b>)<br>
토큰(<span class="mmono">{_text_02_}</span> ) 쓰려면 <b>단건(콤마)</b> 또는 <b>CSV 업로드</b> 이용하세요.
</div>
</section> </section>
<section data-panel="csv" style="display:none;"> {{-- CSV 업로드(내부 send_mode=template) --}}
<section data-panel="template" style="display:none;">
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;"> <div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<label class="sms-btn"> <label class="sms-btn">
CSV 업로드 CSV 업로드
<input type="file" name="template_csv" id="templateCsv" accept=".csv,.txt" style="display:none;"> <input type="file" name="template_csv" id="templateCsv" accept=".csv,.txt" style="display:none;">
</label> </label>
<span class="a-muted">* 번째 컬럼은 <b>수신번호</b>, 이후 컬럼은 <b>치환값</b>입니다.</span> <span class="a-muted" style="font-size:12px;">
1: <b>수신번호</b> / 2~: <b>치환값(선택)</b>
</span>
</div>
<div class="hintBox">
CSV가 <b>전화번호만</b> 있어도 발송 가능합니다 (<b>문구에 토큰이 없을 </b>).<br>
문구에 <code>{_text_02_}</code> 같은 토큰을 넣으면 CSV에 2 이상(치환값) 필요합니다.<br>
토큰은 <b>{_text_02_}부터 연속</b>으로 사용하세요. (: {_text_02_}, {_text_03_}, {_text_04_} )
</div> </div>
{{-- CSV 미리보기 --}} {{-- CSV 미리보기 --}}
@ -137,10 +196,9 @@
<div class="a-muted" id="tplCsvError" style="display:none; margin-top:10px; line-height:1.6; color:#ff6b6b;"></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="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 style="font-weight:800; margin-bottom:8px;">CSV 이용 안내</div>
<div class="a-muted" style="line-height:1.7;"> <div class="a-muted" style="line-height:1.7;">
<div style="margin-bottom:10px;"> <div style="margin-bottom:10px;">
@ -149,51 +207,30 @@
<div style="margin-top:8px;"> <div style="margin-top:8px;">
<div class="a-muted" style="margin-bottom:6px;">예시</div> <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;"> <div style="
01036828958,홍길동,20260111 padding:8px 10px;
01036828901,이순신,20260112 border-radius:12px;
01036828902,김개똥,20260113 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; line-height:1.45; text-align:left;">
<pre style="margin:0; white-space:pre;">01036828958,홍길동,10000,쿠폰
01036828901,이순신,20000,상품권
01036828902,김개똥,30000,쿠폰</pre>
</div> </div>
</div> </div>
<ul style="margin:10px 0 0 18px;"> <ul style="margin:10px 0 0 18px;">
<li><b>1번째 컬럼</b>: 수신 전화번호(고정)</li> <li><b>1번째 컬럼</b>: 수신 전화번호(고정)</li>
<li><b>2번째 컬럼</b>: 사용자 문구 1 ( <code>{_text_02_}</code>)</li> <li><b>2번째 컬럼</b>: 치환값 1 ( <code>{_text_02_}</code>)</li>
<li><b>3번째 컬럼</b>: 사용자 문구 2 ( <code>{_text_03_}</code>)</li> <li><b>3번째 컬럼</b>: 치환값 2 ( <code>{_text_03_}</code>)</li>
<li> 이런 식으로 컬럼이 계속 이어집니다.</li> <li><b>4번째 컬럼</b>: 치환값 3 ( <code>{_text_04_}</code>)</li>
</ul> </ul>
</div> </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> <div>
<b>3) 사용 가능한 토큰 범위</b><br> <b>2) 토큰을 쓰지 않으면?</b><br>
<span class="a-muted"> <span class="a-muted">문구에 토큰이 없으면 CSV는 “전화번호만(1) 있어도 정상 발송됩니다.</span>
사용자 문구는 <code>{_text_02_}</code>부터 최대 8개까지 지원합니다.
(<code>{_text_02_} ~ {_text_09_}</code>)
</span>
</div> </div>
</div> </div>
</div> </div>
@ -235,6 +272,10 @@
<span class="a-muted">토큰 빠른 삽입</span> <span class="a-muted">토큰 빠른 삽입</span>
</div> </div>
<div class="a-muted" style="margin-top:8px; line-height:1.6;">
토큰 치환은 <b>단건(콤마 입력)</b> 또는 <b>CSV 업로드</b>에서만 적용됩니다. (붙여넣기 대량은 미적용)
</div>
<hr style="margin:16px 0; opacity:.15;"> <hr style="margin:16px 0; opacity:.15;">
<div class="sms-actions"> <div class="sms-actions">
@ -248,13 +289,60 @@
</div> </div>
</form> </form>
{{-- ===== 예약발송 날짜/시간/(5분단위) 선택 모달 (추가) ===== --}}
<div id="schedulePickerModal" class="smsModal" aria-hidden="true">
<div id="schedulePickerBackdrop" class="smsModal__back"></div>
<div class="smsModal__box" role="dialog" aria-modal="true" aria-label="예약 발송 시간 선택">
<div class="smsModal__head">
<div>
<div style="font-weight:900;">예약 발송 시간 선택</div>
<div class="smsModal__hint">날짜 · 시간 · (5 단위)</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button type="button" class="sms-btn" id="closeSchedulePicker">닫기</button>
</div>
</div>
<div class="smsModal__body">
<div class="a-card" style="padding:14px;">
<div class="sms-row" style="align-items:end;">
<div>
<div class="a-muted" style="margin-bottom:6px;">날짜</div>
<input class="a-input" type="date" id="schDate" style="width:180px;">
</div>
<div>
<div class="a-muted" style="margin-bottom:6px;">시간</div>
<select class="a-input" id="schHour" style="width:120px;"></select>
</div>
<div>
<div class="a-muted" style="margin-bottom:6px;"></div>
<select class="a-input" id="schMin" style="width:120px;"></select>
</div>
</div>
<div class="sms-row" style="margin-top:12px; justify-content:flex-end;">
<button type="button" class="sms-btn sms-btn--ghost" id="resetSchedulePicker">초기화</button>
<button type="button" class="sms-btn sms-btn--primary" id="applySchedulePicker">적용</button>
</div>
<div class="a-muted" style="font-size:12px; margin-top:10px; opacity:.85;">
선택 결과는 <span class="mmono">YYYY-MM-DD HH:mm</span> 형식으로 저장됩니다.
(서비스에서 <span class="mmono">:00</span> 붙여 처리)
</div>
</div>
</div>
</div>
</div>
@push('scripts') @push('scripts')
<script> <script>
(() => { (() => {
const form = document.getElementById('smsSendForm'); const form = document.getElementById('smsSendForm');
if (!form) return; if (!form) return;
// 서버에서 내려온 템플릿(본문 포함)
const templates = @json($templates ?? []); const templates = @json($templates ?? []);
// tabs // tabs
@ -265,20 +353,155 @@
function setTab(tab){ function setTab(tab){
tabBtns.forEach(b => b.classList.toggle('is-active', b.dataset.tab === tab)); tabBtns.forEach(b => b.classList.toggle('is-active', b.dataset.tab === tab));
panels.forEach(p => p.style.display = (p.dataset.panel === tab) ? '' : 'none'); panels.forEach(p => p.style.display = (p.dataset.panel === tab) ? '' : 'none');
sendModeEl.value = tab; sendModeEl.value = tab; // one | many | template
} }
tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab))); tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab)));
// schedule // ===== schedule (기존 로직 유지 + 선택 버튼/모달만 추가)
const scheduledAt = document.getElementById('scheduledAt'); const scheduledAt = document.getElementById('scheduledAt');
const openSchBtn = document.getElementById('openSchedulePicker');
// 직접 입력/붙여넣기 방지(실수 차단)
if (scheduledAt){
scheduledAt.readOnly = true;
scheduledAt.addEventListener('keydown', (e) => { if (e.key === 'Tab') return; e.preventDefault(); });
scheduledAt.addEventListener('paste', (e) => e.preventDefault());
scheduledAt.addEventListener('drop', (e) => e.preventDefault());
}
form.querySelectorAll('input[name="schedule_type"]').forEach(r => { form.querySelectorAll('input[name="schedule_type"]').forEach(r => {
r.addEventListener('change', () => { r.addEventListener('change', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]').checked; const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]').checked;
scheduledAt.disabled = !isSch; scheduledAt.disabled = !isSch;
if (openSchBtn) openSchBtn.disabled = !isSch;
if(!isSch) scheduledAt.value = ''; if(!isSch) scheduledAt.value = '';
}); });
}); });
// ===== 예약발송 Picker modal (추가)
const schModalEl = document.getElementById('schedulePickerModal');
const schBackEl = document.getElementById('schedulePickerBackdrop');
const schCloseBtn = document.getElementById('closeSchedulePicker');
const schDateEl = document.getElementById('schDate');
const schHourEl = document.getElementById('schHour');
const schMinEl = document.getElementById('schMin');
const schApplyBtn = document.getElementById('applySchedulePicker');
const schResetBtn = document.getElementById('resetSchedulePicker');
function pad2(n){ return String(n).padStart(2,'0'); }
function formatScheduledAt(dateStr, hh, mm){
return `${dateStr} ${pad2(hh)}:${pad2(mm)}`;
}
function parseScheduledAt(v){
const s = String(v || '').trim();
const m = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})$/);
if(!m) return null;
return { date: m[1], hh: Number(m[2]), mm: Number(m[3]) };
}
function next5min(){
const d = new Date();
d.setSeconds(0,0);
const m = d.getMinutes();
const add = (5 - (m % 5)) % 5;
d.setMinutes(m + add);
return d;
}
function toDateInputValue(d){
const y = d.getFullYear();
const m = pad2(d.getMonth()+1);
const day = pad2(d.getDate());
return `${y}-${m}-${day}`;
}
function buildSelectOptions(){
if (schHourEl && schHourEl.options.length === 0){
for(let h=0; h<24; h++){
const opt = document.createElement('option');
opt.value = String(h);
opt.textContent = pad2(h);
schHourEl.appendChild(opt);
}
}
if (schMinEl && schMinEl.options.length === 0){
for(let m=0; m<60; m+=5){
const opt = document.createElement('option');
opt.value = String(m);
opt.textContent = pad2(m);
schMinEl.appendChild(opt);
}
}
}
function fillPickerFromCurrent(){
buildSelectOptions();
const cur = parseScheduledAt(scheduledAt?.value || '');
if (cur){
if (schDateEl) schDateEl.value = cur.date;
if (schHourEl) schHourEl.value = String(cur.hh);
if (schMinEl) schMinEl.value = String(cur.mm - (cur.mm % 5));
return;
}
const d = next5min();
if (schDateEl) schDateEl.value = toDateInputValue(d);
if (schHourEl) schHourEl.value = String(d.getHours());
if (schMinEl) schMinEl.value = String(d.getMinutes() - (d.getMinutes()%5));
}
let schModalOpen = false;
function openScheduleModal(){
if(!schModalEl) return;
fillPickerFromCurrent();
schModalEl.style.display = 'block';
document.documentElement.style.overflow = 'hidden';
schModalOpen = true;
}
function closeScheduleModal(){
if(!schModalEl) return;
schModalEl.style.display = 'none';
document.documentElement.style.overflow = '';
schModalOpen = false;
}
openSchBtn?.addEventListener('click', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (!isSch) return;
openScheduleModal();
});
schCloseBtn?.addEventListener('click', closeScheduleModal);
schBackEl?.addEventListener('click', closeScheduleModal);
schResetBtn?.addEventListener('click', () => {
if (scheduledAt) scheduledAt.value = '';
fillPickerFromCurrent();
});
schApplyBtn?.addEventListener('click', () => {
const dateStr = (schDateEl?.value || '').trim();
const hh = Number(schHourEl?.value || 0);
const mm = Number(schMinEl?.value || 0);
if (!dateStr){
alert('날짜를 선택하세요.');
return;
}
if (scheduledAt){
scheduledAt.value = formatScheduledAt(dateStr, hh, mm); // "YYYY-MM-DD HH:mm"
}
closeScheduleModal();
});
// 초기 schedule 상태 반영
(() => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if (openSchBtn) openSchBtn.disabled = !isSch;
})();
// byte count // byte count
const msg = document.getElementById('messageText'); const msg = document.getElementById('messageText');
const byteBadge = document.getElementById('byteBadge'); const byteBadge = document.getElementById('byteBadge');
@ -313,32 +536,75 @@
msg.value = msg.value.slice(0,start) + token + msg.value.slice(end); msg.value = msg.value.slice(0,start) + token + msg.value.slice(end);
msg.focus(); msg.focus();
msg.selectionStart = msg.selectionEnd = start + token.length; msg.selectionStart = msg.selectionEnd = start + token.length;
refreshBytes(); msg.dispatchEvent(new Event('input', { bubbles: true }));
}); });
}); });
// many stats // many stats + clear
const toNumbersText = document.getElementById('toNumbersText'); const toNumbersText = document.getElementById('toNumbersText');
const manyCountText = document.getElementById('manyCountText'); const manyCountText = document.getElementById('manyCountText');
const manyStats = document.getElementById('manyStats'); const manyStats = document.getElementById('manyStats');
const clearMany = document.getElementById('clearMany'); const clearMany = document.getElementById('clearMany');
function parsePhonesRough(text){ function parsePhonesRough(text){
const raw = (text||'').split(/[\s,]+/).map(s=>s.trim()).filter(Boolean); const lines = (text || '')
const digits = raw.map(v=>v.replace(/\D+/g,'')).filter(Boolean); .split(/\r\n|\n|\r/)
const valid = digits.filter(v=>/^01\d{8,9}$/.test(v)); .map(l => l.trim())
.filter(Boolean);
const digits = [];
for (const line of lines) {
// ✅ 콤마가 있으면 "첫 토큰(수신번호)"만 사용
const first = line.split(',')[0].trim();
const d = first.replace(/\D+/g, '');
if (d) digits.push(d);
}
const valid = digits.filter(v => /^01\d{8,9}$/.test(v));
const invalid = digits.length - valid.length; const invalid = digits.length - valid.length;
const uniq = Array.from(new Set(valid)); const uniq = Array.from(new Set(valid));
return {total:digits.length, valid:valid.length, uniq:uniq.length, invalid};
return { total: digits.length, valid: valid.length, uniq: uniq.length, invalid };
} }
function parseManyLines(text){
const lines = (text || '')
.split(/\r\n|\n|\r/)
.map(s => s.trim())
.filter(Boolean);
let total = lines.length;
let invalid = 0;
const validPhones = [];
for (const line of lines){
const first = line.split(',')[0].trim(); // ✅ 콤마 앞만 phone
const digits = first.replace(/\D+/g,'');
if(/^01\d{8,9}$/.test(digits)) validPhones.push(digits);
else invalid++;
}
const uniq = Array.from(new Set(validPhones));
const dup = validPhones.length - uniq.length;
return { total, valid: validPhones.length, uniq: uniq.length, invalid, dup };
}
function refreshMany(){ function refreshMany(){
if(!toNumbersText) return; if(!toNumbersText) return;
const r = parsePhonesRough(toNumbersText.value); const r = parseManyLines(toNumbersText.value);
if (manyCountText) manyCountText.textContent = r.uniq + '건'; if (manyCountText) manyCountText.textContent = r.uniq + '건';
if (manyStats) manyStats.textContent = `입력 ${r.total} / 유효 ${r.valid} / 중복제거 ${r.uniq} / 오류 ${r.invalid}`; if (manyStats) {
const over = r.uniq > 100;
manyStats.innerHTML =
`입력 ${r.total} / 유효 ${r.valid} / 중복제거 ${r.uniq} / 오류 ${r.invalid}` +
(over ? ` <span class="warn">→ 100건 초과 (CSV 업로드 사용)</span>` : '');
}
} }
toNumbersText?.addEventListener('input', refreshMany); toNumbersText?.addEventListener('input', refreshMany);
clearMany?.addEventListener('click', () => { if(toNumbersText){ toNumbersText.value=''; refreshMany(); } }); clearMany?.addEventListener('click', () => {
if(toNumbersText){ toNumbersText.value=''; refreshMany(); }
});
refreshMany(); refreshMany();
// template apply (프리셋) // template apply (프리셋)
@ -369,30 +635,150 @@
btnR?.addEventListener('click', () => apply('replace')); btnR?.addEventListener('click', () => apply('replace'));
btnA?.addEventListener('click', () => apply('append')); btnA?.addEventListener('click', () => apply('append'));
// helper: extract tokens indexes [2..9]
function extractTokenIdxs(text){
const s = String(text || '');
const m = s.match(/\{_text_0([2-9])_\}/g) || [];
const idxs = Array.from(new Set(m.map(t => Number((t.match(/\{_text_0([2-9])_\}/)||[])[1]))))
.filter(n => n >= 2 && n <= 9)
.sort((a,b)=>a-b);
return idxs;
}
function isContiguousFrom2(idxs){
if (!idxs.length) return true;
if (idxs[0] !== 2) return false;
for (let i=0;i<idxs.length;i++){
if (idxs[i] !== 2 + i) return false;
}
return true;
}
// CSV preview 상태(제출 전 검증용)
window.__CSV_MAX_COLS__ = 0;
// submit check (최종 검증은 서버) // submit check (최종 검증은 서버)
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
const mode = sendModeEl?.value || 'one'; const mode = sendModeEl?.value || 'one';
const m = (msg?.value || '').trim(); const m = (msg?.value || '').trim();
if(!m){ e.preventDefault(); alert('발송 문구를 입력하세요.'); return; } if(!m){ e.preventDefault(); alert('발송 문구를 입력하세요.'); return; }
if(mode === 'one'){ // ✅ many도 토큰 치환 지원 (1줄=1명: "전화번호,치환값...")
const v = (document.getElementById('toNumber')?.value || '').replace(/\D+/g,''); if(mode === 'many'){
if(!/^01\d{8,9}$/.test(v)){ e.preventDefault(); alert('수신번호(단건)를 올바르게 입력하세요.'); return; } const idxs = extractTokenIdxs(m);
// 토큰 연속성(02부터)만 강제
if (idxs.length && !isContiguousFrom2(idxs)) {
e.preventDefault();
alert('토큰은 {_text_02_}부터 연속으로 사용해야 합니다. (예: {_text_02_}, {_text_03_}, {_text_04_}...)');
return;
}
// 줄 단위로 파싱(콤마로 전체 분해하면 안됨)
const lines = (toNumbersText?.value || '')
.split(/\r\n|\n|\r/)
.map(s => s.trim())
.filter(Boolean);
if (lines.length < 1) {
e.preventDefault();
alert('대량 수신번호를 입력하세요.');
return;
}
// 유효번호 + 중복제거
const validPhones = [];
let invalid = 0;
for (const line of lines){
const first = (line.split(',')[0] || '').trim();
const digits = first.replace(/\D+/g,'');
if(/^01\d{8,9}$/.test(digits)) validPhones.push(digits);
else invalid++;
}
const uniqPhones = Array.from(new Set(validPhones));
if (uniqPhones.length > 100) {
e.preventDefault();
alert('대량(붙여넣기)은 최대 100건입니다. 100건 초과는 CSV 업로드를 이용해 주세요.');
return;
}
// 토큰이 있으면, 각 줄마다 치환값 개수 체크
if (idxs.length){
const needArgs = idxs.length; // {_text_02_}부터 연속 개수
for (let i=0;i<lines.length;i++){
const parts = lines[i].split(',').map(s=>s.trim());
const phone = (parts[0] || '').replace(/\D+/g,'');
if(!/^01\d{8,9}$/.test(phone)) continue; // invalid 줄은 서버에서 카운트
const argsCnt = Math.max(0, parts.length - 1);
if (argsCnt < needArgs){
e.preventDefault();
alert(`대량 ${i+1}번째 줄: 토큰 ${needArgs}개({_text_02_}~)를 쓰고 있어 치환값이 ${needArgs}개 필요합니다.\n형식: 전화번호,값,값,...`);
return;
}
}
}
} }
if(mode === 'one'){
const raw = (document.getElementById('toNumber')?.value || '').trim();
const first = raw.split(',')[0].trim();
const v = first.replace(/\D+/g,'');
if(!/^01\d{8,9}$/.test(v)){
e.preventDefault();
alert('수신번호(단건)를 올바르게 입력하세요. (예: 01012345678 또는 01012345678,이름,...)');
return;
}
}
if(mode === 'many'){ if(mode === 'many'){
const r = parsePhonesRough(toNumbersText?.value || ''); const r = parsePhonesRough(toNumbersText?.value || '');
const hasCsv = (document.getElementById('toNumbersCsv')?.files || []).length > 0; if(r.uniq < 1){ e.preventDefault(); alert('대량 수신번호를 입력하세요.'); return; }
if(r.uniq < 1 && !hasCsv){ e.preventDefault(); alert('대량 수신번호를 입력하거나 CSV 업로드하세요.'); return; } if(r.uniq > 100){ e.preventDefault(); alert('대량(붙여넣기)은 최대 100건입니다. 100건 초과는 CSV 업로드를 이용해 주세요.'); return; }
} }
if(mode === 'template'){ if(mode === 'template'){
const hasT = (document.getElementById('templateCsv')?.files || []).length > 0; const hasT = (document.getElementById('templateCsv')?.files || []).length > 0;
if(!hasT){ e.preventDefault(); alert('템플릿 CSV를 업로드하세요.'); return; } if(!hasT){ e.preventDefault(); alert('CSV 파일을 업로드하세요.'); return; }
const idxs = extractTokenIdxs(m);
if (idxs.length){
if (!isContiguousFrom2(idxs)){
e.preventDefault();
alert('토큰은 {_text_02_}부터 연속으로 사용해야 합니다. (예: {_text_02_}, {_text_03_}, {_text_04_}...)');
return;
}
const needArgs = idxs.length;
const maxCols = Number(window.__CSV_MAX_COLS__ || 0);
if (maxCols > 0 && maxCols < (1 + needArgs)){
e.preventDefault();
alert(`현재 문구에 토큰 ${needArgs}개가 있습니다. CSV는 최소 ${1+needArgs}개 컬럼(전화번호 + 치환값 ${needArgs}개)이 필요합니다.`);
return;
}
}
} }
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked; const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]')?.checked;
if(isSch && !(scheduledAt?.value || '').trim()){ e.preventDefault(); alert('예약 시간을 입력하세요.'); return; } if(isSch && !(scheduledAt?.value || '').trim()){
e.preventDefault();
alert('예약 시간을 선택하세요.');
return;
}
}); });
// Esc 닫기
document.addEventListener('keydown', (e) => {
if(e.key !== 'Escape') return;
if (schModalOpen) closeScheduleModal();
});
})(); })();
</script> </script>
<script> <script>
(() => { (() => {
const input = document.getElementById('templateCsv'); const input = document.getElementById('templateCsv');
@ -405,6 +791,7 @@
const hint = document.getElementById('tplCsvHint'); const hint = document.getElementById('tplCsvHint');
const errBox = document.getElementById('tplCsvError'); const errBox = document.getElementById('tplCsvError');
const clearBtn = document.getElementById('tplCsvClearBtn'); const clearBtn = document.getElementById('tplCsvClearBtn');
const msgEl = document.getElementById('messageText');
function showErr(msg){ function showErr(msg){
if (!errBox) return; if (!errBox) return;
@ -422,10 +809,8 @@
} }
function parseCsvLoose(text){ function parseCsvLoose(text){
// “간단/안전” 파서: 기본은 콤마 분리, 따옴표 포함 라인은 최소한 지원
// (완전한 CSV RFC 파서는 아니지만 관리자 도구용으로 실사용 충분)
const lines = (text || '') const lines = (text || '')
.replace(/^\uFEFF/, '') // BOM 제거 .replace(/^\uFEFF/, '') // BOM 제거
.split(/\r\n|\n|\r/) .split(/\r\n|\n|\r/)
.map(l => l.trim()) .map(l => l.trim())
.filter(Boolean); .filter(Boolean);
@ -439,7 +824,6 @@
for (let i=0;i<line.length;i++){ for (let i=0;i<line.length;i++){
const ch = line[i]; const ch = line[i];
if (ch === '"'){ if (ch === '"'){
// "" 이스케이프 처리
if (inQ && line[i+1] === '"'){ cur += '"'; i++; continue; } if (inQ && line[i+1] === '"'){ cur += '"'; i++; continue; }
inQ = !inQ; inQ = !inQ;
continue; continue;
@ -458,16 +842,38 @@
} }
function normalizePhone(v){ function normalizePhone(v){
const d = String(v||'').replace(/\D+/g,''); return String(v||'').replace(/\D+/g,'');
return d; }
function extractTokenIdxs(text){
const s = String(text || '');
const m = s.match(/\{_text_0([2-9])_\}/g) || [];
const idxs = Array.from(new Set(m.map(t => Number((t.match(/\{_text_0([2-9])_\}/)||[])[1]))))
.filter(n => n >= 2 && n <= 9)
.sort((a,b)=>a-b);
return idxs;
}
function isContiguousFrom2(idxs){
if (!idxs.length) return true;
if (idxs[0] !== 2) return false;
for (let i=0;i<idxs.length;i++){
if (idxs[i] !== 2 + i) return false;
}
return true;
} }
function buildPreview(rows){ function buildPreview(rows){
// 최대 5줄 표시
const sample = rows.slice(0, 5); const sample = rows.slice(0, 5);
const maxCols = sample.reduce((m, r) => Math.max(m, r.length), 0);
// 헤더(Col1..) // 최대 컬럼수(파일 전체 기준으로는 무거우니 500줄 정도만 훑음)
const scan = rows.slice(0, 500);
const maxCols = scan.reduce((m, r) => Math.max(m, (r||[]).length), 0);
// 제출 전 UX 검증용
window.__CSV_MAX_COLS__ = maxCols;
// 헤더
headTr.innerHTML = ''; headTr.innerHTML = '';
for (let c=0;c<Math.max(1,maxCols);c++){ for (let c=0;c<Math.max(1,maxCols);c++){
const th = document.createElement('th'); const th = document.createElement('th');
@ -477,18 +883,19 @@
// 바디 // 바디
bodyTb.innerHTML = ''; bodyTb.innerHTML = '';
sample.forEach((r, idx) => { sample.forEach((r) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
for (let c=0;c<Math.max(1,maxCols);c++){ for (let c=0;c<Math.max(1,maxCols);c++){
const td = document.createElement('td'); const td = document.createElement('td');
const val = (r[c] ?? ''); const val = (r[c] ?? '');
td.innerHTML = esc(val); td.innerHTML = esc(val);
if (c===0){ if (c===0){
const p = normalizePhone(val); const p = normalizePhone(val);
if (!/^01\d{8,9}$/.test(p)) { if (!/^01\d{8,9}$/.test(p)) {
td.style.color = '#ff6b6b'; td.style.color = '#ff6b6b';
td.style.fontWeight = '800'; td.style.fontWeight = '800';
td.title = '휴대폰 형식이 올바르지 않습니다 (010/011/016/017/018/019 10~11자리)'; td.title = '휴대폰 형식이 올바르지 않습니다 (01X 10~11자리 숫자)';
} }
} }
tr.appendChild(td); tr.appendChild(td);
@ -498,34 +905,53 @@
// 메타/힌트 // 메타/힌트
const totalRows = rows.length; const totalRows = rows.length;
const sampleCols = maxCols; const tokenIdxs = extractTokenIdxs(msgEl?.value || '');
const tokenFrom = 2; const hasTokens = tokenIdxs.length > 0;
const tokenTo = Math.min(9, tokenFrom + (sampleCols - 2)); // phone(1) + val2.. -> 토큰 02부터 const needArgs = tokenIdxs.length;
const tokenInfo = (sampleCols <= 1)
? '치환값 컬럼이 없습니다. (phone만 존재)' let tokenInfo = '';
: `치환 가능 토큰: {_text_02_} ~ {_text_0${tokenTo}_} (샘플 기준)`; if (maxCols <= 1){
tokenInfo = '치환값 컬럼이 없습니다. (phone만 존재)';
} else {
const tokenTo = Math.min(9, 1 + (maxCols - 1));
tokenInfo = `치환 가능 토큰(컬럼 기준): {_text_02_} ~ {_text_0${tokenTo}_}`;
}
meta.innerHTML = ` meta.innerHTML = `
<div> <b>${totalRows}</b> · 샘플 컬럼수 <b>${sampleCols}</b></div> <div> <b>${totalRows}</b> · 컬럼수 <b>${maxCols}</b></div>
<div>${tokenInfo}</div> <div>${tokenInfo}</div>
`; `;
let hintHtml = ''; let hintHtml = '';
if (sampleCols <= 1){ if (maxCols <= 1){
hintHtml += `<div>• 현재 CSV는 수신번호만 있어 “개인화 치환”은 불가능합니다.</div>`; hintHtml += `<div>• CSV는 수신번호만 있습니다. 토큰 치환은 불가능합니다.</div>`;
} else { } else {
hintHtml += `<div>• 1번째 컬럼(phone) → 수신번호</div>`; hintHtml += `<div>• 1(phone) → 수신번호</div>`;
hintHtml += `<div>• 2번째 컬럼(val2) → <code>{_text_02_}</code></div>`; hintHtml += `<div>• 2(val2) → <code>{_text_02_}</code></div>`;
if (sampleCols >= 3) hintHtml += `<div>• 3번째 컬럼(val3) → <code>{_text_03_}</code></div>`; if (maxCols >= 3) hintHtml += `<div>• 3열(val3) → <code>{_text_03_}</code></div>`;
if (sampleCols >= 4) hintHtml += `<div>• 4번째 컬럼(val4) → <code>{_text_04_}</code></div>`; if (maxCols >= 4) hintHtml += `<div>• 4열(val4) → <code>{_text_04_}</code></div>`;
if (sampleCols > 4) hintHtml += `<div>• 이후 컬럼은 순서대로 <code>{_text_05_}</code> …</div>`; if (maxCols > 4) hintHtml += `<div>• 이후 컬럼은 순서대로 <code>{_text_05_}</code> …</div>`;
} }
hintHtml += `<div class="a-muted" style="margin-top:6px;">* 최종 유효성/중복 제거/치환은 서버에서 한 번 더 처리합니다.</div>`; hintHtml += `<div class="a-muted" style="margin-top:6px;">* 최종 유효성/중복 제거/치환은 서버에서 한 번 더 처리합니다.</div>`;
hint.innerHTML = hintHtml; hint.innerHTML = hintHtml;
// 토큰 사용중인데 CSV에 치환값이 부족하면 경고
if (hasTokens){
if (!isContiguousFrom2(tokenIdxs)){
showErr('문구의 토큰은 {_text_02_}부터 연속으로 사용해야 합니다. (예: {_text_02_}, {_text_03_}, {_text_04_}...)');
return;
}
if (maxCols < (1 + needArgs)) {
showErr(`현재 문구에 토큰 ${needArgs}개가 있습니다. CSV는 최소 ${1+needArgs}개 컬럼(전화번호 + 치환값 ${needArgs}개)이 필요합니다.`);
return;
}
}
} }
function handleFile(file){ function handleFile(file){
showErr(''); showErr('');
window.__CSV_MAX_COLS__ = 0;
if (!file){ if (!file){
wrap.style.display = 'none'; wrap.style.display = 'none';
return; return;
@ -556,16 +982,17 @@
wrap.style.display = 'none'; wrap.style.display = 'none';
return; return;
} }
// phone 컬럼 존재 체크
const badPhone = rows.slice(0, 200).some(r => !/^01\d{8,9}$/.test(normalizePhone(r[0] ?? ''))); const badPhone = rows.slice(0, 200).some(r => !/^01\d{8,9}$/.test(normalizePhone(r[0] ?? '')));
if (badPhone){
showErr('미리보기 범위 내에 휴대폰 형식이 올바르지 않은 줄이 있습니다. (빨간색 표시)');
} else {
showErr('');
}
buildPreview(rows); buildPreview(rows);
wrap.style.display = ''; wrap.style.display = '';
if (badPhone && !(errBox?.textContent || '').trim()){
showErr('미리보기 범위 내에 휴대폰 형식이 올바르지 않은 줄이 있습니다. (빨간색 표시)');
} else if (!badPhone && !(errBox?.textContent || '').trim()){
showErr('');
}
}catch(e){ }catch(e){
showErr('CSV 파싱 중 오류가 발생했습니다. 파일 형식을 확인해 주세요.'); showErr('CSV 파싱 중 오류가 발생했습니다. 파일 형식을 확인해 주세요.');
wrap.style.display = 'none'; wrap.style.display = 'none';
@ -587,7 +1014,48 @@
input.value = ''; input.value = '';
wrap.style.display = 'none'; wrap.style.display = 'none';
showErr(''); showErr('');
window.__CSV_MAX_COLS__ = 0;
}); });
// 문구 변경 시 토큰 경고만 갱신
msgEl?.addEventListener('input', () => {
if (!input.files?.length) return;
function extractTokenIdxs(text){
const s = String(text || '');
const m = s.match(/\{_text_0([2-9])_\}/g) || [];
const idxs = Array.from(new Set(m.map(t => Number((t.match(/\{_text_0([2-9])_\}/)||[])[1]))))
.filter(n => n >= 2 && n <= 9)
.sort((a,b)=>a-b);
return idxs;
}
function isContiguousFrom2(idxs){
if (!idxs.length) return true;
if (idxs[0] !== 2) return false;
for (let i=0;i<idxs.length;i++){
if (idxs[i] !== 2 + i) return false;
}
return true;
}
const idxs = extractTokenIdxs(msgEl.value || '');
if (!idxs.length) {
if ((errBox?.textContent || '').includes('토큰')) showErr('');
return;
}
if (!isContiguousFrom2(idxs)){
showErr('문구의 토큰은 {_text_02_}부터 연속으로 사용해야 합니다. (예: {_text_02_}, {_text_03_}, {_text_04_}...)');
return;
}
const needArgs = idxs.length;
const maxCols = Number(window.__CSV_MAX_COLS__ || 0);
if (maxCols > 0 && maxCols < (1 + needArgs)){
showErr(`현재 문구에 토큰 ${needArgs}개가 있습니다. CSV는 최소 ${1+needArgs}개 컬럼(전화번호 + 치환값 ${needArgs}개)이 필요합니다.`);
return;
}
if ((errBox?.textContent || '').includes('토큰')) showErr('');
});
})(); })();
</script> </script>
@endpush @endpush

View File

@ -0,0 +1,48 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination" style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<div class="a-muted" style="font-size:12px;">
@php
$from = ($paginator->currentPage() - 1) * $paginator->perPage() + 1;
$to = min($paginator->total(), $paginator->currentPage() * $paginator->perPage());
@endphp
@if($paginator->total() > 0)
{{ $from }}-{{ $to }} / {{ $paginator->total() }}
@else
0 / 0
@endif
</div>
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="lbtn lbtn--ghost" style="opacity:.45; pointer-events:none;">이전</span>
@else
<a class="lbtn lbtn--ghost" href="{{ $paginator->previousPageUrl() }}" rel="prev">이전</a>
@endif
{{-- Pages --}}
@foreach ($elements as $element)
@if (is_string($element))
<span class="lbtn lbtn--ghost" style="opacity:.55; pointer-events:none;">{{ $element }}</span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span class="lbtn lbtn--primary" style="pointer-events:none;">{{ $page }}</span>
@else
<a class="lbtn" href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next --}}
@if ($paginator->hasMorePages())
<a class="lbtn lbtn--ghost" href="{{ $paginator->nextPageUrl() }}" rel="next">다음</a>
@else
<span class="lbtn lbtn--ghost" style="opacity:.45; pointer-events:none;">다음</span>
@endif
</div>
</nav>
@endif

View File

@ -108,12 +108,12 @@ function registerScheduleCron(string $name, string $cron, $job, array $opt = [])
// => scheduler + redis + worker 전체 체인이 정상임을 증명 // => scheduler + redis + worker 전체 체인이 정상임을 증명
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
//Schedule::call(function () { /*메일 발송 스케줄러*/
// dispatch(new \App\Jobs\WorkerHeartbeatJob()); registerScheduleCron('admin_mail_dispatch_due', '* * * * *', 'admin-mail:dispatch-due --limit=20', [
//}) 'without_overlapping' => true,
// ->everyMinute() 'on_one_server' => true, // 스케줄러 컨테이너가 여러개일 수 있으면 유지(1개면 있어도 무해)
// ->name('worker_heartbeat_dispatch') 'run_in_background' => true,
// ->withoutOverlapping(); ]);