- NumberingRule 모델, 서비스, 컨트롤러 추가 - API/Blade 라우트 등록 - CRUD + 미리보기 기능 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
365 lines
15 KiB
PHP
365 lines
15 KiB
PHP
<script>
|
|
// ========================================
|
|
// 채번 규칙 세그먼트 에디터 (Vanilla JS)
|
|
// ========================================
|
|
|
|
let segments = [];
|
|
let sequencePadding = 2;
|
|
|
|
const SEGMENT_TYPES = [
|
|
{ value: 'static', label: '고정 문자열' },
|
|
{ value: 'separator', label: '구분자' },
|
|
{ value: 'date', label: '날짜' },
|
|
{ value: 'param', label: '외부 파라미터' },
|
|
{ value: 'mapping', label: '값 매핑' },
|
|
{ value: 'sequence', label: '자동 순번' },
|
|
];
|
|
|
|
const DATE_FORMATS = [
|
|
{ value: 'ymd', label: 'YYMMDD (260207)' },
|
|
{ value: 'Ymd', label: 'YYYYMMDD (20260207)' },
|
|
{ value: 'Ym', label: 'YYYYMM (202602)' },
|
|
{ value: 'ym', label: 'YYMM (2602)' },
|
|
{ value: 'Y', label: 'YYYY (2026)' },
|
|
{ value: 'y', label: 'YY (26)' },
|
|
];
|
|
|
|
// ========================================
|
|
// 초기화
|
|
// ========================================
|
|
function initPatternEditor(initialSegments, initialPadding) {
|
|
sequencePadding = initialPadding || 2;
|
|
|
|
segments = (initialSegments || []).map(function(seg) {
|
|
var s = Object.assign({ type: '', value: '', format: 'ymd', key: '', default: '', map: {}, _mapEntries: [] }, seg);
|
|
if (s.type === 'mapping' && s.map && typeof s.map === 'object' && !Array.isArray(s.map)) {
|
|
s._mapEntries = Object.entries(s.map).map(function(pair) {
|
|
return { key: pair[0], value: pair[1] };
|
|
});
|
|
}
|
|
return s;
|
|
});
|
|
|
|
renderSegments();
|
|
updatePreview();
|
|
|
|
var paddingInput = document.querySelector('[name="sequence_padding"]');
|
|
if (paddingInput) {
|
|
paddingInput.addEventListener('input', function() {
|
|
sequencePadding = parseInt(this.value) || 2;
|
|
updatePreview();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 세그먼트 CRUD
|
|
// ========================================
|
|
function addSegment() {
|
|
segments.push({
|
|
type: 'static', value: '', format: 'ymd',
|
|
key: '', default: '', map: {}, _mapEntries: [],
|
|
});
|
|
renderSegments();
|
|
updatePreview();
|
|
}
|
|
|
|
function removeSegment(index) {
|
|
segments.splice(index, 1);
|
|
renderSegments();
|
|
updatePreview();
|
|
}
|
|
|
|
function moveSegment(from, direction) {
|
|
var to = from + direction;
|
|
if (to < 0 || to >= segments.length) return;
|
|
var temp = segments.splice(from, 1)[0];
|
|
segments.splice(to, 0, temp);
|
|
renderSegments();
|
|
updatePreview();
|
|
}
|
|
|
|
// ========================================
|
|
// 필드값 변경 핸들러
|
|
// ========================================
|
|
function onTypeChange(index, newType) {
|
|
segments[index].type = newType;
|
|
segments[index].value = (newType === 'separator') ? '-' : '';
|
|
segments[index].format = 'ymd';
|
|
segments[index].key = '';
|
|
segments[index].default = '';
|
|
segments[index].map = {};
|
|
segments[index]._mapEntries = [];
|
|
renderSegments();
|
|
updatePreview();
|
|
}
|
|
|
|
function onSegFieldChange(index, field, value) {
|
|
segments[index][field] = value;
|
|
updatePreview();
|
|
}
|
|
|
|
// ========================================
|
|
// 매핑 엔트리 관리
|
|
// ========================================
|
|
function addMapEntry(segIndex) {
|
|
if (!segments[segIndex]._mapEntries) segments[segIndex]._mapEntries = [];
|
|
segments[segIndex]._mapEntries.push({ key: '', value: '' });
|
|
renderSegments();
|
|
}
|
|
|
|
function removeMapEntry(segIndex, entryIndex) {
|
|
segments[segIndex]._mapEntries.splice(entryIndex, 1);
|
|
renderSegments();
|
|
updatePreview();
|
|
}
|
|
|
|
function onMapEntryChange(segIndex, entryIndex, field, value) {
|
|
segments[segIndex]._mapEntries[entryIndex][field] = value;
|
|
updatePreview();
|
|
}
|
|
|
|
// ========================================
|
|
// 타입별 동적 필드 HTML 생성
|
|
// ========================================
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function getFieldsHtml(seg, index) {
|
|
switch (seg.type) {
|
|
case 'static':
|
|
case 'separator':
|
|
return '<input type="text" value="' + escapeHtml(seg.value) + '" placeholder="' + (seg.type === 'separator' ? '구분자 (예: -)' : '값') + '" ' +
|
|
'onchange="onSegFieldChange(' + index + ', \'value\', this.value)" ' +
|
|
'class="flex-1 min-w-[100px] px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">';
|
|
case 'date':
|
|
var opts = DATE_FORMATS.map(function(f) {
|
|
return '<option value="' + f.value + '"' + (seg.format === f.value ? ' selected' : '') + '>' + f.label + '</option>';
|
|
}).join('');
|
|
return '<select onchange="onSegFieldChange(' + index + ', \'format\', this.value)" ' +
|
|
'class="flex-1 min-w-[180px] px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
|
opts + '</select>';
|
|
case 'param':
|
|
return '<input type="text" value="' + escapeHtml(seg.key) + '" placeholder="파라미터 키 (예: pair_code)" ' +
|
|
'onchange="onSegFieldChange(' + index + ', \'key\', this.value)" ' +
|
|
'class="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
|
'<input type="text" value="' + escapeHtml(seg.default) + '" placeholder="기본값" ' +
|
|
'onchange="onSegFieldChange(' + index + ', \'default\', this.value)" ' +
|
|
'class="w-24 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">';
|
|
case 'mapping':
|
|
var mapHtml = (seg._mapEntries || []).map(function(entry, ei) {
|
|
return '<div class="flex gap-1 items-center">' +
|
|
'<input type="text" value="' + escapeHtml(entry.key) + '" placeholder="입력값" ' +
|
|
'onchange="onMapEntryChange(' + index + ', ' + ei + ', \'key\', this.value)" ' +
|
|
'class="w-28 px-2 py-1 border border-gray-300 rounded text-xs">' +
|
|
'<span class="text-gray-400 text-xs">→</span>' +
|
|
'<input type="text" value="' + escapeHtml(entry.value) + '" placeholder="변환값" ' +
|
|
'onchange="onMapEntryChange(' + index + ', ' + ei + ', \'value\', this.value)" ' +
|
|
'class="w-20 px-2 py-1 border border-gray-300 rounded text-xs">' +
|
|
'<button type="button" onclick="removeMapEntry(' + index + ', ' + ei + ')" ' +
|
|
'class="text-red-400 hover:text-red-600 text-xs px-1">×</button>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
return '<div class="flex-1">' +
|
|
'<div class="flex gap-2 mb-2">' +
|
|
'<input type="text" value="' + escapeHtml(seg.key) + '" placeholder="파라미터 키" ' +
|
|
'onchange="onSegFieldChange(' + index + ', \'key\', this.value)" ' +
|
|
'class="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
|
'<input type="text" value="' + escapeHtml(seg.default) + '" placeholder="기본값" ' +
|
|
'onchange="onSegFieldChange(' + index + ', \'default\', this.value)" ' +
|
|
'class="w-24 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
|
'</div>' +
|
|
'<div class="ml-4 space-y-1">' +
|
|
mapHtml +
|
|
'<button type="button" onclick="addMapEntry(' + index + ')" ' +
|
|
'class="text-xs text-blue-600 hover:text-blue-800">+ 매핑 추가</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
case 'sequence':
|
|
return '<span class="text-sm text-gray-400 pt-2">자동 순번 (설정 없음)</span>';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 세그먼트 전체 렌더링
|
|
// ========================================
|
|
function renderSegments() {
|
|
var container = document.getElementById('segmentsContainer');
|
|
|
|
if (segments.length === 0) {
|
|
container.innerHTML = '<p class="text-gray-400 text-sm py-4 text-center">세그먼트를 추가하세요.</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = segments.map(function(seg, index) {
|
|
var typeOpts = SEGMENT_TYPES.map(function(t) {
|
|
return '<option value="' + t.value + '"' + (seg.type === t.value ? ' selected' : '') + '>' + t.label + '</option>';
|
|
}).join('');
|
|
|
|
return '<div class="flex items-start gap-2 mb-3 p-3 bg-gray-50 rounded-lg">' +
|
|
'<span class="text-sm text-gray-400 pt-2 min-w-[24px]">' + (index + 1) + '.</span>' +
|
|
'<select onchange="onTypeChange(' + index + ', this.value)" ' +
|
|
'class="w-36 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
|
typeOpts +
|
|
'</select>' +
|
|
'<div class="flex-1 flex flex-wrap gap-2">' +
|
|
getFieldsHtml(seg, index) +
|
|
'</div>' +
|
|
'<div class="flex gap-1 shrink-0">' +
|
|
'<button type="button" onclick="moveSegment(' + index + ', -1)"' + (index === 0 ? ' disabled' : '') +
|
|
' class="px-2 py-1 text-gray-400 hover:text-gray-600 disabled:opacity-30" title="위로">↑</button>' +
|
|
'<button type="button" onclick="moveSegment(' + index + ', 1)"' + (index === segments.length - 1 ? ' disabled' : '') +
|
|
' class="px-2 py-1 text-gray-400 hover:text-gray-600 disabled:opacity-30" title="아래로">↓</button>' +
|
|
'<button type="button" onclick="removeSegment(' + index + ')" ' +
|
|
'class="px-2 py-1 text-red-400 hover:text-red-600" title="삭제">×</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
// ========================================
|
|
// 실시간 미리보기
|
|
// ========================================
|
|
function generatePreviewStr(seqNum) {
|
|
var now = new Date();
|
|
var pad2 = function(n) { return String(n).padStart(2, '0'); };
|
|
var yy = String(now.getFullYear()).slice(-2);
|
|
var yyyy = String(now.getFullYear());
|
|
var mm = pad2(now.getMonth() + 1);
|
|
var dd = pad2(now.getDate());
|
|
|
|
var formatDate = function(fmt) {
|
|
switch (fmt) {
|
|
case 'ymd': return yy + mm + dd;
|
|
case 'Ymd': return yyyy + mm + dd;
|
|
case 'Ym': return yyyy + mm;
|
|
case 'ym': return yy + mm;
|
|
case 'Y': return yyyy;
|
|
case 'y': return yy;
|
|
default: return yy + mm + dd;
|
|
}
|
|
};
|
|
|
|
return segments.map(function(seg) {
|
|
switch (seg.type) {
|
|
case 'static': return seg.value || '?';
|
|
case 'separator': return seg.value || '-';
|
|
case 'date': return formatDate(seg.format || 'ymd');
|
|
case 'param': return seg.default || ('{' + (seg.key || '?') + '}');
|
|
case 'mapping': return seg.default || ('{' + (seg.key || '?') + '}');
|
|
case 'sequence': return String(seqNum).padStart(sequencePadding, '0');
|
|
default: return '';
|
|
}
|
|
}).join('');
|
|
}
|
|
|
|
function updatePreview() {
|
|
var previewEl = document.getElementById('previewArea');
|
|
if (segments.length === 0) {
|
|
previewEl.innerHTML = '<p class="text-gray-400">세그먼트를 추가하면 미리보기가 표시됩니다.</p>';
|
|
return;
|
|
}
|
|
previewEl.innerHTML =
|
|
'<div class="text-lg font-mono">' +
|
|
'<span class="text-gray-500">1번:</span> ' +
|
|
'<span class="font-bold text-blue-700">' + escapeHtml(generatePreviewStr(1)) + '</span>' +
|
|
'</div>' +
|
|
'<div class="text-lg font-mono mt-1">' +
|
|
'<span class="text-gray-500">2번:</span> ' +
|
|
'<span class="font-bold text-blue-700">' + escapeHtml(generatePreviewStr(2)) + '</span>' +
|
|
'</div>';
|
|
}
|
|
|
|
// ========================================
|
|
// 폼 제출 (fetch + JSON)
|
|
// ========================================
|
|
function prepareSubmitData() {
|
|
return segments.map(function(seg) {
|
|
var clean = { type: seg.type };
|
|
switch (seg.type) {
|
|
case 'static':
|
|
case 'separator':
|
|
clean.value = seg.value;
|
|
break;
|
|
case 'date':
|
|
clean.format = seg.format;
|
|
break;
|
|
case 'param':
|
|
clean.key = seg.key;
|
|
if (seg.default) clean.default = seg.default;
|
|
break;
|
|
case 'mapping':
|
|
clean.key = seg.key;
|
|
if (seg.default) clean.default = seg.default;
|
|
clean.map = {};
|
|
(seg._mapEntries || []).forEach(function(entry) {
|
|
if (entry.key) clean.map[entry.key] = entry.value;
|
|
});
|
|
break;
|
|
case 'sequence':
|
|
break;
|
|
}
|
|
return clean;
|
|
});
|
|
}
|
|
|
|
async function submitForm(url, method) {
|
|
method = method || 'POST';
|
|
|
|
var docType = document.querySelector('[name="document_type"]').value;
|
|
if (!docType) {
|
|
showToast('문서유형을 선택해주세요.', 'error');
|
|
return;
|
|
}
|
|
if (segments.length === 0) {
|
|
showToast('최소 1개 이상의 세그먼트를 추가해주세요.', 'error');
|
|
return;
|
|
}
|
|
|
|
var formData = {
|
|
document_type: docType,
|
|
rule_name: document.querySelector('[name="rule_name"]').value,
|
|
reset_period: document.querySelector('[name="reset_period"]').value,
|
|
sequence_padding: parseInt(document.querySelector('[name="sequence_padding"]').value) || 2,
|
|
is_active: document.querySelector('[name="is_active"]').checked ? 1 : 0,
|
|
pattern: prepareSubmitData(),
|
|
};
|
|
|
|
try {
|
|
var response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
},
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
var result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(result.message, 'success');
|
|
if (result.redirect) window.location.href = result.redirect;
|
|
} else if (response.status === 422) {
|
|
var errors = result.errors || {};
|
|
var errorMsg = '입력 오류: ';
|
|
for (var field in errors) {
|
|
errorMsg += errors[field].join(', ') + ' ';
|
|
}
|
|
showToast(errorMsg.trim(), 'error');
|
|
} else {
|
|
showToast(result.message || '저장에 실패했습니다.', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Submit error:', error);
|
|
showToast('요청 처리 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
}
|
|
</script>
|