메일, sms 예약발송 완료
This commit is contained in:
parent
2b6e097fc1
commit
0db9e2bdc5
@ -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']) {
|
||||||
|
|||||||
@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,9 +15,10 @@ 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
|
||||||
{
|
{
|
||||||
@ -36,19 +37,11 @@ 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'),
|
|
||||||
$msgRaw
|
|
||||||
),
|
|
||||||
'template' => $this->buildTargetsTemplate(
|
|
||||||
$request->file('template_csv'),
|
|
||||||
$msgRaw
|
|
||||||
),
|
|
||||||
default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'),
|
default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'),
|
||||||
};
|
};
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@ -59,13 +52,12 @@ final class AdminSmsService
|
|||||||
$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,16 +77,12 @@ 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);
|
||||||
|
|
||||||
// 4) 즉시 발송이면 실제 provider queue(DB)에 insert (SmsService가 sms_server에 넣음)
|
|
||||||
if ($scheduleType === 'now') {
|
|
||||||
$this->repo->updateBatch($batchId, ['status' => 'submitting']);
|
$this->repo->updateBatch($batchId, ['status' => 'submitting']);
|
||||||
|
|
||||||
$submitted = 0;
|
$submitted = 0;
|
||||||
@ -105,7 +93,8 @@ final class AdminSmsService
|
|||||||
'from_number' => $from,
|
'from_number' => $from,
|
||||||
'to_number' => $it['to_number'],
|
'to_number' => $it['to_number'],
|
||||||
'message' => $it['message_final'],
|
'message' => $it['message_final'],
|
||||||
'sms_type' => $it['sms_type'], // 힌트(최종은 SmsService 내부 기준)
|
'sms_type' => $it['sms_type'],
|
||||||
|
'scheduled_at' => $scheduledAt,
|
||||||
];
|
];
|
||||||
|
|
||||||
$ok = $this->smsService->send($payload, 'lguplus');
|
$ok = $this->smsService->send($payload, 'lguplus');
|
||||||
@ -123,7 +112,7 @@ final class AdminSmsService
|
|||||||
'provider_message' => 'SmsService send() returned false',
|
'provider_message' => 'SmsService send() returned false',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$status = 'submitted';
|
$status = 'submitted';
|
||||||
if ($submitted < 1) $status = 'failed';
|
if ($submitted < 1) $status = 'failed';
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 로직과 동일한 우선순위 유지
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
예) <span class="mmono">010...,홍길동,쿠폰,18000,다날,발송</span><br>
|
||||||
|
→ <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 또는 줄바꿈으로 붙여넣기">{{ 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,8 +809,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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/)
|
||||||
@ -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
|
||||||
|
|||||||
48
resources/views/vendor/pagination/admin.blade.php
vendored
Normal file
48
resources/views/vendor/pagination/admin.blade.php
vendored
Normal 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
|
||||||
@ -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();
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user