2026-03-03 15:13:16 +09:00

1063 lines
52 KiB
PHP

@extends('admin.layouts.app')
@section('title', '관리자 SMS 발송')
@section('page_title', '관리자 SMS 발송')
@section('page_desc', '단건 / 대량(붙여넣기) / CSV 업로드 발송')
@push('head')
<style>
/* 이 페이지에서만 쓰는 로컬 스타일 */
.sms-grid{display:grid;grid-template-columns:1fr 1.15fr;gap:16px;align-items:start;}
@media (max-width: 980px){.sms-grid{grid-template-columns:1fr;}}
.sms-toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:end;}
.sms-badges{margin-left:auto;display:flex;gap:10px;align-items:center;}
.sms-seg{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);}
.sms-tab{padding:8px 12px;font-size:13px;border-radius:999px;line-height:1;border:0;background:transparent;color:inherit;cursor:pointer;white-space:nowrap;}
.sms-tab.is-active{background:rgba(255,255,255,.14);}
.sms-btn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;white-space:nowrap;}
.sms-btn:hover{background:rgba(255,255,255,.10);}
.sms-btn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.sms-btn--primary:hover{background:rgba(59,130,246,.98);}
.sms-btn--ghost{background:transparent;}
.sms-badge{padding:6px 10px;border-radius:999px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);font-size:12px;}
.sms-badge--type{font-weight:800;}
.sms-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
.sms-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
.sms-select{min-width:280px;}
.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>
@endpush
@section('content')
<form method="POST" action="{{ route('admin.sms.send.store') }}" enctype="multipart/form-data" id="smsSendForm">
@csrf
{{-- send_mode: one | many | template(CSV 업로드) --}}
<input type="hidden" name="send_mode" id="sendMode" value="one">
<input type="hidden" name="sms_type_hint" id="smsTypeHint" value="auto">
{{-- 상단 --}}
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="sms-toolbar">
<div style="min-width:280px;">
<div class="a-muted" style="margin-bottom:6px;">발신번호</div>
<input class="a-input" name="from_number" value="{{ config('services.sms.from','1833-4856') }}" readonly>
</div>
<div style="min-width:340px;">
<div class="a-muted" style="margin-bottom:6px;">발송 시점</div>
<div class="sms-row">
<label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시</label>
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약</label>
{{-- 직접 입력 불가(실수 방지): 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="sms-btn" id="openSchedulePicker" disabled>선택</button>
</div>
</div>
<div class="sms-badges">
<div class="sms-badge sms-badge--type" id="smsTypeBadge">SMS</div>
<div class="sms-badge" id="byteBadge">0 Bytes</div>
</div>
</div>
</div>
<div class="sms-grid">
{{-- 좌측: 수신/모드 --}}
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:8px;">발송 유형</div>
<div class="sms-seg" style="margin-bottom:12px;">
<button type="button" class="sms-tab is-active" data-tab="one">단건</button>
<button type="button" class="sms-tab" data-tab="many">대량(붙여넣기)</button>
<button type="button" class="sms-tab" data-tab="template">CSV 업로드</button>
</div>
{{-- 단건 --}}
<section data-panel="one">
<div class="a-muted" style="margin-bottom:6px;">수신번호 (1)</div>
<input class="a-input" name="to_number" id="toNumber"
placeholder="01012345678 또는 01012345678,이름,금지어,18000,생존템,발송"
value="{{ old('to_number') }}">
<div class="a-muted" style="margin-top:8px; line-height:1.6;">
<b>전화번호만</b> 입력하면 그대로 1 발송합니다.<br>
<b>콤마(,)</b> 뒤에 값을 붙이면 토큰이 치환됩니다.<br>
&nbsp;&nbsp;) <span class="mmono">010...,홍길동,쿠폰,18000,다날,발송</span><br>
&nbsp;&nbsp; <span class="mmono">{_text_02_}=홍길동</span>, <span class="mmono">{_text_03_}=쿠폰</span>, <span class="mmono">{_text_04_}=18000</span>, <span class="mmono">{_text_05_}=다날</span>, <span class="mmono">{_text_06_}=발송</span>
</div>
</section>
{{-- 대량(붙여넣기) --}}
<section data-panel="many" style="display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
<div class="a-muted">수신번호 (여러건 · 붙여넣기)</div>
<div class="a-muted" id="manyCountText">0</div>
</div>
<textarea class="a-input" name="to_numbers_text" id="toNumbersText" rows="10"
placeholder="전화번호만 1줄에 1개씩 입력하세요.
예)
01011112222
01033366660
• 최대 100건(중복 제거 후)
• 100건 초과 또는 개인화(토큰) 발송은 CSV 업로드를 사용하세요.">{{ old('to_numbers_text') }}</textarea>
<div class="sms-row" style="margin-top:10px; justify-content:space-between;">
<div class="a-muted" id="manyStats"></div>
<button type="button" class="sms-btn sms-btn--ghost" id="clearMany">비우기</button>
</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>
{{-- CSV 업로드(내부 send_mode=template) --}}
<section data-panel="template" style="display:none;">
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<label class="sms-btn">
CSV 업로드
<input type="file" name="template_csv" id="templateCsv" accept=".csv,.txt" style="display:none;">
</label>
<span class="a-muted" 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>
{{-- CSV 미리보기 --}}
<div id="tplCsvPreviewWrap"
style="display:none; margin-top:12px; padding:12px; border-radius:14px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<div style="font-weight:800;">CSV 미리보기 (상위 5)</div>
<button type="button" class="sms-btn" id="tplCsvClearBtn">파일 비우기</button>
</div>
<div class="a-muted" id="tplCsvMeta" style="margin-top:8px; line-height:1.6;"></div>
<div style="overflow:auto; margin-top:10px;">
<table class="a-table" style="width:100%; min-width:720px;">
<thead>
<tr id="tplCsvHeadRow"></tr>
</thead>
<tbody id="tplCsvBodyRows"></tbody>
</table>
</div>
<div class="a-muted" id="tplCsvHint" style="margin-top:10px; line-height:1.7;"></div>
</div>
<div class="a-muted" id="tplCsvError" style="display:none; margin-top:10px; line-height:1.6; color:#ff6b6b;"></div>
{{-- 이용 안내 --}}
<div style="margin-top:12px; padding:12px; border-radius:14px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08);">
<div style="font-weight:800; margin-bottom:8px;">CSV 이용 안내</div>
<div class="a-muted" style="line-height:1.7;">
<div style="margin-bottom:10px;">
<b>1) CSV 파일 작성 요령</b><br>
<span class="a-muted"> 줄이 “1명”이며, 쉼표(<code>,</code>) 컬럼을 구분합니다.</span>
<div style="margin-top:8px;">
<div class="a-muted" style="margin-bottom:6px;">예시</div>
<div style="
padding:8px 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; line-height:1.45; text-align:left;">
<pre style="margin:0; white-space:pre;">01036828958,홍길동,10000,쿠폰
01036828901,이순신,20000,상품권
01036828902,김개똥,30000,쿠폰</pre>
</div>
</div>
<ul style="margin:10px 0 0 18px;">
<li><b>1번째 컬럼</b>: 수신 전화번호(고정)</li>
<li><b>2번째 컬럼</b>: 치환값 1 ( <code>{_text_02_}</code>)</li>
<li><b>3번째 컬럼</b>: 치환값 2 ( <code>{_text_03_}</code>)</li>
<li><b>4번째 컬럼</b>: 치환값 3 ( <code>{_text_04_}</code>)</li>
</ul>
</div>
<div>
<b>2) 토큰을 쓰지 않으면?</b><br>
<span class="a-muted">문구에 토큰이 없으면 CSV는 “전화번호만(1) 있어도 정상 발송됩니다.</span>
</div>
</div>
</div>
</section>
</div>
{{-- 우측: 문구/프리셋/액션 --}}
<div style="display:flex; flex-direction:column; gap:16px;">
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:6px;">문구 템플릿(프리셋) 선택</div>
<div class="sms-row">
<select class="a-input sms-select" id="tplSelect">
<option value="">선택하세요</option>
@foreach(($templates ?? []) as $t)
<option value="{{ $t->id ?? $t['id'] }}">
{{ ($t->title ?? $t['title']) }} ({{ $t->code ?? $t['code'] }})
</option>
@endforeach
</select>
<button type="button" class="sms-btn" id="tplApplyReplace">덮어쓰기</button>
<button type="button" class="sms-btn sms-btn--ghost" id="tplApplyAppend">뒤에붙이기</button>
</div>
<div class="a-muted" style="margin-top:8px;">* “덮어쓰기”는 현재 문구를 교체합니다.</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:6px;">발송 문구</div>
<textarea class="a-input" name="message" id="messageText" rows="10" placeholder="메시지를 입력하세요">{{ old('message') }}</textarea>
<div class="sms-row" style="margin-top:10px;">
<button type="button" class="sms-btn sms-btn--ghost" data-insert="{_text_02_}">{_text_02_}</button>
<button type="button" class="sms-btn sms-btn--ghost" data-insert="{_text_03_}">{_text_03_}</button>
<button type="button" class="sms-btn sms-btn--ghost" data-insert="{_text_04_}">{_text_04_}</button>
<span class="a-muted">토큰 빠른 삽입</span>
</div>
<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;">
<div class="sms-actions">
<button type="submit" class="sms-btn sms-btn--primary">발송</button>
<a class="sms-btn" href="{{ route('admin.sms.logs') }}">발송 이력</a>
</div>
<div class="a-muted" style="margin-top:8px;">* 90 bytes 초과는 MMS 표시(최종은 서버 기준)</div>
</div>
</div>
</div>
</form>
{{-- ===== 예약발송 날짜/시간/(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')
<script>
(() => {
const form = document.getElementById('smsSendForm');
if (!form) return;
const templates = @json($templates ?? []);
// tabs
const tabBtns = Array.from(form.querySelectorAll('[data-tab]'));
const panels = Array.from(form.querySelectorAll('[data-panel]'));
const sendModeEl = document.getElementById('sendMode');
function setTab(tab){
tabBtns.forEach(b => b.classList.toggle('is-active', b.dataset.tab === tab));
panels.forEach(p => p.style.display = (p.dataset.panel === tab) ? '' : 'none');
sendModeEl.value = tab; // one | many | template
}
tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab)));
// ===== schedule (기존 로직 유지 + 선택 버튼/모달만 추가)
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 => {
r.addEventListener('change', () => {
const isSch = form.querySelector('input[name="schedule_type"][value="schedule"]').checked;
scheduledAt.disabled = !isSch;
if (openSchBtn) openSchBtn.disabled = !isSch;
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
const msg = document.getElementById('messageText');
const byteBadge = document.getElementById('byteBadge');
const typeBadge = document.getElementById('smsTypeBadge');
const smsTypeHint = document.getElementById('smsTypeHint');
function calcBytes(str){
let byte = 0;
for (let i=0;i<str.length;i++){
const c = str.charCodeAt(i);
byte += (c > 127) ? 2 : 1;
}
return byte;
}
function refreshBytes(){
const b = calcBytes(msg?.value || '');
if (byteBadge) byteBadge.textContent = b + ' Bytes';
const type = (b > 90) ? 'mms' : 'sms';
if (typeBadge) typeBadge.textContent = type.toUpperCase();
if (smsTypeHint) smsTypeHint.value = 'auto';
}
msg?.addEventListener('input', refreshBytes);
refreshBytes();
// token insert
form.querySelectorAll('[data-insert]').forEach(btn => {
btn.addEventListener('click', () => {
if(!msg) return;
const token = btn.getAttribute('data-insert') || '';
const start = msg.selectionStart ?? msg.value.length;
const end = msg.selectionEnd ?? msg.value.length;
msg.value = msg.value.slice(0,start) + token + msg.value.slice(end);
msg.focus();
msg.selectionStart = msg.selectionEnd = start + token.length;
msg.dispatchEvent(new Event('input', { bubbles: true }));
});
});
// many stats + clear
const toNumbersText = document.getElementById('toNumbersText');
const manyCountText = document.getElementById('manyCountText');
const manyStats = document.getElementById('manyStats');
const clearMany = document.getElementById('clearMany');
function parsePhonesRough(text){
const lines = (text || '')
.split(/\r\n|\n|\r/)
.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 uniq = Array.from(new Set(valid));
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(){
if(!toNumbersText) return;
const r = parseManyLines(toNumbersText.value);
if (manyCountText) manyCountText.textContent = r.uniq + '건';
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);
clearMany?.addEventListener('click', () => {
if(toNumbersText){ toNumbersText.value=''; refreshMany(); }
});
refreshMany();
// template apply (프리셋)
const sel = document.getElementById('tplSelect');
const btnR = document.getElementById('tplApplyReplace');
const btnA = document.getElementById('tplApplyAppend');
function getSelectedBody(){
const id = Number(sel?.value || 0);
if (!id) return '';
const t = templates.find(x => Number(x.id) === id);
if (!t) return '';
return String(t.body ?? t.content ?? t.message ?? '');
}
function apply(mode){
if (!msg) return;
const body = getSelectedBody();
if (!body) { alert('템플릿을 선택하세요.'); return; }
if (mode === 'replace') msg.value = body;
else msg.value = msg.value ? (msg.value + "\n" + body) : body;
msg.dispatchEvent(new Event('input', { bubbles: true }));
msg.focus();
}
btnR?.addEventListener('click', () => apply('replace'));
btnA?.addEventListener('click', () => apply('append'));
// 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 (최종 검증은 서버)
form.addEventListener('submit', (e) => {
const mode = sendModeEl?.value || 'one';
const m = (msg?.value || '').trim();
if(!m){ e.preventDefault(); alert('발송 문구를 입력하세요.'); return; }
// many도 토큰 치환 지원 (1줄=1명: "전화번호,치환값...")
if(mode === 'many'){
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'){
const r = parsePhonesRough(toNumbersText?.value || '');
if(r.uniq < 1){ e.preventDefault(); alert('대량 수신번호를 입력하세요.'); return; }
if(r.uniq > 100){ e.preventDefault(); alert('대량(붙여넣기)은 최대 100건입니다. 100건 초과는 CSV 업로드를 이용해 주세요.'); return; }
}
if(mode === 'template'){
const hasT = (document.getElementById('templateCsv')?.files || []).length > 0;
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;
if(isSch && !(scheduledAt?.value || '').trim()){
e.preventDefault();
alert('예약 시간을 선택하세요.');
return;
}
});
// Esc 닫기
document.addEventListener('keydown', (e) => {
if(e.key !== 'Escape') return;
if (schModalOpen) closeScheduleModal();
});
})();
</script>
<script>
(() => {
const input = document.getElementById('templateCsv');
if (!input) return;
const wrap = document.getElementById('tplCsvPreviewWrap');
const meta = document.getElementById('tplCsvMeta');
const headTr = document.getElementById('tplCsvHeadRow');
const bodyTb = document.getElementById('tplCsvBodyRows');
const hint = document.getElementById('tplCsvHint');
const errBox = document.getElementById('tplCsvError');
const clearBtn = document.getElementById('tplCsvClearBtn');
const msgEl = document.getElementById('messageText');
function showErr(msg){
if (!errBox) return;
errBox.style.display = msg ? '' : 'none';
errBox.textContent = msg || '';
}
function esc(s){
return String(s ?? '')
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'",'&#039;');
}
function parseCsvLoose(text){
const lines = (text || '')
.replace(/^\uFEFF/, '') // BOM 제거
.split(/\r\n|\n|\r/)
.map(l => l.trim())
.filter(Boolean);
const rows = [];
for (const line of lines){
const cells = [];
let cur = '';
let inQ = false;
for (let i=0;i<line.length;i++){
const ch = line[i];
if (ch === '"'){
if (inQ && line[i+1] === '"'){ cur += '"'; i++; continue; }
inQ = !inQ;
continue;
}
if (ch === ',' && !inQ){
cells.push(cur.trim());
cur = '';
} else {
cur += ch;
}
}
cells.push(cur.trim());
rows.push(cells);
}
return rows;
}
function normalizePhone(v){
return String(v||'').replace(/\D+/g,'');
}
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){
const sample = rows.slice(0, 5);
// 최대 컬럼수(파일 전체 기준으로는 무거우니 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 = '';
for (let c=0;c<Math.max(1,maxCols);c++){
const th = document.createElement('th');
th.textContent = (c===0) ? 'phone' : `val${c+1}`;
headTr.appendChild(th);
}
// 바디
bodyTb.innerHTML = '';
sample.forEach((r) => {
const tr = document.createElement('tr');
for (let c=0;c<Math.max(1,maxCols);c++){
const td = document.createElement('td');
const val = (r[c] ?? '');
td.innerHTML = esc(val);
if (c===0){
const p = normalizePhone(val);
if (!/^01\d{8,9}$/.test(p)) {
td.style.color = '#ff6b6b';
td.style.fontWeight = '800';
td.title = '휴대폰 형식이 올바르지 않습니다 (01X 10~11자리 숫자)';
}
}
tr.appendChild(td);
}
bodyTb.appendChild(tr);
});
// 메타/힌트
const totalRows = rows.length;
const tokenIdxs = extractTokenIdxs(msgEl?.value || '');
const hasTokens = tokenIdxs.length > 0;
const needArgs = tokenIdxs.length;
let tokenInfo = '';
if (maxCols <= 1){
tokenInfo = '치환값 컬럼이 없습니다. (phone만 존재)';
} else {
const tokenTo = Math.min(9, 1 + (maxCols - 1));
tokenInfo = `치환 가능 토큰(컬럼 기준): {_text_02_} ~ {_text_0${tokenTo}_}`;
}
meta.innerHTML = `
<div>총 <b>${totalRows}</b>줄 · 컬럼수 <b>${maxCols}</b>개</div>
<div>${tokenInfo}</div>
`;
let hintHtml = '';
if (maxCols <= 1){
hintHtml += `<div>• CSV는 수신번호만 있습니다. 토큰 치환은 불가능합니다.</div>`;
} else {
hintHtml += `<div>• 1열(phone) → 수신번호</div>`;
hintHtml += `<div>• 2열(val2) → <code>{_text_02_}</code></div>`;
if (maxCols >= 3) hintHtml += `<div>• 3열(val3) → <code>{_text_03_}</code></div>`;
if (maxCols >= 4) hintHtml += `<div>• 4열(val4) → <code>{_text_04_}</code></div>`;
if (maxCols > 4) hintHtml += `<div>• 이후 컬럼은 순서대로 <code>{_text_05_}</code> …</div>`;
}
hintHtml += `<div class="a-muted" style="margin-top:6px;">* 최종 유효성/중복 제거/치환은 서버에서 한 번 더 처리합니다.</div>`;
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){
showErr('');
window.__CSV_MAX_COLS__ = 0;
if (!file){
wrap.style.display = 'none';
return;
}
const name = (file.name || '').toLowerCase();
if (!name.endsWith('.csv') && !name.endsWith('.txt')){
showErr('CSV/TXT 파일만 업로드 가능합니다.');
input.value = '';
wrap.style.display = 'none';
return;
}
if (file.size > 5 * 1024 * 1024){
showErr('파일 용량은 5MB 이하만 가능합니다.');
input.value = '';
wrap.style.display = 'none';
return;
}
const reader = new FileReader();
reader.onload = () => {
try{
const text = String(reader.result || '');
const rows = parseCsvLoose(text);
if (!rows.length){
showErr('CSV 내용이 비어있습니다.');
wrap.style.display = 'none';
return;
}
const badPhone = rows.slice(0, 200).some(r => !/^01\d{8,9}$/.test(normalizePhone(r[0] ?? '')));
buildPreview(rows);
wrap.style.display = '';
if (badPhone && !(errBox?.textContent || '').trim()){
showErr('미리보기 범위 내에 휴대폰 형식이 올바르지 않은 줄이 있습니다. (빨간색 표시)');
} else if (!badPhone && !(errBox?.textContent || '').trim()){
showErr('');
}
}catch(e){
showErr('CSV 파싱 중 오류가 발생했습니다. 파일 형식을 확인해 주세요.');
wrap.style.display = 'none';
}
};
reader.onerror = () => {
showErr('파일을 읽을 수 없습니다. 다시 시도해 주세요.');
wrap.style.display = 'none';
};
reader.readAsText(file);
}
input.addEventListener('change', () => {
const file = (input.files || [])[0];
handleFile(file);
});
clearBtn?.addEventListener('click', () => {
input.value = '';
wrap.style.display = 'none';
showErr('');
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>
@endpush
@endsection