595 lines
31 KiB
PHP

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