4. แอป Flutter: แกนกลาง (core) ไฟล์ที่ทำงานก่อนทุกหน้าจอ และค่ากลางที่ทุกหน้าใช้ร่วมกัน
main.dart — จุดเริ่มต้นของแอป
lib/main.dart แกนกลาง
เมื่อเปิดแอป ฟังก์ชัน main() ทำงานเป็นอย่างแรก ไล่ตามลำดับดังนี้
WidgetsFlutterBinding.ensureInitialized() ต้องเรียกก่อนใช้ปลั๊กอินใด ๆ ก่อน runApp เพราะ SharedPreferences ต้องคุยกับระบบปฏิบัติการ
ล็อกจอเป็นแนวตั้ง และทำให้แถบสถานะด้านบนโปร่งใส
await SessionService.instance.init() โหลดข้อมูลล็อกอินจากเครื่องให้เสร็จก่อน แสดงหน้าแรก เพราะ router ต้องรู้ทันทีว่าผู้ใช้ล็อกอินอยู่หรือไม่
ตั้งค่า ApiService.onUnauthorized: ถ้า API ตอบ 401 (เช่น บัญชีถูกลบ) ให้ล้างข้อมูลล็อกอินแล้วพากลับหน้าแรก
runApp() สร้าง MaterialApp.router โดยผูกธีม (AppTheme.theme) และตัวจัดการเส้นทาง (AppRouter.router)
lib/main.dart บรรทัด 8–39 8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39 void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
);
await SessionService.instance.init();
// เซสชันหมดอายุ (API ตอบ 401) -> ล้างข้อมูลเข้าสู่ระบบแล้วกลับไปหน้าเริ่มต้นให้ล็อกอินใหม่
ApiService.onUnauthorized = () async {
if (!SessionService.instance.isLoggedIn) return;
await SessionService.instance.clearSession();
AppRouter.router.go('/welcome');
};
runApp(const JobMatchEmployerApp());
}
class JobMatchEmployerApp extends StatelessWidget {
const JobMatchEmployerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'JobMatch',
theme: AppTheme.theme,
routerConfig: AppRouter.router,
debugShowCheckedModeBanner: false,
);
}
}
ชื่อคลาส JobMatchEmployerApp และชื่อแพ็กเกจ jobmatch_employer มีคำว่า employer แต่แอปนี้รองรับทั้งผู้ว่าจ้างและผู้หางาน
app_constants.dart — ที่อยู่ API และค่าคงที่
lib/core/constants/app_constants.dart แกนกลาง
baseUrl คือที่อยู่ของ API อ่านจาก String.fromEnvironment('API_URL') ซึ่งกำหนดได้ตอนสั่งรัน/สั่ง build โดยไม่ต้องแก้โค้ด เช่น
flutter run --dart-define=API_URL=http://192.168.1.50/jobmatch_api ถ้าไม่ระบุจะใช้ค่าเริ่มต้นในโค้ด
ส่วนเวอร์ชันเว็บ build ด้วย API_URL=/jobmatch_api (path สัมพัทธ์) จึงเรียก API จากเครื่องเดียวกับที่เปิดหน้าเว็บ ใช้ได้ทุก IP
resolveUrl() แปลง path ของรูป/ไฟล์ที่ได้จาก API ให้เป็น URL เต็มที่เปิดได้จริง:
ค่าที่ได้จาก API ผลลัพธ์
uploads/employer_5_1712.png (path สัมพัทธ์)ต่อหน้าด้วย baseUrl → {baseUrl}/uploads/employer_5_1712.png
http://192.168.1.10/jobmatch_api/uploads/a.jpg (URL เต็มที่มี IP เก่าติดมา)ตัดส่วนหน้าทิ้งแล้วใช้ baseUrl ปัจจุบัน
https://images.unsplash.com/… (เว็บภายนอก)คืนค่าเดิม
lib/core/constants/app_constants.dart บรรทัด 6–28 6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28 static const String baseUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'http://192.168.100.99/jobmatch_api',
);
/// แปลง path ไฟล์จาก API ให้เป็น URL เต็มที่ชี้ไปยังเซิร์ฟเวอร์ปัจจุบัน
/// - path สัมพัทธ์ (uploads/x.jpg) -> ต่อ baseUrl ให้
/// - URL เต็มของ jobmatch_api ที่มี IP เก่าติดมา -> เปลี่ยนเป็น baseUrl ปัจจุบัน
/// - URL ภายนอกอื่นๆ -> คืนค่าเดิม
static String? resolveUrl(String? path) {
if (path == null || path.isEmpty) return path;
if (path.startsWith('http://') || path.startsWith('https://')) {
const marker = '/jobmatch_api/';
final i = path.indexOf(marker);
if (i == -1) return path;
return _absolute('$baseUrl/${path.substring(i + marker.length)}');
}
return _absolute('$baseUrl/${path.startsWith('/') ? path.substring(1) : path}');
}
/// เวอร์ชันเว็บที่ baseUrl เป็น path สัมพัทธ์: เติมโดเมนของหน้าเว็บให้เป็น URL เต็ม (ใช้กับรูป/PDF)
static String _absolute(String url) =>
url.startsWith('/') ? Uri.base.resolve(url).toString() : url;
ส่วนที่เหลือของไฟล์ (บรรทัด 30–79) เป็นค่าคงที่ 3 กลุ่ม: ที่อยู่ endpoint ทั้ง 27 ตัว, ชื่อคีย์ที่ใช้เก็บข้อมูลในเครื่อง และข้อความสถานะใบสมัคร (รอดำเนินการ, นัดสัมภาษณ์, ผ่านการคัดเลือก, ไม่ผ่านการคัดเลือก) ซึ่งฐานข้อมูลเก็บเป็นภาษาไทยตรง ๆ
ทำไมฐานข้อมูลเก็บแค่ path สัมพัทธ์
ถ้าเก็บ URL เต็ม เช่น http://192.168.100.99/… เมื่อย้ายเครื่องหรือเปลี่ยน Wi-Fi แล้ว IP เปลี่ยน รูปทั้งหมดจะเสีย การเก็บ uploads/x.jpg แล้วให้แอปต่อ baseUrl เองจึงทนต่อการเปลี่ยน IP
app_router.dart — แผนที่เส้นทางของทุกหน้า
lib/core/router/app_router.dart แกนกลาง
ใช้แพ็กเกจ go_router กำหนดว่า URL ไหนเปิดหน้าไหน แอปเริ่มที่ /splash เสมอ และทุกครั้งที่จะเปลี่ยนหน้า ฟังก์ชัน redirect จะถูกเรียกก่อนเพื่อตรวจสิทธิ์:
สถานการณ์ redirect ทำอะไร
กำลังอยู่ที่ /splash ไม่ทำอะไร ปล่อยให้ SplashPage ตัดสินเอง
ยังไม่ล็อกอิน แต่จะเข้าหน้าที่ต้องล็อกอิน ส่งไป /welcome
ล็อกอินแล้ว แต่จะเข้าหน้าล็อกอิน/สมัคร/ลืมรหัส ส่งไปหน้าหลักตามบทบาท: /seeker-home หรือ /home
กรณีอื่น คืน null = ไปหน้าที่ขอได้เลย
lib/core/router/app_router.dart บรรทัด 26–55 26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55 class AppRouter {
static final router = GoRouter(
initialLocation: '/splash',
// ลิงก์ผิด/หน้าที่ไม่มีอยู่ (เจอได้บนเว็บ) -> หน้าภาษาไทยพร้อมปุ่มกลับหน้าแรก แทนข้อความ error ภาษาอังกฤษ
errorBuilder: (context, state) => const NotFoundPage(),
redirect: (context, state) {
final isLoggedIn = SessionService.instance.isLoggedIn;
final role = SessionService.instance.userRole;
final loc = state.uri.path;
final isUnauthRoute =
loc == '/login' ||
loc == '/register' ||
loc == '/welcome' ||
loc == '/seeker-login' ||
loc == '/seeker-register' ||
loc == '/forgot-password';
if (loc == '/splash') return null;
if (!isLoggedIn && !isUnauthRoute) {
return '/welcome';
}
if (isLoggedIn && isUnauthRoute) {
return role == 'seeker' ? '/seeker-home' : '/home';
}
return null;
},
เส้นทางทั้งหมดของแอป (หน้าที่ซ้อนอยู่ใต้ /home หรือ /seeker-home เปิดทับหน้าหลัก กดย้อนกลับได้):
เส้นทาง หน้า หมายเหตุ
/splashSplashPage หน้าโหลดตอนเปิดแอป
/welcomeWelcomePage เลือกเป็นผู้หางานหรือผู้ว่าจ้าง
/login, /registerLoginPage, RegisterEmployerPage ของผู้ว่าจ้าง
/seeker-login, /seeker-registerSeekerLoginPage, SeekerRegisterPage ของผู้หางาน
/forgot-passwordForgotPasswordPage ใช้ร่วมกันทั้งสองบทบาท
/home?tab=nMainShell หน้าหลักผู้ว่าจ้าง 5 แท็บ (tab=3 เปิดแท็บแจ้งเตือน)
/home/post-jobPostJobPage ส่ง extra: job มาด้วย = โหมดแก้ไข
/home/candidates/:jobIdCandidateListPage ผู้สมัครของงานนั้น
/home/candidate/:applicationIdCandidateDetailPage โปรไฟล์ผู้สมัคร 1 คน
/home/interview/:applicationIdInterviewPage ฟอร์มนัดสัมภาษณ์
/home/edit-profileEditProfilePage แก้ข้อมูลบริษัท
/seeker-home?tab=nSeekerMainShell หน้าหลักผู้หางาน 5 แท็บ
/seeker-home/job/:jobIdSeekerJobDetailPage รายละเอียดงาน + ปุ่มสมัคร
/seeker-home/company/:employerUserIdSeekerCompanyDetailPage หน้าบริษัท
/seeker-home/profile, /seeker-home/notificationsSeekerProfileTab, SeekerNotificationsTab เปิดเป็นหน้าเดี่ยวได้ด้วย
…/chat/:receiverId/:receiverNameChatRoomPage มีทั้งใต้ /home และ /seeker-home
การส่งข้อมูลไปหน้าใหม่มี 3 แบบ: path parameter (:jobId อ่านด้วย s.pathParameters['jobId']),
query parameter (?tab=3 อ่านด้วย s.uri.queryParameters['tab']) และ extra (ส่งอ็อบเจกต์ทั้งก้อน เช่น งานที่จะแก้ไข)
lib/core/router/app_router.dart บรรทัด 109–122 109
110
111
112
113
114
115
116
117
118
119
120
121
122 GoRoute(
path: '/home',
builder: (_, s) => MainShell(initialTab: int.tryParse(s.uri.queryParameters['tab'] ?? '') ?? 0),
routes: [
GoRoute(
path: 'post-job',
// ถ้ามีการส่ง JobPostingModel มาผ่าน context.push('/home/post-job', extra: job)
// จะได้ค่าตรงนี้ผ่าน state.extra แล้วส่งต่อเป็นโหมดแก้ไข ถ้าไม่ได้ส่งมา (กดปุ่ม
// "สร้างงานใหม่" ปกติ) extra จะเป็น null และหน้าจะเป็นโหมดโพสต์ใหม่ตามเดิม
builder: (_, state) {
final job = state.extra as JobPostingModel?;
return PostJobPage(editJob: job);
},
),
ถ้าเปิด URL ที่ไม่มีอยู่ (เจอได้บนเว็บ) errorBuilder จะแสดง NotFoundPage ภาษาไทยพร้อมปุ่มกลับหน้าแรก แทนข้อความ error ภาษาอังกฤษ
app_theme.dart — สีและหน้าตาของทั้งแอป
lib/core/theme/app_theme.dart แกนกลาง
รวมสีทั้งหมดไว้เป็นค่าคงที่ (ธีม “ฟ้าครีม”: ฟ้าเข้ม #1287B5 เป็นสีหลัก, พื้นครีม #FEF6F1, ตัวอักษรสีหมึก #231F20)
และสร้าง ThemeData แบบ Material 3 ที่ใช้ฟอนต์ Kanit ซึ่งฝังมาในแอป ทุกหน้าเรียกสีผ่าน AppTheme.primary, AppTheme.error ฯลฯ
ถ้าอยากเปลี่ยนสีทั้งแอปจึงแก้ที่ไฟล์นี้ไฟล์เดียว
lib/core/theme/app_theme.dart บรรทัด 5–45 5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45 class AppTheme {
// ---- Color Palette ----
static const Color primary = Color(0xFF1287B5); // ฟ้าเข้ม: แถบหัว ปุ่ม ไอคอน หัวข้อ
static const Color primaryLight = Color(0xFF45CBF3); // ฟ้าสด (สีอ้างอิง): ไฮไลต์ ไล่เฉดสี
static const Color primaryDark = Color(0xFF0B6A90); // ฟ้าเข้มกว่า: ปลายไล่เฉดสีของแถบหัว
static const Color primaryDarker = Color(0xFF0E5577);
static const Color primarySoft = Color(0xFFDFF5FD); // ฟ้าอ่อน: พื้นหลังป้าย/ชิป/ไอคอน
static const Color primaryTint = Color(0xFFEFFAFE); // ฟ้าอ่อนมาก: พื้นการ์ดไฮไลต์
static const Color primaryBorder = Color(0xFFB9E9FA); // เส้นขอบโทนฟ้า
static const Color accent = Color(0xFF45CBF3);
static const Color accentLight = Color(0xFFA6E6FA);
static const Color ink = Color(0xFF231F20); // หมึกเข้ม: ตัวอักษรหลัก ของเด่นโทนเข้ม
static const Color inkLight = Color(0xFF4A4340); // ปลายไล่เฉดสีของการ์ดสีหมึก
static const Color cream = Color(0xFFFEF6F1); // ครีม (สีอ้างอิง): พื้นหลังหน้าจอ
static const Color creamDark = Color(0xFFF3E9E2); // ครีมเข้ม: โครงตอนโหลดข้อมูล พื้นไอคอน
static const Color creamLight = Color(0xFFFBF4EF);
static const Color success = Color(0xFF2E9E6A);
static const Color warning = Color(0xFFF29A2E);
static const Color error = Color(0xFFE5484D);
static const Color background = cream;
static const Color surface = Color(0xFFFFFFFF);
static const Color textPrimary = ink;
static const Color textSecondary = Color(0xFF6B625C); // เทาอมน้ำตาล เข้ากับพื้นครีม
static const Color divider = Color(0xFFEFE2D8);
static ThemeData get theme => ThemeData(
useMaterial3: true,
fontFamily: 'Kanit',
colorScheme: ColorScheme.fromSeed(
seedColor: primary,
primary: primary,
secondary: primaryLight,
surface: surface,
error: error,
onSurface: textPrimary,
),
scaffoldBackgroundColor: background,
appBarTheme: const AppBarTheme(
backgroundColor: primary,
foregroundColor: Colors.white,
elevation: 0,
ส่วนที่เหลือของไฟล์ตั้งหน้าตาเริ่มต้นของปุ่ม (elevatedButtonTheme), ช่องกรอก (inputDecorationTheme), การ์ด และแถบด้านล่าง หน้าจอจึงไม่ต้องกำหนดสไตล์ซ้ำทุกปุ่ม
lib/core/utils/formatters.dart แกนกลาง
ฟังก์ชัน ตัวอย่างค่าเข้า ผลลัพธ์
formatThaiDate2026-10-1515 ต.ค. 2569 (ปี ค.ศ. + 543)
formatRelativeThaiเวลาที่สร้างแจ้งเตือน เมื่อสักครู่ / 5 นาทีที่แล้ว / 3 ชม.ที่แล้ว / เมื่อวาน / ถ้าเก่ากว่านั้นแสดงวันที่
formatSalary15000-24000350-500 บาท/วันตามตกลง15,000 – 24,000 บาท 350 – 500 บาท/วัน ตามตกลง (ไม่มีตัวเลขจึงคืนค่าเดิม)
lib/core/utils/formatters.dart บรรทัด 3–41 3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41 const _thaiMonths = ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];
final _number = NumberFormat('#,##0');
/// แปลง "2026-10-15" (หรือ "2026-10-15 08:30:00") เป็น "15 ต.ค. 2569"
String formatThaiDate(String? isoDate) {
final d = DateTime.tryParse(isoDate ?? '');
if (d == null) return isoDate ?? '';
return '${d.day} ${_thaiMonths[d.month - 1]} ${d.year + 543}';
}
/// เวลาแบบสัมพัทธ์สำหรับแจ้งเตือน: "เมื่อสักครู่", "5 นาทีที่แล้ว", "3 ชม.ที่แล้ว", "เมื่อวาน", เกินนั้นแสดงวันที่
String formatRelativeThai(String? dateTime) {
final d = DateTime.tryParse(dateTime ?? '');
if (d == null) return dateTime ?? '';
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 1) return 'เมื่อสักครู่';
if (diff.inMinutes < 60) return '${diff.inMinutes} นาทีที่แล้ว';
if (diff.inHours < 24) return '${diff.inHours} ชม.ที่แล้ว';
if (diff.inDays == 1) return 'เมื่อวาน';
return formatThaiDate(dateTime);
}
/// จัดรูปแบบค่าจ้าง: "15000-24000 บาท" -> "15,000 – 24,000 บาท", "350-500 บาท/วัน" -> "350 – 500 บาท/วัน"
/// ข้อความที่ไม่มีตัวเลข (เช่น "ตามตกลง") คืนค่าเดิม
String formatSalary(String? raw) {
final text = (raw ?? '').trim();
if (text.isEmpty) return '';
final numbers = RegExp(r'\d[\d,]*(?:\.\d+)?')
.allMatches(text)
.map((m) => double.tryParse(m.group(0)!.replaceAll(',', '')))
.whereType<double>()
.toList();
if (numbers.isEmpty) return text;
final unit = RegExp(r'/\s*\S+').firstMatch(text)?.group(0)?.replaceAll(' ', '') ?? '';
final amount = numbers.length >= 2
? '${_number.format(numbers[0])} – ${_number.format(numbers[1])}'
: _number.format(numbers[0]);
return '$amount บาท$unit';
}
formatSalary ใช้ Regular Expression สองตัว: \d[\d,]*(?:\.\d+)? หาตัวเลขทุกตัวในข้อความ (รองรับมีคอมมาและทศนิยม) และ /\s*\S+ หาหน่วยหลังเครื่องหมาย / เช่น “/วัน” แล้วนำมาประกอบใหม่
location_support.dart — ใช้ GPS ได้ไหม
lib/core/utils/location_support.dart แกนกลาง
บนมือถือคืน true เสมอ แต่บนเว็บ เบราว์เซอร์ (โดยเฉพาะ Safari บน iPhone) อนุญาตให้ใช้ตำแหน่งปัจจุบันเฉพาะหน้าเว็บ https หรือ localhost
ถ้าเปิดผ่าน http://192.168… คำขอ GPS อาจค้างไม่ตอบ ทำให้หน้าค้นหางานโหลดไม่ขึ้น จึงตรวจก่อนแล้วข้ามไปใช้พิกัดจากที่อยู่ในโปรไฟล์แทน
ใช้ในหน้าค้นหางาน (seeker_search_tab.dart) และหน้าโพสต์งาน (post_job_page.dart)
lib/core/utils/location_support.dart บรรทัด 1–10 1
2
3
4
5
6
7
8
9
10 import 'package:flutter/foundation.dart' show kIsWeb;
/// ใช้ GPS ได้หรือไม่
/// เบราว์เซอร์ (โดยเฉพาะ Safari บน iPhone) ไม่ให้ใช้ตำแหน่งปัจจุบันบนเว็บแบบ http
/// และบางครั้งไม่ตอบกลับเลย ทำให้หน้าที่รอ GPS ค้าง จึงข้ามไปใช้พิกัดจากที่อยู่ในโปรไฟล์แทน
bool get canUseGps {
if (!kIsWeb) return true;
final uri = Uri.base;
return uri.scheme == 'https' || uri.host == 'localhost' || uri.host == '127.0.0.1';
}
6. แอป Flutter: หน้าจอ (presentation) ทุกหน้าที่ผู้ใช้เห็น แยกตามบทบาท พร้อมโค้ดส่วนที่ทำงานจริง
เกือบทุกหน้าในโปรเจกต์เขียนด้วยแพตเทิร์นเดียวกัน จำแพตเทิร์นนี้ไว้แล้วจะอ่านหน้าไหนก็เข้าใจเร็ว:
ฟิลด์สถานะ ในคลาส State เช่น _isLoading, _jobs, _isSaving
initState() เรียกเมธอดโหลดข้อมูล เช่น _fetchJobs() (บางหน้าเริ่ม Timer สำหรับ polling ด้วย)
เมธอดโหลด/สั่งงาน ตั้ง _isLoading = true → await ApiService()… → เช็ก mounted → setState ใส่ข้อมูลใหม่
build() แสดง 3 สถานะ: กำลังโหลด (วงหมุน/โครงสีเทา), ไม่มีข้อมูล (ข้อความ + ไอคอน) และมีข้อมูล (รายการ/การ์ด)
ผลลัพธ์ สำเร็จหรือผิดพลาดแจ้งผู้ใช้ด้วย SnackBar สีเขียว/แดง ข้อความผิดพลาดใช้ res['message'] ที่ API ส่งมา
แผนผังหน้าจอของทั้งสองบทบาท
ผู้ว่าจ้าง: MainShell (5 แท็บ)
หน้าแรก → DashboardTab
งานของฉัน → ManageJobsTab
แชท → ChatTab
แจ้งเตือน → NotificationsTab
บริษัท → ProfileTab
หน้าที่เปิดซ้อน: เพิ่ม/แก้ไขงาน, รายชื่อผู้สมัคร, โปรไฟล์ผู้สมัคร, นัดสัมภาษณ์, ห้องแชท, แก้ไขบริษัท
ผู้หางาน: SeekerMainShell (5 แท็บ)
หน้าแรก → SeekerHomeTab
งานของฉัน → SeekerApplicationsTab
ค้นหา → SeekerSearchTab
ข้อความ → SeekerChatTab
โปรไฟล์ → SeekerProfileTab
หน้าที่เปิดซ้อน: รายละเอียดงาน, หน้าบริษัท, แผนที่งาน, แจ้งเตือน (ไอคอนกระดิ่งในหน้าแรก), ห้องแชท, ดู PDF
เปิดแอปและเข้าสู่ระบบ
SplashPage — หน้าโหลดตอนเปิดแอป
lib/presentation/pages/splash/splash_page.dart หน้าจอ
แสดงโลโก้ 2 วินาที แล้วตัดสินว่าจะไปหน้าไหนจากข้อมูลใน SessionService: ล็อกอินเป็นผู้หางาน → /seeker-home, เป็นผู้ว่าจ้าง → /home, ยังไม่ล็อกอิน → /welcome
lib/presentation/pages/splash/splash_page.dart บรรทัด 20–32 20
21
22
23
24
25
26
27
28
29
30
31
32 Future<void> _checkSession() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
if (SessionService.instance.isLoggedIn) {
if (SessionService.instance.userRole == 'seeker') {
context.go('/seeker-home');
} else {
context.go('/home');
}
} else {
context.go('/welcome');
}
}
WelcomePage — เลือกบทบาท
lib/presentation/pages/welcome/welcome_page.dart หน้าจอ
หน้าแรกของผู้ที่ยังไม่ล็อกอิน มีปุ่มเข้าสู่ระบบแยกของผู้หางาน (/seeker-login) และผู้ว่าจ้าง (/login)
และ _showLoginOptions() ที่เปิดแผ่นเลื่อนจากด้านล่าง (showModalBottomSheet) ให้เลือกบทบาท หน้าสมัครสมาชิกและลืมรหัสผ่านเข้าได้จากลิงก์ในหน้าเข้าสู่ระบบของแต่ละบทบาท
LoginPage / SeekerLoginPage — เข้าสู่ระบบ
lib/presentation/pages/auth/login_page.dart · seeker/seeker_login_page.dart หน้าจอ
สองหน้าทำงานเหมือนกัน ต่างกันแค่บทบาทที่ยอมรับ ลำดับการทำงานของ _handleLogin():
ตรวจฟอร์ม (อีเมล/รหัสผ่านต้องไม่ว่าง) ถ้าไม่ผ่านหยุดทันที
เรียก login() ถ้าสำเร็จเช็กว่า role ตรงกับหน้านี้ (ผู้หางานล็อกอินในหน้าผู้ว่าจ้างไม่ได้)
เรียก getMember() ดึงโปรไฟล์เต็ม (รูป ชื่อบริษัท ฯลฯ) แล้วบันทึกลง SessionService
context.go('/home') ไปหน้าหลัก ใช้ go ไม่ใช่ push เพื่อไม่ให้กดย้อนกลับมาหน้าล็อกอินได้
lib/presentation/pages/auth/login_page.dart บรรทัด 22–64 22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64 Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final res = await ApiService().login(
_emailController.text.trim(),
_passwordController.text,
);
setState(() => _isLoading = false);
if (!mounted) return;
if (res['success'] == true) {
final userData = res['data'];
final role = userData['role'] ?? 'employer';
if (role != 'employer') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('บัญชีนี้ไม่ใช่บัญชีสำหรับนายจ้าง'),
backgroundColor: AppTheme.error,
),
);
return;
}
final member = MemberModel.fromJson(userData);
final fullMember = await ApiService().getMember(member.userId);
await SessionService.instance.saveSession(fullMember ?? member);
if (!mounted) return;
context.go('/home');
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(res['message'] ?? 'เข้าสู่ระบบไม่สำเร็จ'),
backgroundColor: AppTheme.error,
),
);
}
}
RegisterEmployerPage — สมัครสมาชิกผู้ว่าจ้าง
lib/presentation/pages/auth/register_employer_page.dart หน้าจอ
มีตัวเลือกประเภท “บริษัท/นิติบุคคล” หรือ “บุคคลทั่วไป” (ถ้าเป็นบุคคลทั่วไป ระบบใช้ชื่อคนเป็นชื่อบริษัท) ที่อยู่เลือกจังหวัด/อำเภอ/ตำบลจากรายการ แล้วรวมเป็นข้อความรูปแบบ
… ต.… อ.… จ.… รหัสไปรษณีย์ ซึ่ง backend ใช้หาพิกัดต่อ เมื่อสมัครสำเร็จจะล็อกอินให้อัตโนมัติ ไม่ต้องกรอกซ้ำ
lib/presentation/pages/auth/register_employer_page.dart บรรทัด 81–124 81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124 final isCompany = _employerType == EmployerType.company;
final subdistrict = _thaiAddressController.subdistrict?.nameTh ?? '';
final district = _thaiAddressController.district?.nameTh ?? '';
final province = _thaiAddressController.province?.nameTh ?? '';
final zipCodeText =
_thaiAddressController.subdistrict?.postcode.toString() ?? '';
final fullAddress = _addressDetailController.text.trim();
final completeAddress =
'$fullAddress ต.$subdistrict อ.$district จ.$province $zipCodeText';
final res = await ApiService().registerEmployer(
name: _nameController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
contactPhone: _contactPhoneController.text.trim(),
companyAddress: completeAddress.trim(),
companyName: isCompany
? _companyNameController.text.trim()
: _nameController.text.trim(),
companyDetail: isCompany
? _companyDetailController.text.trim()
: 'บุคคลทั่วไป',
latitude: _latitude,
longitude: _longitude,
);
if (!mounted) return;
if (res['success'] == true) {
final loginRes = await ApiService().login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
if (loginRes['success'] == true) {
final userData = loginRes['data'];
final member = MemberModel.fromJson(userData);
final fullMember = await ApiService().getMember(member.userId);
await SessionService.instance.saveSession(fullMember ?? member);
if (!mounted) return;
context.go('/home');
SeekerRegisterPage — สมัครสมาชิกผู้หางาน (3 ขั้นตอน)
lib/presentation/pages/seeker/seeker_register_page.dart หน้าจอ
ฟอร์มแบ่งเป็น 3 ขั้น แต่ละขั้นมี GlobalKey<FormState> ของตัวเอง ปุ่ม “ถัดไป” ตรวจเฉพาะขั้นปัจจุบัน ผ่านแล้วค่อยเลื่อนขั้น ขั้นสุดท้ายจึงส่งข้อมูล
ตอนเปิดหน้าจะโหลดคลังทักษะ (getKeywords) มาให้เลือกตามหมวด หลังสมัครสำเร็จจะล็อกอินอัตโนมัติและบันทึก seekerId
lib/presentation/pages/seeker/seeker_register_page.dart บรรทัด 153–170 153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170 void _nextStep() {
bool isStepValid = false;
if (_currentStep == 0) {
isStepValid = _step1FormKey.currentState!.validate();
} else if (_currentStep == 1) {
isStepValid = _step2FormKey.currentState!.validate();
} else if (_currentStep == 2) {
isStepValid = _step3FormKey.currentState!.validate();
}
if (isStepValid) {
if (_currentStep < 2) {
setState(() => _currentStep += 1);
} else {
_handleRegister();
}
}
}
lib/presentation/pages/seeker/seeker_register_page.dart บรรทัด 211–236 สมัครสำเร็จ → ล็อกอินให้ทันที → ดึงโปรไฟล์ → บันทึก session → ไปหน้าหลักผู้หางาน 211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236 if (res['success'] == true) {
final loginRes = await ApiService().login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
if (loginRes['success'] == true) {
final userData = loginRes['data'];
final member = MemberModel.fromJson(userData);
final fullMember = await ApiService().getMember(member.userId);
await SessionService.instance.saveSession(
fullMember ?? member,
seekerId: userData['seeker_id'] as int?,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('สมัครสมาชิกและเข้าสู่ระบบสำเร็จ!'),
backgroundColor: AppTheme.success,
),
);
context.go('/seeker-home');
ForgotPasswordPage — ลืมรหัสผ่าน
lib/presentation/pages/auth/forgot_password_page.dart หน้าจอ
กรอกอีเมล + เบอร์โทรที่ใช้สมัคร + รหัสผ่านใหม่ ถ้าข้อมูลตรงกับในระบบ backend จะตั้งรหัสใหม่ให้ แล้วหน้านี้ปิดตัวเองกลับไปหน้าล็อกอิน
lib/presentation/pages/auth/forgot_password_page.dart บรรทัด 33–54 33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54 Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final res = await ApiService().resetPassword(
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
newPassword: _passwordController.text,
);
if (!mounted) return;
setState(() => _isLoading = false);
final ok = res['success'] == true;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(res['message'] ?? (ok ? 'ตั้งรหัสผ่านใหม่สำเร็จ' : 'ตั้งรหัสผ่านใหม่ไม่สำเร็จ')),
backgroundColor: ok ? AppTheme.success : AppTheme.error,
),
);
if (ok) context.pop();
}
โครงหน้าหลัก (Shell) และแถบเมนูด้านล่าง
MainShell และ SeekerMainShell
lib/presentation/pages/home/main_shell.dart · seeker/seeker_main_shell.dart หน้าจอ
Shell คือ “กรอบ” ที่มีแถบเมนูด้านล่าง และสลับเนื้อหาตรงกลางตามแท็บที่เลือก ทั้งสองไฟล์ยังถามเซิร์ฟเวอร์ทุก 5 วินาทีว่ามีข้อความ/แจ้งเตือนที่ยังไม่อ่านกี่รายการ เพื่อแสดงตัวเลขสีแดง (Badge) บนไอคอน
lib/presentation/pages/home/main_shell.dart บรรทัด 21–47 21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47 class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
int _unreadChatCount = 0;
int _unreadNotiCount = 0;
Timer? _unreadTimer;
final List<Widget> _tabs = const [
DashboardTab(),
ManageJobsTab(),
ChatTab(),
NotificationsTab(),
ProfileTab(),
];
@override
void initState() {
super.initState();
_currentIndex = widget.initialTab.clamp(0, _tabs.length - 1);
_fetchUnreadCount();
_unreadTimer = Timer.periodic(const Duration(seconds: 5), (_) => _fetchUnreadCount());
}
@override
void dispose() {
_unreadTimer?.cancel();
super.dispose();
}
ฝั่งผู้ว่าจ้างใช้ IndexedStack ซึ่งสร้างทุกแท็บไว้พร้อมกันแล้วแสดงทีละแท็บ ส่วนฝั่งผู้หางานใช้ PageView + PageController
โดยปิดการปัดด้วย NeverScrollableScrollPhysics (สลับแท็บได้จากแถบล่างเท่านั้น) และเปิดเมธอด switchTab() ให้หน้าลูกเรียกสลับแท็บเองได้
lib/presentation/pages/seeker/seeker_main_shell.dart บรรทัด 66–91 66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91 void _onPageChanged(int index) {
setState(() => _currentIndex = index);
}
void switchTab(int index) {
_pageController.jumpToPage(index);
if (index == 3) _fetchUnreadCount();
}
void _onBottomNavTapped(int index) {
_pageController.jumpToPage(index);
if (index == 3) _fetchUnreadCount();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: _tabs,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: _onBottomNavTapped,
ตัวอย่างที่หน้าลูกสั่งสลับแท็บ: กดกระดิ่งในหน้าแรก → เปิดหน้าแจ้งเตือน → ถ้าผู้ใช้แตะแจ้งเตือนเรื่องใบสมัคร หน้าแจ้งเตือนจะ pop พร้อมค่า 'applications' กลับมา หน้าแรกจึงหา Shell ที่ครอบอยู่ด้วย findAncestorStateOfType แล้วสั่งไปแท็บ “งานของฉัน”
lib/presentation/pages/seeker/tabs/seeker_home_tab.dart บรรทัด 160–167 160
161
162
163
164
165
166
167 onPressed: () {
context.push<String>('/seeker-home/notifications').then((result) {
_fetchUnreadNotifications();
// แตะแจ้งเตือนเรื่องใบสมัคร -> สลับไปแท็บ "งานของฉัน"
if (result == kOpenApplicationsTab && context.mounted) {
context.findAncestorStateOfType<SeekerMainShellState>()?.switchTab(1);
}
});
หน้าฝั่งผู้ว่าจ้าง
DashboardTab — แดชบอร์ด
lib/presentation/pages/home/tabs/dashboard_tab.dart หน้าจอ
ส่วนหัวแสดงโลโก้และชื่อบริษัท ตามด้วยการ์ดสถิติ 4 ใบ (งานที่เปิดรับ, ผู้สมัครทั้งหมด, รอดำเนินการ, นัดสัมภาษณ์แล้ว) ผู้สมัครล่าสุด และงานที่เปิดรับอยู่
ตัวเลขทั้งหมดนับในแอปจากรายการงานและใบสมัครที่โหลดมา
ระหว่างโหลดจะแสดงโครงสีเทากะพริบ (shimmer) แล้วค่อย ๆ ปรากฏด้วย FadeTransition ดึงหน้าจอลงเพื่อรีเฟรชได้ (RefreshIndicator)
แตะผู้สมัครจะไปหน้าโปรไฟล์ผู้สมัคร แตะงานจะไปหน้ารายชื่อผู้สมัครของงานนั้น
lib/presentation/pages/home/tabs/dashboard_tab.dart บรรทัด 58–71 58
59
60
61
62
63
64
65
66
67
68
69
70
71 Future<void> _loadDashboardData() async {
if (!mounted) return;
setState(() => _isLoading = true);
final employerId = SessionService.instance.employerId;
final jobs = await ApiService().getEmployerJobs(employerId);
final apps = await ApiService().getApplicationsByEmployer(employerId);
if (!mounted) return;
setState(() {
_jobs = jobs;
_recentApplications = apps;
_isLoading = false;
});
_fadeController.forward(from: 0);
}
ManageJobsTab + ManageJobsCubit — จัดการประกาศงาน
lib/presentation/pages/home/tabs/manage_jobs_tab.dart · blocs/manage_jobs_cubit.dart หน้าจอ
หน้าเดียวในโปรเจกต์ที่จัดการสถานะด้วย Cubit (จากแพ็กเกจ flutter_bloc) แทน setState แนวคิดคือแยก “ตรรกะการโหลดข้อมูล” ออกจากหน้าจอ
Cubit ปล่อย (emit) สถานะออกมาทีละแบบ: เริ่มต้น → กำลังโหลด → โหลดสำเร็จ (มีรายการงาน) หรือ ผิดพลาด (มีข้อความ)
lib/presentation/blocs/manage_jobs_cubit.dart บรรทัด 6–38 6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 // กำหนด State
abstract class ManageJobsState {}
class ManageJobsInitial extends ManageJobsState {}
class ManageJobsLoading extends ManageJobsState {}
class ManageJobsLoaded extends ManageJobsState {
final List<JobPostingModel> jobs;
ManageJobsLoaded(this.jobs);
}
class ManageJobsError extends ManageJobsState {
final String message;
ManageJobsError(this.message);
}
// สร้าง Cubit
class ManageJobsCubit extends Cubit<ManageJobsState> {
final ApiService _apiService;
ManageJobsCubit(this._apiService) : super(ManageJobsInitial());
Future<void> fetchJobs() async {
emit(ManageJobsLoading());
try {
final employerId = SessionService.instance.employerId;
final jobs = await _apiService.getEmployerJobs(employerId);
emit(ManageJobsLoaded(jobs));
} catch (e) {
emit(ManageJobsError("ไม่สามารถดึงข้อมูลได้: ${e.toString()}"));
}
}
ฝั่งหน้าจอใช้ BlocBuilder ฟังสถานะ แล้ววาดหน้าตามชนิดของสถานะด้วย if (state is …) ปุ่มแก้ไขงานจะเปิด /home/post-job พร้อม extra: job และเมื่อกลับมาจะสั่ง fetchJobs() ใหม่
lib/presentation/pages/home/tabs/manage_jobs_tab.dart บรรทัด 160–192 160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192 return BlocProvider.value(
value: _manageJobsCubit,
child: Scaffold(
appBar: AppBar(
title: const Text('จัดการประกาศงาน'),
),
body: BlocBuilder<ManageJobsCubit, ManageJobsState>(
builder: (context, state) {
if (state is ManageJobsLoading || state is ManageJobsInitial) {
return const Center(child: CircularProgressIndicator());
}
if (state is ManageJobsError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppTheme.error),
const SizedBox(height: 12),
Text(state.message, style: const TextStyle(color: AppTheme.error)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _manageJobsCubit.fetchJobs(),
icon: const Icon(Icons.refresh),
label: const Text('ลองใหม่'),
),
],
),
);
}
if (state is ManageJobsLoaded) {
PostJobPage — เพิ่ม/แก้ไขประกาศงาน
lib/presentation/pages/jobs/post_job_page.dart หน้าจอ
หน้าเดียวใช้ได้ 2 โหมด: ถ้าได้รับ editJob มา (_isEditMode) จะเติมค่าเดิมลงฟอร์มและบันทึกด้วย updateJob ไม่อย่างนั้นเป็นการโพสต์ใหม่ด้วย postJob
เลือกทักษะ: โหลดคลังคำสำคัญจาก getKeywords() แล้วจัดกลุ่มตามหมวด (Map<หมวด, List<ทักษะ>>) ผู้ใช้เลือกหมวดก่อนแล้วติ๊กทักษะ ถ้าไม่มีในคลังพิมพ์เพิ่มได้ (เรียก addKeyword)
สถานที่ทำงาน: พิมพ์เองได้ หรือกดปุ่มใช้ตำแหน่งปัจจุบัน ซึ่งขอสิทธิ์ GPS → อ่านพิกัด → แปลงเป็นชื่อสถานที่ด้วยแพ็กเกจ geocoding ถ้าแปลงไม่ได้จะเขียนเป็น “พิกัด xx, yy” (backend จะแปลงเป็น ต./อ./จ. ให้อีกที)
lib/presentation/pages/jobs/post_job_page.dart บรรทัด 203–242 อ่าน GPS แล้วแปลงเป็นชื่อสถานที่ เก็บพิกัดไว้ส่งไปพร้อมประกาศงาน 203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242 try {
Position position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
double realLat = position.latitude;
double realLng = position.longitude;
String locationName = '';
try {
List<Placemark> placemarks = await placemarkFromCoordinates(
realLat,
realLng,
);
if (placemarks.isNotEmpty) {
Placemark place = placemarks[0];
locationName =
place.street ??
place.subLocality ??
place.locality ??
place.subAdministrativeArea ??
place.administrativeArea ??
'';
}
} catch (e) {
debugPrint('ไม่สามารถแปลงพิกัดเป็นชื่อเมืองได้: $e');
}
if (locationName.isEmpty) {
locationName =
'พิกัด ${realLat.toStringAsFixed(6)}, ${realLng.toStringAsFixed(6)}';
}
if (!mounted) return;
setState(() {
_latitude = realLat.toString();
_longitude = realLng.toString();
_jobLocationController.text = locationName;
});
lib/presentation/pages/jobs/post_job_page.dart บรรทัด 261–297 กดบันทึก: เลือกเรียก updateJob หรือ postJob ตามโหมด (ค่าจ้างตัดคอมมาออกก่อนส่ง) 261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297 Future<void> _handlePostJob() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final employerId = SessionService.instance.employerId;
final vacancy = int.tryParse(_vacancyController.text.trim()) ?? 1;
final Map<String, dynamic> res;
if (_isEditMode) {
res = await ApiService().updateJob(
jobId: widget.editJob!.jobId,
jobTitle: _jobTitleController.text.trim(),
jobDescription: _jobDescriptionController.text.trim(),
qualifications: _qualificationsController.text.trim(),
jobLocation: _jobLocationController.text.trim(),
requiredSkills: _requiredSkillsController.text.trim(),
jobType: _selectedJobType,
salary: _salaryController.text.trim().replaceAll(',', ''),
vacancy: vacancy,
latitude: _latitude,
longitude: _longitude,
);
} else {
res = await ApiService().postJob(
employerId: employerId,
jobTitle: _jobTitleController.text.trim(),
jobDescription: _jobDescriptionController.text.trim(),
qualifications: _qualificationsController.text.trim(),
jobLocation: _jobLocationController.text.trim(),
requiredSkills: _requiredSkillsController.text.trim(),
jobType: _selectedJobType,
salary: _salaryController.text.trim().replaceAll(',', ''),
vacancy: vacancy,
latitude: _latitude,
longitude: _longitude,
);
}
สำเร็จแล้วเรียก context.pop(true) ปิดหน้าและบอกหน้าก่อนหน้าว่ามีการเปลี่ยนแปลง
CandidateListPage — รายชื่อผู้สมัครของงาน
lib/presentation/pages/applications/candidate_list_page.dart หน้าจอ
โหลดข้อมูล 2 อย่างพร้อมกันด้วย Future.wait (ผู้สมัครของงานนี้ + ข้อมูลงานเพื่อรู้จำนวนที่รับ) เร็วกว่าเรียกทีละอัน ผู้สมัครเรียงตามคะแนนจากมากไปน้อย (backend เรียงให้)
แถบด้านบนบอก “รับแล้ว X/Y คน” ถ้ารับครบแล้วจะกดรับเพิ่มไม่ได้
lib/presentation/pages/applications/candidate_list_page.dart บรรทัด 57–83 57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83 Future<void> _fetchAll() async {
setState(() => _isLoading = true);
final employerId = SessionService.instance.employerId;
final results = await Future.wait([
ApiService().getApplicationsByJob(widget.jobId),
ApiService().getEmployerJobs(employerId),
]);
if (!mounted) return;
final candidates = results[0] as List<ApplicationModel>;
final jobs = results[1] as List<JobPostingModel>;
JobPostingModel? job;
for (final j in jobs) {
if (j.jobId == widget.jobId) {
job = j;
break;
}
}
setState(() {
_candidates = candidates;
_job = job;
_isLoading = false;
});
}
ปุ่ม “รับเข้าทำงาน” จะเปิด showHireDialog ให้เลือกวันเริ่มงานก่อน ถ้ากดยกเลิกในหน้าต่างจะไม่เปลี่ยนสถานะ
lib/presentation/pages/applications/candidate_list_page.dart บรรทัด 85–106 85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106 Future<void> _updateStatus(ApplicationModel cand, String newStatus) async {
if (newStatus == kAcceptedStatus && (_job?.isFull ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('รับครบตามจำนวนที่กำหนดแล้ว'), backgroundColor: AppTheme.error),
);
return;
}
// รับเข้าทำงาน -> ให้เลือกวันเริ่มงานก่อน (กดยกเลิก = ไม่เปลี่ยนสถานะ)
HireDetails? hire;
if (newStatus == kAcceptedStatus) {
hire = await showHireDialog(context, candidateName: cand.fullname);
if (hire == null || !mounted) return;
}
setState(() => _isUpdatingStatus = true);
final res = await ApiService().updateApplicationStatus(
cand.applicationId,
newStatus,
startDate: hire?.startDate,
message: hire?.message,
);
CandidateDetailPage — โปรไฟล์ผู้สมัคร
lib/presentation/pages/applications/candidate_detail_page.dart หน้าจอ
แสดงข้อมูลผู้สมัคร ทักษะ คะแนนความเหมาะสม สถานะ และปุ่มดำเนินการ: นัดสัมภาษณ์ (ไป InterviewPage), รับเข้าทำงาน, ไม่ผ่าน, แชท และเปิด Resume ด้วย PdfViewerPage
หน้านี้ดึงใบสมัครทั้งหมดของบริษัทแล้วเลือกใบที่ applicationId ตรงกัน
lib/presentation/pages/applications/candidate_detail_page.dart บรรทัด 53–77 53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77 Future<void> _updateStatus(String newStatus) async {
// รับเข้าทำงาน -> ให้เลือกวันเริ่มงานก่อน (กดยกเลิก = ไม่เปลี่ยนสถานะ)
HireDetails? hire;
if (newStatus == AppConstants.statusPassed) {
hire = await showHireDialog(context, candidateName: _application?.fullname);
if (hire == null || !mounted) return;
}
final res = await ApiService().updateApplicationStatus(
widget.applicationId,
newStatus,
startDate: hire?.startDate,
message: hire?.message,
);
if (!mounted) return;
if (res['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('อัปเดตสถานะเป็น "$newStatus" สำเร็จ'), backgroundColor: AppTheme.success),
);
_loadCandidateDetail();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(res['message'] ?? 'อัปเดตสถานะไม่สำเร็จ'), backgroundColor: AppTheme.error),
);
}
}
InterviewPage — นัดสัมภาษณ์
lib/presentation/pages/applications/interview_page.dart หน้าจอ
เลือกวัน เวลา รูปแบบ (ออนไซต์/ออนไลน์) สถานที่หรือลิงก์ และหมายเหตุ จุดที่น่าสนใจคือแยกค่าที่แสดงกับค่าที่ส่ง : ช่องกรอกแสดง “15 ต.ค. 2569” และ “10:00 น.” ให้อ่านง่าย
แต่เก็บค่ารูปแบบมาตรฐาน 2026-10-15 และ 10:00:00 ไว้ในตัวแปรแยกเพื่อส่งให้ API
lib/presentation/pages/applications/interview_page.dart บรรทัด 47–74 47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74 Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 1)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 90)),
);
if (picked != null) {
setState(() {
_dateValue = "${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}";
_dateController.text = formatThaiDate(_dateValue);
});
}
}
Future<void> _selectTime() async {
final picked = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 10, minute: 0),
);
if (picked != null) {
final hhmm = "${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}";
setState(() {
_timeValue = '$hhmm:00';
_timeController.text = '$hhmm น.';
});
}
}
ChatTab, NotificationsTab, ProfileTab, EditProfilePage
lib/presentation/pages/home/tabs/… · profile/edit_profile_page.dart หน้าจอ
ChatTab แสดงรายการห้องแชท (getChatList) พร้อมรูป ข้อความล่าสุด และจำนวนที่ยังไม่อ่าน แตะแล้วเปิดห้องแชท กลับมาจะโหลดรายการใหม่
NotificationsTab แตะแจ้งเตือนจะทำเครื่องหมายว่าอ่านแล้ว และถ้า type เป็น application_<job_id> จะแยกเลขงานออกมาแล้วเปิดหน้ารายชื่อผู้สมัครของงานนั้น ปัดซ้าย/ขวาเพื่อลบได้
ProfileTab หน้าโปรไฟล์บริษัท เปลี่ยนโลโก้และภาพปกได้ (ภาพปกส่ง kind: 'cover') ปุ่มออกจากระบบจะล้าง session แล้วไป /login
EditProfilePage โหลดข้อมูลบริษัทด้วย getMember ใส่ฟอร์ม แล้วบันทึกด้วย updateEmployerProfile
lib/presentation/pages/home/tabs/notifications_tab.dart บรรทัด 87–101 แจ้งเตือนเรื่องใบสมัครมี type = application_<job_id> จึงแยกเลขงานด้วย split('_') 87
88
89
90
91
92
93
94
95
96
97
98
99
100
101 onTap: () async {
if (!noti.isRead) {
await ApiService().markNotificationAsRead(noti.notiId);
_loadNotifications();
}
if (noti.type.startsWith('application_')) {
final parts = noti.type.split('_');
if (parts.length > 1) {
final jobId = parts[1];
if (context.mounted) {
context.push('/home/candidates/$jobId');
}
return;
}
lib/presentation/pages/home/tabs/profile_tab.dart บรรทัด 61–92 อัปโหลดโลโก้/ภาพปก: ย่อขนาดรูปก่อนส่ง (ภาพปกกว้างกว่าโลโก้) แล้วโหลดโปรไฟล์ใหม่ 61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92 Future<void> _pickAndUploadImage({bool cover = false}) async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(
source: ImageSource.gallery,
// ภาพปกเป็นแนวนอนเต็มความกว้าง จึงให้ความละเอียดมากกว่าโลโก้
maxWidth: cover ? 1600 : 800,
maxHeight: cover ? 900 : 800,
imageQuality: 85,
);
if (pickedFile == null) return;
setState(() => _isUploading = true);
final userId = SessionService.instance.userId;
final res = await ApiService().uploadProfileImage(
userId: userId,
role: 'employer',
bytes: await pickedFile.readAsBytes(),
fileName: pickedFile.name,
kind: cover ? 'cover' : null,
);
setState(() => _isUploading = false);
if (!mounted) return;
if (res['success'] == true && res['image_url'] != null) {
final imageUrl = res['image_url'] as String;
if (!cover) await SessionService.instance.updateProfileImage(imageUrl);
await _loadProfile();
หน้าฝั่งผู้หางาน
SeekerHomeTab — งานแนะนำ
lib/presentation/pages/seeker/tabs/seeker_home_tab.dart หน้าจอ
ดึงงานทั้งหมดพร้อมคะแนนจาก get_jobs.php แล้วแบ่งเป็น 2 กลุ่ม: งานแนะนำ (isRecommended = คะแนนถึงระดับ “เหมาะสมดี” ตามเกณฑ์ที่แอดมินตั้ง) และงานอื่นที่น่าสนใจ (เรียงตามคะแนน คะแนนเท่ากันเอางานใหม่ขึ้นก่อน)
ทุก 10 วินาทีจะรีเฟรชแบบเงียบ (silent: true = ไม่โชว์วงหมุน และถ้าโหลดพลาดก็คงรายการเดิมไว้) งานที่บริษัทเพิ่งโพสต์จึงขึ้นมาเอง
lib/presentation/pages/seeker/tabs/seeker_home_tab.dart บรรทัด 66–103 66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103 Future<void> _fetchJobs({bool silent = false}) async {
// silent = รีเฟรชเบื้องหลัง ไม่แสดงวงหมุน และถ้าโหลดไม่สำเร็จก็คงรายการเดิมไว้
if (!silent) setState(() => _isLoading = true);
final userId = SessionService.instance.userId;
if (userId <= 0) {
if (mounted) setState(() => _isLoading = false);
return;
}
final res = await ApiService().getJobsForSeeker(userId);
if (!mounted) return;
if (res['success'] == true && res['data'] is List) {
final list = (res['data'] as List)
.map((j) => JobPostingModel.fromJson(j))
.toList();
// งานแนะนำ: คะแนนถึงระดับ "เหมาะสมดี" ขึ้นไป ตามเกณฑ์ที่ Admin ตั้ง (ตารางที่ 2.1)
final matched = list.where((j) => j.isRecommended).toList();
// งานที่น่าสนใจ: เอางานที่เหลือที่ไม่ได้อยู่ใน matched มาแสดงเป็นงานมาใหม่
final interesting = list.where((j) => !j.isRecommended).toList();
// เรียงตามคะแนนความเหมาะสมก่อน (ขอบเขต 1.3.4.4) คะแนนเท่ากันเอางานใหม่ขึ้นก่อน
interesting.sort((a, b) {
final byScore = b.matchScore.compareTo(a.matchScore);
return byScore != 0 ? byScore : b.jobId.compareTo(a.jobId);
});
setState(() {
_allJobs = list;
_filteredJobs = matched;
_interestingJobs = interesting;
_isLoading = false;
});
} else if (!silent) {
setState(() => _isLoading = false);
}
}
SeekerSearchTab — ค้นหาและกรองงาน
lib/presentation/pages/seeker/tabs/seeker_search_tab.dart หน้าจอ
ตอนเปิดหน้าจะลองอ่านตำแหน่ง GPS (รอได้ไม่เกิน 6 วินาที) แล้วส่ง lat/lng ไปให้ backend คำนวณระยะทาง ถ้าไม่ได้ตำแหน่ง backend จะใช้พิกัดจากที่อยู่ในโปรไฟล์แทน
lib/presentation/pages/seeker/tabs/seeker_search_tab.dart บรรทัด 73–118 73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118 /// ขอตำแหน่ง GPS ปัจจุบัน (ถ้าผู้ใช้ไม่อนุญาต จะใช้พิกัดในโปรไฟล์แทนที่ backend)
Future<Position?> _currentPosition() async {
try {
if (!canUseGps) return null; // เว็บแบบ http: ไม่รอ GPS ให้โหลดรายการงานทันที
if (!await Geolocator.isLocationServiceEnabled()) return null;
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
return null;
}
return await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium),
).timeout(const Duration(seconds: 8));
} catch (_) {
return null;
}
}
Future<void> _fetchJobs() async {
setState(() => _isLoading = true);
// จำกัดเวลารอ GPS/หน้าต่างขออนุญาต ไม่ให้รายการงานค้าง ถ้าเกินเวลาใช้พิกัดในโปรไฟล์แทน
final position = await _currentPosition()
.timeout(const Duration(seconds: 6), onTimeout: () => null);
final res = await ApiService().getJobsForSeeker(
SessionService.instance.userId,
lat: position?.latitude,
lng: position?.longitude,
);
if (!mounted) return;
if (res['success'] == true && res['data'] is List) {
final origin = res['origin'];
setState(() {
_allJobs = (res['data'] as List).map((j) => JobPostingModel.fromJson(j)).toList();
_origin = origin is Map
? LatLng((origin['lat'] as num).toDouble(), (origin['lng'] as num).toDouble())
: null;
_isLoading = false;
});
_applyFilters();
} else {
setState(() => _isLoading = false);
}
}
การค้นหาและกรองทำในเครื่อง ทั้งหมด (ไม่เรียก API ซ้ำ) จึงตอบสนองทันที: ค้นคำในชื่องาน/บริษัท/สถานที่/ประเภท/ทักษะ, กรองประเภทงาน, ระยะทางสูงสุด, ค่าจ้างขั้นต่ำ แล้วเรียงตาม คะแนน / ระยะใกล้ / ค่าจ้างสูง / ล่าสุด
สังเกตการใช้ switch แบบ expression ของ Dart 3 ในการเรียง และงานที่ไม่มีข้อมูล (ไม่มีพิกัด/ค่าจ้าง) ถูกไว้ท้ายสุดเสมอ
lib/presentation/pages/seeker/tabs/seeker_search_tab.dart บรรทัด 120–154 120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154 void _applyFilters() {
final q = _searchController.text.trim().toLowerCase();
final result = _allJobs.where((j) {
if (q.isNotEmpty) {
final haystack = [
j.jobTitle,
j.companyName ?? '',
j.jobLocation,
j.jobType,
j.requiredSkills.join(' '),
].join(' ').toLowerCase();
if (!haystack.contains(q)) return false;
}
if (_jobTypes.isNotEmpty && !_jobTypes.contains(j.jobType)) return false;
if (_maxDistanceKm != null && (j.distanceKm == null || j.distanceKm! > _maxDistanceKm!)) return false;
if (_minSalary != null && (j.salaryValue == null || j.salaryValue! < _minSalary!)) return false;
return true;
}).toList();
int compareNullable(double? a, double? b, {bool descending = false}) {
if (a == null && b == null) return 0;
if (a == null) return 1; // ไม่มีข้อมูลไว้ท้ายสุด
if (b == null) return -1;
return descending ? b.compareTo(a) : a.compareTo(b);
}
result.sort((a, b) => switch (_sortBy) {
_SortBy.match => b.matchScore.compareTo(a.matchScore),
_SortBy.distance => compareNullable(a.distanceKm, b.distanceKm),
_SortBy.salary => compareNullable(a.salaryValue, b.salaryValue, descending: true),
_SortBy.newest => b.jobId.compareTo(a.jobId),
});
setState(() => _filteredJobs = result);
}
SeekerJobMapPage — แผนที่งาน
lib/presentation/pages/seeker/seeker_job_map_page.dart หน้าจอ
รับรายการงาน (ที่ผ่านตัวกรองแล้ว) และตำแหน่งผู้ใช้จากหน้าค้นหา แสดงบนแผนที่ OpenStreetMap ด้วยแพ็กเกจ flutter_map ซึ่งซ้อนเป็นชั้น (layer):
TileLayer = ภาพแผนที่, MarkerLayer = หมุดงานและจุดตำแหน่งผู้ใช้, RichAttributionWidget = เครดิต OSM ที่ต้องแสดงตามเงื่อนไขการใช้งาน
lib/presentation/pages/seeker/seeker_job_map_page.dart บรรทัด 32–43 งานหลายตำแหน่งที่อยู่จุดเดียวกันจะซ้อนทับกัน จึงปัดพิกัดเหลือทศนิยม 4 ตำแหน่ง (~11 เมตร) แล้วรวมเป็นกลุ่ม 32
33
34
35
36
37
38
39
40
41
42
43 /// งานหลายตำแหน่งที่พิกัดเดียวกันจะซ้อนกัน จึงรวมเป็นกลุ่มต่อพิกัด
Map<LatLng, List<JobPostingModel>> get _groupedJobs {
final groups = <LatLng, List<JobPostingModel>>{};
for (final job in _jobsWithLocation) {
final point = LatLng(
double.parse(job.latitude!.toStringAsFixed(4)),
double.parse(job.longitude!.toStringAsFixed(4)),
);
groups.putIfAbsent(point, () => []).add(job);
}
return groups;
}
lib/presentation/pages/seeker/seeker_job_map_page.dart บรรทัด 111–156 111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156 FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: _initialCenter,
initialZoom: widget.origin != null ? 11 : 6,
onTap: (_, __) => setState(() => _selected = null),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.jobmatch.jobmatch_employer',
),
MarkerLayer(
markers: [
if (widget.origin != null)
Marker(
point: widget.origin!,
width: 26,
height: 26,
child: Container(
decoration: BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 4)],
),
),
),
for (final entry in groups.entries)
Marker(
point: entry.key,
width: 44,
height: 44,
alignment: Alignment.topCenter,
child: GestureDetector(
onTap: () => _showJobsAt(entry.value),
child: _JobPin(jobs: entry.value),
),
),
],
),
const RichAttributionWidget(
attributions: [TextSourceAttribution('OpenStreetMap contributors')],
),
],
),
แตะหมุดที่มีงานเดียวจะแสดงการ์ดงานด้านล่าง ถ้ามีหลายงานจะเปิดรายการให้เลือก (_showJobsAt)
SeekerJobDetailPage — รายละเอียดงานและปุ่มสมัคร
lib/presentation/pages/seeker/seeker_job_detail_page.dart หน้าจอ
แสดงสรุปงาน (ค่าจ้าง สถานที่ ประเภท จำนวนรับ) คะแนนการจับคู่ ทักษะที่ตรง/ที่ยังขาด รายละเอียด และคุณสมบัติ ปุ่มด้านล่างเปลี่ยนตามสถานะ:
ยังไม่สมัคร = “สมัครงาน”, สมัครแล้ว = “ยกเลิกการสมัคร” นอกจากนี้แสดงเบอร์ติดต่อของบริษัท และกดเปิดแผนที่ Google Maps, แชท หรือเปิดหน้าบริษัทได้
lib/presentation/pages/seeker/seeker_job_detail_page.dart บรรทัด 136–193 136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193 Future<void> _checkApplicationStatus() async {
final seekerId = SessionService.instance.seekerId;
if (seekerId <= 0) return;
final apps = await ApiService().getApplicationsBySeeker(seekerId);
final found = apps
.where(
(a) => a.jobId == widget.jobId && a.status != _cancelledStatus,
)
.toList();
if (!mounted) return;
setState(() {
_myApplication = found.isNotEmpty ? found.first : null;
});
}
Future<void> _handleApply() async {
final seekerId = SessionService.instance.seekerId;
if (seekerId <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('กรุณาล็อกอินด้วยบัญชีผู้หางานก่อนสมัครงาน'),
backgroundColor: AppTheme.error,
),
);
return;
}
setState(() => _isApplying = true);
final res = await ApiService().applyJob(
jobId: widget.jobId,
seekerId: seekerId,
);
if (!mounted) return;
if (res['success'] == true) {
// ดึงสถานะใบสมัครล่าสุดมาแสดง เพื่อสลับปุ่มเป็น "ยกเลิกการสมัคร"
await _checkApplicationStatus();
if (!mounted) return;
setState(() => _isApplying = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('สมัครงานสำเร็จแล้ว! นายจ้างจะพิจารณาในลำดับถัดไป'),
backgroundColor: AppTheme.success,
),
);
} else {
setState(() => _isApplying = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(res['message'] ?? 'เกิดข้อผิดพลาดในการสมัครงาน'),
backgroundColor: AppTheme.error,
),
);
}
}
ปุ่มแชทต้องใช้ user_id ของบริษัท แต่ประกาศงานรู้แค่ employer_id หน้านี้จึงเรียก getEmployerUserId() แปลงก่อน (_fetchEmployerInfo บรรทัด 71–89)
SeekerApplicationsTab — งานของฉัน (สถานะใบสมัคร)
lib/presentation/pages/seeker/tabs/seeker_applications_tab.dart หน้าจอ
แสดงทุกใบสมัครพร้อมสถานะ นัดสัมภาษณ์ และวันเริ่มงาน ถามเซิร์ฟเวอร์ทุก 20 วินาที และหยุดถามเมื่อแอปถูกพับไปเบื้องหลัง (ฟังวงจรชีวิตแอปผ่าน didChangeAppLifecycleState) เพื่อประหยัดแบตเตอรี่และเน็ต
ทุกครั้งที่โหลด จะเทียบสถานะใหม่กับสถานะที่จำไว้ ถ้าเปลี่ยนจะแจ้ง “สถานะอัปเดต: …” ทันที
lib/presentation/pages/seeker/tabs/seeker_applications_tab.dart บรรทัด 45–95 45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95 // หยุด/เริ่มโพลใหม่ตามสถานะแอป จะได้ไม่ยิง request ทิ้งขว้างตอนแอปอยู่เบื้องหลัง
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_startPolling();
_fetchApplications(silent: true);
} else {
_pollTimer?.cancel();
}
}
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(_pollInterval, (_) => _fetchApplications(silent: true));
}
// silent = true: รีเฟรชเบื้องหลังแบบเงียบๆ (ใช้กับ polling) ไม่โชว์ spinner เต็มจอ
Future<void> _fetchApplications({bool silent = false}) async {
if (!silent) setState(() => _isLoading = true);
final seekerId = SessionService.instance.seekerId;
final list = await ApiService().getApplicationsBySeeker(seekerId);
if (!mounted) return;
// เทียบสถานะเก่ากับใหม่ เพื่อแจ้งเตือนผู้ใช้เมื่อมีอะไรเปลี่ยน (สไตล์ real-time)
final changedTitles = <String>[];
for (final app in list) {
final prevStatus = _lastKnownStatuses[app.applicationId];
if (prevStatus != null && prevStatus != app.status) {
changedTitles.add(app.jobTitle ?? 'ตำแหน่งงาน');
}
}
_lastKnownStatuses = {for (final a in list) a.applicationId: a.status};
setState(() {
_applications = list;
_isLoading = false;
});
if (changedTitles.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('สถานะอัปเดต: ${changedTitles.join(", ")}'),
backgroundColor: AppTheme.primary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
}
เมื่อได้รับการคัดเลือกแล้ว ผู้หางานกด “ตอบรับ” หรือ “ปฏิเสธ” งานได้ ซึ่งส่งสถานะ ตอบรับงานแล้ว / ปฏิเสธงาน ไปที่ update_application.php ตัวเดียวกับที่ผู้ว่าจ้างใช้ (backend แยกสิทธิ์ให้เอง)
lib/presentation/pages/seeker/tabs/seeker_applications_tab.dart บรรทัด 151–184 151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184 Future<void> _respondToOffer(ApplicationModel app, bool accept) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(accept ? 'ยืนยันตอบรับงาน' : 'ยืนยันปฏิเสธงาน'),
content: Text(
accept
? 'คุณต้องการตอบรับตำแหน่ง "${app.jobTitle ?? ''}" ใช่หรือไม่?'
: 'คุณต้องการปฏิเสธตำแหน่ง "${app.jobTitle ?? ''}" ใช่หรือไม่? การตัดสินใจนี้ไม่สามารถย้อนกลับได้',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('ยกเลิก', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: accept ? AppTheme.success : Colors.red,
foregroundColor: Colors.white,
),
child: Text(accept ? 'ตอบรับ' : 'ปฏิเสธ'),
),
],
),
);
if (confirm != true) return;
if (!mounted) return;
setState(() => _isLoading = true);
final res = await ApiService().updateApplicationStatus(
app.applicationId,
accept ? _acceptedStatus : _rejectedStatus,
SeekerNotificationsTab — แจ้งเตือนของผู้หางาน
lib/presentation/pages/seeker/tabs/seeker_notifications_tab.dart หน้าจอ
ใช้ type ของแจ้งเตือนตัดสินว่าแตะแล้วไปไหน: recommend_job_<id> → เปิดรายละเอียดงานนั้น, application_<id> → กลับไปแท็บ “งานของฉัน”
การทำเครื่องหมายว่าอ่านแล้วเป็นแบบมองโลกในแง่ดี (optimistic update) : เปลี่ยนบนจอทันทีโดยไม่รอเซิร์ฟเวอร์ ถ้าบันทึกพลาดค่อยคืนค่าเดิม ผู้ใช้จึงรู้สึกว่าแอปเร็ว
lib/presentation/pages/seeker/tabs/seeker_notifications_tab.dart บรรทัด 43–76 43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76 // แจ้งเตือนงานแนะนำ (recommend_job_<job_id>) -> เปิดหน้ารายละเอียดงานนั้น
int? _recommendedJobId(String type) =>
type.startsWith('recommend_job_') ? int.tryParse(type.substring('recommend_job_'.length)) : null;
// แจ้งเตือนเรื่องใบสมัคร (application_<job_id>: นัดสัมภาษณ์ / ผลการพิจารณา) -> ไปแท็บ "งานของฉัน"
bool _isApplicationUpdate(String type) => type.startsWith('application_');
bool _hasDestination(String type) => _recommendedJobId(type) != null || _isApplicationUpdate(type);
IconData _getIconForType(NotificationModel n) => notificationVisual(n).icon;
Color _getColorForType(NotificationModel n) => notificationVisual(n).color;
// แตะแจ้งเตือน -> ทำเครื่องหมายว่าอ่านแล้ว (แจ้งเตือนยังอยู่ในรายการแต่จางลง) แล้วพาไปหน้าที่เกี่ยวข้อง
Future<void> _handleTap(int index) async {
final item = _notifications[index];
if (!item.isRead) {
setState(() => _notifications[index] = _asRead(item));
ApiService().markNotificationAsRead(item.notiId).then((res) {
if (res['success'] != true && mounted) {
// บันทึกไม่สำเร็จ -> คืนสถานะยังไม่อ่าน
setState(() => _notifications[index] = item);
}
});
}
final jobId = _recommendedJobId(item.type);
if (jobId != null) {
context.push('/seeker-home/job/$jobId');
} else if (_isApplicationUpdate(item.type)) {
context.pop(kOpenApplicationsTab);
}
}
SeekerProfileTab — โปรไฟล์ผู้หางาน
lib/presentation/pages/seeker/tabs/seeker_profile_tab.dart หน้าจอ
มี 2 โหมดสลับด้วยปุ่มดินสอ (_isEditing): โหมดดูแสดงข้อมูลและทักษะเป็นชิป โหมดแก้ไขเปิดช่องกรอกทั้งหมด เปลี่ยนรูป อัปโหลด Resume และเลือกทักษะได้
lib/presentation/pages/seeker/tabs/seeker_profile_tab.dart บรรทัด 289–321 เลือก PDF: ตรวจนามสกุลและขนาด (ไม่เกิน 5 MB) ฝั่งแอปก่อน ลดการส่งไฟล์ที่ใช้ไม่ได้ไปเซิร์ฟเวอร์ 289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321 Future<void> _pickAndUploadDocument(String type) async {
if (!_isEditing) return;
final label = type == 'portfolio' ? 'Portfolio' : 'Resume';
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: true,
);
if (result == null || !mounted) return;
final file = result.files.single;
String? error;
if (!file.name.toLowerCase().endsWith('.pdf')) {
error = 'กรุณาเลือกไฟล์ PDF เท่านั้น';
} else if (file.size > _maxDocumentBytes) {
error = 'ไฟล์มีขนาดเกิน 5 MB';
}
if (error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: AppTheme.error),
);
return;
}
setState(() => _isSaving = true);
final res = await ApiService().uploadResume(
userId: SessionService.instance.userId,
filePath: file.path,
bytes: file.bytes,
fileName: file.name,
type: type,
);
เมื่ออัปโหลด Resume สำเร็จ backend จะอ่านข้อความใน PDF หาทักษะที่ตรงกับคลังคำ แล้วส่งรายการคำที่เจอกลับมา แอปจึงแจ้ง “พบทักษะในเอกสาร: …” (ดูpdf_text.php )
lib/presentation/pages/seeker/tabs/seeker_profile_tab.dart บรรทัด 420–427 บัญชีเก่าที่ไม่ได้บันทึกหมวดความสนใจ: เดาหมวดจากทักษะที่มี 420
421
422
423
424
425
426
427 String? get _effectiveCategory {
if (_jobCategories.containsKey(_selectedCategory)) return _selectedCategory;
final skills = _skillsController.text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
for (final entry in _jobCategories.entries) {
if (entry.value.any(skills.contains)) return entry.key;
}
return null;
}
lib/presentation/pages/seeker/tabs/seeker_profile_tab.dart บรรทัด 547–571 บันทึก: หมวดความสนใจถูกเก็บรวมไว้ต้นข้อความ profile_detail และใช้ที่อยู่ใหม่เฉพาะเมื่อกรอกครบ 547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571 String finalProfileDetail = _experienceController.text.trim();
if (_selectedCategory != null) {
finalProfileDetail = "ความสนใจ: $_selectedCategory\n\n$finalProfileDetail";
}
// ยังไม่เคยมีที่อยู่ ช่องกรอกจะเปิดอยู่แล้วโดยไม่ต้องกด "เปลี่ยนที่อยู่"
final changingAddress = _isChangingAddress || _addressController.text.trim().isEmpty;
final address = changingAddress && _addressInput.isComplete
? _addressInput.fullAddress
: _addressController.text.trim();
final res = await ApiService().updateSeekerProfile(
userId: userId,
name: _nameController.text.trim(),
phone: _phoneController.text.trim(),
skills: _skillsController.text.trim(),
profileDetail: finalProfileDetail.trim(),
address: address,
age: int.tryParse(_ageController.text.trim()) ?? 0,
idCard: _idCardController.text.trim(),
dateOfBirth: _formatDateForApi(_dobController.text.trim()),
education: _selectedEducation ?? '',
gender: _selectedGender ?? 'ไม่ระบุ',
experience: _experienceController.text.trim(),
);
SeekerChatTab และ SeekerCompanyDetailPage
lib/presentation/pages/seeker/… หน้าจอ
SeekerChatTab รายการห้องแชทแบบเดียวกับฝั่งผู้ว่าจ้าง แต่รีเฟรชเองทุก 3 วินาที
SeekerCompanyDetailPage หน้าบริษัท: ภาพปก โลโก้ รายละเอียด ที่อยู่ (กดเปิด Google Maps) โทร/อีเมล (เปิดแอปโทรศัพท์/อีเมลด้วย url_launcher) แชท และรายการงานที่เปิดรับของบริษัทนั้น
(get_employer_jobs.php คืนเฉพาะงานที่เปิดรับเมื่อผู้เรียกไม่ใช่เจ้าของบริษัท)
หน้าที่ใช้ร่วมกันทั้งสองบทบาท
ChatRoomPage — ห้องแชท
lib/presentation/pages/chat/chat_room_page.dart หน้าจอ
รับ receiverId (user_id ของอีกฝ่าย) และชื่อผ่าน URL ดึงข้อความทุก 3 วินาที (การเปิดห้องแชทถือว่าอ่านข้อความแล้ว backend จะตั้ง is_read ให้)
ส่งข้อความแล้วโหลดใหม่และเลื่อนลงล่างสุด ข้อความที่มีลิงก์ (เช่น ลิงก์ Zoom ที่ระบบส่งตอนนัดสัมภาษณ์) แสดงผ่าน LinkifyText ซึ่งแตะเปิดลิงก์ได้
lib/presentation/pages/chat/chat_room_page.dart บรรทัด 32–86 32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86 @override
void initState() {
super.initState();
_fetchMessages();
// Polling ข้อความใหม่ทุก 3 วินาที
_timer = Timer.periodic(const Duration(seconds: 3), (_) => _fetchMessages(silent: true));
}
@override
void dispose() {
_timer?.cancel();
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _fetchMessages({bool silent = false}) async {
if (!silent) setState(() => _isLoading = true);
final senderId = SessionService.instance.userId;
if (senderId <= 0) {
if (!silent && mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ไม่พบข้อมูลผู้ใช้งาน กรุณาล็อกอินใหม่'),
backgroundColor: AppTheme.error,
),
);
}
return;
}
final msgs = await ApiService().getMessages(senderId, widget.receiverId);
if (!mounted) return;
setState(() {
_messages = msgs;
if (!silent) _isLoading = false;
});
if (_messages.isNotEmpty && !silent) {
_scrollToBottom();
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
_scrollToBottom() ใช้ addPostFrameCallback เพื่อรอให้ Flutter วาดข้อความใหม่เสร็จก่อน ค่อยเลื่อนไปท้ายสุด ถ้าเลื่อนทันทีจะยังไม่รู้ความสูงจริงของรายการ
PdfViewerPage และ NotFoundPage
lib/presentation/pages/pdf_viewer_page.dart · not_found_page.dart หน้าจอ
PdfViewerPage เปิด PDF จาก URL ด้วย SfPdfViewer.network() ใช้ทั้งตอนผู้หางานดู Resume ตัวเอง และตอนผู้ว่าจ้างดู Resume ผู้สมัคร (เปิดด้วย Navigator.push แบบเดิมของ Flutter ไม่ผ่าน go_router)
ส่วน NotFoundPage คือหน้า “ไม่พบหน้านี้” ที่ router แสดงเมื่อ URL ผิด
match_badge.dart — ป้ายคะแนนการจับคู่
lib/presentation/widgets/match_badge.dart หน้าจอ
ฟังก์ชัน matchLevelColor() แปลงระดับ (very_high / good / medium / low) เป็นสี และ MatchBadge แสดงป้าย เช่น “85% · เหมาะสมสูงมาก” ใช้ทั้งในการ์ดงาน แผนที่ และหน้ารายละเอียด
lib/presentation/widgets/match_badge.dart บรรทัด 5–16 5
6
7
8
9
10
11
12
13
14
15
16 Color matchLevelColor(String level) {
switch (level) {
case 'very_high':
return const Color(0xFF047857);
case 'good':
return AppTheme.primary;
case 'medium':
return const Color(0xFFB45309);
default:
return AppTheme.textSecondary;
}
}
hire_dialog.dart — หน้าต่างรับเข้าทำงาน
lib/presentation/widgets/hire_dialog.dart หน้าจอ
ฟังก์ชัน showHireDialog() เปิดหน้าต่างให้เลือกวันเริ่มงาน (บังคับ) และข้อความถึงผู้สมัคร (ไม่บังคับ) แล้วคืนค่าเป็น HireDetails หรือ null ถ้ากดยกเลิก
ใช้ StatefulBuilder เพื่อให้หน้าต่างอัปเดตตัวเอง (เช่น โชว์ error) ได้ทั้งที่ไม่ใช่ StatefulWidget
lib/presentation/widgets/hire_dialog.dart บรรทัด 79–88 79
80
81
82
83
84
85
86
87
88 onPressed: () {
if (startDate == null) {
setDialogState(() => error = 'กรุณาเลือกวันเริ่มงาน');
return;
}
Navigator.pop(
dialogContext,
HireDetails(startDate!.toIso8601String().substring(0, 10), messageController.text.trim()),
);
},
notification_swipe_delete.dart — ไอคอนแจ้งเตือนและการปัดลบ
lib/presentation/widgets/notification_swipe_delete.dart หน้าจอ
มี 3 ฟังก์ชันที่ทั้งสองบทบาทใช้ร่วมกัน: notificationVisual() เลือกไอคอน/สีจาก type และหัวข้อ (คืนค่าเป็น record ของ Dart 3 คือ ({IconData icon, Color color})),
swipeToDeleteNotification() ห่อการ์ดด้วย Dismissible ให้ปัดลบได้ และ deleteNotificationWithUndo() ที่ลบจากจอทันทีแต่รอ 4 วินาที ก่อนลบจริงที่เซิร์ฟเวอร์ เผื่อผู้ใช้กด “เลิกทำ”
lib/presentation/widgets/notification_swipe_delete.dart บรรทัด 58–86 58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86 void deleteNotificationWithUndo(
BuildContext context, {
required NotificationModel item,
required VoidCallback onRemove,
required VoidCallback onRestore,
}) {
onRemove();
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger
.showSnackBar(
SnackBar(
content: const Text('ลบการแจ้งเตือนแล้ว'),
duration: const Duration(seconds: 4),
action: SnackBarAction(label: 'เลิกทำ', textColor: AppTheme.primaryLight, onPressed: onRestore),
),
)
.closed
.then((reason) async {
if (reason == SnackBarClosedReason.action) return;
final res = await ApiService().deleteNotification(item.notiId);
if (res['success'] != true) {
onRestore();
messenger.showSnackBar(
const SnackBar(content: Text('ลบการแจ้งเตือนไม่สำเร็จ ลองใหม่อีกครั้ง'), backgroundColor: AppTheme.error),
);
}
});
}
thai_address_input.dart — ช่องกรอกที่อยู่ไทย
lib/presentation/widgets/thai_address_input.dart หน้าจอ
ช่องบ้านเลขที่/ถนน + ตัวเลือกจังหวัด อำเภอ ตำบล (แพ็กเกจ thai_provinces_flutter) มีคลาส Controller แยกให้หน้าที่ใช้งานอ่านค่า isComplete และ fullAddress
ในหน้าแก้ไขโปรไฟล์จะแสดงที่อยู่เดิมก่อน และเปิดช่องกรอกเมื่อกด “เปลี่ยนที่อยู่”
lib/presentation/widgets/thai_address_input.dart บรรทัด 6–23 6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 class ThaiAddressInputController {
final TextEditingController detail = TextEditingController();
final ThaiAddressController thai = ThaiAddressController();
bool get isComplete =>
detail.text.trim().isNotEmpty &&
thai.province != null &&
thai.district != null &&
thai.subdistrict != null;
/// ที่อยู่รวมเป็นข้อความเดียว เช่น "99/1 ต.ในเมือง อ.เมืองขอนแก่น จ.ขอนแก่น 40000"
/// (backend ใช้รูปแบบ ต./อ./จ. นี้หาพิกัดของตำบล)
String get fullAddress {
final sub = thai.subdistrict;
return '${detail.text.trim()} ต.${sub?.nameTh ?? ''} อ.${thai.district?.nameTh ?? ''} '
'จ.${thai.province?.nameTh ?? ''} ${sub?.postcode ?? ''}'
.trim();
}
7. API ฝั่งเซิร์ฟเวอร์ (PHP) โครงของ endpoint, ไฟล์กลาง, รายการ endpoint ทั้งหมด และไฟล์สำคัญทีละไฟล์
API อยู่ที่ C:\xampp\htdocs\jobmatch_api\ เมื่อเปิด XAMPP (Apache + MySQL) แอปจะเรียกได้ที่ http://<IP เครื่อง>/jobmatch_api/ชื่อไฟล์.php
ลองเปิด http://localhost/jobmatch_api/get_keywords.php ในเบราว์เซอร์ดูได้เลย จะเห็น JSON ที่แอปได้รับจริง
โครงสร้างของ endpoint 1 ไฟล์ (ตัวอย่าง apply_job.php)
เกือบทุก endpoint เรียงขั้นตอนแบบเดียวกัน อ่านไฟล์นี้ให้เข้าใจแล้วไฟล์อื่นจะง่ายขึ้นมาก
require ไฟล์กลาง เชื่อมฐานข้อมูล ($pdo), ฟังก์ชันตรวจสิทธิ์ และฟังก์ชันอื่นที่ต้องใช้
ตรวจ HTTP method ถ้าไม่ใช่ที่กำหนดตอบ 405
อ่านข้อมูลขาเข้า ถอด JSON จาก body ถ้าว่างตอบ 400
ระบุตัวและตรวจสิทธิ์ ใช้รหัสจากผู้ที่ล็อกอินอยู่ ไม่เชื่อรหัสที่ส่งมาในข้อมูล (กันสมัครงานแทนคนอื่น)
ตรวจเงื่อนไขทางธุรกิจ งานมีอยู่จริงไหม เปิดรับอยู่ไหม สมัครซ้ำไหม
ทำงานกับฐานข้อมูล INSERT/UPDATE และงานข้างเคียง เช่น สร้างแจ้งเตือน
ตอบกลับ ด้วยรหัสสถานะ + JSON ถ้า SQL ผิดพลาด (PDOException) เขียนรายละเอียดลง error log แล้วตอบข้อความกลาง ๆ พร้อม 500
jobmatch_api/apply_job.php บรรทัด 14–40 ขั้น 1–4: require, ตรวจ method, อ่าน JSON, ใช้ seeker_id จากผู้ที่ล็อกอิน 14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 require_once 'db_connect.php';
require_once 'api_auth.php';
require_once 'matching_service.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method Not Allowed']);
exit();
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ไม่ได้รับข้อมูล']);
exit();
}
$jobId = (int) ($input['job_id'] ?? 0);
$seekerId = (int) ($input['seeker_id'] ?? 0);
// ใช้ seeker_id จาก token เสมอ ไม่เชื่อค่าที่ส่งมา (กันสมัครงานแทนคนอื่น)
$seekerId = requireSeeker($pdo)['seeker_id'];
if ($jobId === 0 || $seekerId === 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'กรุณาระบุ job_id และ seeker_id']);
exit();
}
jobmatch_api/apply_job.php บรรทัด 42–107 ขั้น 5–7: ตรวจงาน, กันสมัครซ้ำ, บันทึก, แจ้งเตือนบริษัท, ตอบกลับ 42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107 try {
// ตรวจสอบว่างานนี้มีอยู่จริง
$checkJob = $pdo->prepare('SELECT employer_id, job_title, status FROM job_posting WHERE job_id = ?');
$checkJob->execute([$jobId]);
$jobPosting = $checkJob->fetch();
if (!$jobPosting) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'ไม่พบตำแหน่งงานนี้']);
exit();
}
if (!isJobVisible($jobPosting['status'])) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'ตำแหน่งงานนี้ยังไม่เปิดรับสมัคร หรือปิดรับสมัครแล้ว']);
exit();
}
$employerId = $jobPosting['employer_id'];
$jobTitle = $jobPosting['job_title'];
// ป้องกันสมัครงานเดิมซ้ำ
$checkDup = $pdo->prepare('SELECT COUNT(*) FROM application WHERE job_id = ? AND seeker_id = ?');
$checkDup->execute([$jobId, $seekerId]);
if ($checkDup->fetchColumn() > 0) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'คุณสมัครงานนี้ไปแล้ว']);
exit();
}
$stmt = $pdo->prepare('
INSERT INTO application (job_id, seeker_id, apply_date, status)
VALUES (?, ?, CURDATE(), ?)
');
$stmt->execute([$jobId, $seekerId, 'รอดำเนินการ']);
$applicationId = (int) $pdo->lastInsertId();
// ดึงข้อมูลชื่อผู้สมัคร
$getSeeker = $pdo->prepare('SELECT fullname FROM job_seeker WHERE seeker_id = ?');
$getSeeker->execute([$seekerId]);
$seekerRow = $getSeeker->fetch();
$seekerName = $seekerRow ? $seekerRow['fullname'] : 'มีผู้';
// ดึง user_id ของ employer เพื่อส่งแจ้งเตือน
$getEmp = $pdo->prepare('SELECT user_id FROM employer WHERE employer_id = ?');
$getEmp->execute([$employerId]);
$empUser = $getEmp->fetch();
if ($empUser) {
$empUserId = $empUser['user_id'];
$notiTitle = "มีผู้สมัครงานใหม่";
$notiContent = $seekerName . " ได้สมัครงานในตำแหน่ง " . $jobTitle;
$notiType = 'application_' . $jobId;
$insertNoti = $pdo->prepare('INSERT INTO notification (user_id, title, content, type) VALUES (?, ?, ?, ?)');
$insertNoti->execute([$empUserId, $notiTitle, $notiContent, $notiType]);
}
http_response_code(201);
echo json_encode([
'success' => true,
'message' => 'สมัครงานสำเร็จ',
'application_id' => $applicationId,
]);
} catch (PDOException $e) {
http_response_code(500);
error_log('apply_job: ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'เกิดข้อผิดพลาดของระบบ กรุณาลองใหม่อีกครั้ง'], JSON_UNESCAPED_UNICODE);
}
ทำไมเช็กสมัครซ้ำแล้ว ฐานข้อมูลยังมี UNIQUE KEY อีกชั้น
ตาราง application มี UNIQUE KEY (job_id, seeker_id) ถ้าผู้ใช้กดปุ่มสมัครรัว ๆ จนคำขอ 2 อันมาถึงพร้อมกัน การเช็กด้วย SELECT อาจผ่านทั้งคู่ แต่ฐานข้อมูลจะไม่ยอมให้บันทึกแถวซ้ำ จึงเป็นการป้องกันชั้นสุดท้าย
ไฟล์กลางที่ทุก endpoint ใช้
db_connect.php — เชื่อมฐานข้อมูลและตั้งค่า header
jobmatch_api/db_connect.php API
ปิดการแสดง error บนหน้าจอ (display_errors = 0) เพราะ PHP จะพิมพ์คำเตือนเป็น HTML ปนเข้าไปใน JSON ทำให้แอปถอดข้อมูลไม่ได้ แต่ยังเขียนลง log ไว้ตรวจ
header บอกว่าคำตอบเป็น JSON (UTF-8) และเปิด CORS ให้เวอร์ชันเว็บเรียกได้ คำขอ OPTIONS คือคำขอ “ถามก่อน” (preflight) ที่เบราว์เซอร์ส่งมาก่อนคำขอจริง ตอบ 200 แล้วจบเลย
PDO options : ERRMODE_EXCEPTION = SQL ผิดให้โยน exception (ใช้ try/catch จับได้), FETCH_ASSOC = ผลลัพธ์เป็น array ตามชื่อคอลัมน์, EMULATE_PREPARES => false = ใช้ prepared statement จริงของ MySQL
jobmatch_api/db_connect.php บรรทัด 7–38 7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 // ปิดการแสดง error เป็น HTML (ป้องกัน <br /> ปนใน JSON response)
ini_set('display_errors', '0');
error_reporting(E_ALL);
ini_set('log_errors', '1');
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-User-Id');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
$host = 'localhost';
$db = 'job_matching_system';
$user = 'root';
$pass = '';
try {
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
error_log('db_connect: ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'เกิดข้อผิดพลาดของระบบ กรุณาลองใหม่อีกครั้ง'], JSON_UNESCAPED_UNICODE);
exit();
}
api_auth.php — รู้ว่าใครเรียก และตรวจความเป็นเจ้าของข้อมูล
jobmatch_api/api_auth.php API
แอปแนบ header X-User-Id มากับทุกคำขอ ไฟล์นี้อ่านค่านั้น ($_SERVER['HTTP_X_USER_ID']) แล้วค้นในฐานข้อมูลว่าเป็นผู้ใช้คนไหน บทบาทอะไร มี seeker_id/employer_id อะไร และบัญชียัง active อยู่ไหม
ผลลัพธ์ถูกเก็บในตัวแปร static จึงค้นฐานข้อมูลแค่ครั้งเดียวต่อคำขอ แม้จะเรียก currentUser() หลายครั้ง
jobmatch_api/api_auth.php บรรทัด 8–59 8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59 /** user_id ของผู้ใช้ที่ล็อกอินอยู่ แอปส่งมาใน header "X-User-Id" ทุกคำขอ (0 = ไม่ได้ล็อกอิน) */
function requestUserId(): int {
$value = $_SERVER['HTTP_X_USER_ID'] ?? '';
return ctype_digit(trim((string) $value)) ? (int) $value : 0;
}
function authFail(int $code, string $message): void {
http_response_code($code);
echo json_encode(['success' => false, 'message' => $message, 'auth_error' => $code === 401], JSON_UNESCAPED_UNICODE);
exit;
}
/**
* คืนข้อมูลผู้ใช้ที่ล็อกอินอยู่: ['user_id', 'role', 'seeker_id' | null, 'employer_id' | null]
* $required = false ใช้กับ endpoint ที่ดูได้โดยไม่ต้องล็อกอิน (คืน null ถ้าไม่ได้ส่ง user_id)
*/
function currentUser(PDO $pdo, bool $required = true): ?array {
static $user = false;
if ($user !== false) return $user;
$userId = requestUserId();
if ($userId === 0) {
if ($required) authFail(401, 'กรุณาเข้าสู่ระบบใหม่อีกครั้ง');
return $user = null;
}
$stmt = $pdo->prepare('
SELECT m.user_id, m.role, m.status, js.seeker_id, e.employer_id
FROM member m
LEFT JOIN job_seeker js ON js.user_id = m.user_id
LEFT JOIN employer e ON e.user_id = m.user_id
WHERE m.user_id = ?
LIMIT 1
');
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row) {
if ($required) authFail(401, 'ไม่พบบัญชีผู้ใช้ กรุณาเข้าสู่ระบบใหม่อีกครั้ง');
return $user = null;
}
if (($row['status'] ?? 'active') !== 'active') {
authFail(403, 'บัญชีของคุณถูกระงับการใช้งาน');
}
return $user = [
'user_id' => (int) $row['user_id'],
'role' => $row['role'],
'seeker_id' => $row['seeker_id'] !== null ? (int) $row['seeker_id'] : null,
'employer_id' => $row['employer_id'] !== null ? (int) $row['employer_id'] : null,
];
}
ฟังก์ชันช่วยที่ endpoint ต่าง ๆ เรียกใช้:
jobmatch_api/api_auth.php บรรทัด 61–94 61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94 function requireSeeker(PDO $pdo): array {
$user = currentUser($pdo);
if ($user['seeker_id'] === null) authFail(403, 'เฉพาะผู้หางานเท่านั้น');
return $user;
}
function requireEmployer(PDO $pdo): array {
$user = currentUser($pdo);
if ($user['employer_id'] === null) authFail(403, 'เฉพาะผู้ว่าจ้างเท่านั้น');
return $user;
}
/** ปฏิเสธคำขอถ้าเงื่อนไขความเป็นเจ้าของไม่ผ่าน */
function ensureOwner(bool $isOwner): void {
if (!$isOwner) authFail(403, 'คุณไม่มีสิทธิ์เข้าถึงข้อมูลนี้');
}
function employerOwnsJob(PDO $pdo, int $employerId, int $jobId): bool {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM job_posting WHERE job_id = ? AND employer_id = ?');
$stmt->execute([$jobId, $employerId]);
return $stmt->fetchColumn() > 0;
}
/** ข้อมูลเจ้าของใบสมัคร: seeker_id ของผู้สมัคร และ employer_id ของงาน */
function applicationOwners(PDO $pdo, int $applicationId): ?array {
$stmt = $pdo->prepare('
SELECT a.seeker_id, j.employer_id
FROM application a JOIN job_posting j ON a.job_id = j.job_id
WHERE a.application_id = ?
');
$stmt->execute([$applicationId]);
$row = $stmt->fetch();
return $row ? ['seeker_id' => (int) $row['seeker_id'], 'employer_id' => (int) $row['employer_id']] : null;
}
ฟังก์ชัน ทำอะไร ตัวอย่างที่ใช้
currentUser($pdo, $required)คืนข้อมูลผู้เรียก ถ้าบังคับแต่ไม่มี → 401, ถูกระงับ → 403 get_messages, send_message
requireSeeker / requireEmployerต้องเป็นบทบาทนั้นเท่านั้น ไม่ใช่ → 403 apply_job / post_job
ensureOwner($เงื่อนไข)เงื่อนไขเป็นเท็จ → 403 “ไม่มีสิทธิ์เข้าถึงข้อมูลนี้” get_applications, update_application
employerOwnsJobงานนี้เป็นของบริษัทนี้ไหม update_job, delete_job
applicationOwnersใบสมัครนี้เป็นของผู้หางานคนไหน / งานของบริษัทไหน update_application, post_interview, cancel_application
config.php และ .htaccess — ค่าลับและการปิดไฟล์ภายใน
jobmatch_api/config.php · .htaccess API
config.php คืนค่าเป็น array ของค่าลับ (เช่น API key ของ Gemini และชื่อโมเดล) ไฟล์อื่นอ่านด้วย $config = require 'config.php';
ไฟล์นี้ห้ามส่งขึ้น git และห้ามนำไปแสดงในรายงาน (คู่มือนี้จึงไม่แสดงเนื้อหาไฟล์) ส่วน config.example.php ใน backend/ เป็นแม่แบบที่ไม่มีค่าจริง
.htaccess สั่ง Apache ไม่ให้ใครเรียกไฟล์ค่าลับและไฟล์ฟังก์ชันภายในผ่านเว็บโดยตรง (ไฟล์เหล่านี้มีไว้ให้ require เท่านั้น) และปิดการแสดงรายชื่อไฟล์ในโฟลเดอร์
backend/.htaccess บรรทัด 1–5 1
2
3
4
5 # ไฟล์ตั้งค่าและไฟล์ฟังก์ชันภายใน ห้ามเรียกตรงจากเว็บ
<FilesMatch "^(config|db_connect|api_auth|matching_service|skill_matcher|pdf_text|ai_service|geocode_service)\.php$">
Require all denied
</FilesMatch>
Options -Indexes
รายการ endpoint ทั้งหมด
“สิทธิ์” คือใครเรียกได้ (ตรวจผ่าน api_auth.php) และ “ผลข้างเคียง” คืองานอื่นที่เกิดขึ้นนอกจากหน้าที่หลัก
ไฟล์ Method สิทธิ์ หน้าที่หลัก ผลข้างเคียง
บัญชีผู้ใช้และโปรไฟล์
login.phpPOST ทุกคน ตรวจอีเมล/รหัสผ่าน คืนข้อมูลผู้ใช้ + ข้อมูลบริษัทหรือผู้หางาน แปลงรหัสแบบเก่าเป็น bcrypt, สร้างโปรไฟล์ผู้หางานเปล่าถ้ายังไม่มี
register_seeker.phpPOST ทุกคน INSERT member + job_seeker ใน transaction เดียว หาพิกัดจากที่อยู่, คำนวณการจับคู่ใหม่ทั้งหมด
register_employer.phpPOST ทุกคน INSERT member + employer หาพิกัดจากที่อยู่บริษัท
reset_password.phpPOST ทุกคน อีเมล + เบอร์โทรตรงกัน → ตั้งรหัสผ่านใหม่ —
get_member.phpGET ล็อกอิน ข้อมูลผู้ใช้ตาม user_id ซ่อนวันเกิดและคีย์เวิร์ด Resume ถ้าไม่ใช่เจ้าของบัญชี
update_seeker.phpPOST/PUT ผู้หางาน (ตัวเอง) UPDATE job_seeker (+ ชื่อใน member) AI แปลงทักษะ, หาพิกัดใหม่ถ้าที่อยู่เปลี่ยน, คำนวณจับคู่ใหม่เมื่อมีทักษะ
update_employer.phpPOST/PUT ผู้ว่าจ้าง (ตัวเอง) UPDATE employer หาพิกัดใหม่ถ้าที่อยู่เปลี่ยน
upload_profile_image.phpPOST (ไฟล์) ล็อกอิน เก็บรูปใน uploads/ แล้วบันทึก path เป็นรูปโปรไฟล์ / โลโก้ / ภาพปก —
upload_resume.phpPOST (ไฟล์) ผู้หางาน เก็บ PDF ใน uploads/resumes/ บันทึก resume_url อ่านคำสำคัญจาก PDF, ลบไฟล์เก่า, คำนวณจับคู่ใหม่
คลังคำสำคัญ
get_keywords.phpGET ทุกคน รายการคำสำคัญเรียงตามหมวด (“หมวดใหม่” อยู่ท้ายสุด) —
add_keyword.phpPOST ล็อกอิน เพิ่มคำสำคัญใหม่ถ้ายังไม่มี —
ประกาศงาน
get_jobs.phpGET ทุกคน งานที่เปิดรับ + คะแนน ระดับ ระยะทาง ทักษะที่ตรง/ขาด (มีคะแนนเมื่อล็อกอินเป็นผู้หางาน) —
get_employer_jobs.phpGET ล็อกอิน งานของบริษัท + จำนวนผู้สมัคร เจ้าของเห็นทุกสถานะ คนอื่นเห็นเฉพาะที่เปิดรับ
post_job.phpPOST ผู้ว่าจ้าง INSERT job_posting (สถานะ approved แสดงทันที) AI แปลงทักษะ, แปลงพิกัด↔ชื่อสถานที่, คำนวณจับคู่, แจ้งเตือนงานแนะนำ
update_job.phpPUT เจ้าของงาน UPDATE job_posting AI แปลงทักษะ, คำนวณจับคู่ของงานนี้ใหม่
delete_job.phpPOST/DELETE เจ้าของงาน DELETE job_posting ใบสมัคร นัดสัมภาษณ์ และผลจับคู่ของงานถูกลบตาม (ON DELETE CASCADE)
get_employer_user_id.phpGET ล็อกอิน หา user_id, ชื่อบริษัท, เบอร์ จาก employer_id —
ใบสมัครและนัดสัมภาษณ์
apply_job.phpPOST ผู้หางาน INSERT application สถานะ “รอดำเนินการ” แจ้งเตือนบริษัท “มีผู้สมัครงานใหม่”
cancel_application.phpPOST/DELETE เจ้าของใบสมัคร DELETE application —
get_applications.phpGET เจ้าของข้อมูล 3 โหมด: ตามงาน / ตามบริษัท / ตามผู้หางาน คำนวณคะแนนสดและจัดอันดับผู้สมัคร
update_application.phpPUT/POST ผู้ว่าจ้างเจ้าของงาน หรือผู้หางาน (ตอบรับ/ปฏิเสธ) เปลี่ยนสถานะใบสมัคร (+ วันเริ่มงาน/ข้อความ) นับ hired_count ใหม่, ปิด/เปิดรับงานอัตโนมัติ, แจ้งเตือน, ส่งข้อความแชท
post_interview.phpPOST ผู้ว่าจ้างเจ้าของงาน สร้างหรือเลื่อนนัด + สถานะ “นัดสัมภาษณ์” แจ้งเตือน + ส่งรายละเอียดนัดเข้าแชท
แชทและแจ้งเตือน
get_messages.phpGET คู่สนทนา 2 โหมด: ข้อความในห้อง / รายการห้องแชท เปิดห้อง = ตั้งข้อความที่ส่งถึงเราเป็นอ่านแล้ว
send_message.phpPOST ล็อกอิน INSERT message (ผู้ส่ง = ผู้ล็อกอินเสมอ) —
get_notifications.phpGET เจ้าของ แจ้งเตือนของผู้ใช้ เรียงใหม่สุดก่อน —
mark_notification_read.phpPOST เจ้าของ ตั้ง Is_read = 1 —
delete_notification.phpPOST เจ้าของ ลบแจ้งเตือน —
รายละเอียด endpoint สำคัญ
login.php — เข้าสู่ระบบ
jobmatch_api/login.php API
ค้นผู้ใช้จากอีเมล → บล็อกบัญชีที่ไม่ active → ตรวจรหัสผ่าน จุดที่น่าสนใจคือรองรับข้อมูลเก่าที่เก็บรหัสเป็นข้อความธรรมดา: ถ้ารหัสใน DB ยังไม่ใช่ bcrypt จะเทียบตรง ๆ ด้วย hash_equals (กันการวัดเวลาเดารหัส) และถ้าถูก ก็แปลงเป็น bcrypt ทันที
jobmatch_api/login.php บรรทัด 45–63 45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63 $storedPassword = $user['password'];
$isBcryptHash = password_get_info($storedPassword)['algo'] !== null;
if ($isBcryptHash) {
$isValid = password_verify($password, $storedPassword);
} else {
$isValid = hash_equals($storedPassword, $password);
if ($isValid) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
$upd = $pdo->prepare('UPDATE member SET password = ? WHERE user_id = ?');
$upd->execute([$newHash, $user['user_id']]);
}
}
if (!$isValid) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'อีเมลหรือรหัสผ่านไม่ถูกต้อง']);
exit();
}
เมื่อผ่านแล้วจะลบรหัสผ่านออกจากข้อมูลก่อนส่งกลับ (unset($user['password'])) แล้วรวมข้อมูลบริษัทหรือผู้หางานเข้าไป ถ้าเป็นผู้หางานรุ่นเก่าที่ยังไม่มีแถวใน job_seeker จะสร้างให้เลย
jobmatch_api/login.php บรรทัด 75–101 75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101 if ($user['role'] === 'employer') {
$empStmt = $pdo->prepare('SELECT employer_id, company_name, company_detail, contact_phone, company_address FROM employer WHERE user_id = ? LIMIT 1');
$empStmt->execute([$user['user_id']]);
$emp = $empStmt->fetch(PDO::FETCH_ASSOC);
if ($emp) {
$emp['employer_id'] = (int) $emp['employer_id'];
$data = array_merge($data, $emp);
}
} elseif ($user['role'] === 'seeker') {
$seekerStmt = $pdo->prepare('SELECT seeker_id, fullname, age, date_of_birth, education, phone, skills, profile_detail, address FROM job_seeker WHERE user_id = ? LIMIT 1');
$seekerStmt->execute([$user['user_id']]);
$seeker = $seekerStmt->fetch(PDO::FETCH_ASSOC);
if (!$seeker) {
// บัญชีเก่าที่ยังไม่มีโปรไฟล์ผู้หางาน -> สร้างโปรไฟล์เปล่าให้ จะได้สมัครงานและกรอกทักษะได้
$pdo->prepare('INSERT INTO job_seeker (user_id, fullname, phone) VALUES (?, ?, ?)')
->execute([$user['user_id'], mb_substr($user['name'], 0, 50), mb_substr($user['phone'] ?? '', 0, 10)]);
$seekerStmt->execute([$user['user_id']]);
$seeker = $seekerStmt->fetch(PDO::FETCH_ASSOC);
}
if ($seeker) {
$seeker['seeker_id'] = (int) $seeker['seeker_id'];
if (isset($seeker['age'])) {
$seeker['age'] = (int) $seeker['age'];
}
$data = array_merge($data, $seeker);
}
}
register_seeker.php — สมัครสมาชิกผู้หางาน
jobmatch_api/register_seeker.php API
ตรวจข้อมูล (อีเมลถูกรูปแบบ, รหัส ≥ 6 ตัว, เบอร์ ≤ 10 หลัก, อีเมลไม่ซ้ำ) แล้วบันทึก 2 ตาราง ใน transaction เดียว : ถ้าบันทึกตารางที่สองพลาด rollBack() จะยกเลิกตารางแรกด้วย ไม่เกิดบัญชีครึ่ง ๆ กลาง ๆ
jobmatch_api/register_seeker.php บรรทัด 84–140 84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140 $passwordHash = password_hash($password, PASSWORD_DEFAULT);
try {
$pdo->beginTransaction();
// 1. บันทึกลงตาราง member
$stmt = $pdo->prepare('INSERT INTO member (name, email, password, role) VALUES (?, ?, ?, "seeker")');
$stmt->execute([$name, $email, $passwordHash]);
$userId = (int) $pdo->lastInsertId();
// 2. บันทึกลงตาราง job_seeker (ครบถ้วนทั้ง 12 ฟิลด์ตรงกับโครงสร้างตาราง)[cite: 1]
$stmt2 = $pdo->prepare('
INSERT INTO job_seeker
(user_id, fullname, age, date_of_birth, phone, gender, skills, profile_detail, education, experience, address, latitude, longitude)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt2->execute([
$userId,
$name,
$age,
$dob,
$phone,
$gender,
$skills,
$profileDetail,
$education,
$experience,
$address,
$latitude,
$longitude
]);
$seekerId = (int) $pdo->lastInsertId();
$pdo->commit();
// ผู้หางานใหม่ -> คำนวณการจับคู่กับงานทั้งหมด (ขอบเขต 1.3.4.6)
try {
recalculateAllMatching($pdo);
} catch (PDOException $e) {
error_log('register_seeker matching: ' . $e->getMessage());
}
http_response_code(201);
echo json_encode([
'success' => true,
'message' => 'สมัครสมาชิกผู้หางานสำเร็จ',
'user_id' => $userId,
'seeker_id' => $seekerId,
]);
} catch (PDOException $e) {
$pdo->rollBack();
http_response_code(500);
error_log('register_seeker: ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => 'เกิดข้อผิดพลาดของระบบ กรุณาลองใหม่อีกครั้ง'], JSON_UNESCAPED_UNICODE);
}
get_member.php — ข้อมูลผู้ใช้และความเป็นส่วนตัว
jobmatch_api/get_member.php API
ใครล็อกอินอยู่ก็ดูโปรไฟล์คนอื่นได้ (เช่น ผู้ว่าจ้างดูผู้สมัคร) แต่ถ้าไม่ใช่เจ้าของบัญชี จะตัดข้อมูลส่วนตัวออกก่อนส่ง และ normalize ค่า role (ตัดช่องว่าง/ตัวพิมพ์ใหญ่) กันข้อมูลใน DB เพี้ยนแล้วไม่ดึงตารางลูก
jobmatch_api/get_member.php บรรทัด 57–76 57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76 if ($normalizedRole === 'seeker') {
$seekerStmt = $pdo->prepare(
'SELECT seeker_id, fullname, age, date_of_birth, education, phone, gender, skills, experience, profile_detail, address,
resume_url, resume_keywords, updated_at, profile_image
FROM job_seeker WHERE user_id = ? LIMIT 1'
);
$seekerStmt->execute([$userId]);
$seeker = $seekerStmt->fetch();
if ($seeker) {
$seeker['seeker_id'] = (int) $seeker['seeker_id'];
// แปลง age ให้เป็น int ถ้ามีค่า
if (isset($seeker['age'])) {
$seeker['age'] = (int) $seeker['age'];
}
if (!$isSelf) {
// ข้อมูลส่วนบุคคล: เลขบัตรประชาชนและวันเกิดให้เห็นเฉพาะเจ้าของบัญชี
unset($seeker['date_of_birth'], $seeker['resume_keywords']);
}
$data = array_merge($data, $seeker);
}
update_application.php — เปลี่ยนสถานะใบสมัคร
jobmatch_api/update_application.php API
ไฟล์ที่ตรรกะซับซ้อนที่สุดใน API เพราะใช้ทั้งผู้ว่าจ้าง (ตัดสินผล) และผู้หางาน (ตอบรับ/ปฏิเสธข้อเสนอ) ขั้นแรกตรวจว่าใครมีสิทธิ์เปลี่ยนเป็นสถานะไหน และเปลี่ยนจากสถานะเดิมได้ไหม :
jobmatch_api/update_application.php บรรทัด 58–83 58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83 // ผู้หางานตอบรับ/ปฏิเสธได้เฉพาะใบสมัครของตัวเอง ส่วนสถานะอื่นผู้ว่าจ้างเจ้าของงานเป็นคนเปลี่ยน
$isSeekerAction = in_array($status, $offerResponseStatuses, true);
ensureOwner($isSeekerAction
? $owners['seeker_id'] === $authUser['seeker_id']
: $owners['employer_id'] === $authUser['employer_id']);
// ลำดับสถานะที่อนุญาต: รอดำเนินการ -> นัดสัมภาษณ์ -> ผ่าน/ไม่ผ่าน -> (ผู้หางาน) ตอบรับ/ปฏิเสธ
$currentStmt = $pdo->prepare('SELECT status FROM application WHERE application_id = ?');
$currentStmt->execute([$applicationId]);
$currentStatus = $currentStmt->fetchColumn();
if ($currentStatus === $status) {
// กดซ้ำ -> ไม่ต้องบันทึกและไม่ส่งแจ้งเตือนซ้ำ
echo json_encode(['success' => true, 'message' => 'สถานะนี้ถูกบันทึกไว้แล้ว', 'status' => $status]);
exit();
}
if ($isSeekerAction && $currentStatus !== 'ผ่านการคัดเลือก') {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'ตอบรับหรือปฏิเสธได้เฉพาะเมื่อผ่านการคัดเลือกแล้ว']);
exit();
}
if (!$isSeekerAction && in_array($currentStatus, $offerResponseStatuses, true)) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'ผู้สมัครตอบกลับข้อเสนอแล้ว ไม่สามารถเปลี่ยนสถานะได้']);
exit();
}
ลำดับสถานะของใบสมัคร
รอดำเนินการ
นัดสัมภาษณ์
ผ่านการคัดเลือก
ไม่ผ่านการคัดเลือก
ตอบรับงานแล้ว
ปฏิเสธงาน
ผู้หางานกด “ยกเลิกการสมัคร” = ลบใบสมัครทิ้ง (cancel_application.php)
ผู้ว่าจ้างเปลี่ยน ผู้หางานตอบ ข้ามขั้นได้ (ตัดสินโดยไม่สัมภาษณ์)
ภาพที่ 2 ลำดับสถานะของใบสมัคร (ข้อความสถานะเก็บเป็นภาษาไทยในคอลัมน์ application.status)
หลังบันทึกสถานะ จะนับจำนวนคนที่รับแล้ว (hired_count) ใหม่จากใบสมัครจริงทุกครั้ง (ไม่ใช้วิธี +1/−1 ซึ่งเพี้ยนได้ถ้ากดซ้ำ) แล้วปิดรับงานอัตโนมัติเมื่อรับครบ หรือเปิดรับใหม่ถ้ามีคนปฏิเสธจนกลับมาไม่ครบ
jobmatch_api/update_application.php บรรทัด 110–126 110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126 // อัปเดตจำนวน hired_count ของงานนั้นใหม่ (นับเฉพาะคนที่ ผ่านการคัดเลือก หรือ ตอบรับงานแล้ว)
if ($jobId) {
$updateHiredCountStmt = $pdo->prepare("
UPDATE job_posting
SET hired_count = (
SELECT COUNT(*)
FROM application
WHERE job_id = ? AND status IN ('ผ่านการคัดเลือก', 'ตอบรับงานแล้ว')
)
WHERE job_id = ?
");
$updateHiredCountStmt->execute([$jobId, $jobId]);
// รับครบตามจำนวนที่เปิดรับแล้ว -> ปิดรับสมัครอัตโนมัติ, ถ้ากลับมาไม่ครบ (เช่น ผู้สมัครปฏิเสธ) -> เปิดรับอีกครั้ง
$pdo->prepare("UPDATE job_posting SET status = 'closed' WHERE job_id = ? AND hired_count >= vacancy AND status IN ('open', 'approved')")->execute([$jobId]);
$pdo->prepare("UPDATE job_posting SET status = 'approved' WHERE job_id = ? AND hired_count < vacancy AND status = 'closed'")->execute([$jobId]);
}
สุดท้ายสร้างแจ้งเตือนให้อีกฝ่าย และถ้าเป็นการแจ้งผลจากบริษัทจะส่งข้อความเข้าแชทด้วย (ผู้ส่ง = user_id ของบริษัท) ผู้หางานจึงเห็นผลทั้งในแจ้งเตือนและในแชท
jobmatch_api/update_application.php บรรทัด 158–172 158
159
160
161
162
163
164
165
166
167
168
169
170
171
172 // ส่งผลการพิจารณาเข้าแชทด้วย (จากบริษัทถึงผู้สมัคร) เหมือนตอนนัดสัมภาษณ์
if ($status === 'ผ่านการคัดเลือก') {
$chatMessage = "ยินดีด้วยครับ/ค่ะ คุณผ่านการคัดเลือกในตำแหน่ง " . $jobTitle;
if ($startDate !== '') {
$chatMessage .= "\nวันเริ่มงาน: " . date('d/m/Y', strtotime($startDate));
}
$chatMessage .= "\nกรุณากดตอบรับหรือปฏิเสธงานในหน้า \"งานของฉัน\"";
} else {
$chatMessage = "ขอบคุณที่สนใจสมัครงานตำแหน่ง " . $jobTitle . " ขออภัย คุณไม่ผ่านการคัดเลือกในครั้งนี้";
}
if ($resultMessage !== '') {
$chatMessage .= "\nเพิ่มเติม: " . $resultMessage;
}
$pdo->prepare('INSERT INTO message (sender_id, receiver_id, message, sent_date, is_read) VALUES (?, ?, ?, NOW(), 0)')
->execute([$info['employer_user_id'], $seekerUserId, $chatMessage]);
post_interview.php — นัดสัมภาษณ์ / เลื่อนนัด
jobmatch_api/post_interview.php API
ใบสมัคร 1 ใบมีนัดได้ 1 นัด ถ้ามีนัดอยู่แล้วถือเป็นการเลื่อนนัด (UPDATE แถวเดิม) และถ้าข้อมูลเหมือนเดิมทุกช่อง (เช่น กดบันทึกซ้ำ) จะตอบสำเร็จโดยไม่ส่งแจ้งเตือน/ข้อความซ้ำ
จากนั้นตั้งสถานะใบสมัครเป็น “นัดสัมภาษณ์” แล้วส่งแจ้งเตือน + ข้อความแชทที่มีวัน เวลา รูปแบบ และสถานที่/ลิงก์
jobmatch_api/post_interview.php บรรทัด 55–83 55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83 // 1 ใบสมัครมีนัดสัมภาษณ์ได้ 1 นัด: ถ้ามีอยู่แล้วถือเป็นการเลื่อนนัด (แก้ไขนัดเดิม ไม่สร้างซ้ำ)
$existing = $pdo->prepare('SELECT interview_id, interview_date, interview_time, interview_type, location_link, additional_notes FROM interview WHERE application_id = ? ORDER BY interview_id DESC LIMIT 1');
$existing->execute([$applicationId]);
$existingRow = $existing->fetch();
$isReschedule = (bool) $existingRow;
if ($existingRow
&& $existingRow['interview_date'] === $interviewDate
&& substr($existingRow['interview_time'], 0, 5) === substr($interviewTime, 0, 5)
&& $existingRow['interview_type'] === $interviewType
&& $existingRow['location_link'] === $locationLink
&& $existingRow['additional_notes'] === $additionalNotes) {
// ข้อมูลเหมือนเดิมทุกอย่าง (เช่น กดปุ่มซ้ำ) -> ไม่ส่งแจ้งเตือน/แชทซ้ำ
echo json_encode(['success' => true, 'message' => 'นัดสัมภาษณ์นี้ถูกบันทึกไว้แล้ว', 'interview_id' => (int) $existingRow['interview_id']]);
exit();
}
if ($isReschedule) {
$interviewId = (int) $existingRow['interview_id'];
$pdo->prepare('UPDATE interview SET interview_date = ?, interview_time = ?, interview_type = ?, location_link = ?, additional_notes = ?, status = "scheduled" WHERE interview_id = ?')
->execute([$interviewDate, $interviewTime, $interviewType, $locationLink, $additionalNotes, $interviewId]);
} else {
$stmt = $pdo->prepare('
INSERT INTO interview (application_id, interview_date, interview_time, interview_type, location_link, additional_notes)
VALUES (?, ?, ?, ?, ?, ?)
');
$stmt->execute([$applicationId, $interviewDate, $interviewTime, $interviewType, $locationLink, $additionalNotes]);
$interviewId = (int) $pdo->lastInsertId();
}
upload_resume.php และ upload_profile_image.php — อัปโหลดไฟล์
jobmatch_api/upload_resume.php · upload_profile_image.php API
ไฟล์ที่อัปโหลดแบบ multipart มาอยู่ใน $_FILES['file'] PHP เก็บไว้ในโฟลเดอร์ชั่วคราวก่อน แล้ว move_uploaded_file() ย้ายไปที่จริง
การตรวจ Resume เช็กเนื้อไฟล์จริง ไม่ใช่แค่นามสกุล: 4 ไบต์แรกต้องเป็น %PDF และชนิด MIME ต้องเป็น application/pdf (กันคนเปลี่ยนชื่อไฟล์อื่นเป็น .pdf)
jobmatch_api/upload_resume.php บรรทัด 43–48 43
44
45
46
47
48 // ตรวจเนื้อไฟล์จริง ไม่ใช่แค่นามสกุล: ต้องเป็น PDF (ขึ้นต้นด้วย %PDF)
$header = (string) file_get_contents($file['tmp_name'], false, null, 0, 5);
$mime = function_exists('finfo_open') ? finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file['tmp_name']) : 'application/pdf';
if (strncmp($header, '%PDF', 4) !== 0 || $mime !== 'application/pdf') {
fail(400, 'อนุญาตให้อัปโหลดเฉพาะไฟล์ PDF เท่านั้น');
}
jobmatch_api/upload_resume.php บรรทัด 66–85 อ่านข้อความใน PDF → หาคำสำคัญ → บันทึก → คำนวณจับคู่ใหม่ → ลบไฟล์เก่า (1 คนมี Resume ได้ 1 ไฟล์) 66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85 try {
$column = 'resume_url';
$keywords = [];
if ($type === 'resume') {
// Keyword Matching จากเนื้อหา Resume (PDF ที่เป็นภาพสแกนจะดึงข้อความไม่ได้ -> ไม่มีคีย์เวิร์ด)
$keywords = extractKeywordsFromText($pdo, extractPdfText(__DIR__ . '/' . $relativePath));
$stmt = $pdo->prepare('UPDATE job_seeker SET resume_url = ?, resume_keywords = ? WHERE user_id = ?');
$stmt->execute([$relativePath, implode(', ', $keywords), $userId]);
recalculateAllMatching($pdo);
} else {
$stmt = $pdo->prepare("UPDATE job_seeker SET $column = ? WHERE user_id = ?");
$stmt->execute([$relativePath, $userId]);
}
// ลบไฟล์เก่าของประเภทเดียวกัน (1 ไฟล์ต่อประเภท)
$old = $seekerRow[$column] ?? '';
if ($old && $old !== $relativePath && str_starts_with($old, 'uploads/resumes/') && is_file(__DIR__ . '/' . $old)) {
@unlink(__DIR__ . '/' . $old);
}
อัปโหลดรูปไม่เชื่อ role/user_id ที่ส่งมา แต่ใช้ของผู้ที่ล็อกอินอยู่ ส่ง kind=cover มาจะบันทึกเป็นภาพปกบริษัท ชื่อไฟล์ใหม่ประกอบจากบทบาท + user_id + เวลา (time()) จึงไม่ชนกัน และบันทึกลง DB เป็น path สัมพัทธ์
jobmatch_api/upload_profile_image.php บรรทัด 21–27 21
22
23
24
25
26
27 $userId = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
$role = isset($_POST['role']) ? trim($_POST['role']) : 'seeker';
// อัปโหลดได้เฉพาะรูปของตัวเอง
$authUser = currentUser($pdo);
$userId = $authUser['user_id'];
$role = $authUser['employer_id'] !== null ? 'employer' : 'seeker';
$isCover = $role === 'employer' && ($_POST['kind'] ?? '') === 'cover';
get_messages.php และ send_message.php — แชท
jobmatch_api/get_messages.php · send_message.php API
ข้อความทุกข้อความอยู่ในตาราง message แถวละ 1 ข้อความ (sender_id, receiver_id เป็น user_id) “ห้องแชท” ไม่มีตารางของตัวเอง แต่เกิดจากการจัดกลุ่มข้อความตามคู่สนทนา:
jobmatch_api/get_messages.php บรรทัด 61–77 CASE หา 'อีกฝ่าย' ของแต่ละข้อความ แล้ว GROUP BY ให้เหลือ 1 แถวต่อคู่สนทนา, SUM(CASE…) นับข้อความที่ยังไม่อ่าน 61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77 $stmt = $pdo->prepare('
SELECT
CASE WHEN m.sender_id = ? THEN m.receiver_id ELSE m.sender_id END AS other_user_id,
mem.name AS other_name,
COALESCE(NULLIF(MAX(e.company_logo), ""), MAX(js.profile_image)) AS other_image,
m.message AS last_message,
m.sent_date AS last_sent_date,
SUM(CASE WHEN m.receiver_id = ? AND m.is_read = 0 THEN 1 ELSE 0 END) AS unread_count
FROM message m
JOIN member mem ON mem.user_id = CASE WHEN m.sender_id = ? THEN m.receiver_id ELSE m.sender_id END
LEFT JOIN employer e ON e.user_id = mem.user_id
LEFT JOIN job_seeker js ON js.user_id = mem.user_id
WHERE m.sender_id = ? OR m.receiver_id = ?
GROUP BY other_user_id
ORDER BY last_sent_date DESC
');
$stmt->execute([$userId, $userId, $userId, $userId, $userId]);
ส่วน send_message.php ไม่ใช้ sender_id ที่แอปส่งมา แต่ใช้ผู้ที่ล็อกอินเป็นผู้ส่งเสมอ จึงปลอมเป็นคนอื่นส่งข้อความไม่ได้
reset_password.php — ลืมรหัสผ่าน
jobmatch_api/reset_password.php API
เบอร์โทรเก็บคนละตารางตามบทบาท จึงใช้ COALESCE(js.phone, e.contact_phone) เลือกค่าที่มี ตัดอักขระที่ไม่ใช่ตัวเลขออกก่อนเทียบ (พิมพ์ 081-234-5678 ก็ได้)
และเมื่อไม่ตรง ระบบไม่บอกว่าผิดที่อีเมลหรือที่เบอร์ เพื่อไม่ให้ใช้ทดลองเดาว่าอีเมลไหนมีในระบบ
jobmatch_api/reset_password.php บรรทัด 36–57 36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57 // เบอร์โทรเก็บแยกตามบทบาท: ผู้หางาน = job_seeker.phone, ผู้ว่าจ้าง = employer.contact_phone
$stmt = $pdo->prepare('
SELECT m.user_id, m.status, COALESCE(js.phone, e.contact_phone) AS phone
FROM member m
LEFT JOIN job_seeker js ON js.user_id = m.user_id
LEFT JOIN employer e ON e.user_id = m.user_id
WHERE m.email = ? AND m.role IN ("seeker", "employer")
LIMIT 1
');
$stmt->execute([$email]);
$user = $stmt->fetch();
// ไม่บอกว่าผิดที่อีเมลหรือเบอร์ เพื่อไม่ให้ใช้เดาว่าอีเมลไหนมีในระบบ
if (!$user || preg_replace('/\D/', '', $user['phone'] ?? '') !== $phone) {
fail(401, 'อีเมลหรือเบอร์โทรศัพท์ไม่ตรงกับข้อมูลที่สมัครไว้');
}
if ($user['status'] !== 'active') {
fail(403, 'บัญชีของคุณถูกระงับการใช้งาน');
}
$pdo->prepare('UPDATE member SET password = ? WHERE user_id = ?')
->execute([password_hash($newPassword, PASSWORD_DEFAULT), $user['user_id']]);
post_job.php / update_job.php — บันทึกประกาศงาน
jobmatch_api/post_job.php · update_job.php API
ก่อนบันทึกมีการเตรียมข้อมูล 3 อย่าง: (1) ให้ AI แปลงทักษะที่พิมพ์เองเป็นคำในคลัง (2) ถ้าสถานที่เป็นข้อความ “พิกัด xx, yy” แปลงเป็น ต./อ./จ. (3) ถ้าพิมพ์สถานที่เองโดยไม่มีพิกัด ค้นหาพิกัดจากข้อความให้
jobmatch_api/post_job.php บรรทัด 21–44 21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44 $employerId = (int) ($input['employer_id'] ?? 0);
// ใช้ employer_id จาก token เสมอ (กันโพสต์งานในนามบริษัทอื่น)
$employerId = requireEmployer($pdo)['employer_id'];
$jobTitle = trim($input['job_title'] ?? '');
$jobDescription = trim($input['job_description'] ?? '');
$qualifications = trim($input['qualifications'] ?? '');
$jobLocation = trim($input['job_location'] ?? '');
$rawRequiredSkills = trim($input['required_skills'] ?? '');
// ให้ AI ช่วยแปลงคำศัพท์เป็นคำมาตรฐาน
$requiredSkills = normalizeSkillsWithAI($rawRequiredSkills);
$jobType = trim($input['job_type'] ?? '');
$salary = trim($input['salary'] ?? '');
// ระบบไม่มีขั้นตอนอนุมัติ: ประกาศงานแสดงให้ผู้หางานเห็นทันที
$status = 'approved';
$vacancy = (int) ($input['vacancy'] ?? 1);
$latitude = isset($input['latitude']) && $input['latitude'] !== '' ? (float) $input['latitude'] : null;
$longitude = isset($input['longitude']) && $input['longitude'] !== '' ? (float) $input['longitude'] : null;
// กดใช้ตำแหน่งปัจจุบันแล้วแอปหาชื่อสถานที่ไม่ได้ จะได้ "พิกัด xx, yy" -> แปลงเป็น ต./อ./จ.
$jobLocation = readableLocation($jobLocation, $latitude, $longitude);
// พิมพ์ที่อยู่เอง (ไม่ได้ใช้ GPS) -> หาพิกัดจากที่อยู่ เพื่อคำนวณระยะทาง "ห่าง x กม." ได้
if (($latitude === null || $longitude === null) && $jobLocation !== '') {
[$latitude, $longitude] = coordinatesForAddress($jobLocation, null, null);
}
บันทึกแล้วคำนวณการจับคู่ของงานใหม่ทันที และส่งแจ้งเตือน “งานแนะนำสำหรับคุณ” ให้ผู้หางานที่คะแนนถึงเกณฑ์ (update_job.php คำนวณใหม่แต่ไม่ส่งแจ้งเตือนซ้ำ)
jobmatch_api/post_job.php บรรทัด 63–72 63
64
65
66
67
68
69
70
71
72 $stmt = $pdo->prepare('
INSERT INTO job_posting (employer_id, job_title, job_description, qualifications, job_location, required_skills, job_type, salary, status, post_date, vacancy, latitude, longitude)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURDATE(), ?, ?, ?)
');
$stmt->execute([$employerId, $jobTitle, $jobDescription, $qualifications, $jobLocation, $requiredSkills, $jobType, $salary, $status, $vacancy, $latitude, $longitude]);
$jobId = (int) $pdo->lastInsertId();
// คำนวณการจับคู่ทันที แล้วแจ้งเตือนงานแนะนำให้ผู้หางานที่คะแนนถึงเกณฑ์
recalculateMatchingForJob($pdo, $jobId);
notifyRecommendedSeekers($pdo, $jobId);
คำสั่ง ALTER TABLE … ADD COLUMN IF NOT EXISTS ในไฟล์ (บรรทัด 53–61) เป็นตัวช่วยสำหรับฐานข้อมูลรุ่นเก่าที่ยังไม่มีคอลัมน์พิกัด/จำนวนรับ ถ้ามีคอลัมน์อยู่แล้วคำสั่งนี้ไม่ทำอะไร
บริการเสริม: หาพิกัด, AI, อ่าน PDF
geocode_service.php — ที่อยู่ ↔ พิกัด (OpenStreetMap Nominatim)
jobmatch_api/geocode_service.php API
ผู้ใช้ไม่ต้องปักหมุดเอง ระบบหาพิกัดระดับตำบลจากที่อยู่ เพื่อคำนวณ “ห่าง x กม.” coordinatesForAddress() ตัดสินว่าจะใช้พิกัดไหน:
jobmatch_api/geocode_service.php บรรทัด 15–26 15
16
17
18
19
20
21
22
23
24
25
26 function coordinatesForAddress(string $address, $latitude, $longitude, ?array $current = null): array {
if (is_numeric($latitude) && is_numeric($longitude)) {
return [(float) $latitude, (float) $longitude];
}
$address = trim($address);
if ($current !== null && ($address === '' || $address === trim((string) ($current['address'] ?? '')))
&& $current['latitude'] !== null && $current['longitude'] !== null) {
return [(float) $current['latitude'], (float) $current['longitude']];
}
$found = geocodeThaiAddress($address !== '' ? $address : (string) ($current['address'] ?? ''));
return $found ? [$found['latitude'], $found['longitude']] : [null, null];
}
geocodeThaiAddress() ดึง ต./อ./จ. ออกจากที่อยู่ด้วย regex แล้วลองค้นหาไล่จากละเอียดไปหยาบ (ตำบล → แบบไม่มีคำนำหน้าสำหรับกรุงเทพฯ → ระดับอำเภอ)
ระหว่างแต่ละครั้งหน่วง 1.1 วินาที เพราะ Nominatim อนุญาตแค่ 1 คำขอต่อวินาที และถ้าเชื่อมต่อไม่ได้จะคืน null ทันที การบันทึกที่อยู่จึงทำต่อได้แม้ไม่มีเน็ต
jobmatch_api/geocode_service.php บรรทัด 87–125 87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125 function geocodeThaiAddress(string $address): ?array {
$address = trim($address);
if ($address === '') return null;
$queries = [];
if (preg_match('/ต\.\s*(\S+)\s+อ\.\s*(\S+)\s+จ\.\s*(\S+)/u', $address, $m)) {
[, $sub, $dist, $prov] = $m;
$queries[] = "ตำบล$sub อำเภอ$dist จังหวัด$prov";
$queries[] = "$sub $dist $prov"; // กรุงเทพฯ ใช้แขวง/เขต จึงลองแบบไม่ใส่คำนำหน้าด้วย
$queries[] = "$dist $prov"; // หาตำบลไม่เจอ ใช้พิกัดระดับอำเภอแทน
} else {
$queries[] = $address;
}
foreach ($queries as $i => $query) {
if ($i > 0) usleep(1100000); // Nominatim จำกัด 1 คำขอต่อวินาที
$url = 'https://nominatim.openstreetmap.org/search?format=json&limit=1&countrycodes=th&q=' . rawurlencode($query);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 5,
CURLOPT_USERAGENT => 'JobMatch-student-project/1.0',
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200) {
error_log("geocode_service: Nominatim unavailable (HTTP $code)");
return null; // เชื่อมต่อไม่ได้ ไม่ต้องลองคำค้นถัดไปให้ผู้ใช้รอนาน
}
$rows = json_decode($body, true);
if (is_array($rows) && isset($rows[0]['lat'], $rows[0]['lon'])) {
return ['latitude' => round((float) $rows[0]['lat'], 7), 'longitude' => round((float) $rows[0]['lon'], 7)];
}
}
return null;
}
ทางกลับกัน reverseGeocodeThai() แปลงพิกัดเป็น “ต.… อ.… จ.…” โดยใช้ zoom=10 (ระดับตำบล) และเลือกฟิลด์ตามรูปแบบข้อมูลไทยของ OSM (province = จังหวัด, county = อำเภอ, municipality/city_district = ตำบล ส่วนกรุงเทพฯ ใช้แขวง/เขต)
ai_service.php — ให้ AI ช่วยแปลงทักษะเป็นคำมาตรฐาน
jobmatch_api/ai_service.php API
ปัญหา: ผู้ใช้พิมพ์ทักษะไม่ตรงกับคลังคำ เช่น “เขียนเว็บ” ขณะที่คลังมี “PHP” ทำให้จับคู่ไม่ได้ ฟังก์ชัน normalizeSkillsWithAI() แก้ปัญหานี้แบบประหยัดและปลอดภัย:
ทักษะที่ตรงกับคลังอยู่แล้ว (ไม่สนตัวพิมพ์เล็กใหญ่) ใช้ได้เลย ไม่ส่งให้ AI
ส่งเฉพาะคำที่ไม่รู้จักไปให้ Google Gemini พร้อมรายการคลังคำ สั่งให้ตอบเป็น JSON array เท่านั้น และตั้ง temperature: 0 (ตอบคงที่ ไม่สุ่ม)
ไม่เชื่อ AI ทั้งหมด : ยอมรับคำตอบเฉพาะคำที่มีอยู่ในคลังจริง ถ้า AI ตอบคำอื่นใช้คำที่ผู้ใช้พิมพ์
ถ้าไม่มี API key, AI ตอบช้าเกิน 8 วินาที หรือตอบผิดรูปแบบ ใช้ข้อความเดิมทั้งหมด ระบบไม่พัง
jobmatch_api/ai_service.php บรรทัด 26–51 26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51 $keywords = $pdo->query('SELECT keyword FROM keywords')->fetchAll(PDO::FETCH_COLUMN);
$inputSkills = array_values(array_filter(array_map('trim', explode(',', $rawSkillsString)), fn($s) => $s !== ''));
// ทักษะที่ตรงกับฐานข้อมูลอยู่แล้ว ไม่ต้องส่งให้ AI
$lowerKeywords = array_combine(array_map('mb_strtolower', $keywords), $keywords);
$result = [];
$unknown = [];
foreach ($inputSkills as $skill) {
if (isset($lowerKeywords[mb_strtolower($skill)])) {
$result[] = $lowerKeywords[mb_strtolower($skill)];
} else {
$unknown[] = $skill;
}
}
if (!$unknown) {
return implode(', ', array_unique($result));
}
$model = $config['gemini_model'] ?? 'gemini-flash-latest';
$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent';
$prompt = "You map job skills typed by users to a fixed keyword list.\n"
. "Keyword list: " . implode(' | ', $keywords) . "\n"
. "For each input skill, answer with the single closest keyword from the list, copied exactly, "
. "or the input skill unchanged if nothing in the list has the same meaning.\n"
. "Return ONLY a JSON array of strings in the same order as the input.\n"
. "Input: " . json_encode($unknown, JSON_UNESCAPED_UNICODE);
jobmatch_api/ai_service.php บรรทัด 75–85 75
76
77
78
79
80
81
82
83
84
85 if (!is_array($mapped) || count($mapped) !== count($unknown)) {
error_log('ai_service: Gemini unavailable (HTTP ' . $httpCode . '), keeping raw skills');
return implode(', ', array_unique(array_merge($result, $unknown)));
}
foreach ($mapped as $i => $value) {
$value = trim((string) $value);
// ยอมรับเฉพาะคำที่อยู่ในฐานข้อมูลจริง ถ้า AI ตอบคำอื่นมาให้ใช้คำที่ผู้ใช้พิมพ์
$result[] = isset($lowerKeywords[mb_strtolower($value)]) ? $lowerKeywords[mb_strtolower($value)] : $unknown[$i];
}
return implode(', ', array_unique($result));
API key ส่งไปใน header x-goog-api-key ไม่ใส่ใน URL เพื่อไม่ให้ key ติดไปใน log ของเซิร์ฟเวอร์ และอ่านจาก environment variable ก่อน แล้วค่อยอ่านจาก config.php
pdf_text.php — ดึงข้อความจากไฟล์ PDF
jobmatch_api/pdf_text.php API
อ่าน PDF โดยไม่ต้องติดตั้งโปรแกรมเสริม ทำงาน 3 ขั้น: (1) หา stream ทั้งหมดในไฟล์ ถ้าบีบอัดแบบ FlateDecode ให้คลายด้วย gzuncompress
(2) รวมตาราง ToUnicode (CMap) ซึ่งบอกว่ารหัสตัวอักษรในไฟล์ตรงกับอักษรอะไร (ไฟล์จาก Word/Google Docs ใช้แบบนี้ จำเป็นมากสำหรับภาษาไทย)
(3) อ่านข้อความจากคำสั่งแสดงข้อความของ PDF (Tj, TJ, ', ") ที่อยู่ระหว่าง BT … ET
jobmatch_api/pdf_text.php บรรทัด 17–30 17
18
19
20
21
22
23
24
25
26
27
28
29
30 // 1) อ่าน stream ทั้งหมด (คลายการบีบอัดถ้าเป็น FlateDecode)
$streams = [];
if (preg_match_all('/<<(.*?)>>\s*stream\r?\n(.*?)\r?\nendstream/s', $data, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$content = $m[2];
if (str_contains($m[1], '/FlateDecode')) {
$inflated = @gzuncompress($content);
if ($inflated === false) $inflated = @gzinflate($content);
if ($inflated === false) continue;
$content = $inflated;
}
$streams[] = $content;
}
}
jobmatch_api/pdf_text.php บรรทัด 56–77 56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77 // 3) ดึงข้อความจากคำสั่งแสดงผลข้อความใน content stream
$text = '';
foreach ($streams as $s) {
// เฉพาะ content stream ที่มีบล็อกข้อความ (BT ... ET) ข้ามฟอนต์/รูปภาพ
if (!preg_match('/\bBT\b/', $s)) continue;
if (!preg_match_all('/(\[(?:[^\]\\\\]|\\\\.)*\]|\((?:[^()\\\\]|\\\\.)*\)|<[0-9A-Fa-f\s]+>)\s*(Tj|TJ|\'|")/s', $s, $ops, PREG_SET_ORDER)) {
continue;
}
foreach ($ops as $op) {
$operand = $op[1];
if ($operand[0] === '[') {
preg_match_all('/\((?:[^()\\\\]|\\\\.)*\)|<[0-9A-Fa-f\s]+>/s', $operand, $parts);
foreach ($parts[0] as $part) $text .= decodePdfString($part, $cmap);
} else {
$text .= decodePdfString($operand, $cmap);
}
$text .= ' ';
}
$text .= "\n";
}
return trim(preg_replace('/[ \t]+/u', ' ', $text) ?? '');
PDF ที่เป็นภาพสแกน หรือเข้ารหัสไว้ จะดึงข้อความไม่ได้ (ได้ข้อความว่าง) ผลคือไม่มีคำสำคัญจาก Resume แต่ทักษะในโปรไฟล์ยังใช้จับคู่ได้ตามปกติ
8. ระบบจับคู่งาน (หัวใจของโปรเจกต์) คำนวณคะแนนอย่างไร ใช้คะแนนที่ไหน และคำนวณใหม่เมื่อไหร่
แนวคิดและสูตร
ระบบเทียบ “ชุดทักษะ” สองชุด:
A = ทักษะของผู้หางาน = ทักษะที่กรอกในโปรไฟล์ + คำสำคัญที่พบใน Resume (seekerSkillSet)
B = ทักษะที่งานต้องการ = ทักษะที่บริษัทระบุ ถ้าไม่ได้ระบุ ใช้คำสำคัญที่พบในรายละเอียดงานและคุณสมบัติแทน (jobSkillSet)
คะแนน = |A ∩ B| ÷ |B| × 100
จำนวนทักษะที่ตรงกัน หารด้วย จำนวนทักษะที่งานต้องการ · งานที่ไม่ระบุทักษะเลยได้ 50 · ผู้หางานที่ไม่มีทักษะเลยได้ 0
ทำไมไม่ใช้สูตร Jaccard
สูตรเดิมในโค้ดคือ |A ∩ B| ÷ |A ∪ B| ซึ่งทำให้คนที่มีทักษะมากกว่า ที่งานต้องการได้คะแนนลดลง (ตัวหารใหญ่ขึ้น) ทั้งที่ควรเหมาะกับงานมากกว่า
สูตรปัจจุบันหารด้วยทักษะที่งานต้องการอย่างเดียว คนที่มีครบตามที่งานต้องการจึงได้ 100% เสมอ
ภาพที่ 3 ผังการจับคู่ (ไฟล์ docs/matching_flow.svg ที่ใช้ในรายงาน)
skill_matcher.php — ทำคำให้เป็นมาตรฐาน แล้วคิดคะแนน
ก่อนเทียบ ทุกทักษะผ่าน normalizeSkill(): ตัดช่องว่าง ทำเป็นตัวพิมพ์เล็ก แล้วแปลงคำพ้องความหมาย/คำย่อเป็นคำมาตรฐานเดียวกัน เช่น “JS” → “javascript”, “React.js” → “react”, “บาริสต้า” → “barista”
jobmatch_api/skill_matcher.php บรรทัด 8–14 พจนานุกรมคำพ้อง (ตัดมาบางส่วน) ซ้าย = คำที่ผู้ใช้มักพิมพ์ ขวา = คำมาตรฐาน 8
9
10
11
12
13
14 function normalizeSkill($skill) {
// พจนานุกรมคำพ้องความหมาย (ซ้ายคือคำที่มักจะพิมพ์มา -> ขวาคือคำมาตรฐาน)
$synonyms = [
// JavaScript Ecosystem
'js' => 'javascript',
'react.js' => 'react',
'reactjs' => 'react',
jobmatch_api/skill_matcher.php บรรทัด 71–93 71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93 $clean = mb_strtolower(trim($skill));
// เช็คว่ามีในพจนานุกรมหรือไม่ ถ้ามีให้แปลงเป็นคำมาตรฐาน
if (isset($synonyms[$clean])) {
return $synonyms[$clean];
}
return $clean;
}
/**
* แปลง array ทักษะทั้งหมดให้เป็นคำมาตรฐาน
*/
function normalizeSkillsArray($skillsArray) {
$normalized = [];
foreach ($skillsArray as $skill) {
$clean = normalizeSkill($skill);
if ($clean !== '') {
$normalized[] = $clean;
}
}
return array_values(array_unique($normalized)); // ตัดตัวซ้ำ
}
jobmatch_api/skill_matcher.php บรรทัด 99–115 หัวใจของระบบ: array_intersect หาทักษะที่ตรงกัน แล้วหารด้วยจำนวนทักษะที่งานต้องการ 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115 function calculateMatchScore($seekerSkillsString, $jobSkillsString) {
$jobSkills = normalizeSkillsArray(explode(',', (string) $jobSkillsString));
if (empty($jobSkills)) {
return 50.0;
}
$seekerSkills = normalizeSkillsArray(explode(',', (string) $seekerSkillsString));
if (empty($seekerSkills)) {
return 0.0;
}
// คะแนน = ทักษะที่ตรงกัน / ทักษะที่งานต้องการ (|A ∩ B| / |B|)
// ผู้หางานที่มีทักษะครบตามที่งานต้องการได้ 100% ทักษะอื่นที่มีเกินมาไม่ทำให้คะแนนลดลง
// (สูตร Jaccard เดิม: count($intersect) / count(array_unique(array_merge($seekerSkills, $jobSkills))))
$intersect = array_intersect($seekerSkills, $jobSkills);
return round(count($intersect) / count($jobSkills) * 100, 2);
}
ตัวอย่างการคำนวณ
ขั้น ตัวอย่างที่ 1 ตัวอย่างที่ 2 (คำพ้อง)
งานต้องการ (B) PHP, MySQL, HTML, CSS JavaScript, React
ผู้หางานมี (A) โปรไฟล์: php, Html, Flutter + จาก Resume: MySQL JS, React.js
หลัง normalize B = {php, mysql, html, css} A = {php, html, flutter, mysql} B = {javascript, react} A = {javascript, react}
A ∩ B {php, html, mysql} = 3 ทักษะ {javascript, react} = 2 ทักษะ
คะแนน 3 ÷ 4 × 100 = 75% 2 ÷ 2 × 100 = 100%
ระดับ (เกณฑ์เริ่มต้น) ≥ 70 → เหมาะสมสูงมาก ≥ 70 → เหมาะสมสูงมาก
ผลที่เกิดขึ้น อยู่ใน “งานแนะนำ” (≥ 50), ได้แจ้งเตือนตอนโพสต์งาน (≥ 70), ยังไม่ติดป้ายผู้สมัครแนะนำ (ต้อง ≥ 80) ครบทุกเกณฑ์ รวมถึงติดป้ายผู้สมัครแนะนำ
matching_service.php — เกณฑ์ ระดับ และชุดทักษะ
ไฟล์นี้เป็นฟังก์ชันกลางที่ทั้ง API และหน้าแอดมิน require ไปใช้ เกณฑ์ทั้ง 5 ค่าอ่านจากตาราง system_settings (แอดมินแก้ได้ในหน้า “เกณฑ์การจับคู่”) ถ้ายังไม่มีในตาราง ใช้ค่าเริ่มต้นในโค้ด
jobmatch_api/matching_service.php บรรทัด 13–51 13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51 const VISIBLE_JOB_STATUSES = ['open', 'approved'];
// ค่าเริ่มต้น ใช้เมื่อยังไม่ได้รัน migration หรือไม่มีค่าในตาราง
const DEFAULT_MATCH_SETTINGS = [
'level_very_high' => 70,
'level_good' => 50,
'level_medium' => 40,
'notify_threshold' => 70,
'top_candidate_threshold' => 80,
];
function getMatchSettings(PDO $pdo): array {
static $cache = null;
if ($cache !== null) return $cache;
$settings = DEFAULT_MATCH_SETTINGS;
try {
$rows = $pdo->query('SELECT setting_key, setting_value FROM system_settings')->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($rows as $key => $value) {
if (array_key_exists($key, $settings)) {
$settings[$key] = (float) $value;
}
}
} catch (PDOException $e) {
// ตารางยังไม่มี ใช้ค่าเริ่มต้น
}
return $cache = $settings;
}
/**
* แปลคะแนนเป็นระดับความเหมาะสม (ตารางที่ 2.1)
* คืนค่า ['key' => very_high|good|medium|low, 'label' => ข้อความไทย]
*/
function getMatchLevel(float $score, array $settings): array {
if ($score >= $settings['level_very_high']) return ['key' => 'very_high', 'label' => 'เหมาะสมสูงมาก'];
if ($score >= $settings['level_good']) return ['key' => 'good', 'label' => 'เหมาะสมดี'];
if ($score >= $settings['level_medium']) return ['key' => 'medium', 'label' => 'เหมาะสมปานกลาง'];
return ['key' => 'low', 'label' => 'เหมาะสมน้อย'];
}
ค่าตั้ง ค่าเริ่มต้น ใช้ที่ไหน
level_very_high / level_good / level_medium70 / 50 / 40 แบ่งระดับ: เหมาะสมสูงมาก / ดี / ปานกลาง / ต่ำกว่า 40 = น้อย (ตารางที่ 2.1 ในรายงาน)
level_good50 get_jobs.php: คะแนน ≥ ค่านี้ = is_recommended แสดงใน “งานแนะนำ” หน้าแรกผู้หางาน
notify_threshold70 post_job.php → notifyRecommendedSeekers: ส่งแจ้งเตือนงานใหม่ให้ผู้หางานที่คะแนนถึง
top_candidate_threshold80 get_applications.php: ติดป้าย “ผู้สมัครแนะนำ” ให้ผู้ว่าจ้างเห็นก่อน
ชุดทักษะ A และ B ประกอบขึ้นจากฟังก์ชันด้านล่าง ชุดของงานใช้ทักษะที่บริษัทระบุก่อน และใช้คำจากรายละเอียดงานเฉพาะเมื่อไม่ได้ระบุ เพราะคำทั่วไปในรายละเอียด (เช่น “ความรับผิดชอบ”) จะไปเพิ่มตัวหาร ทำให้คะแนนต่ำโดยไม่จำเป็น
jobmatch_api/matching_service.php บรรทัด 185–204 185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204 /**
* ชุดทักษะของงาน (B) = ทักษะที่ผู้ว่าจ้างระบุ
* ถ้าผู้ว่าจ้างไม่ได้ระบุทักษะ จึงใช้คำสำคัญที่พบในรายละเอียดงานและคุณสมบัติผู้สมัครแทน (Keyword Matching)
* ไม่รวมสองส่วนเข้าด้วยกัน เพราะคำในรายละเอียดงาน (เช่น "ความรับผิดชอบ") ทำให้คะแนนต่ำลงทั้งที่ผู้ว่าจ้างไม่ได้ต้องการ
*/
function jobSkillSet(PDO $pdo, array $job): string {
$skills = array_values(array_filter(array_map('trim', explode(',', $job['required_skills'] ?? ''))));
if ($skills) {
return implode(', ', array_unique($skills));
}
$fromText = extractKeywordsFromText($pdo, ($job['job_description'] ?? '') . "\n" . ($job['qualifications'] ?? ''));
return implode(', ', $fromText);
}
/**
* ชุดทักษะของผู้หางาน (A) = ทักษะในโปรไฟล์ + คำสำคัญที่พบใน Resume
*/
function seekerSkillSet(array $seeker): string {
return trim(($seeker['skills'] ?? '') . ', ' . ($seeker['resume_keywords'] ?? ''), ', ');
}
การหาคำสำคัญในข้อความอิสระ (Keyword Matching)
extractKeywordsFromText() ไล่ทุกคำในคลังคำสำคัญแล้วดูว่าปรากฏในข้อความ (Resume หรือรายละเอียดงาน) ไหม แยกวิธีตามภาษา:
คำภาษาอังกฤษ ต้องเป็นคำทั้งคำ เช่น “Git” ต้องไม่นับเมื่อเจอในคำว่า “digit” จึงใช้ lookbehind/lookahead (?<![A-Za-z0-9]) … (?![A-Za-z0-9]) และยอมให้มีช่องว่างแทรกระหว่างตัวอักษร (\s*) เพราะ PDF บางไฟล์แยกตัวอักษรเป็นช่วง ๆ
คำภาษาไทย ไม่มีการเว้นวรรคระหว่างคำ จึงลบช่องว่างทั้งหมดก่อนแล้วค้นแบบ substring และรวมสระอำที่ PDF มักเก็บแยกเป็น นิคหิต + สระอา กลับเป็นสระอำ
jobmatch_api/matching_service.php บรรทัด 165–183 165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183 function extractKeywordsFromText(PDO $pdo, string $text): array {
if (trim($text) === '') return [];
// PDF มักเก็บสระอำเป็น นิคหิต + สระอา (บางครั้งมีช่องว่างคั่น) -> ตัดช่องว่างก่อนแล้วรวมกลับเป็นสระอำ
$compactThai = str_replace("\u{0E4D}\u{0E32}", "\u{0E33}", preg_replace('/\s+/u', '', $text));
$found = [];
foreach (getKeywordList($pdo) as $keyword) {
$kw = trim($keyword);
if ($kw === '') continue;
if (preg_match('/^[\x20-\x7E]+$/', $kw)) {
$letters = array_map(fn($ch) => preg_quote($ch, '/'), mb_str_split(str_replace(' ', '', $kw)));
$pattern = '/(?<![A-Za-z0-9])' . implode('\s*', $letters) . '(?![A-Za-z0-9])/iu';
if (preg_match($pattern, $text)) $found[] = $kw;
} elseif (mb_strpos($compactThai, preg_replace('/\s+/u', '', $kw)) !== false) {
$found[] = $kw;
}
}
return $found;
}
คะแนนฝั่งผู้หางาน: get_jobs.php
ทุกครั้งที่ผู้หางานเปิดหน้าแรก/ค้นหา endpoint นี้คำนวณคะแนนสด ให้ทุกงาน พร้อมแยกทักษะที่ตรงและที่ขาด (แสดงในหน้ารายละเอียดงาน) และระยะทาง แล้วเรียงงานจากคะแนนมากไปน้อย
jobmatch_api/get_jobs.php บรรทัด 55–83 55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83 foreach ($stmt->fetchAll() as $job) {
$jobSkillsOriginal = array_values(array_filter(array_map('trim', explode(',', $job['required_skills'] ?? '')), fn($s) => $s !== ''));
// ชุดทักษะของงาน = ทักษะที่ระบุ + คีย์เวิร์ดที่พบในรายละเอียดงาน/คุณสมบัติ (Keyword Matching)
$jobSkillSetString = jobSkillSet($pdo, $job);
$jobSkillsAll = [];
foreach (array_filter(array_map('trim', explode(',', $jobSkillSetString))) as $skill) {
$jobSkillsAll[normalizeSkill($skill)] ??= $skill; // ตัดคำซ้ำ
}
// ผู้ใช้ที่ไม่ได้ล็อกอินเป็นผู้หางาน (userId = 0) ไม่มีคะแนน
$matchScore = $userId > 0 ? calculateMatchScore($seekerSkillsString, $jobSkillSetString) : 0.0;
$level = getMatchLevel($matchScore, $settings);
// พิกัดงาน: ใช้ของประกาศงานก่อน ถ้าไม่มีใช้พิกัดบริษัท
$jobLat = $job['latitude'] ?? $job['company_lat'];
$jobLng = $job['longitude'] ?? $job['company_lng'];
$distance = ($originLat !== null && $jobLat !== null && $jobLng !== null)
? round(distanceKm($originLat, $originLng, (float) $jobLat, (float) $jobLng), 1)
: null;
$matchedSkills = [];
$missingSkills = [];
foreach ($jobSkillsAll as $canonical => $skill) {
if (in_array($canonical, $userSkillsCanonical, true)) {
$matchedSkills[] = $skill;
} else {
$missingSkills[] = $skill;
}
}
jobmatch_api/get_jobs.php บรรทัด 122–123 เรียงด้วย spaceship operator <=> บน array [คะแนน, job_id]: คะแนนมากก่อน ถ้าเท่ากันงานใหม่ (id มากกว่า) ก่อน 122
123 // จัดอันดับตามคะแนนความเหมาะสมจากมากไปน้อย (ขอบเขต 1.3.4.4) งานใหม่กว่าขึ้นก่อนเมื่อคะแนนเท่ากัน
usort($result, fn($a, $b) => [$b['match_score'], $b['job_id']] <=> [$a['match_score'], $a['job_id']]);
คะแนนฝั่งผู้ว่าจ้าง: จัดอันดับผู้สมัคร
get_applications.php (โหมดตามงาน/ตามบริษัท) คำนวณคะแนนของผู้สมัครแต่ละคนสดจากทักษะปัจจุบัน ใส่ระดับ ป้ายผู้สมัครแนะนำ แล้วจัดอันดับภายในแต่ละงาน (1 = คะแนนสูงสุด)
jobmatch_api/get_applications.php บรรทัด 135–173 135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173 // คำนวณคะแนนสดจากทักษะปัจจุบัน (ไม่ใช้ตาราง matching ซึ่งคำนวณไว้แค่ตอนโพสต์งาน
// ทำให้ผู้หางานที่สมัครสมาชิก/แก้ทักษะทีหลัง หรืองานที่ถูกแก้ไข ได้คะแนนผิด)
// แล้วจัดอันดับผู้สมัครภายในแต่ละงาน (1 = คะแนนสูงสุด)
if ($seekerId === 0) {
$settings = getMatchSettings($pdo);
foreach ($rows as &$row) {
// A = ทักษะในโปรไฟล์ + คีย์เวิร์ดจาก Resume, B = ทักษะที่ระบุ + คีย์เวิร์ดจากรายละเอียดงาน
$row['match_score'] = calculateMatchScore(
seekerSkillSet($row),
jobSkillSet($pdo, [
'required_skills' => $row['required_skills'] ?? '',
'job_description' => $row['job_description_for_match'] ?? '',
'qualifications' => $row['qualifications_for_match'] ?? '',
])
);
$level = getMatchLevel($row['match_score'], $settings);
$row['match_level'] = $level['key'];
$row['match_level_label'] = $level['label'];
// ผู้สมัครที่คะแนนถึงเกณฑ์ให้ผู้ว่าจ้างเห็นเป็นลำดับแรก (เอกสารบทที่ 2: 80% ขึ้นไป)
$row['is_top_candidate'] = $row['match_score'] >= $settings['top_candidate_threshold'];
}
unset($row);
$byJob = [];
foreach ($rows as $i => $row) {
$byJob[$row['job_id']][] = $i;
}
foreach ($byJob as $indexes) {
usort($indexes, fn($a, $b) => $rows[$b]['match_score'] <=> $rows[$a]['match_score']);
foreach ($indexes as $rank => $i) {
$rows[$i]['ranking'] = $rank + 1;
}
}
if ($jobId > 0) {
// เรียงตามคะแนน แล้วตามวันสมัครล่าสุด (usort ของ PHP 8 เป็น stable sort)
usort($rows, fn($a, $b) => $b['match_score'] <=> $a['match_score']);
}
}
ตาราง matching และการคำนวณใหม่
นอกจากคำนวณสด ระบบยังเก็บผลไว้ในตาราง matching (ผู้หางาน × งาน, คะแนน, อันดับ) ใช้แจ้งเตือนงานแนะนำ และสรุปจำนวนตามระดับในหน้าแอดมิน
ฟังก์ชันคำนวณใหม่จะลบผลเก่าของงานแล้วบันทึกใหม่ทั้งหมด เก็บเฉพาะคู่ที่คะแนนมากกว่า 0
jobmatch_api/matching_service.php บรรทัด 61–94 61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94 function recalculateMatchingForJob(PDO $pdo, int $jobId): array {
$job = $pdo->prepare('SELECT required_skills, job_description, qualifications FROM job_posting WHERE job_id = ?');
$job->execute([$jobId]);
$jobRow = $job->fetch();
if (!$jobRow) return [];
$jobSkills = jobSkillSet($pdo, $jobRow);
$scores = [];
foreach ($pdo->query('SELECT seeker_id, user_id, skills, resume_keywords FROM job_seeker')->fetchAll() as $s) {
$score = calculateMatchScore(seekerSkillSet($s), $jobSkills);
if ($score > 0) {
$scores[] = ['seeker_id' => (int) $s['seeker_id'], 'user_id' => (int) $s['user_id'], 'score' => $score];
}
}
usort($scores, fn($a, $b) => $b['score'] <=> $a['score']);
$pdo->prepare('DELETE FROM matching WHERE job_id = ?')->execute([$jobId]);
$insert = $pdo->prepare('INSERT INTO matching (seeker_id, job_id, ranking, totalScore) VALUES (?, ?, ?, ?)');
foreach ($scores as $rank => $row) {
$insert->execute([$row['seeker_id'], $jobId, $rank + 1, $row['score']]);
}
return $scores;
}
/**
* คำนวณตาราง matching ใหม่ทั้งหมด (เมื่อผู้หางานแก้ทักษะ / สมัครสมาชิก / Admin แก้ Keyword)
* ต้องคำนวณทุกงาน เพราะอันดับของงานเปลี่ยนตามคะแนนของผู้หางานทุกคน
*/
function recalculateAllMatching(PDO $pdo): void {
$jobIds = $pdo->query('SELECT job_id FROM job_posting')->fetchAll(PDO::FETCH_COLUMN);
foreach ($jobIds as $jobId) {
recalculateMatchingForJob($pdo, (int) $jobId);
}
}
เหตุการณ์ ไฟล์ คำนวณใหม่
บริษัทโพสต์งานใหม่ post_job.php เฉพาะงานนั้น + ส่งแจ้งเตือนงานแนะนำ
บริษัทแก้ไขงาน update_job.php เฉพาะงานนั้น
ผู้หางานสมัครสมาชิก register_seeker.php ทุกงาน
ผู้หางานแก้ทักษะ update_seeker.php ทุกงาน
ผู้หางานอัปโหลด Resume upload_resume.php ทุกงาน
แอดมินเพิ่ม/แก้/ลบคำสำคัญ keywords.php (แอดมิน) ทุกงาน
แอดมินกดปุ่มคำนวณใหม่ matching.php (แอดมิน) ทุกงาน
ข้อสังเกตเรื่องประสิทธิภาพ
“คำนวณใหม่ทุกงาน” = (จำนวนงาน × จำนวนผู้หางาน) รอบ ข้อมูลระดับโปรเจกต์นักศึกษาทำงานได้ทันที แต่ถ้าข้อมูลหลักหมื่นขึ้นไปควรคำนวณเฉพาะส่วนที่เปลี่ยน หรือย้ายไปทำเบื้องหลัง (background job)
ส่งแจ้งเตือนงานแนะนำ
เมื่อโพสต์งาน notifyRecommendedSeekers() ไล่ผู้หางานจากคะแนนมากไปน้อย หยุดทันทีเมื่อคะแนนต่ำกว่าเกณฑ์ (break ได้เพราะเรียงแล้ว) และกันส่งซ้ำด้วย type = recommend_job_<job_id>
ค่า type นี้เองที่แอปใช้รู้ว่าแตะแจ้งเตือนแล้วต้องเปิดงานไหน
jobmatch_api/matching_service.php บรรทัด 101–134 101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134 function notifyRecommendedSeekers(PDO $pdo, int $jobId): int {
$settings = getMatchSettings($pdo);
$job = $pdo->prepare('
SELECT j.job_title, j.status, e.company_name
FROM job_posting j LEFT JOIN employer e ON j.employer_id = e.employer_id
WHERE j.job_id = ?
');
$job->execute([$jobId]);
$jobRow = $job->fetch();
if (!$jobRow || !isJobVisible($jobRow['status'])) return 0;
$type = 'recommend_job_' . $jobId;
$already = $pdo->prepare('SELECT COUNT(*) FROM notification WHERE user_id = ? AND type = ?');
$insert = $pdo->prepare('INSERT INTO notification (user_id, title, content, type) VALUES (?, ?, ?, ?)');
$sent = 0;
foreach (recalculateMatchingForJob($pdo, $jobId) as $row) {
if ($row['score'] < $settings['notify_threshold']) break; // เรียงจากมากไปน้อยแล้ว
$already->execute([$row['user_id'], $type]);
if ($already->fetchColumn() > 0) continue;
$level = getMatchLevel($row['score'], $settings);
$content = sprintf(
'ตำแหน่ง %s%s ตรงกับทักษะของคุณ %s%% (%s)',
$jobRow['job_title'],
$jobRow['company_name'] ? ' ที่ ' . $jobRow['company_name'] : '',
rtrim(rtrim(number_format($row['score'], 2), '0'), '.'),
$level['label']
);
$insert->execute([$row['user_id'], 'งานแนะนำสำหรับคุณ', $content, $type]);
$sent++;
}
return $sent;
}
ระยะทาง: สูตร Haversine
ระยะ “ห่าง x กม.” คำนวณจากพิกัดผู้หางาน (GPS ปัจจุบัน หรือพิกัดจากที่อยู่ในโปรไฟล์) กับพิกัดงาน (ถ้างานไม่มี ใช้พิกัดบริษัท) ด้วยสูตร Haversine ที่คิดความโค้งของโลก (รัศมี 6,371 กม.)
ผลคือระยะเส้นตรง ไม่ใช่ระยะทางตามถนน
jobmatch_api/matching_service.php บรรทัด 136–145 136
137
138
139
140
141
142
143
144
145 /**
* ระยะทางเป็นกิโลเมตรระหว่างสองพิกัด (สูตร Haversine) ใช้แสดง "ห่าง x กม." และกรองงานใกล้ฉัน
*/
function distanceKm(float $lat1, float $lng1, float $lat2, float $lng2): float {
$earthRadius = 6371.0;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
9. เว็บผู้ดูแลระบบ (Admin) หน้าเว็บ PHP แบบสร้างหน้าที่เซิร์ฟเวอร์ ใช้ session และฟอร์ม POST
เว็บแอดมินต่างจาก API ตรงที่ PHP สร้างหน้า HTML ทั้งหน้า ส่งให้เบราว์เซอร์ (ไม่ได้ตอบ JSON) เปิดที่ http://localhost/jobmatch_admin/
ทุกหน้า (ยกเว้นหน้าล็อกอิน) เขียนเรียง 4 ส่วนเหมือนกัน:
require ไฟล์กลาง + requireLogin() ยังไม่ล็อกอินจะถูกส่งกลับไป index.php
จัดการคำสั่ง (ถ้าเป็น POST) ปุ่มต่าง ๆ ในหน้าเป็นฟอร์มที่ส่ง action กลับมาที่หน้าเดิม ใช้ switch แยกคำสั่ง ทำงานกับฐานข้อมูล แล้วบันทึกประวัติด้วย logAction()
อ่านตัวกรอง/คำค้น/หน้า จาก $_GET แล้ว query ข้อมูลที่จะแสดง
แสดงผล include header.php (เมนูด้านข้าง) → HTML ตาราง/กราฟ → footer.php (JavaScript ช่วยเหลือ)
jobmatch_admin/users.php บรรทัด 7–54 ตัวอย่างขั้น 1–2 จาก users.php: ระงับ / ยกเลิก / เปิดใช้บัญชี แล้วบันทึกประวัติ 7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54 require_once __DIR__ . '/config/database.php';
require_once __DIR__ . '/includes/auth.php';
requireLogin();
$pdo = getDBConnection();
// Handle actions
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// รองรับทั้ง member_id และ user_id
$memberId = intval($_POST['member_id'] ?? $_POST['user_id'] ?? 0);
if ($memberId > 0) {
try {
switch ($action) {
case 'suspend':
$stmt = $pdo->prepare("UPDATE member SET status = 'suspended' WHERE user_id = ?");
$stmt->execute([$memberId]);
logAction($pdo, 'suspend_user', 'member', $memberId, 'ระงับบัญชีผู้ใช้');
$message = 'ระงับบัญชีผู้ใช้สำเร็จ';
$messageType = 'warning';
break;
case 'ban':
$stmt = $pdo->prepare("UPDATE member SET status = 'banned' WHERE user_id = ?");
$stmt->execute([$memberId]);
logAction($pdo, 'ban_user', 'member', $memberId, 'ยกเลิกบัญชีผู้ใช้');
$message = 'ยกเลิกบัญชีผู้ใช้สำเร็จ';
$messageType = 'danger';
break;
case 'activate':
$stmt = $pdo->prepare("UPDATE member SET status = 'active' WHERE user_id = ?");
$stmt->execute([$memberId]);
logAction($pdo, 'activate_user', 'member', $memberId, 'เปิดใช้งานบัญชีผู้ใช้');
$message = 'เปิดใช้งานบัญชีผู้ใช้สำเร็จ';
$messageType = 'success';
break;
}
} catch (Exception $e) {
$message = 'เกิดข้อผิดพลาด: ' . $e->getMessage();
$messageType = 'danger';
}
}
}
ล็อกอินและ session
index.php — หน้าเข้าสู่ระบบ
jobmatch_admin/index.php แอดมิน
บัญชีผู้ดูแลอยู่ในตาราง member เดียวกับผู้ใช้แอป แต่ role = 'admin' พิมพ์ได้ทั้งอีเมลเต็มหรือแค่ส่วนหน้า @ ตรวจรหัสแบบเดียวกับ login.php ของแอป
และเมื่อผ่านจะเรียก session_regenerate_id(true) สร้างรหัส session ใหม่ ป้องกันการโจมตีแบบ session fixation (ผู้ไม่หวังดีฝังรหัส session ไว้ล่วงหน้า)
jobmatch_admin/index.php บรรทัด 29–58 29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58 // บัญชีผู้ดูแลระบบอยู่ในตาราง member (role = 'admin') ล็อกอินด้วยอีเมลเต็ม หรือส่วนหน้า @ ก็ได้ (เช่น admin)
$stmt = $pdo->prepare("SELECT * FROM member WHERE role = 'admin' AND status = 'active' AND (email = ? OR SUBSTRING_INDEX(email, '@', 1) = ?)");
$stmt->execute([$username, $username]);
$admin = $stmt->fetch();
// ตรวจรหัสผ่านกับค่า bcrypt ที่เก็บไว้ ถ้ายังเป็นรหัสแบบข้อความธรรมดา (ข้อมูลเก่า)
// ให้เทียบตรงๆ แล้วแปลงเป็น bcrypt ทันที เหมือน login.php ของแอป
$isValid = false;
if ($admin) {
$stored = (string) $admin['password'];
if (password_get_info($stored)['algo'] !== null) {
$isValid = password_verify($password, $stored);
} elseif (hash_equals($stored, $password)) {
$isValid = true;
$upd = $pdo->prepare('UPDATE member SET password = ? WHERE user_id = ?');
$upd->execute([password_hash($password, PASSWORD_DEFAULT), $admin['user_id']]);
}
}
if ($isValid) {
// ป้องกัน session fixation: สร้าง session id ใหม่หลัง login สำเร็จ
session_regenerate_id(true);
$_SESSION['admin_id'] = $admin['user_id'];
$_SESSION['admin_name'] = $admin['name'];
$_SESSION['admin_username'] = $admin['email'];
session_write_close(); // Ensure session is saved before redirect
header('Location: dashboard.php');
exit;
includes/session_config.php และ includes/auth.php
jobmatch_admin/includes/ แอดมิน
session_config.php ใช้แทนการเรียก session_start() ตรง ๆ เพื่อกำหนดโฟลเดอร์เก็บ session ให้แน่นอน และตั้ง cookie แบบ httponly (JavaScript อ่าน cookie ไม่ได้ ลดความเสี่ยง XSS) กับ samesite=Lax
jobmatch_admin/includes/session_config.php บรรทัด 20–30 20
21
22
23
24
25
26
27
28
29
30 // ตั้งค่า cookie ให้ path ครอบคลุมทั้ง root และ /api/ (กัน session หายเวลาข้ามโฟลเดอร์)
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
auth.php มีฟังก์ชันตรวจล็อกอิน และ logAction() ที่บันทึกทุกการกระทำของแอดมินลงตาราง system_logs (ใคร ทำอะไร กับข้อมูลไหน จาก IP ไหน) ซึ่งหน้า “รายงาน” นำมาแสดงเป็นภาษาไทย
jobmatch_admin/includes/auth.php บรรทัด 11–45 11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45 function requireLogin() {
if (!isset($_SESSION['admin_id'])) {
header('Location: index.php');
exit;
}
}
function isLoggedIn() {
return isset($_SESSION['admin_id']);
}
function getAdminName() {
return $_SESSION['admin_name'] ?? 'Administrator';
}
function getAdminId() {
return $_SESSION['admin_id'] ?? null;
}
// Log admin actions
function logAction($pdo, $action, $targetType = null, $targetId = null, $details = null) {
try {
$stmt = $pdo->prepare("INSERT INTO system_logs (admin_id, action, target_type, target_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([
getAdminId(),
$action,
$targetType,
$targetId,
$details,
$_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'
]);
} catch (Exception $e) {
// Silently fail - logging should not break the application
}
}
logout.php ล้างข้อมูลใน session ลบ cookie ของ session ฝั่งเบราว์เซอร์ ทำลาย session แล้วกลับไปหน้าเข้าสู่ระบบ
โครงหน้า: header, footer, css
jobmatch_admin/includes/header.php · footer.php · assets/css/style.css แอดมิน
header.php เรียก requireLogin() ให้อีกชั้น หาว่ากำลังอยู่หน้าไหน ($currentPage) เพื่อไฮไลต์เมนู และเตรียม “กิจกรรมล่าสุด” ของปุ่มกระดิ่ง (สมาชิกใหม่ 6 คน + ประกาศงานใหม่ 6 งาน เรียงตามเวลา เหลือ 8 รายการ)
โหลด Chart.js จาก CDN ไว้ให้ทุกหน้าใช้วาดกราฟ
jobmatch_admin/includes/header.php บรรทัด 25–56 25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56 // กิจกรรมล่าสุดสำหรับปุ่มแจ้งเตือน: สมาชิกสมัครใหม่ + ประกาศงานใหม่
// "ใหม่" = id มากกว่าที่ผู้ดูแลเคยเปิดดูแล้ว (จำไว้ใน localStorage ฝั่งเบราว์เซอร์)
$adminActivity = [];
try {
$roleLabels = ['seeker' => 'ผู้หางาน', 'employer' => 'ผู้ว่าจ้าง'];
$rows = $pdo->query("SELECT user_id, name, email, role, created_at FROM member
WHERE role IN ('seeker','employer') ORDER BY user_id DESC LIMIT 6")->fetchAll();
foreach ($rows as $r) {
$adminActivity[] = [
'type' => 'member', 'id' => (int) $r['user_id'], 'icon' => 'fa-user-plus',
'title' => 'สมาชิกใหม่: ' . $r['name'],
'sub' => ($roleLabels[$r['role']] ?? $r['role']) . ' · ' . date('d/m/Y H:i', strtotime($r['created_at'])),
'url' => 'users.php?search=' . urlencode($r['email']),
'sort' => strtotime($r['created_at']),
];
}
$rows = $pdo->query("SELECT j.job_id, j.job_title, j.post_date, e.company_name FROM job_posting j
LEFT JOIN employer e ON e.employer_id = j.employer_id
ORDER BY j.job_id DESC LIMIT 6")->fetchAll();
foreach ($rows as $r) {
$adminActivity[] = [
'type' => 'job', 'id' => (int) $r['job_id'], 'icon' => 'fa-briefcase',
'title' => 'ประกาศงานใหม่: ' . $r['job_title'],
'sub' => ($r['company_name'] ?: 'ไม่ระบุบริษัท') . ' · ' . date('d/m/Y', strtotime($r['post_date'])),
'url' => 'job_postings.php?search=' . urlencode($r['job_title']),
// post_date ไม่มีเวลา: ใช้สิ้นวันเพื่อให้งานของวันเดียวกันอยู่ใกล้สมาชิกใหม่ของวันนั้น
'sort' => strtotime($r['post_date'] . ' 23:59:59'),
];
}
usort($adminActivity, fn($a, $b) => $b['sort'] <=> $a['sort']);
$adminActivity = array_slice($adminActivity, 0, 8);
} catch (Exception $e) { $adminActivity = []; }
footer.php มี JavaScript ที่ทุกหน้าใช้: เปิด/ปิดเมนู, toast แจ้งผล, modal, ส่งออก CSV, ฟังก์ชัน apiCall() และตรรกะจุดแดงของกระดิ่ง ซึ่งจำ id ล่าสุดที่แอดมินเคยเห็นไว้ใน localStorage ของเบราว์เซอร์ (ไม่ได้เก็บในฐานข้อมูล)
jobmatch_admin/includes/footer.php บรรทัด 19–59 19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59 // แจ้งเตือนกิจกรรมล่าสุด: จำ id ล่าสุดที่เคยเห็นแยกตามประเภท เพื่อแสดงจุดแดงเมื่อมีรายการใหม่
const NOTI_KEY = 'jobmatchAdminNotiSeen';
function readNotiSeen() {
try { return JSON.parse(localStorage.getItem(NOTI_KEY)) || {}; } catch (e) { return {}; }
}
function refreshNotiState() {
const seen = readNotiSeen();
let hasNew = false;
document.querySelectorAll('.noti-item').forEach(item => {
const isNew = Number(item.dataset.id) > (seen[item.dataset.type] || 0);
item.classList.toggle('is-new', isNew);
if (isNew) hasNew = true;
});
const dot = document.getElementById('notiDot');
if (dot) dot.hidden = !hasNew;
}
function toggleNotiPanel(e) {
e.stopPropagation();
const panel = document.getElementById('notiPanel');
const opening = !panel.classList.contains('open');
panel.classList.toggle('open', opening);
if (opening) {
// เปิดดูแล้ว = ถือว่าเห็นทุกรายการ แต่ยังคงไฮไลต์ "ใหม่" ไว้จนกว่าจะปิดแผง
const seen = readNotiSeen();
document.querySelectorAll('.noti-item').forEach(item => {
seen[item.dataset.type] = Math.max(seen[item.dataset.type] || 0, Number(item.dataset.id));
});
try { localStorage.setItem(NOTI_KEY, JSON.stringify(seen)); } catch (e) {}
document.getElementById('notiDot').hidden = true;
} else {
refreshNotiState();
}
}
document.addEventListener('click', function(e) {
const panel = document.getElementById('notiPanel');
if (panel && panel.classList.contains('open') && !panel.contains(e.target)) {
panel.classList.remove('open');
refreshNotiState();
}
});
refreshNotiState();
style.css (ราว 1,400 บรรทัด) กำหนดหน้าตาทั้งเว็บ: เมนูด้านข้าง การ์ดสถิติ ตาราง ป้ายสถานะ ปุ่ม modal และแผงแจ้งเตือน (.noti-*)
หน้าหลัก 6 หน้า
หน้า แสดงอะไร ทำอะไรได้
dashboard.phpจำนวนสมาชิก (ไม่นับแอดมิน) ผู้หางาน ผู้ว่าจ้าง ประกาศงาน ใบสมัคร, สัดส่วนสถานะงาน, สมาชิก/งาน/ใบสมัครล่าสุด 5 รายการ, กราฟการสมัครสมาชิกรายวัน/เดือน/ปี ดูภาพรวม
users.phpรายชื่อสมาชิก กรองตามบทบาท/ถูกระงับ ค้นหา แบ่งหน้าละ 15 คน ดูรายละเอียด, ระงับ, เปิดใช้, ยกเลิกบัญชี
job_postings.phpประกาศงานทั้งหมดพร้อมชื่อบริษัทและป้ายสถานะ ค้นหา/กรอง, ดูรายละเอียด, ลบประกาศ
keywords.phpคลังคำสำคัญแยกหมวด พร้อมจำนวนคำต่อหมวด เพิ่มทีละคำ, เพิ่มหลายคำพร้อมกัน, แก้ไข, ลบ — ทุกครั้งที่สำเร็จจะคำนวณการจับคู่ใหม่ทั้งหมด
matching.phpเกณฑ์คะแนน 5 ค่า, จำนวนคู่ในแต่ละระดับ, บริษัทที่ตอบกลับผู้สมัครมากที่สุด แก้เกณฑ์ (มีผลทันที), กดคำนวณผลการจับคู่ใหม่
reports.phpสถิติรวม, กราฟสมาชิก/ประกาศงานตามช่วงเวลา, สถานะงาน, สถิติคำสำคัญ, ประวัติการทำงานของแอดมิน (แปลชื่อการกระทำเป็นภาษาไทย) ส่งออกรายงานเป็น PDF (html2canvas จับภาพหน้า + jsPDF สร้างไฟล์)
matching.php — แก้เกณฑ์การจับคู่
jobmatch_admin/matching.php แอดมิน
require matching_service.php ของ API มาใช้ตรง ๆ (บรรทัด 10) ตรวจว่าทุกค่าเป็นตัวเลข 0–100 และเรียงลำดับถูกต้อง (ปานกลาง ≤ ดี ≤ สูงมาก) แล้วบันทึกด้วย
INSERT … ON DUPLICATE KEY UPDATE คือ “ถ้ายังไม่มีคีย์นี้ให้เพิ่ม ถ้ามีแล้วให้แก้ค่า” ในคำสั่งเดียว
jobmatch_admin/matching.php บรรทัด 25–59 25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59 if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') {
$values = [];
$errors = [];
foreach ($fields as $key => $meta) {
$v = $_POST[$key] ?? '';
if (!is_numeric($v) || $v < 0 || $v > 100) {
$errors[] = $meta['label'] . ' ต้องเป็นตัวเลข 0-100';
}
$values[$key] = (float) $v;
}
if (!$errors && !($values['level_medium'] <= $values['level_good'] && $values['level_good'] <= $values['level_very_high'])) {
$errors[] = 'ระดับคะแนนต้องเรียงกัน: ปานกลาง ≤ ดี ≤ สูงมาก';
}
if ($errors) {
$message = implode(' / ', $errors);
$messageType = 'danger';
} else {
$stmt = $pdo->prepare('INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)');
foreach ($values as $key => $value) {
$stmt->execute([$key, rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.')]);
}
logAction($pdo, 'update_matching_settings', 'system_settings', null, json_encode($values, JSON_UNESCAPED_UNICODE));
$message = 'บันทึกเกณฑ์การจับคู่สำเร็จ ระบบจะใช้เกณฑ์ใหม่ทันที';
$messageType = 'success';
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'recalculate') {
recalculateAllMatching($pdo);
logAction($pdo, 'recalculate_matching', 'matching', null, 'คำนวณผลการจับคู่ใหม่ทั้งหมด');
$message = 'คำนวณผลการจับคู่ใหม่ทั้งหมดเรียบร้อย';
$messageType = 'success';
}
เพราะ API อ่านเกณฑ์จากตารางเดียวกันทุกครั้งที่มีคำขอ ค่าใหม่จึงมีผลกับแอปทันที (ระดับความเหมาะสม งานแนะนำ ป้ายผู้สมัครแนะนำ)
api/manage_user.php และ api/manage_keyword.php
jobmatch_admin/api/ แอดมิน
endpoint ที่ตอบ JSON สำหรับเรียกแบบ AJAX ผ่าน apiCall() ใน footer.php ตรวจล็อกอินด้วย session เหมือนหน้าอื่น
ปัจจุบันหน้า users.php และ keywords.php ใช้ฟอร์ม POST แทน สองไฟล์นี้จึงเป็นทางเลือกที่ยังไม่ได้เรียกใช้
jobmatch_admin/api/manage_user.php บรรทัด 27–44 27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44 $validStatuses = [
'approve' => 'active',
'suspend' => 'suspended',
'ban' => 'banned',
'activate' => 'active',
];
if (!isset($validStatuses[$action])) {
echo json_encode(['success' => false, 'message' => 'Invalid action']);
exit;
}
try {
$newStatus = $validStatuses[$action];
$stmt = $pdo->prepare("UPDATE member SET status = ? WHERE user_id = ?");
$stmt->execute([$newStatus, $memberId]);
logAction($pdo, $action . '_user', 'member', $memberId, "API: $action user");
config/database.php มีฟังก์ชัน getDBConnection() ที่สร้าง PDO ครั้งเดียวแล้วเก็บไว้ในตัวแปร static (เรียกกี่ครั้งก็ได้การเชื่อมต่อเดิม) ส่วน sql/admin_setup.sql เป็นสคริปต์สร้างตารางฝั่งแอดมินตอนติดตั้งครั้งแรก