Commit 91bc8256 by sawit

ปรับ input จองห้องประชุม

parent 559c22d0
......@@ -160,30 +160,35 @@
type="number"
min="0"
formControlName="attendeeCount"
[value]="attendees.length"
class="w-full px-3 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200">
class="w-full px-3 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 bg-gray-100"
readonly>
</div>
<!-- Add Attendees -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">เพิ่มผู้เข้าร่วม</label>
<div class="border border-gray-300 rounded-lg p-3 min-h-[42px]">
<mat-chip-grid #chipList aria-label="Attendees" class="w-full">
<mat-chip-row *ngFor="let a of attendees" [removable]="true" (removed)="removeAttendee(a)"
class="bg-blue-100 text-blue-800 text-sm px-2 py-1 rounded-full mr-2 mb-2 inline-flex items-center">
{{ a }}
<button matChipRemove aria-label="remove" class="ml-1 text-blue-600 hover:text-blue-800">
<i class="ri-close-line text-sm"></i>
</button>
</mat-chip-row>
<input
class="border-0 outline-none w-full text-sm"
placeholder="พิมพ์อีเมลแล้วกด Enter"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addAttendee($event)" />
</mat-chip-grid>
</div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="ri-group-line mr-2 text-blue-500"></i>
ผู้เข้าร่วมประชุม
</label>
<div class="relative">
<ejs-multiselect
id='attendee-multiselect'
formControlName="attendees"
[dataSource]="allEmployees"
[fields]="employeeFields"
[placeholder]="employeeWatermark"
mode="Box"
[allowFiltering]="true"
cssClass="w-full e-outline custom-multiselect">
</ejs-multiselect>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="ri-user-add-line text-gray-400"></i>
</div>
</div>
<p class="text-xs text-gray-500 mt-1">
<i class="ri-information-line mr-1"></i>
เลือกผู้เข้าร่วมประชุมจากรายชื่อพนักงาน
</p>
</div>
</div>
</div>
......
......@@ -646,6 +646,231 @@
}
}
// Custom Multiselect Styling
::ng-deep {
.custom-multiselect {
.e-multiselect {
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
min-height: 48px;
&:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-1px);
}
&:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
.e-multiselect-wrapper {
.e-multiselect-input {
font-size: 14px;
color: #374151;
font-weight: 500;
padding: 0;
margin: 0;
background: transparent;
border: none;
outline: none;
&::placeholder {
color: #9ca3af;
font-weight: 400;
}
}
.e-multiselect-chip {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
border: none;
border-radius: 20px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
margin: 2px 4px 2px 0;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
.e-chip-close {
color: white;
opacity: 0.8;
font-size: 14px;
margin-left: 6px;
&:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
padding: 2px;
}
}
}
.e-multiselect-arrow {
color: #6b7280;
font-size: 16px;
transition: all 0.3s ease;
&:hover {
color: #3b82f6;
transform: scale(1.1);
}
}
}
}
// Dropdown styling
.e-multiselect-dropdown {
border: 2px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
background: white;
overflow: hidden;
margin-top: 4px;
.e-multiselect-list {
.e-list-item {
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 12px;
&:hover {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #1d4ed8;
transform: translateX(4px);
}
&.e-active {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
font-weight: 600;
}
.e-checkbox-wrapper {
.e-checkbox {
width: 18px;
height: 18px;
border: 2px solid #d1d5db;
border-radius: 4px;
background: white;
transition: all 0.2s ease;
&:checked {
background: #3b82f6;
border-color: #3b82f6;
}
}
}
.e-list-text {
flex: 1;
font-weight: 500;
}
}
.e-list-item:last-child {
border-bottom: none;
}
}
// Search box styling
.e-multiselect-search {
padding: 12px 16px;
border-bottom: 2px solid #f3f4f6;
background: #f9fafb;
.e-multiselect-searchbox {
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
background: white;
transition: all 0.3s ease;
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
&::placeholder {
color: #9ca3af;
font-weight: 400;
}
}
}
}
// Loading state
.e-multiselect-loading {
.e-multiselect-wrapper {
.e-multiselect-arrow {
animation: spin 1s linear infinite;
}
}
}
}
// Animation keyframes
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Focus states
.custom-multiselect.e-focused {
.e-multiselect {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
// Error state
.custom-multiselect.e-error {
.e-multiselect {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
// Disabled state
.custom-multiselect.e-disabled {
.e-multiselect {
background: #f9fafb;
border-color: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
opacity: 0.6;
}
}
}
// Responsive Design
@media (max-width: 768px) {
.page-header .header-content {
......@@ -670,4 +895,22 @@
margin: 1rem;
width: calc(100% - 2rem);
}
// Mobile multiselect adjustments
::ng-deep .custom-multiselect {
.e-multiselect {
padding: 10px 14px;
min-height: 44px;
}
.e-multiselect-dropdown {
margin-top: 2px;
border-radius: 8px;
.e-multiselect-list .e-list-item {
padding: 10px 14px;
font-size: 13px;
}
}
}
}
......@@ -25,11 +25,12 @@ import { Observable } from 'rxjs';
// Syncfusion Schedule imports
import { ScheduleModule, View, EventSettingsModel, DayService, WeekService, WorkWeekService, MonthService, AgendaService, ResizeService, DragAndDropService } from '@syncfusion/ej2-angular-schedule';
import { DateTimePickerModule } from '@syncfusion/ej2-angular-calendars';
import { DropDownListModule } from '@syncfusion/ej2-angular-dropdowns';
import { DropDownListModule, MultiSelectModule } from '@syncfusion/ej2-angular-dropdowns';
import { L10n, setCulture } from '@syncfusion/ej2-base';
import { MeetingBookingService } from '../services/meeting-booking.service';
import { MeetingRoom, MeetingBooking, BookingTimeSlot, BookingStatistics } from '../models/meeting-booking.model';
import { PermissionModel2 } from '../models/permission/permission.model';
@Component({
selector: 'app-meeting-booking',
......@@ -59,7 +60,8 @@ import { MeetingRoom, MeetingBooking, BookingTimeSlot, BookingStatistics } from
// Syncfusion modules
ScheduleModule,
DateTimePickerModule,
DropDownListModule
DropDownListModule,
MultiSelectModule
],
providers: [
DayService,
......@@ -85,11 +87,12 @@ export class MeetingBookingComponent implements OnInit {
isLoading = false;
bookingForm: FormGroup;
attendees: string[] = [];
allEmployees: (PermissionModel2 & { displayName: string })[] = [];
public employeeFields: Object = { text: 'displayName' };
public employeeWatermark: string = 'ค้นหาและเลือกผู้เข้าร่วม';
displayedColumns: string[] = ['title', 'room', 'startTime', 'endTime', 'organizer', 'status', 'actions'];
separatorKeysCodes: number[] = [13, 188]; // ENTER and COMMA key codes
displayedColumns: string[] = ['title', 'room', 'startTime', 'endTime', 'organizer', 'status', 'actions'];
// Syncfusion Schedule properties
public selectedDate_schedule: Date = new Date();
......@@ -134,10 +137,16 @@ export class MeetingBookingComponent implements OnInit {
description: [''],
startDateTime: ['', Validators.required],
endDateTime: ['', Validators.required],
startTime: ['', Validators.required],
endTime: ['', Validators.required],
attendees: [[]],
attendeeCount: [0, [Validators.min(0)]]
});
this.bookingForm.get('attendees')?.valueChanges.subscribe(val => {
this.bookingForm.get('attendeeCount')?.setValue(val?.length || 0);
});
this.statistics$ = this.meetingBookingService.getBookingStatistics(
new Date(new Date().setMonth(new Date().getMonth() - 1)),
new Date()
......@@ -149,6 +158,30 @@ export class MeetingBookingComponent implements OnInit {
this.loadTimeSlots();
this.loadScheduleData();
this.loadSampleData(); // เพิ่มข้อมูลตัวอย่าง
this.loadEmployees();
}
loadEmployees(): void {
this.meetingBookingService.getAllEmployeesMini().subscribe(response => {
// The type `PermissionModel` suggests a direct array, but the runtime error
// indicates an object is being returned. This is common for paginated APIs
// where the array is in a 'content' property.
let employees: PermissionModel2[] = [];
if (response && (response as any).content && Array.isArray((response as any).content)) {
employees = (response as any).content;
} else if (Array.isArray(response)) {
// Handle the case where the API correctly returns a direct array.
employees = response;
} else {
console.error('Received employee data is not in a recognized format (Array or { content: Array }).', response);
}
// Add a displayName property for the multiselect component
this.allEmployees = employees.map(emp => ({
...emp,
displayName: `${emp.fname} ${emp.lname}`
}));
});
}
private setupLocale(): void {
......@@ -204,39 +237,34 @@ export class MeetingBookingComponent implements OnInit {
}
}
addAttendee(event: MatChipInputEvent): void {
const value = (event.value || '').trim();
if (value) {
this.attendees.push(value);
this.bookingForm.patchValue({ attendees: this.attendees, attendeeCount: this.attendees.length });
}
event.chipInput!.clear();
}
removeAttendee(attendee: string): void {
const index = this.attendees.indexOf(attendee);
if (index >= 0) {
this.attendees.splice(index, 1);
this.bookingForm.patchValue({ attendees: this.attendees, attendeeCount: this.attendees.length });
}
}
createBooking(): void {
if (this.bookingForm.valid) {
const formValue = this.bookingForm.value;
// Combine Date and Time
const startDate = new Date(formValue.startDateTime);
const [startHours, startMinutes] = formValue.startTime.split(':');
startDate.setHours(startHours, startMinutes, 0, 0);
const endDate = new Date(formValue.endDateTime);
const [endHours, endMinutes] = formValue.endTime.split(':');
endDate.setHours(endHours, endMinutes, 0, 0);
const selectedAttendees: PermissionModel2[] = formValue.attendees || [];
const booking = {
roomId: formValue.roomId,
roomName: '', // Will be filled by service
title: formValue.title,
description: formValue.description,
startDateTime: formValue.startDateTime,
endDateTime: formValue.endDateTime,
startDateTime: startDate, // Use combined value
endDateTime: endDate, // Use combined value
organizerId: 'current-user', // From auth service
organizerName: 'Current User', // From auth service
attendees: this.attendees.map(email => ({
userId: '',
userName: email,
email: email,
attendees: selectedAttendees.map(emp => ({
userId: emp.employeeId,
userName: `${emp.fname} ${emp.lname}`,
email: '', // email is not available in the employee mini data
status: 'pending' as const
})),
status: 'pending' as const
......@@ -246,7 +274,6 @@ export class MeetingBookingComponent implements OnInit {
next: () => {
this.snackBar.open('จองห้องประชุมสำเร็จ', 'ปิด', { duration: 3000 });
this.bookingForm.reset();
this.attendees = [];
this.loadTimeSlots();
},
error: (error) => {
......
......@@ -12,6 +12,8 @@ import {
BookingFilter,
BookingStatistics
} from '../models/meeting-booking.model';
import { environment } from '../../../environments/environment';
import { PermissionModel } from '../models/permission/permission.model';
@Injectable({
providedIn: 'root'
......@@ -25,6 +27,12 @@ export class MeetingBookingService {
private dataUrl = 'assets/data/meeting-booking.json';
lang: string = "";
private readonly baseUrl: string = environment.url; // portal api base
private readonly hrplusUrl: string = 'https://hrplus.myhr.co.th/plus';
private readonly employeePath = this.hrplusUrl + '/employee';
constructor(private http: HttpClient) {
this.loadInitialData();
}
......@@ -360,4 +368,9 @@ export class MeetingBookingService {
}
];
}
getAllEmployeesMini(): Observable<PermissionModel> {
const url = `${this.employeePath}/workings/mini?page=0&size=500`;
return this.http.get<PermissionModel>(url);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment