ระบบจองห้อง โต๊ะ สนาม หรือคิว เก็บในเครื่อง กันจองซ้อน ดูปฏิทินรายวัน สรุปรายได้ และส่งออก CSV

ระบบจองห้องประชุม โต๊ะร้านอาหาร สนามกีฬา หรือคิวช่าง ลงทะเบียนสิ่งที่ให้จองพร้อมราคาต่อชั่วโมง จองตามวันและช่วงเวลา กันจองซ้อนอัตโนมัติ (บอกช่วงว่างให้เมื่อชน) คิดราคาให้ ดูตารางรายวันแบบปฏิทินในแผง สรุปรายได้รายเดือน และส่งออก CSV ที่ Excel อ่านภาษาไทยได้ ข้อมูลเก็บในเครื่องเท่านั้น
| คำสั่ง | ทำอะไร |
|---|---|
/book resource add ห้องA ห้องประชุม 300 | เพิ่มสิ่งที่ให้จอง ราคา 300 บาท/ชม. |
/book resource list / remove <ชื่อ> | ดู/ลบ |
/book add ห้องA พรุ่งนี้ 13:00-15:00 คุณสมชาย ประชุมทีม | จอง (วัน: วันนี้, พรุ่งนี้, 25/10, 25/10/2569, 2026-10-25) |
/book free ห้องA พรุ่งนี้ | ช่วงที่ว่าง |
/book cancel <id> / done <id> | ยกเลิก / ปิดงาน |
/book day [วัน] | ตารางรายวัน + เปิดแผง |
/book list [วัน] | รายการจอง |
/book revenue [YYYY-MM] | สรุปรายได้รายเดือน |
/book export | ส่งออก bookings.csv |
/plugin install booking-system-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
เปิดให้จองช่วง 08:00-22:00 แก้ได้ที่ค่าคงที่ OPEN และ CLOSE ข้อมูลอยู่ในเครื่องเดียว ถ้าต้องการให้ลูกค้าจองเองผ่านเว็บ ต้องทำเว็บแยก
hooks/register.tsx 290 lines1/**
2 * ============================================================================
3 * MOD 25: Booking System TH - ระบบจองห้อง โต๊ะ สนาม หรือคิว (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: ระบบจองห้องประชุม, จองสนามแบด, จองคิวร้านตัดผม
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. ลงทะเบียนสิ่งที่ให้จอง (ห้อง/โต๊ะ/สนาม/ช่าง) พร้อมราคาต่อชั่วโมง
9 * 2. จองตามวันและช่วงเวลา กันจองซ้อนอัตโนมัติ คิดราคาให้
10 * 3. ดูตารางรายวันแบบปฏิทินในแผง, ดูช่วงว่าง
11 * 4. ยกเลิก / ปิดงาน, สรุปรายได้รายเดือน, ส่งออก CSV
12 * 5. ข้อมูลเก็บในเครื่อง ($.store) ไม่ส่งไปที่ไหน
13 */
14
15import type { EngineInterface, Register } from 'claude-code'
16
17interface Resource {
18 name: string
19 type: string
20 pricePerHour: number
21}
22interface Booking {
23 id: number
24 resource: string
25 date: string // YYYY-MM-DD
26 start: number // นาทีนับจากเที่ยงคืน เช่น 9:30 = 570
27 end: number
28 customer: string
29 note: string
30 price: number
31 status: 'confirmed' | 'completed' | 'cancelled'
32 createdAt: number
33}
34
35const PANE = 'booking-system-th'
36const OPEN = 8 * 60 // เวลาเปิดให้จอง 08:00
37const CLOSE = 22 * 60 // ปิด 22:00
38let resources: Resource[] = []
39let bookings: Booking[] = []
40let nextId = 1
41let viewDate = ''
42
43async function save($: EngineInterface): Promise<void> {
44 await $.store.set('booking', { resources, bookings, nextId })
45}
46
47// ==========================================================================
48// บทที่ 1: แปลงวันที่และเวลา
49// ==========================================================================
50function ymd(d: Date): string {
51 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
52}
53
54// รับ today, tomorrow, วันนี้, พรุ่งนี้, 2026-10-25, 25/10, 25/10/2026
55function parseDate(text: string | undefined, now: number): string | undefined {
56 const base = new Date(now)
57 if (!text || text === 'today' || text === 'วันนี้') return ymd(base)
58 if (text === 'tomorrow' || text === 'พรุ่งนี้') return ymd(new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1))
59 let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text)
60 if (m) return ymd(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])))
61 m = /^(\d{1,2})\/(\d{1,2})(?:\/(\d{4}))?$/.exec(text)
62 if (m) {
63 let year = Number(m[3] ?? base.getFullYear())
64 if (year > 2400) year -= 543 // ปี พ.ศ. -> ค.ศ.
65 return ymd(new Date(year, Number(m[2]) - 1, Number(m[1])))
66 }
67 return undefined
68}
69
70function toMinutes(t: string): number | undefined {
71 const m = /^([01]?\d|2[0-4])[:.]?([0-5]\d)?$/.exec(t)
72 return m ? Number(m[1]) * 60 + Number(m[2] ?? 0) : undefined
73}
74
75function hhmm(min: number): string {
76 return `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
77}
78
79const baht = (n: number) => `฿${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`
80
81// ==========================================================================
82// บทที่ 2: หัวใจของระบบจอง - เช็คช่วงเวลาซ้อนกัน
83// ==========================================================================
84// สองช่วง [a1, a2) กับ [b1, b2) ซ้อนกันเมื่อ a1 < b2 และ b1 < a2
85function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
86 return a.start < b.end && b.start < a.end
87}
88
89function activeOn(resource: string, date: string): Booking[] {
90 return bookings.filter(b => b.resource === resource && b.date === date && b.status !== 'cancelled').sort((a, b) => a.start - b.start)
91}
92
93function freeSlots(resource: string, date: string): { start: number; end: number }[] {
94 const slots: { start: number; end: number }[] = []
95 let cursor = OPEN
96 for (const b of activeOn(resource, date)) {
97 if (b.start > cursor) slots.push({ start: cursor, end: b.start })
98 cursor = Math.max(cursor, b.end)
99 }
100 if (cursor < CLOSE) slots.push({ start: cursor, end: CLOSE })
101 return slots
102}
103
104// ==========================================================================
105// บทที่ 3: ตารางรายวันแบบข้อความ (ช่องละ 30 นาที)
106// ==========================================================================
107function dayGrid(date: string): string[] {
108 const header = `${'เวลา'.padEnd(12)}${Array.from({ length: (CLOSE - OPEN) / 60 }, (_, i) => String((OPEN / 60 + i) % 100).padStart(2, '0')).join('')}`
109 const rows = resources.map(r => {
110 let row = ''
111 for (let t = OPEN; t < CLOSE; t += 30) row += activeOn(r.name, date).some(b => overlaps(b, { start: t, end: t + 30 })) ? '█' : '·'
112 return `${r.name.slice(0, 11).padEnd(12)}${row}`
113 })
114 return [header, ...rows]
115}
116
117function bookingLine(b: Booking): string {
118 const icon = b.status === 'cancelled' ? '❌' : b.status === 'completed' ? '✅' : '📌'
119 return `${icon} #${b.id} ${b.date} ${hhmm(b.start)}-${hhmm(b.end)} ${b.resource} · ${b.customer} · ${baht(b.price)}${b.note ? ` · ${b.note}` : ''}`
120}
121
122const HELP = `📅 Booking System TH (Mod 25)
123========================================
124ตั้งค่า:
125 /book resource add <ชื่อ> [ประเภท] [ราคา/ชม.] เช่น /book resource add ห้องA ห้องประชุม 300
126 /book resource list | remove <ชื่อ>
127จอง:
128 /book add <ชื่อ> <วัน> <เริ่ม>-<จบ> <ลูกค้า> [หมายเหตุ]
129 เช่น /book add ห้องA พรุ่งนี้ 13:00-15:00 คุณสมชาย ประชุมทีม
130 วัน: วันนี้ | พรุ่งนี้ | 25/10 | 2026-10-25
131 /book free <ชื่อ> [วัน] ช่วงที่ว่าง
132 /book cancel <id> | done <id>
133ดู:
134 /book day [วัน] ตารางรายวัน + เปิดแผง
135 /book list [วัน] รายการจอง
136 /book revenue [YYYY-MM] สรุปรายได้รายเดือน
137 /book export ส่งออก CSV
138เวลาเปิดให้จอง ${hhmm(OPEN)}-${hhmm(CLOSE)}`
139
140export const register: Register = on => {
141 on('session.start', async ($, e, next) => {
142 const saved = (await $.store.get('booking')) as { resources?: Resource[]; bookings?: Booking[]; nextId?: number } | undefined
143 resources = saved?.resources ?? []
144 bookings = saved?.bookings ?? []
145 nextId = saved?.nextId ?? 1
146 viewDate = ymd(new Date(await $.clock.now()))
147 await $.command.register({ name: 'book', description: 'ระบบจองห้อง โต๊ะ สนาม หรือคิว (/book help)', argumentHint: 'add|free|day|list|cancel|revenue' })
148 return next(e)
149 })
150
151 // ==========================================================================
152 // บทที่ 4: แผงตารางรายวัน
153 // ==========================================================================
154 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155 const { Box, Text } = $.ui.resolve(e)
156 const list = bookings.filter(b => b.date === viewDate && b.status !== 'cancelled').sort((a, b) => a.start - b.start)
157 return (
158 <Box flexDirection="column">
159 <Text bold>📅 {viewDate}</Text>
160 {dayGrid(viewDate).map((line, i) => (
161 <Text dimColor={i === 0} wrap="truncate-end">
162 {line}
163 </Text>
164 ))}
165 <Box flexDirection="column" marginTop={1}>
166 {list.length === 0 && <Text dimColor>ยังไม่มีการจองวันนี้</Text>}
167 {list.map(b => (
168 <Text wrap="truncate-end">
169 {hhmm(b.start)}-{hhmm(b.end)} {b.resource} · {b.customer}
170 </Text>
171 ))}
172 </Box>
173 </Box>
174 )
175 })
176
177 // ==========================================================================
178 // บทที่ 5: คำสั่ง /book
179 // ==========================================================================
180 on('command.run', { command: 'book' }, async ($, e) => {
181 const [sub = 'help', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
182 const now = await $.clock.now()
183 const redraw = () => $.ui.invalidate('ui.render')
184
185 switch (sub) {
186 case 'resource': {
187 const [action, name, type, price] = rest
188 if (action === 'add' && name) {
189 if (resources.some(r => r.name === name)) return { text: `ℹ️ มี ${name} อยู่แล้ว` }
190 resources.push({ name, type: type ?? 'ทั่วไป', pricePerHour: Number(price) || 0 })
191 await save($)
192 redraw()
193 return { text: `➕ เพิ่ม ${name} (${type ?? 'ทั่วไป'}) ${baht(Number(price) || 0)}/ชม.` }
194 }
195 if (action === 'remove' && name) {
196 if (bookings.some(b => b.resource === name && b.status === 'confirmed' && b.date >= ymd(new Date(now)))) return { text: `❌ ${name} ยังมีการจองที่ยังไม่ถึงวัน ยกเลิกก่อน` }
197 resources = resources.filter(r => r.name !== name)
198 await save($)
199 redraw()
200 return { text: `🗑️ ลบ ${name} แล้ว` }
201 }
202 return { text: resources.length ? ['🏷️ รายการที่ให้จอง', ...resources.map(r => ` ${r.name.padEnd(12)} ${r.type.padEnd(12)} ${baht(r.pricePerHour)}/ชม.`)].join('\n') : 'ยังไม่มี เพิ่มด้วย /book resource add ห้องA ห้องประชุม 300' }
203 }
204 case 'add': {
205 const [name = '', dateText, range = '', customer, ...noteWords] = rest
206 const resource = resources.find(r => r.name === name)
207 if (!resource) return { text: `❌ ไม่พบ "${name}" (ดูรายการด้วย /book resource list)` }
208 const date = parseDate(dateText, now)
209 const [s, en] = range.split('-')
210 const start = toMinutes(s ?? '')
211 const end = toMinutes(en ?? '')
212 if (!date || start === undefined || end === undefined || !customer) return { text: 'วิธีใช้: /book add ห้องA พรุ่งนี้ 13:00-15:00 คุณสมชาย [หมายเหตุ]' }
213 if (end <= start) return { text: '❌ เวลาจบต้องหลังเวลาเริ่ม' }
214 if (start < OPEN || end > CLOSE) return { text: `❌ จองได้ช่วง ${hhmm(OPEN)}-${hhmm(CLOSE)}` }
215 if (new Date(`${date}T00:00`).getTime() + end * 60_000 <= now) return { text: '❌ ช่วงเวลานี้ผ่านไปแล้ว' }
216 const clash = activeOn(name, date).find(b => overlaps(b, { start, end }))
217 if (clash) {
218 const free = freeSlots(name, date).map(f => `${hhmm(f.start)}-${hhmm(f.end)}`).join(', ')
219 return { text: `❌ ชนกับการจอง #${clash.id} ${hhmm(clash.start)}-${hhmm(clash.end)} (${clash.customer})\nช่วงว่างวันนั้น: ${free || 'ไม่มี'}` }
220 }
221 const b: Booking = { id: nextId++, resource: name, date, start, end, customer, note: noteWords.join(' '), price: ((end - start) / 60) * resource.pricePerHour, status: 'confirmed', createdAt: now }
222 bookings.push(b)
223 await save($)
224 viewDate = date
225 redraw()
226 return { text: `✅ จองแล้ว\n${bookingLine(b)}` }
227 }
228 case 'free': {
229 const [name = '', dateText] = rest
230 const date = parseDate(dateText, now)
231 if (!resources.some(r => r.name === name) || !date) return { text: 'วิธีใช้: /book free ห้องA พรุ่งนี้' }
232 const free = freeSlots(name, date)
233 return { text: `🟢 ${name} วันที่ ${date} ว่าง: ${free.map(f => `${hhmm(f.start)}-${hhmm(f.end)}`).join(', ') || 'เต็มทั้งวัน'}` }
234 }
235 case 'cancel':
236 case 'done': {
237 const b = bookings.find(x => x.id === Number(rest[0]))
238 if (!b) return { text: `❌ ไม่พบการจอง #${rest[0] ?? ''}` }
239 if (b.status !== 'confirmed') return { text: `ℹ️ #${b.id} ${b.status === 'cancelled' ? 'ยกเลิกไปแล้ว' : 'ปิดงานไปแล้ว'}` }
240 b.status = sub === 'cancel' ? 'cancelled' : 'completed'
241 await save($)
242 redraw()
243 return { text: `${sub === 'cancel' ? '❌ ยกเลิก' : '✅ ปิดงาน'} ${bookingLine(b)}` }
244 }
245 case 'day': {
246 const date = parseDate(rest[0], now)
247 if (!date) return { text: 'วิธีใช้: /book day พรุ่งนี้' }
248 viewDate = date
249 await $.ui.open({ id: PANE, title: '📅 Booking' })
250 redraw()
251 return { text: resources.length ? [`📅 ${date} (█ = จองแล้ว, ช่องละ 30 นาที)`, ...dayGrid(date)].join('\n') : 'ยังไม่มีรายการให้จอง' }
252 }
253 case 'list': {
254 const date = rest[0] ? parseDate(rest[0], now) : undefined
255 const list = bookings.filter(b => (date ? b.date === date : b.date >= ymd(new Date(now)) && b.status === 'confirmed')).sort((a, b) => a.date.localeCompare(b.date) || a.start - b.start)
256 return { text: list.length ? [`📋 การจอง${date ? ` วันที่ ${date}` : 'ที่กำลังจะถึง'}`, ...list.map(bookingLine)].join('\n') : 'ไม่มีการจอง' }
257 }
258 case 'revenue': {
259 const month = /^\d{4}-\d{2}$/.test(rest[0] ?? '') ? (rest[0] ?? '') : ymd(new Date(now)).slice(0, 7)
260 const inMonth = bookings.filter(b => b.date.startsWith(month) && b.status !== 'cancelled')
261 const byResource = new Map<string, number>()
262 for (const b of inMonth) byResource.set(b.resource, (byResource.get(b.resource) ?? 0) + b.price)
263 const done = inMonth.filter(b => b.status === 'completed').reduce((s, b) => s + b.price, 0)
264 const total = inMonth.reduce((s, b) => s + b.price, 0)
265 const cancelled = bookings.filter(b => b.date.startsWith(month) && b.status === 'cancelled').length
266 return { text: [`💰 รายได้ ${month}`, ` รวม ${baht(total)} (${inMonth.length} การจอง) | ปิดงานแล้ว ${baht(done)} | ยกเลิก ${cancelled}`, ...[...byResource.entries()].sort((a, b) => b[1] - a[1]).map(([r, v]) => ` ${r.padEnd(12)} ${baht(v)}`)].join('\n') }
267 }
268 case 'export': {
269 const root = (await $.session.cwd()).replace(/[\\/]$/, '')
270 const path = `${root}${root.includes('\\') ? '\\' : '/'}bookings.csv`
271 const q = (s: string) => `"${s.replace(/"/g, '""')}"`
272 const csv = ['id,date,start,end,resource,customer,note,price,status', ...bookings.map(b => [b.id, b.date, hhmm(b.start), hhmm(b.end), q(b.resource), q(b.customer), q(b.note), b.price, b.status].join(','))].join('\n')
273 await $.fs.write(path, '' + csv + '\n') // BOM ให้ Excel อ่านภาษาไทยถูก
274 return { text: `💾 บันทึก ${bookings.length} รายการ: ${path}` }
275 }
276 default:
277 return { text: HELP }
278 }
279 })
280}
281
282/**
283 * ========================================================================
284 * การบ้าน / ต่อยอด
285 * ========================================================================
286 * 1. ส่งยืนยันการจองเป็น LINE Flex Message (Mod 12) พร้อม QR มัดจำพร้อมเพย์ (Mod 14)
287 * 2. เตือนก่อนถึงเวลาจอง 30 นาทีด้วย $.clock.every + $.ui.toast หรือ Mod 22
288 * 3. จองแบบซ้ำทุกสัปดาห์ (ทุกวันจันทร์ 10:00-12:00)
289 */
290