From c96a92bcb5ca2debca370dae00e552084a02fa8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 20:48:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[approvals]=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9D=B8=EA=B0=90=EA=B3=84=20=EC=96=91=EC=8B=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 증명서 카테고리에 사용인감계(seal_usage) 양식 등록 - 입력 폼: 사용일자, 인감종류, 용도, 제출처, 비고 - 회사 정보 자동 로드 (테넌트 정보 기반) - 미리보기/인쇄 기능 (원본 DOCX 유사 레이아웃) - create/edit/show 3개 페이지 모두 지원 --- app/Http/Controllers/ApprovalController.php | 33 +++- resources/views/approvals/create.blade.php | 144 ++++++++++++++- resources/views/approvals/edit.blade.php | 169 +++++++++++++++++- .../partials/_seal-usage-form.blade.php | 139 ++++++++++++++ .../partials/_seal-usage-show.blade.php | 109 +++++++++++ resources/views/approvals/show.blade.php | 72 ++++++++ 6 files changed, 659 insertions(+), 7 deletions(-) create mode 100644 resources/views/approvals/partials/_seal-usage-form.blade.php create mode 100644 resources/views/approvals/partials/_seal-usage-show.blade.php diff --git a/app/Http/Controllers/ApprovalController.php b/app/Http/Controllers/ApprovalController.php index d86ed24a..e9b9c31d 100644 --- a/app/Http/Controllers/ApprovalController.php +++ b/app/Http/Controllers/ApprovalController.php @@ -4,6 +4,8 @@ use App\Models\Finance\BankAccount; use App\Models\Finance\CorporateCard; +use App\Models\Tenants\Tenant; +use App\Models\Tenants\TenantSetting; use App\Services\ApprovalService; use App\Services\HR\LeaveService; use Illuminate\Http\Request; @@ -41,8 +43,9 @@ public function create(Request $request): View|Response $lines = $this->service->getApprovalLines(); [$cards, $accounts] = $this->getCardAndAccountData(); $employees = app(LeaveService::class)->getActiveEmployees(); + $tenantInfo = $this->getTenantInfo(); - return view('approvals.create', compact('forms', 'lines', 'cards', 'accounts', 'employees')); + return view('approvals.create', compact('forms', 'lines', 'cards', 'accounts', 'employees', 'tenantInfo')); } /** @@ -64,8 +67,9 @@ public function edit(Request $request, int $id): View|Response $lines = $this->service->getApprovalLines(); [$cards, $accounts] = $this->getCardAndAccountData(); $employees = app(LeaveService::class)->getActiveEmployees(); + $tenantInfo = $this->getTenantInfo(); - return view('approvals.edit', compact('approval', 'forms', 'lines', 'cards', 'accounts', 'employees')); + return view('approvals.edit', compact('approval', 'forms', 'lines', 'cards', 'accounts', 'employees', 'tenantInfo')); } /** @@ -118,6 +122,31 @@ public function completed(Request $request): View|Response return view('approvals.completed'); } + private function getTenantInfo(): array + { + $tenantId = session('selected_tenant_id'); + $tenant = Tenant::find($tenantId); + + if (! $tenant) { + return []; + } + + $displaySetting = TenantSetting::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('setting_group', 'company') + ->where('setting_key', 'display_company_name') + ->first(); + $displayName = $displaySetting?->setting_value ?? ''; + + return [ + 'company_name' => ! empty($displayName) ? $displayName : ($tenant->company_name ?? ''), + 'business_num' => $tenant->business_num ?? '', + 'ceo_name' => $tenant->ceo_name ?? '', + 'address' => $tenant->address ?? '', + 'phone' => $tenant->phone ?? '', + ]; + } + private function getCardAndAccountData(): array { $tenantId = session('selected_tenant_id'); diff --git a/resources/views/approvals/create.blade.php b/resources/views/approvals/create.blade.php index b1cbb268..11165c07 100644 --- a/resources/views/approvals/create.blade.php +++ b/resources/views/approvals/create.blade.php @@ -130,6 +130,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline- 'employees' => $employees ?? collect(), ]) + {{-- 사용인감계 전용 폼 --}} + @include('approvals.partials._seal-usage-form', [ + 'tenantInfo' => $tenantInfo ?? [], + ]) + {{-- 지출결의서 전용 폼 --}} @include('approvals.partials._expense-form', [ 'initialData' => [], @@ -304,6 +309,11 @@ class="p-1 text-gray-400 hover:text-gray-600 transition"> color: 'border-gray-300 bg-gray-50', titleColor: 'text-gray-800', textColor: 'text-gray-600', text: '퇴직 의사를 공식적으로 표명하는 문서입니다. 사직 사유와 희망 퇴직일을 기재하며, 결재 완료 후 퇴직 절차가 진행됩니다.', }, + seal_usage: { + title: '사용인감계', icon: '🔏', + color: 'border-rose-200 bg-rose-50', titleColor: 'text-rose-800', textColor: 'text-rose-600', + text: '법인인감, 사용인감 등 회사 인감의 사용을 신청하는 문서입니다. 인감 종류, 용도, 제출처를 기재하며, 승인 후 인감을 사용할 수 있습니다.', + }, pr_expense: { title: '지출품의서', icon: '📋', color: 'border-orange-200 bg-orange-50', titleColor: 'text-orange-800', textColor: 'text-orange-700', @@ -335,7 +345,7 @@ class="p-1 text-gray-400 hover:text-gray-600 transition"> const formCategoryMap = { BUSINESS_DRAFT: '일반', leave: '인사/근태', attendance_request: '인사/근태', resignation: '인사/근태', reason_report: '인사/근태', - employment_cert: '증명서', career_cert: '증명서', appointment_cert: '증명서', + employment_cert: '증명서', career_cert: '증명서', appointment_cert: '증명서', seal_usage: '증명서', pr_expense: '품의', pr_contract: '품의', pr_purchase: '품의', pr_trip: '품의', pr_settlement: '품의', expense: '재무', }; @@ -421,6 +431,7 @@ function updateFormDescription(formId) { let isCareerCertForm = false; let isAppointmentCertForm = false; let isResignationForm = false; +let isSealUsageForm = false; // 양식코드별 표시할 유형 목록 const leaveTypesByFormCode = { @@ -669,6 +680,7 @@ function switchFormMode(formId) { const careerCertContainer = document.getElementById('career-cert-form-container'); const appointmentCertContainer = document.getElementById('appointment-cert-form-container'); const resignationContainer = document.getElementById('resignation-form-container'); + const sealUsageContainer = document.getElementById('seal-usage-form-container'); const bodyArea = document.getElementById('body-area'); const expenseLoadBtn = document.getElementById('expense-load-btn'); @@ -682,6 +694,7 @@ function switchFormMode(formId) { careerCertContainer.style.display = 'none'; appointmentCertContainer.style.display = 'none'; resignationContainer.style.display = 'none'; + sealUsageContainer.style.display = 'none'; expenseLoadBtn.style.display = 'none'; bodyArea.style.display = 'none'; isExpenseForm = false; @@ -691,6 +704,7 @@ function switchFormMode(formId) { isCareerCertForm = false; isAppointmentCertForm = false; isResignationForm = false; + isSealUsageForm = false; if (code === 'expense') { isExpenseForm = true; @@ -750,6 +764,9 @@ function switchFormMode(formId) { resignationContainer.style.display = ''; const rgUserId = document.getElementById('rg-user-id').value; if (rgUserId) loadResignationInfo(rgUserId); + } else if (code === 'seal_usage') { + isSealUsageForm = true; + sealUsageContainer.style.display = ''; } else { bodyArea.style.display = ''; } @@ -760,7 +777,7 @@ function applyBodyTemplate(formId) { switchFormMode(formId); // 전용 폼이면 제목을 양식명으로 설정하고 body template 적용 건너뜀 - if (isExpenseForm || isPurchaseRequestForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm) { + if (isExpenseForm || isPurchaseRequestForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm || isSealUsageForm) { const titleEl = document.getElementById('title'); const formSelect = document.getElementById('form_id'); titleEl.value = formSelect.options[formSelect.selectedIndex].text; @@ -954,6 +971,30 @@ function applyBodyTemplate(formId) { issue_date: document.getElementById('ac-issue-date').value, }; formBody = null; + } else if (isSealUsageForm) { + const suPurpose = document.getElementById('su-purpose').value.trim(); + if (!suPurpose) { + showToast('용도를 입력해주세요.', 'warning'); + return; + } + const suSubmitTo = document.getElementById('su-submit-to').value.trim(); + if (!suSubmitTo) { + showToast('제출처를 입력해주세요.', 'warning'); + return; + } + + formContent = { + usage_date: document.getElementById('su-usage-date').value, + seal_type: getSealUsageType(), + purpose: suPurpose, + submit_to: suSubmitTo, + remarks: document.getElementById('su-remarks').value.trim(), + company_name: document.getElementById('su-company-name').value, + business_num: document.getElementById('su-business-num').value, + ceo_name: document.getElementById('su-ceo-name').value, + company_address: document.getElementById('su-company-address').value, + }; + formBody = null; } else if (isResignationForm) { const reason = getResignationReason(); if (!reason) { @@ -1785,5 +1826,104 @@ function buildCertPreviewHtml(d) { `; } + +// ========================================================================= +// 사용인감계 관련 함수 +// ========================================================================= + +document.getElementById('su-seal-type')?.addEventListener('change', function() { + const customWrap = document.getElementById('su-seal-type-custom-wrap'); + if (this.value === '__custom__') { + customWrap.style.display = ''; + document.getElementById('su-seal-type-custom').focus(); + } else { + customWrap.style.display = 'none'; + } +}); + +function getSealUsageType() { + const sel = document.getElementById('su-seal-type'); + if (sel.value === '__custom__') { + return document.getElementById('su-seal-type-custom').value.trim() || '기타'; + } + return sel.value; +} + +function buildSealUsagePreviewHtml(data) { + const e = (s) => s ? String(s).replace(/&/g,'&').replace(//g,'>') : '-'; + return ` +
+

사 용 인 감 계

+
+ + + + + + + + + + + + + + + + + + + ${data.remarks ? ` + + + ` : ''} +
사용일자${e(data.usage_date)}
인감종류${e(data.seal_type)}
용 도${e(data.purpose)}
제출처${e(data.submit_to)}
비 고${e(data.remarks)}
+ +

+ 위와 같이 인감 사용을 신청하오니 허가하여 주시기 바랍니다. +

+ +
+

상 호: ${e(data.company_name)}

+

사업자등록번호: ${e(data.business_num)}

+

주 소: ${e(data.company_address)}

+

대표이사: ${e(data.ceo_name)}

+
+ `; +} + +function openSealUsagePreview() { + const data = { + usage_date: document.getElementById('su-usage-date').value, + seal_type: getSealUsageType(), + purpose: document.getElementById('su-purpose').value, + submit_to: document.getElementById('su-submit-to').value, + remarks: document.getElementById('su-remarks').value, + company_name: document.getElementById('su-company-name').value, + business_num: document.getElementById('su-business-num').value, + ceo_name: document.getElementById('su-ceo-name').value, + company_address: document.getElementById('su-company-address').value, + }; + document.getElementById('seal-usage-preview-content').innerHTML = buildSealUsagePreviewHtml(data); + document.getElementById('seal-usage-preview-modal').style.display = ''; + document.body.style.overflow = 'hidden'; +} + +function closeSealUsagePreview() { + document.getElementById('seal-usage-preview-modal').style.display = 'none'; + document.body.style.overflow = ''; +} + +function printSealUsagePreview() { + const content = document.getElementById('seal-usage-preview-content').innerHTML; + const win = window.open('', '_blank'); + win.document.write('사용인감계'); + win.document.write(''); + win.document.write(''); + win.document.write(content); + win.document.write(''); + win.document.close(); + win.print(); +} @endpush diff --git a/resources/views/approvals/edit.blade.php b/resources/views/approvals/edit.blade.php index 4cc40d04..48d08b54 100644 --- a/resources/views/approvals/edit.blade.php +++ b/resources/views/approvals/edit.blade.php @@ -148,6 +148,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline- 'employees' => $employees ?? collect(), ]) + {{-- 사용인감계 전용 폼 --}} + @include('approvals.partials._seal-usage-form', [ + 'tenantInfo' => $tenantInfo ?? [], + ]) + {{-- 지출결의서 전용 폼 --}} @php $existingFiles = []; @@ -284,6 +289,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon let isExpenseForm = false; let isPurchaseRequestForm = false; let isCertForm = false; +let isSealUsageForm = false; const formDescriptions = { BUSINESS_DRAFT: { @@ -331,6 +337,11 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon color: 'border-gray-300 bg-gray-50', titleColor: 'text-gray-800', textColor: 'text-gray-600', text: '퇴직 의사를 공식적으로 표명하는 문서입니다. 사직 사유와 희망 퇴직일을 기재하며, 결재 완료 후 퇴직 절차가 진행됩니다.', }, + seal_usage: { + title: '사용인감계', icon: '🔏', + color: 'border-rose-200 bg-rose-50', titleColor: 'text-rose-800', textColor: 'text-rose-600', + text: '법인인감, 사용인감 등 회사 인감의 사용을 신청하는 문서입니다. 인감 종류, 용도, 제출처를 기재하며, 승인 후 인감을 사용할 수 있습니다.', + }, pr_expense: { title: '지출품의서', icon: '📋', color: 'border-orange-200 bg-orange-50', titleColor: 'text-orange-800', textColor: 'text-orange-700', @@ -362,7 +373,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon const formCategoryMap = { BUSINESS_DRAFT: '일반', leave: '인사/근태', attendance_request: '인사/근태', resignation: '인사/근태', reason_report: '인사/근태', - employment_cert: '증명서', career_cert: '증명서', appointment_cert: '증명서', + employment_cert: '증명서', career_cert: '증명서', appointment_cert: '증명서', seal_usage: '증명서', pr_expense: '품의', pr_contract: '품의', pr_purchase: '품의', pr_trip: '품의', pr_settlement: '품의', expense: '재무', }; @@ -601,15 +612,18 @@ function switchFormMode(formId) { const expenseContainer = document.getElementById('expense-form-container'); const purchaseRequestContainer = document.getElementById('purchase-request-form-container'); const certContainer = document.getElementById('cert-form-container'); + const sealUsageContainer = document.getElementById('seal-usage-form-container'); const bodyArea = document.getElementById('body-area'); expenseContainer.style.display = 'none'; purchaseRequestContainer.style.display = 'none'; certContainer.style.display = 'none'; + sealUsageContainer.style.display = 'none'; bodyArea.style.display = 'none'; isExpenseForm = false; isPurchaseRequestForm = false; isCertForm = false; + isSealUsageForm = false; if (code === 'expense') { isExpenseForm = true; @@ -626,6 +640,9 @@ function switchFormMode(formId) { certContainer.style.display = ''; const certUserId = document.getElementById('cert-user-id').value; if (certUserId) loadCertInfo(certUserId); + } else if (code === 'seal_usage') { + isSealUsageForm = true; + sealUsageContainer.style.display = ''; } else { bodyArea.style.display = ''; } @@ -636,7 +653,7 @@ function applyBodyTemplate(formId) { switchFormMode(formId); // 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀 - if (isExpenseForm || isPurchaseRequestForm || isCertForm) { + if (isExpenseForm || isPurchaseRequestForm || isCertForm || isSealUsageForm) { const titleEl = document.getElementById('title'); if (!titleEl.value.trim()) { const formSelect = document.getElementById('form_id'); @@ -731,8 +748,31 @@ function applyBodyTemplate(formId) { } } + // 사용인감계 기존 데이터 복원 + if (isSealUsageForm) { + const suContent = @json($approval->content ?? []); + if (suContent.usage_date) { + document.getElementById('su-usage-date').value = suContent.usage_date || ''; + document.getElementById('su-purpose').value = suContent.purpose || ''; + document.getElementById('su-submit-to').value = suContent.submit_to || ''; + document.getElementById('su-remarks').value = suContent.remarks || ''; + + // 인감종류 복원 + const sealTypeSelect = document.getElementById('su-seal-type'); + const sealType = suContent.seal_type || ''; + const predefinedSeals = ['법인인감', '사용인감', '대표이사 인감', '직인']; + if (predefinedSeals.includes(sealType)) { + sealTypeSelect.value = sealType; + } else if (sealType) { + sealTypeSelect.value = '__custom__'; + document.getElementById('su-seal-type-custom-wrap').style.display = ''; + document.getElementById('su-seal-type-custom').value = sealType; + } + } + } + // 전용 폼이 아닌 경우에만 Quill 편집기 자동 활성화 - if (!isExpenseForm && !isPurchaseRequestForm && !isCertForm) { + if (!isExpenseForm && !isPurchaseRequestForm && !isCertForm && !isSealUsageForm) { const existingBody = document.getElementById('body').value; if (/<[a-z][\s\S]*>/i.test(existingBody)) { document.getElementById('useEditor').checked = true; @@ -806,6 +846,30 @@ function applyBodyTemplate(formId) { issue_date: document.getElementById('cert-issue-date').value, }; formBody = null; + } else if (isSealUsageForm) { + const suPurpose = document.getElementById('su-purpose').value.trim(); + if (!suPurpose) { + showToast('용도를 입력해주세요.', 'warning'); + return; + } + const suSubmitTo = document.getElementById('su-submit-to').value.trim(); + if (!suSubmitTo) { + showToast('제출처를 입력해주세요.', 'warning'); + return; + } + + formContent = { + usage_date: document.getElementById('su-usage-date').value, + seal_type: getSealUsageType(), + purpose: suPurpose, + submit_to: suSubmitTo, + remarks: document.getElementById('su-remarks').value.trim(), + company_name: document.getElementById('su-company-name').value, + business_num: document.getElementById('su-business-num').value, + ceo_name: document.getElementById('su-ceo-name').value, + company_address: document.getElementById('su-company-address').value, + }; + formBody = null; } const payload = { @@ -1044,5 +1108,104 @@ function buildCertPreviewHtml(d) { `; } + +// ========================================================================= +// 사용인감계 관련 함수 +// ========================================================================= + +document.getElementById('su-seal-type')?.addEventListener('change', function() { + const customWrap = document.getElementById('su-seal-type-custom-wrap'); + if (this.value === '__custom__') { + customWrap.style.display = ''; + document.getElementById('su-seal-type-custom').focus(); + } else { + customWrap.style.display = 'none'; + } +}); + +function getSealUsageType() { + const sel = document.getElementById('su-seal-type'); + if (sel.value === '__custom__') { + return document.getElementById('su-seal-type-custom').value.trim() || '기타'; + } + return sel.value; +} + +function buildSealUsagePreviewHtml(data) { + const e = (s) => s ? String(s).replace(/&/g,'&').replace(//g,'>') : '-'; + return ` +
+

사 용 인 감 계

+
+ + + + + + + + + + + + + + + + + + + ${data.remarks ? ` + + + ` : ''} +
사용일자${e(data.usage_date)}
인감종류${e(data.seal_type)}
용 도${e(data.purpose)}
제출처${e(data.submit_to)}
비 고${e(data.remarks)}
+ +

+ 위와 같이 인감 사용을 신청하오니 허가하여 주시기 바랍니다. +

+ +
+

상 호: ${e(data.company_name)}

+

사업자등록번호: ${e(data.business_num)}

+

주 소: ${e(data.company_address)}

+

대표이사: ${e(data.ceo_name)}

+
+ `; +} + +function openSealUsagePreview() { + const data = { + usage_date: document.getElementById('su-usage-date').value, + seal_type: getSealUsageType(), + purpose: document.getElementById('su-purpose').value, + submit_to: document.getElementById('su-submit-to').value, + remarks: document.getElementById('su-remarks').value, + company_name: document.getElementById('su-company-name').value, + business_num: document.getElementById('su-business-num').value, + ceo_name: document.getElementById('su-ceo-name').value, + company_address: document.getElementById('su-company-address').value, + }; + document.getElementById('seal-usage-preview-content').innerHTML = buildSealUsagePreviewHtml(data); + document.getElementById('seal-usage-preview-modal').style.display = ''; + document.body.style.overflow = 'hidden'; +} + +function closeSealUsagePreview() { + document.getElementById('seal-usage-preview-modal').style.display = 'none'; + document.body.style.overflow = ''; +} + +function printSealUsagePreview() { + const content = document.getElementById('seal-usage-preview-content').innerHTML; + const win = window.open('', '_blank'); + win.document.write('사용인감계'); + win.document.write(''); + win.document.write(''); + win.document.write(content); + win.document.write(''); + win.document.close(); + win.print(); +} @endpush diff --git a/resources/views/approvals/partials/_seal-usage-form.blade.php b/resources/views/approvals/partials/_seal-usage-form.blade.php new file mode 100644 index 00000000..d6fedeed --- /dev/null +++ b/resources/views/approvals/partials/_seal-usage-form.blade.php @@ -0,0 +1,139 @@ +{{-- + 사용인감계 전용 폼 + Props: + $tenantInfo (array) - 테넌트(회사) 정보 +--}} +@php + $tenantInfo = $tenantInfo ?? []; +@endphp + + + +{{-- 사용인감계 미리보기 모달 --}} + diff --git a/resources/views/approvals/partials/_seal-usage-show.blade.php b/resources/views/approvals/partials/_seal-usage-show.blade.php new file mode 100644 index 00000000..eb0709c3 --- /dev/null +++ b/resources/views/approvals/partials/_seal-usage-show.blade.php @@ -0,0 +1,109 @@ +{{-- + 사용인감계 읽기전용 렌더링 + Props: + $content (array) - approvals.content JSON +--}} +
+ {{-- 미리보기 버튼 --}} +
+ +
+ + {{-- 인감 사용 정보 --}} +
+
+

인감 사용 정보

+
+
+
+
+ 사용일자 +
{{ $content['usage_date'] ?? '-' }}
+
+
+ 인감종류 +
{{ $content['seal_type'] ?? '-' }}
+
+
+ 용도 +
{{ $content['purpose'] ?? '-' }}
+
+
+ 제출처 +
{{ $content['submit_to'] ?? '-' }}
+
+ @if(!empty($content['remarks'])) +
+ 비고 +
{{ $content['remarks'] }}
+
+ @endif +
+
+
+ + {{-- 회사 정보 --}} +
+
+

회사 정보

+
+
+
+
+ 상호 +
{{ $content['company_name'] ?? '-' }}
+
+
+ 사업자등록번호 +
{{ $content['business_num'] ?? '-' }}
+
+
+ 주소 +
{{ $content['company_address'] ?? '-' }}
+
+
+ 대표이사 +
{{ $content['ceo_name'] ?? '-' }}
+
+
+
+
+
+ +{{-- 미리보기 모달 --}} + diff --git a/resources/views/approvals/show.blade.php b/resources/views/approvals/show.blade.php index 7c43b15f..6a54bcdb 100644 --- a/resources/views/approvals/show.blade.php +++ b/resources/views/approvals/show.blade.php @@ -92,6 +92,8 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition @include('approvals.partials._appointment-cert-show', ['content' => $approval->content]) @elseif(!empty($approval->content) && $approval->form?->code === 'resignation') @include('approvals.partials._resignation-show', ['content' => $approval->content]) + @elseif(!empty($approval->content) && $approval->form?->code === 'seal_usage') + @include('approvals.partials._seal-usage-show', ['content' => $approval->content]) @elseif($approval->body && preg_match('/<[a-z][\s\S]*>/i', $approval->body))
{!! strip_tags($approval->body, '