This commit is contained in:
antv
2026-07-15 10:04:06 +07:00
parent ce6c4d2cf3
commit 866d46bf21
7 changed files with 222 additions and 60 deletions
@@ -199,14 +199,4 @@ class AdminController extends Controller
return redirect()->back()->withErrors(['error' => $e->getMessage()]); return redirect()->back()->withErrors(['error' => $e->getMessage()]);
} }
} }
/**
* Reset all cards for active users.
*/
public function resetCards(): RedirectResponse
{
$this->adminService->resetAllCards();
return redirect()->back()->with('success', __('messages.cards_reset_success'));
}
} }
@@ -75,6 +75,23 @@ class UserController extends Controller
$receiverUser = \App\Models\User::where('msnv', $request->input('receiver'))->first(); $receiverUser = \App\Models\User::where('msnv', $request->input('receiver'))->first();
// Send ChatOps notification from backend to avoid CORS issues
$chatOpsUrl = config('constants.CHATOPS_API_URL');
$chatOpsToken = config('constants.CHATOPS_API_TOKEN');
$email = 'antv@runsystem.net';
if ($chatOpsUrl && $chatOpsToken) {
try {
\Illuminate\Support\Facades\Http::timeout(5)->post($chatOpsUrl, [
'message' => "💌 Bạn nhận được một Thank Card mới!\nNhanh chân đến khu vực nhận thư để nhận ngay nhé!",
'userEmail' => $email,
'token' => $chatOpsToken,
]);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('ChatOps API failed: ' . $e->getMessage());
}
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => __('messages.thank_card_send_success'), 'message' => __('messages.thank_card_send_success'),
@@ -29,4 +29,18 @@ class SendThankCardRequest extends FormRequest
'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH, 'amount' => 'required|integer|min:1|max:' . Administration::MAX_SEND_CARD_PER_MONTH,
]; ];
} }
/**
* Handle a failed validation attempt.
*/
protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator)
{
throw new \Illuminate\Http\Exceptions\HttpResponseException(
response()->json([
'success' => false,
'errors' => $validator->errors()
], 422)
);
}
} }
@@ -21,7 +21,6 @@
</svg> </svg>
Thêm user mới Thêm user mới
</a> </a>
<button type="button" onclick="openModal('resetModal')" class="cursor-pointer border border-red-200 bg-red-50/50 hover:bg-red-50 text-red-600 font-bold py-2.5 px-5 rounded-xl text-sm transition-colors shadow-sm w-full sm:w-auto">Reset Card Tháng</button>
</div> </div>
</div> </div>
@@ -44,28 +43,4 @@
</div> </div>
<x-confirm-modal id="deleteModal" title="Xác nhận nghỉ việc" confirmText="Xác nhận Nghỉ việc" /> <x-confirm-modal id="deleteModal" title="Xác nhận nghỉ việc" confirmText="Xác nhận Nghỉ việc" />
<x-modal id="resetModal" title="Xác nhận reset card" maxWidth="md" hideHeader="true">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 flex items-center justify-center w-12 h-12 bg-red-100 rounded-full">
<svg class="w-6 h-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1 mt-1">
<h3 class="text-lg font-bold text-gray-900">Xác nhận reset card tháng</h3>
<p class="text-sm text-gray-600 mt-2 leading-relaxed">
Bạn chắc chắn muốn reset toàn bộ số card hiện tại của các user về 0?<br><br>Hành động này thường chỉ thực hiện vào cuối tháng không thể hoàn tác.
</p>
</div>
</div>
<x-slot name="footer">
<form action="{{ route('admin.reset_cards') }}" method="POST" class="w-full sm:w-auto">
@csrf
<button type="submit" class="w-full sm:w-auto inline-flex items-center justify-center bg-red-600 hover:bg-red-700 text-white font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-md shadow-red-500/10 min-w-[120px] h-[42px]">Đồng ý Reset</button>
</form>
<button type="button" onclick="closeModal('resetModal')" class="w-full sm:w-auto inline-flex items-center justify-center bg-white border border-[#d9dfe7] hover:bg-gray-50 text-[#1a2b49] font-bold py-2.5 px-6 rounded-xl text-sm transition-colors shadow-sm min-w-[120px] h-[42px]">Hủy bỏ</button>
</x-slot>
</x-modal>
@endsection @endsection
+167 -23
View File
@@ -85,6 +85,7 @@ $tpls = [
<option value="{{$u->msnv}}">{{$u->name}} - {{$u->msnv}} ({{$u->mail}}) - {{$dept}}</option> <option value="{{$u->msnv}}">{{$u->name}} - {{$u->msnv}} ({{$u->mail}}) - {{$dept}}</option>
@endforeach @endforeach
</select> </select>
<span id="error-receiver" class="text-red-500 text-[12px] font-semibold mt-1.5 hidden block"></span>
</div> </div>
{{-- Số lượng --}} {{-- Số lượng --}}
@@ -100,16 +101,20 @@ $tpls = [
</select> </select>
<span class="text-[12px] text-gray-400 font-medium">/ tháng</span> <span class="text-[12px] text-gray-400 font-medium">/ tháng</span>
</div> </div>
<span id="error-amount" class="text-red-500 text-[12px] font-semibold mt-1.5 hidden block"></span>
</div> </div>
{{-- Confirm checkbox --}} {{-- Confirm checkbox --}}
<label class="flex items-start gap-3 p-4 rounded-2xl border border-[#e8edf4] bg-white/70 hover:bg-white/90 transition-colors cursor-pointer mb-2"> <div class="mb-2">
<input type="checkbox" name="confirm_written" id="confirm_written" value="1" <label class="flex items-start gap-3 p-4 rounded-2xl border border-[#e8edf4] bg-white/70 hover:bg-white/90 transition-colors cursor-pointer">
class="w-4 h-4 mt-0.5 text-[#3462f7] border-[#d9dfe7] rounded focus:ring-[#3462f7]/20 cursor-pointer shrink-0" required> <input type="checkbox" name="confirm_written" id="confirm_written" value="1"
<span class="text-[13px] font-medium text-gray-600 leading-relaxed select-none"> class="w-4 h-4 mt-0.5 text-[#3462f7] border-[#d9dfe7] rounded focus:ring-[#3462f7]/20 cursor-pointer shrink-0" required>
Tôi xác nhận đã viết nội dung trên ThankCard sẵn sàng gửi đến người nhận. <span class="text-[#3462f7] font-bold">*</span> <span class="text-[13px] font-medium text-gray-600 leading-relaxed select-none">
</span> Tôi xác nhận đã viết nội dung trên ThankCard sẵn sàng gửi đến người nhận. <span class="text-[#3462f7] font-bold">*</span>
</label> </span>
</label>
<span id="error-confirm_written" class="text-red-500 text-[12px] font-semibold mt-1.5 hidden block"></span>
</div>
{{-- Card footer line --}} {{-- Card footer line --}}
<div class="mt-6 pt-4 border-t border-dashed" id="cardFooterLine" style="border-color:#FFE4A0;"> <div class="mt-6 pt-4 border-t border-dashed" id="cardFooterLine" style="border-color:#FFE4A0;">
@@ -132,6 +137,14 @@ $tpls = [
</div> </div>
</div> </div>
<!-- General Error notification block -->
<div id="formGeneralError" class="p-4 bg-red-50 border border-red-100 text-red-800 rounded-2xl text-xs font-semibold leading-relaxed flex items-start gap-2 shadow-sm mb-5 hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 shrink-0 text-red-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span id="generalErrorMessage"></span>
</div>
{{-- Actions --}} {{-- Actions --}}
<div class="flex flex-col sm:flex-row justify-end gap-3"> <div class="flex flex-col sm:flex-row justify-end gap-3">
<a href="{{ route('user.dashboard') }}" id="btnCancelLink" class="btn-secondary rounded-xl font-bold flex items-center justify-center gap-2 order-2 sm:order-1"> <a href="{{ route('user.dashboard') }}" id="btnCancelLink" class="btn-secondary rounded-xl font-bold flex items-center justify-center gap-2 order-2 sm:order-1">
@@ -186,8 +199,26 @@ document.addEventListener('DOMContentLoaded', function() {
footerBorder:'#DDD6FE'}, footerBorder:'#DDD6FE'},
}; };
// ── Theme slugs ──────────────────────────────────────────────────
const templateSlugs = {
1: 'thu_cam_on',
2: 'sakura',
3: 'appreciation',
4: 'celebration'
};
const templateIds = {
'thu_cam_on': 1,
'sakura': 2,
'appreciation': 3,
'celebration': 4
};
// Current selected template ID (defaults to 1) // read from hidden input or localStorage // Current selected template ID (defaults to 1) // read from hidden input or localStorage
let currentTpl = parseInt(localStorage.getItem('selectedTemplate')) || parseInt(document.getElementById('template_id').value) || 1; const savedTemplateSlug = localStorage.getItem('thankcard_selected_template');
let currentTpl = (savedTemplateSlug && templateIds[savedTemplateSlug])
|| parseInt(localStorage.getItem('selectedTemplate'))
|| parseInt(document.getElementById('template_id').value)
|| 1;
function applyTheme(id) { function applyTheme(id) {
const t = T[id]; if (!t) return; const t = T[id]; if (!t) return;
@@ -256,6 +287,9 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('template_id').value = id; document.getElementById('template_id').value = id;
// persist selection // persist selection
localStorage.setItem('selectedTemplate', id); localStorage.setItem('selectedTemplate', id);
if (templateSlugs[id]) {
localStorage.setItem('thankcard_selected_template', templateSlugs[id]);
}
applyTheme(id); applyTheme(id);
}; };
@@ -299,7 +333,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// ── TomSelect ──────────────────────────────────────────────────── // ── TomSelect ────────────────────────────────────────────────────
new TomSelect('#receiver', { const receiverSelect = new TomSelect('#receiver', {
create:false, searchField:['text','value'], maxOptions:10, create:false, searchField:['text','value'], maxOptions:10,
sortField:{field:'text',direction:'asc'}, sortField:{field:'text',direction:'asc'},
placeholder:'Nhập tên hoặc mã nhân viên...', placeholder:'Nhập tên hoặc mã nhân viên...',
@@ -327,7 +361,9 @@ document.addEventListener('DOMContentLoaded', function() {
$mTitle.textContent = title; $mTitle.textContent = title;
$mMsg.innerHTML = msg.replace(/\n/g,'<br>'); $mMsg.innerHTML = msg.replace(/\n/g,'<br>');
if (confirm) { if (confirm) {
$mTitle.className = 'text-lg font-bold text-primary'; $mTitle.className = 'text-lg font-bold';
// Sync text color with current template's accent color
$mTitle.style.color = T[currentTpl].accent;
// Use accent color of current template for confirm button background // Use accent color of current template for confirm button background
const accent = T[currentTpl].accent; const accent = T[currentTpl].accent;
$mAct.innerHTML = `<button id="btnOk" class="w-full sm:w-auto btn-primary !px-6 rounded-xl font-bold" style="background:${accent};border-color:${accent};">Xác nhận gửi</button> $mAct.innerHTML = `<button id="btnOk" class="w-full sm:w-auto btn-primary !px-6 rounded-xl font-bold" style="background:${accent};border-color:${accent};">Xác nhận gửi</button>
@@ -340,41 +376,149 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} else { } else {
$mTitle.className = 'text-lg font-bold text-[#1a2b49]'; $mTitle.className = 'text-lg font-bold text-[#1a2b49]';
$mTitle.style.color = ''; // reset style
$mAct.innerHTML = `<button id="btnClose" class="w-full sm:w-auto btn-secondary !px-6 rounded-xl font-bold">Đóng</button>`; $mAct.innerHTML = `<button id="btnClose" class="w-full sm:w-auto btn-secondary !px-6 rounded-xl font-bold">Đóng</button>`;
document.getElementById('btnClose').onclick = () => window.closeModal('customModal'); document.getElementById('btnClose').onclick = () => window.closeModal('customModal');
} }
window.openModal('customModal'); window.openModal('customModal');
}; };
// ── Error Helpers ────────────────────────────────────────────────
function clearErrors() {
document.getElementById('formGeneralError').classList.add('hidden');
document.getElementById('generalErrorMessage').textContent = '';
document.querySelectorAll('[id^="error-"]').forEach(el => {
el.textContent = '';
el.classList.add('hidden');
});
}
function validateForm() {
clearErrors();
let isValid = true;
const receiver = document.getElementById('receiver').value;
if (!receiver) {
const errEl = document.getElementById('error-receiver');
if (errEl) {
errEl.textContent = 'Vui lòng chọn người nhận.';
errEl.classList.remove('hidden');
}
isValid = false;
}
const amount = document.getElementById('amount').value;
if (!amount) {
const errEl = document.getElementById('error-amount');
if (errEl) {
errEl.textContent = 'Vui lòng chọn số lượng thẻ.';
errEl.classList.remove('hidden');
}
isValid = false;
}
const confirm = document.getElementById('confirm_written').checked;
if (!confirm) {
const errEl = document.getElementById('error-confirm_written');
if (errEl) {
errEl.textContent = 'Bạn cần xác nhận đã viết nội dung trên ThankCard.';
errEl.classList.remove('hidden');
}
isValid = false;
}
return isValid;
}
// ── Submit ─────────────────────────────────────────────────────── // ── Submit ───────────────────────────────────────────────────────
document.getElementById('btnSubmitSend').addEventListener('click', function(){ const $submitBtn = document.getElementById('btnSubmitSend');
const form = document.getElementById('sendCardForm'); const btnOriginalHtml = $submitBtn.innerHTML;
if (!form.checkValidity()) { form.reportValidity(); return; }
function setButtonLoading(loading) {
if (loading) {
$submitBtn.disabled = true;
$submitBtn.innerHTML = `<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg><span>⏳ Đang gửi...</span>`;
} else {
$submitBtn.disabled = false;
$submitBtn.innerHTML = btnOriginalHtml;
// Keep selected template button accent style synchronized
const accent = T[currentTpl]?.accent;
if (accent) {
$submitBtn.style.background = accent;
$submitBtn.style.borderColor = accent;
}
}
}
$submitBtn.addEventListener('click', function(){
if (!validateForm()) { return; }
const msnv = document.getElementById('receiver').value; const msnv = document.getElementById('receiver').value;
const amount = document.getElementById('amount').value; const amount = document.getElementById('amount').value;
showModal('Xác nhận gửi Thank Card', showModal('Xác nhận gửi Thank Card',
'Đừng quên hoàn thành Thank Card của bạn nhé! Chúng mình sẽ gửi thông báo đến người nhận để họ đến khu vực nhận thư.', 'Đừng quên hoàn thành Thank Card của bạn nhé! Chúng mình sẽ gửi thông báo đến người nhận để họ đến khu vực nhận thư.',
true, () => { true, () => {
window.closeModal('customModal');
setButtonLoading(true);
clearErrors();
$.ajax({ $.ajax({
url:'{{ route("user.store_send") }}', method:'POST', contentType:'application/json', url:'{{ route("user.store_send") }}', method:'POST', contentType:'application/json',
headers:{'X-CSRF-TOKEN':$('meta[name="csrf-token"]').attr('content')}, headers:{'X-CSRF-TOKEN':$('meta[name="csrf-token"]').attr('content')},
data:JSON.stringify({receiver:msnv,amount:amount}), data:JSON.stringify({receiver:msnv,amount:amount}),
success:function(res){ success:function(res){
if(res.success){ if(res.success){
var email=res.receiver_email; email="antv@runsystem.net"; setButtonLoading(false);
$.ajax({
url:'{{ config("constants.CHATOPS_API_URL") }}', method:'POST', contentType:'application/json', // Reset form fields
data:JSON.stringify({message:"💌 Bạn nhận được một Thank Card mới!\nNhanh chân đến khu vực nhận thư để nhận ngay nhé!",userEmail:email,token:'{{ config("constants.CHATOPS_API_TOKEN") }}'}), receiverSelect.clear();
complete:function(){localStorage.setItem('flash_success',res.message);window.location.href='{{ route("user.dashboard") }}';} document.getElementById('amount').value = '1';
}); document.getElementById('confirm_written').checked = false;
// Clear local storage for inputs (but not template)
localStorage.removeItem('selectedReceiver');
localStorage.removeItem('selectedAmount');
// Display toast message
if (window.showToast) {
window.showToast("🎉 ThankCard đã được gửi thành công!", "success");
} else {
alert("🎉 ThankCard đã được gửi thành công!");
}
} else { } else {
window.closeModal('customModal'); setButtonLoading(false);
setTimeout(()=>showModal('Lỗi Giao Dịch',res.message||'Có lỗi xảy ra.',false,null),350); if (res.errors) {
for (const [field, messages] of Object.entries(res.errors)) {
const errEl = document.getElementById('error-' + field);
if (errEl) {
errEl.textContent = messages.join(' ');
errEl.classList.remove('hidden');
}
}
}
if (res.message) {
document.getElementById('generalErrorMessage').textContent = res.message;
document.getElementById('formGeneralError').classList.remove('hidden');
}
} }
}, },
error:function(xhr){ error:function(xhr){
window.closeModal('customModal'); setButtonLoading(false);
setTimeout(()=>showModal('Lỗi Hệ Thống',xhr.responseJSON?.message||'Không thể kết nối đến máy chủ.',false,null),350); const res = xhr.responseJSON;
if (res && res.errors) {
for (const [field, messages] of Object.entries(res.errors)) {
const errEl = document.getElementById('error-' + field);
if (errEl) {
errEl.textContent = messages.join(' ');
errEl.classList.remove('hidden');
}
}
}
const errMsg = res?.message || 'Không thể kết nối đến máy chủ.';
document.getElementById('generalErrorMessage').textContent = errMsg;
document.getElementById('formGeneralError').classList.remove('hidden');
} }
}); });
} }
-2
View File
@@ -15,6 +15,4 @@ Route::middleware(['auth', 'force_change_password', 'admin'])->prefix('admin')->
Route::put('/add-cards/{id}', [AdminController::class, 'updateAddCard'])->name('add_cards.update'); Route::put('/add-cards/{id}', [AdminController::class, 'updateAddCard'])->name('add_cards.update');
Route::delete('/add-cards/{id}', [AdminController::class, 'destroyAddCard'])->name('add_cards.destroy'); Route::delete('/add-cards/{id}', [AdminController::class, 'destroyAddCard'])->name('add_cards.destroy');
Route::post('/reset-cards', [AdminController::class, 'resetCards'])->name('reset_cards');
}); });
+24
View File
@@ -19,6 +19,7 @@ class SendThankCardTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
\Illuminate\Support\Facades\Http::fake();
$this->userService = $this->app->make(UserServiceInterface::class); $this->userService = $this->app->make(UserServiceInterface::class);
// Create sender and receiver // Create sender and receiver
@@ -92,4 +93,27 @@ class SendThankCardTest extends TestCase
$this->sender->refresh(); $this->sender->refresh();
$this->assertEquals(8, $this->sender->card); $this->assertEquals(8, $this->sender->card);
} }
public function test_controller_validation_fails_returns_correct_json_format(): void
{
$this->actingAs($this->sender);
$response = $this->postJson(route('user.store_send'), [
'receiver' => '',
'amount' => 999,
]);
$response->assertStatus(422);
$response->assertJson([
'success' => false,
]);
$response->assertJsonStructure([
'success',
'errors' => [
'receiver',
'amount',
]
]);
}
} }