Compare commits
10 Commits
d33238e82c
...
9c74204e27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c74204e27 | ||
|
|
4202aa3804 | ||
|
|
ca26b2b172 | ||
|
|
c51f969f19 | ||
|
|
033a02fbc7 | ||
|
|
ead1675190 | ||
|
|
bd7c1a17ea | ||
|
|
44f7c5c21a | ||
|
|
3407040b6e | ||
|
|
e6e3fae89e |
295
20260101 time check/CACHE_CLEARING_GUIDE.md
Normal file
295
20260101 time check/CACHE_CLEARING_GUIDE.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# 快取清除機制說明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本系統實現了多層次的快取清除機制,確保使用者在程式更新後能夠立即獲得最新版本,避免因瀏覽器快取導致的問題。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 實現方式
|
||||
|
||||
### 1. HTML Meta 標籤(第一層防護)
|
||||
|
||||
在 `index.html` 中添加了以下 meta 標籤:
|
||||
|
||||
```html
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
```
|
||||
|
||||
**作用**: 告訴瀏覽器不要快取 HTML 檔案,每次都從伺服器獲取最新版本。
|
||||
|
||||
---
|
||||
|
||||
### 2. JavaScript 版本檢測(第二層防護)
|
||||
|
||||
在 `index.html` 中添加了智能快取清除腳本:
|
||||
|
||||
```javascript
|
||||
const APP_VERSION = '2.4.0';
|
||||
const CACHE_KEY = 'app_version';
|
||||
|
||||
// 檢查版本是否變更
|
||||
const storedVersion = localStorage.getItem(CACHE_KEY);
|
||||
|
||||
if (storedVersion !== APP_VERSION) {
|
||||
// 清除快取...
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:
|
||||
- ✅ 自動偵測版本變更
|
||||
- ✅ 清除 localStorage(保留重要資料如登入狀態)
|
||||
- ✅ 清除 sessionStorage
|
||||
- ✅ 清除 Service Worker 快取
|
||||
- ✅ 在 Console 顯示清除訊息
|
||||
|
||||
**保留的資料**:
|
||||
- `adminAuth` - 管理員登入狀態
|
||||
- `VITE_SUPABASE_URL` - Supabase 連線 URL
|
||||
- `VITE_SUPABASE_ANON_KEY` - Supabase 金鑰
|
||||
|
||||
---
|
||||
|
||||
### 3. Apache .htaccess 設定(第三層防護)
|
||||
|
||||
在 `.htaccess` 中添加了快取控制規則:
|
||||
|
||||
```apache
|
||||
# 防止 HTML 檔案快取
|
||||
<FilesMatch "\.(html|htm)$">
|
||||
<IfModule mod_headers.c>
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# 靜態資源使用 ETag 驗證
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "public, max-age=2592000"
|
||||
FileETag MTime Size
|
||||
</FilesMatch>
|
||||
```
|
||||
|
||||
**作用**:
|
||||
- HTML 檔案:完全不快取,每次都獲取最新版本
|
||||
- CSS/JS 檔案:使用 ETag 驗證,檔案變更時自動更新
|
||||
- 圖片檔案:長期快取(1年),提升效能
|
||||
|
||||
---
|
||||
|
||||
## 🚀 使用方式
|
||||
|
||||
### 發布新版本時
|
||||
|
||||
1. **更新版本號**
|
||||
|
||||
編輯 `index.html`,修改版本號:
|
||||
```javascript
|
||||
const APP_VERSION = '2.4.1'; // 更新版本號
|
||||
```
|
||||
|
||||
2. **重新建置**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. **部署到 SiteGround**
|
||||
|
||||
上傳 `dist` 資料夾的所有內容
|
||||
|
||||
4. **完成!**
|
||||
|
||||
使用者下次訪問時會自動清除快取並載入新版本
|
||||
|
||||
---
|
||||
|
||||
## 📊 快取清除流程
|
||||
|
||||
```
|
||||
使用者開啟網站
|
||||
↓
|
||||
載入 index.html (不快取)
|
||||
↓
|
||||
執行版本檢測腳本
|
||||
↓
|
||||
版本號相同?
|
||||
├─ 是 → 正常載入
|
||||
└─ 否 → 清除快取
|
||||
↓
|
||||
保存重要資料
|
||||
↓
|
||||
清除 localStorage
|
||||
↓
|
||||
清除 sessionStorage
|
||||
↓
|
||||
清除 Service Worker 快取
|
||||
↓
|
||||
更新版本號
|
||||
↓
|
||||
載入新版本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 驗證快取清除
|
||||
|
||||
### 方法一:查看 Console
|
||||
|
||||
開啟瀏覽器開發者工具(F12),在 Console 中查看:
|
||||
|
||||
- **首次訪問或版本更新時**:
|
||||
```
|
||||
🔄 New version detected, clearing cache...
|
||||
✅ Cache cleared successfully!
|
||||
```
|
||||
|
||||
- **版本未變更時**:
|
||||
(無訊息,正常載入)
|
||||
|
||||
### 方法二:檢查 localStorage
|
||||
|
||||
在 Console 中執行:
|
||||
```javascript
|
||||
localStorage.getItem('app_version')
|
||||
```
|
||||
|
||||
應該顯示當前版本號(例如:`"2.4.0"`)
|
||||
|
||||
### 方法三:Network 標籤
|
||||
|
||||
1. 開啟開發者工具
|
||||
2. 切換到 "Network" 標籤
|
||||
3. 重新整理頁面
|
||||
4. 檢查 `index.html` 的請求:
|
||||
- **Status**: 200(從伺服器獲取)
|
||||
- **Size**: 實際大小(不是 "from cache")
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 進階設定
|
||||
|
||||
### 調整快取策略
|
||||
|
||||
如果需要修改快取策略,可以編輯 `.htaccess`:
|
||||
|
||||
```apache
|
||||
# 修改 CSS/JS 快取時間(預設 30 天)
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "public, max-age=86400" # 改為 1 天
|
||||
</FilesMatch>
|
||||
|
||||
# 修改圖片快取時間(預設 1 年)
|
||||
ExpiresByType image/png "access plus 1 month" # 改為 1 個月
|
||||
```
|
||||
|
||||
### 添加更多保留資料
|
||||
|
||||
如果需要保留更多 localStorage 資料,編輯 `index.html`:
|
||||
|
||||
```javascript
|
||||
const keysToKeep = [
|
||||
'adminAuth',
|
||||
'VITE_SUPABASE_URL',
|
||||
'VITE_SUPABASE_ANON_KEY',
|
||||
'user_preferences', // 新增
|
||||
'theme_setting' // 新增
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常見問題
|
||||
|
||||
### Q1: 為什麼使用者還是看到舊版本?
|
||||
|
||||
**可能原因**:
|
||||
1. 瀏覽器強快取(Ctrl+F5 強制重新整理)
|
||||
2. CDN 快取(如使用 Cloudflare,需清除 CDN 快取)
|
||||
3. 版本號未更新
|
||||
|
||||
**解決方案**:
|
||||
1. 確認 `index.html` 中的版本號已更新
|
||||
2. 請使用者按 Ctrl+F5 強制重新整理
|
||||
3. 清除 CDN 快取(如有使用)
|
||||
|
||||
### Q2: 快取清除會影響效能嗎?
|
||||
|
||||
**答**: 不會。
|
||||
- HTML 檔案很小(< 1KB),每次載入不影響效能
|
||||
- CSS/JS 使用 ETag 驗證,未變更時仍使用快取
|
||||
- 圖片等靜態資源長期快取,效能最佳
|
||||
|
||||
### Q3: 使用者的登入狀態會被清除嗎?
|
||||
|
||||
**答**: 不會。
|
||||
- `adminAuth` 會被保留
|
||||
- 只有在版本更新時才清除快取
|
||||
- 重要資料會先備份再還原
|
||||
|
||||
### Q4: 如何完全停用快取(測試用)?
|
||||
|
||||
在 `.htaccess` 中添加:
|
||||
```apache
|
||||
# 測試用:停用所有快取
|
||||
<IfModule mod_headers.c>
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
**注意**: 僅用於測試,正式環境請移除!
|
||||
|
||||
---
|
||||
|
||||
## 📝 版本更新記錄
|
||||
|
||||
| 版本 | 日期 | 更新內容 |
|
||||
|------|------|----------|
|
||||
| 2.4.0 | 2026-01-03 | 實現多層次快取清除機制 |
|
||||
| 2.3.0 | 2026-01-02 | 新增打卡功能優化 |
|
||||
| 2.2.0 | 2026-01-01 | 新增品牌設定功能 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 檢查清單
|
||||
|
||||
部署新版本時,請確認:
|
||||
|
||||
- [ ] 已更新 `index.html` 中的版本號
|
||||
- [ ] 已執行 `npm run build`
|
||||
- [ ] 已上傳所有 `dist` 資料夾內容
|
||||
- [ ] `.htaccess` 檔案已正確上傳
|
||||
- [ ] 在 Console 中確認快取清除訊息
|
||||
- [ ] 測試主要功能正常運作
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳實踐
|
||||
|
||||
1. **每次發布都更新版本號**
|
||||
- 使用語義化版本(Semantic Versioning)
|
||||
- 例如:2.4.0 → 2.4.1(小更新)
|
||||
- 例如:2.4.0 → 2.5.0(功能更新)
|
||||
- 例如:2.4.0 → 3.0.0(重大更新)
|
||||
|
||||
2. **在更新日誌中記錄變更**
|
||||
- 方便追蹤問題
|
||||
- 幫助使用者了解新功能
|
||||
|
||||
3. **測試環境先驗證**
|
||||
- 在測試環境確認快取清除正常
|
||||
- 確認無副作用後再部署到正式環境
|
||||
|
||||
4. **通知使用者重大更新**
|
||||
- 如有重大變更,可在系統中顯示更新通知
|
||||
- 提醒使用者重新整理頁面
|
||||
|
||||
---
|
||||
|
||||
**實現日期**: 2026-01-03
|
||||
**維護者**: 系統管理員
|
||||
**文件版本**: 1.0
|
||||
191
20260101 time check/DEPLOYMENT_CHECKLIST.md
Normal file
191
20260101 time check/DEPLOYMENT_CHECKLIST.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# SiteGround 部署檢查清單
|
||||
|
||||
## 📋 部署前準備
|
||||
|
||||
### 本地環境
|
||||
- [x] 程式碼已建置完成(`npm run build`)
|
||||
- [ ] 檢查 `dist` 資料夾內容完整
|
||||
- [ ] 確認 `.htaccess` 檔案存在於 `dist` 資料夾
|
||||
- [ ] 測試本地建置版本(可選):`npx serve dist`
|
||||
|
||||
### Supabase 設定
|
||||
- [ ] 資料表已建立:
|
||||
- [ ] `employees`
|
||||
- [ ] `attendance_logs`
|
||||
- [ ] `system_settings`
|
||||
- [ ] `admin_users`
|
||||
- [ ] RLS 政策已啟用並設定
|
||||
- [ ] Storage bucket `system-assets` 已建立
|
||||
- [ ] Storage 政策已設定
|
||||
- [ ] 已執行初始化 SQL 腳本:
|
||||
- [ ] `FIX_DATABASE_AND_STORAGE.sql`
|
||||
- [ ] `FIX_CLOCK_IN_FUNCTION.sql`
|
||||
|
||||
### SiteGround 準備
|
||||
- [ ] 已登入 SiteGround 控制台
|
||||
- [ ] 確認網域已設定
|
||||
- [ ] FTP/SFTP 連線資訊已準備
|
||||
- [ ] 目標資料夾已清空(如果是更新部署)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署步驟
|
||||
|
||||
### 步驟 1: 上傳檔案
|
||||
- [ ] 連接到 SiteGround FTP/SFTP
|
||||
- [ ] 導航到 `public_html` 或網域資料夾
|
||||
- [ ] 上傳 `dist` 資料夾中的**所有內容**:
|
||||
- [ ] `index.html`
|
||||
- [ ] `assets/` 資料夾
|
||||
- [ ] `.htaccess` 檔案
|
||||
|
||||
### 步驟 2: 環境變數設定
|
||||
選擇以下其中一種方式:
|
||||
|
||||
**方式 A: 修改程式碼(快速但不安全)**
|
||||
- [ ] 編輯 `src/supabaseClient.js`
|
||||
- [ ] 將 `import.meta.env.VITE_SUPABASE_URL` 替換為實際 URL
|
||||
- [ ] 將 `import.meta.env.VITE_SUPABASE_ANON_KEY` 替換為實際 Key
|
||||
- [ ] 重新建置並上傳
|
||||
|
||||
**方式 B: 使用 SiteGround 環境變數(推薦)**
|
||||
- [ ] 登入 SiteGround Site Tools
|
||||
- [ ] 前往 "Devs" → "Environment Variables"
|
||||
- [ ] 新增 `VITE_SUPABASE_URL`
|
||||
- [ ] 新增 `VITE_SUPABASE_ANON_KEY`
|
||||
|
||||
### 步驟 3: 檔案權限設定
|
||||
- [ ] 資料夾權限設為 `755`
|
||||
- [ ] 檔案權限設為 `644`
|
||||
- [ ] `.htaccess` 權限設為 `644`
|
||||
|
||||
### 步驟 4: Apache 設定檢查
|
||||
- [ ] 確認 `mod_rewrite` 模組已啟用
|
||||
- [ ] 確認 `.htaccess` 檔案可以被讀取
|
||||
- [ ] 測試 URL 重寫功能
|
||||
|
||||
---
|
||||
|
||||
## ✅ 部署後測試
|
||||
|
||||
### 基本功能測試
|
||||
- [ ] 訪問網站首頁(https://您的網域.com)
|
||||
- [ ] 首頁正確顯示:
|
||||
- [ ] 動態時鐘運作
|
||||
- [ ] 公司 Logo 顯示(如已上傳)
|
||||
- [ ] 「進入程式」按鈕可點擊
|
||||
- [ ] 點擊「進入程式」進入考勤頁面
|
||||
- [ ] 「回上頁」按鈕功能正常
|
||||
|
||||
### 考勤功能測試
|
||||
- [ ] 員工列表正確載入
|
||||
- [ ] 可以選擇員工
|
||||
- [ ] 上班打卡功能正常
|
||||
- [ ] 下班打卡功能正常
|
||||
- [ ] 打卡記錄正確顯示
|
||||
- [ ] 工作狀態正確更新(工作中/未執勤)
|
||||
|
||||
### 管理功能測試
|
||||
- [ ] 管理者登入功能正常
|
||||
- [ ] 管理者後台可以訪問
|
||||
- [ ] 員工管理功能正常
|
||||
- [ ] 考勤記錄查詢正常
|
||||
- [ ] 報表匯出功能正常
|
||||
|
||||
### 系統設定測試
|
||||
- [ ] 系統設定頁面可以訪問
|
||||
- [ ] 工作時間設定可以儲存
|
||||
- [ ] 儲存成功提示正確顯示
|
||||
- [ ] 品牌設定頁面可以訪問
|
||||
- [ ] Logo 上傳功能正常(如已設定 Storage)
|
||||
|
||||
### 效能測試
|
||||
- [ ] 頁面載入速度正常(< 3 秒)
|
||||
- [ ] 圖片載入正常
|
||||
- [ ] CSS/JS 資源載入正常
|
||||
- [ ] 無 404 錯誤
|
||||
|
||||
### 跨瀏覽器測試
|
||||
- [ ] Chrome 正常運作
|
||||
- [ ] Firefox 正常運作
|
||||
- [ ] Safari 正常運作
|
||||
- [ ] Edge 正常運作
|
||||
- [ ] 手機瀏覽器正常運作
|
||||
|
||||
### 響應式設計測試
|
||||
- [ ] 桌面版顯示正常(> 1024px)
|
||||
- [ ] 平板版顯示正常(768px - 1024px)
|
||||
- [ ] 手機版顯示正常(< 768px)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 問題排除
|
||||
|
||||
### 如果遇到白屏
|
||||
1. [ ] 檢查瀏覽器 Console 的錯誤訊息
|
||||
2. [ ] 確認 `.htaccess` 檔案已上傳
|
||||
3. [ ] 檢查 Apache 錯誤日誌
|
||||
4. [ ] 確認所有檔案已完整上傳
|
||||
|
||||
### 如果遇到 404 錯誤
|
||||
1. [ ] 確認 `mod_rewrite` 已啟用
|
||||
2. [ ] 檢查 `.htaccess` 語法
|
||||
3. [ ] 確認檔案路徑正確
|
||||
4. [ ] 清除瀏覽器快取
|
||||
|
||||
### 如果 Supabase 連線失敗
|
||||
1. [ ] 檢查 Console 的網路錯誤
|
||||
2. [ ] 確認 Supabase URL 和 Key 正確
|
||||
3. [ ] 檢查 CORS 設定
|
||||
4. [ ] 確認網域已加入 Supabase 允許清單
|
||||
|
||||
### 如果圖片上傳失敗
|
||||
1. [ ] 確認 Storage bucket 已建立
|
||||
2. [ ] 檢查 Storage RLS 政策
|
||||
3. [ ] 執行 `FIX_DATABASE_AND_STORAGE.sql`
|
||||
4. [ ] 檢查瀏覽器 Console 的錯誤訊息
|
||||
|
||||
---
|
||||
|
||||
## 📊 效能優化(可選)
|
||||
|
||||
### SiteGround 設定
|
||||
- [ ] 啟用 SiteGround SuperCacher
|
||||
- [ ] 啟用 GZIP 壓縮
|
||||
- [ ] 設定瀏覽器快取
|
||||
- [ ] 啟用 HTTP/2
|
||||
|
||||
### CDN 設定(進階)
|
||||
- [ ] 設定 Cloudflare CDN
|
||||
- [ ] 啟用 Auto Minify
|
||||
- [ ] 啟用 Brotli 壓縮
|
||||
|
||||
---
|
||||
|
||||
## 📝 部署記錄
|
||||
|
||||
**部署日期**: _______________
|
||||
**部署人員**: _______________
|
||||
**版本號**: v2.4.0
|
||||
**建置工具**: Vite 5.4.21
|
||||
|
||||
**部署網址**: _______________
|
||||
**Supabase 專案**: _______________
|
||||
|
||||
**備註**:
|
||||
_______________________________________________
|
||||
_______________________________________________
|
||||
_______________________________________________
|
||||
|
||||
---
|
||||
|
||||
## ✨ 完成!
|
||||
|
||||
恭喜!您的考勤管理系統已成功部署到 SiteGround。
|
||||
|
||||
如有任何問題,請參考:
|
||||
- `SITEGROUND_DEPLOYMENT_GUIDE.md` - 詳細部署指南
|
||||
- SiteGround 支援文件
|
||||
- Supabase 文件
|
||||
|
||||
**祝您使用愉快!** 🎉
|
||||
82
20260101 time check/FIX_CLOCK_IN_FUNCTION.sql
Normal file
82
20260101 time check/FIX_CLOCK_IN_FUNCTION.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- ============================================================================
|
||||
-- 打卡功能修復腳本
|
||||
-- 此腳本會建立所有打卡功能需要的資料表
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. 建立 employees 資料表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS public.employees (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
english_name TEXT,
|
||||
initials TEXT,
|
||||
avatar_url TEXT,
|
||||
color TEXT,
|
||||
department TEXT,
|
||||
position TEXT,
|
||||
hourly_rate NUMERIC(10,2) DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. 建立 attendance_logs 資料表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS public.attendance_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
employee_id BIGINT NOT NULL REFERENCES public.employees(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('clock_in', 'clock_out')),
|
||||
location TEXT,
|
||||
is_admin_mode BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 3. 啟用 RLS
|
||||
ALTER TABLE public.employees ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.attendance_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 4. 建立 RLS 政策
|
||||
-- Employees 政策
|
||||
DROP POLICY IF EXISTS "Allow public read access" ON public.employees;
|
||||
CREATE POLICY "Allow public read access" ON public.employees FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public write access" ON public.employees;
|
||||
CREATE POLICY "Allow public write access" ON public.employees FOR ALL USING (true) WITH CHECK (true);
|
||||
|
||||
-- Attendance Logs 政策
|
||||
DROP POLICY IF EXISTS "Allow public read access" ON public.attendance_logs;
|
||||
CREATE POLICY "Allow public read access" ON public.attendance_logs FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public insert access" ON public.attendance_logs;
|
||||
CREATE POLICY "Allow public insert access" ON public.attendance_logs FOR INSERT WITH CHECK (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public update access" ON public.attendance_logs;
|
||||
CREATE POLICY "Allow public update access" ON public.attendance_logs FOR UPDATE USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public delete access" ON public.attendance_logs;
|
||||
CREATE POLICY "Allow public delete access" ON public.attendance_logs FOR DELETE USING (true);
|
||||
|
||||
-- 5. 插入測試員工資料(如果表格是空的)
|
||||
INSERT INTO public.employees (name, english_name, initials, color)
|
||||
SELECT '鈴子', 'Reiko', 'RK', 'bg-pink-100 text-pink-400'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.employees WHERE name = '鈴子');
|
||||
|
||||
INSERT INTO public.employees (name, english_name, initials, color)
|
||||
SELECT 'ryan', 'Ryan', 'RY', 'bg-blue-100 text-blue-400'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.employees WHERE name = 'ryan');
|
||||
|
||||
INSERT INTO public.employees (name, english_name, initials, color)
|
||||
SELECT '均毅', 'Jun Yi', 'JY', 'bg-green-100 text-green-400'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.employees WHERE name = '均毅');
|
||||
|
||||
INSERT INTO public.employees (name, english_name, initials, color)
|
||||
SELECT '良年', 'Liang Nian', 'LN', 'bg-purple-100 text-purple-400'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.employees WHERE name = '良年');
|
||||
|
||||
-- 6. 建立索引以提升效能
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_logs_employee_id ON public.attendance_logs(employee_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_logs_created_at ON public.attendance_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_logs_type ON public.attendance_logs(type);
|
||||
|
||||
-- 7. 重新載入 Schema
|
||||
NOTIFY pgrst, 'reload config';
|
||||
|
||||
-- 完成!現在打卡功能應該可以正常運作了
|
||||
87
20260101 time check/FIX_DATABASE_AND_STORAGE.sql
Normal file
87
20260101 time check/FIX_DATABASE_AND_STORAGE.sql
Normal file
@@ -0,0 +1,87 @@
|
||||
-- ============================================================================
|
||||
-- SQL Repair Script: Fix Missing Tables and Storage
|
||||
-- Description: Creates missing 'system_settings' and 'admin_users' tables,
|
||||
-- and configures the 'system-assets' storage bucket.
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Create system_settings table (Validation of finding: 406 Error)
|
||||
CREATE TABLE IF NOT EXISTS public.system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.system_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create Policies (Using IF NOT EXISTS logic via DO block or drop/create)
|
||||
DO $$
|
||||
BEGIN
|
||||
DROP POLICY IF EXISTS "Allow public read access" ON public.system_settings;
|
||||
CREATE POLICY "Allow public read access" ON public.system_settings FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public update access" ON public.system_settings;
|
||||
CREATE POLICY "Allow public update access" ON public.system_settings FOR UPDATE USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public insert access" ON public.system_settings;
|
||||
CREATE POLICY "Allow public insert access" ON public.system_settings FOR INSERT WITH CHECK (true);
|
||||
END $$;
|
||||
|
||||
-- 2. Create admin_users table (Validation of finding: 404 Error)
|
||||
CREATE TABLE IF NOT EXISTS public.admin_users (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
DROP POLICY IF EXISTS "Allow public read access" ON public.admin_users;
|
||||
CREATE POLICY "Allow public read access" ON public.admin_users FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public write access" ON public.admin_users;
|
||||
CREATE POLICY "Allow public write access" ON public.admin_users FOR ALL USING (true) WITH CHECK (true);
|
||||
END $$;
|
||||
|
||||
-- Insert default admin if missing
|
||||
INSERT INTO public.admin_users (username, password)
|
||||
SELECT 'admin', 'admin123'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.admin_users WHERE username = 'admin'
|
||||
);
|
||||
|
||||
-- 3. Create Storage Bucket (Validation of finding: Bucket Not Found)
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('system-assets', 'system-assets', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Storage Policies
|
||||
-- Note: 'storage.objects' policies often conflict if generic ones exist.
|
||||
-- These policies target specifically the 'system-assets' bucket.
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
CREATE POLICY "Give Public Access to system-assets" ON storage.objects FOR SELECT USING (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END;
|
||||
|
||||
BEGIN
|
||||
CREATE POLICY "Give Admin Insert to system-assets" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END;
|
||||
|
||||
BEGIN
|
||||
CREATE POLICY "Give Admin Update to system-assets" ON storage.objects FOR UPDATE USING (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END;
|
||||
|
||||
BEGIN
|
||||
CREATE POLICY "Give Admin Delete to system-assets" ON storage.objects FOR DELETE USING (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END;
|
||||
END $$;
|
||||
|
||||
-- 4. Reload Schema Cache
|
||||
NOTIFY pgrst, 'reload config';
|
||||
24
20260101 time check/FIX_DUPLICATE_EMPLOYEES.sql
Normal file
24
20260101 time check/FIX_DUPLICATE_EMPLOYEES.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- ============================================================================
|
||||
-- 修復員工資料重複問題
|
||||
-- 此腳本會刪除重複的員工記錄,並建立唯一約束
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. 刪除重複資料
|
||||
-- 保留 ID 最小的那一筆
|
||||
DELETE FROM public.employees
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY name ORDER BY id) AS rnum
|
||||
FROM public.employees
|
||||
) t
|
||||
WHERE t.rnum > 1
|
||||
);
|
||||
|
||||
-- 2. 建立唯一約束 (防止未來再發生)
|
||||
ALTER TABLE public.employees
|
||||
ADD CONSTRAINT employees_name_unique UNIQUE (name);
|
||||
|
||||
-- 3. 重新載入 Schema
|
||||
NOTIFY pgrst, 'reload config';
|
||||
70
20260101 time check/QUICK_FIX_BRAND_UPLOAD.sql
Normal file
70
20260101 time check/QUICK_FIX_BRAND_UPLOAD.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- ============================================================================
|
||||
-- 快速修復:品牌圖片上傳功能
|
||||
-- 請在 Supabase 後台的 SQL Editor 中執行此腳本
|
||||
-- ============================================================================
|
||||
|
||||
-- 步驟 1: 建立 system_settings 資料表
|
||||
CREATE TABLE IF NOT EXISTS public.system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 步驟 2: 啟用 RLS 並設定權限
|
||||
ALTER TABLE public.system_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public read access" ON public.system_settings;
|
||||
CREATE POLICY "Allow public read access" ON public.system_settings FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public update access" ON public.system_settings;
|
||||
CREATE POLICY "Allow public update access" ON public.system_settings FOR UPDATE USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public insert access" ON public.system_settings;
|
||||
CREATE POLICY "Allow public insert access" ON public.system_settings FOR INSERT WITH CHECK (true);
|
||||
|
||||
-- 步驟 3: 建立儲存空間 bucket
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('system-assets', 'system-assets', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 步驟 4: 設定儲存空間權限
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 公開讀取權限
|
||||
BEGIN
|
||||
CREATE POLICY "Public Access to system-assets"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
|
||||
-- 寫入權限
|
||||
BEGIN
|
||||
CREATE POLICY "Admin Write to system-assets"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
|
||||
-- 更新權限
|
||||
BEGIN
|
||||
CREATE POLICY "Admin Update to system-assets"
|
||||
ON storage.objects FOR UPDATE
|
||||
USING (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
|
||||
-- 刪除權限
|
||||
BEGIN
|
||||
CREATE POLICY "Admin Delete to system-assets"
|
||||
ON storage.objects FOR DELETE
|
||||
USING (bucket_id = 'system-assets');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
|
||||
-- 步驟 5: 重新載入 Schema
|
||||
NOTIFY pgrst, 'reload config';
|
||||
|
||||
-- 完成!現在可以回到系統設定頁面上傳圖片了
|
||||
185
20260101 time check/SITEGROUND_DEPLOYMENT_GUIDE.md
Normal file
185
20260101 time check/SITEGROUND_DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# SiteGround 部署指南
|
||||
|
||||
## 📦 建置完成
|
||||
|
||||
您的考勤管理系統已成功建置!建置檔案位於 `dist` 資料夾中。
|
||||
|
||||
### 建置統計
|
||||
- **總大小**: ~565 KB
|
||||
- **主要檔案**:
|
||||
- `index.html` (0.93 KB)
|
||||
- `assets/index-BYrLLqss.css` (62.65 KB)
|
||||
- `assets/index-CuvtnT0y.js` (501.53 KB)
|
||||
- `.htaccess` (已包含 SPA 路由設定)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署到 SiteGround 步驟
|
||||
|
||||
### 方法一:使用 FTP/SFTP(推薦)
|
||||
|
||||
#### 1. 連接到 SiteGround
|
||||
使用 FTP 客戶端(如 FileZilla)連接到您的 SiteGround 主機:
|
||||
- **主機**: 您的網域或 SiteGround 提供的 FTP 主機
|
||||
- **使用者名稱**: 您的 cPanel 使用者名稱
|
||||
- **密碼**: 您的 cPanel 密碼
|
||||
- **連接埠**: 21 (FTP) 或 22 (SFTP,推薦)
|
||||
|
||||
#### 2. 上傳檔案
|
||||
1. 導航到網站根目錄(通常是 `public_html` 或您的網域資料夾)
|
||||
2. **清空目標資料夾**(如果是全新部署)
|
||||
3. 上傳 `dist` 資料夾中的**所有內容**:
|
||||
- `index.html`
|
||||
- `assets/` 資料夾(包含所有 CSS 和 JS 檔案)
|
||||
- `.htaccess` 檔案
|
||||
|
||||
**重要**: 上傳的是 `dist` 資料夾**內**的檔案,不是 `dist` 資料夾本身!
|
||||
|
||||
#### 3. 設定環境變數
|
||||
在 SiteGround 上,您需要確保 Supabase 連線資訊正確:
|
||||
|
||||
**選項 A: 使用 .env 檔案(不推薦用於生產環境)**
|
||||
- 在本地建立 `.env` 檔案並上傳(但這會暴露敏感資訊)
|
||||
|
||||
**選項 B: 直接修改程式碼(臨時方案)**
|
||||
- 修改 `src/supabaseClient.js`,將環境變數替換為實際值
|
||||
- 重新建置並上傳
|
||||
|
||||
**選項 C: 使用 SiteGround 環境變數(推薦)**
|
||||
- 登入 SiteGround Site Tools
|
||||
- 前往 "Devs" → "Environment Variables"
|
||||
- 新增:
|
||||
- `VITE_SUPABASE_URL`: 您的 Supabase 專案 URL
|
||||
- `VITE_SUPABASE_ANON_KEY`: 您的 Supabase Anon Key
|
||||
|
||||
---
|
||||
|
||||
### 方法二:使用 SiteGround File Manager
|
||||
|
||||
#### 1. 登入 SiteGround
|
||||
1. 前往 [SiteGround 客戶區](https://my.siteground.com/)
|
||||
2. 選擇您的主機方案
|
||||
3. 點擊 "Site Tools"
|
||||
|
||||
#### 2. 開啟 File Manager
|
||||
1. 在左側選單選擇 "Site" → "File Manager"
|
||||
2. 導航到 `public_html` 或您的網域資料夾
|
||||
|
||||
#### 3. 上傳檔案
|
||||
1. 點擊 "Upload" 按鈕
|
||||
2. 選擇並上傳 `dist` 資料夾中的所有檔案
|
||||
3. 或者,先將 `dist` 資料夾壓縮成 `.zip`,上傳後解壓縮
|
||||
|
||||
#### 4. 設定權限
|
||||
確保檔案權限設定正確:
|
||||
- 資料夾: 755
|
||||
- 檔案: 644
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 重要設定
|
||||
|
||||
### 1. .htaccess 檔案(已包含)
|
||||
`dist` 資料夾中已包含 `.htaccess` 檔案,用於處理 SPA 路由。內容如下:
|
||||
|
||||
```apache
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
### 2. Supabase 設定
|
||||
確保您的 Supabase 專案已正確設定:
|
||||
- ✅ 資料表已建立(employees, attendance_logs, system_settings)
|
||||
- ✅ RLS 政策已設定
|
||||
- ✅ Storage bucket 已建立(system-assets)
|
||||
- ✅ 網域已加入 Supabase 允許清單
|
||||
|
||||
### 3. CORS 設定
|
||||
如果遇到 CORS 錯誤,請在 Supabase 專案設定中:
|
||||
1. 前往 "Settings" → "API"
|
||||
2. 在 "URL Configuration" 中新增您的 SiteGround 網域
|
||||
|
||||
---
|
||||
|
||||
## 🔍 部署後檢查清單
|
||||
|
||||
部署完成後,請檢查以下項目:
|
||||
|
||||
- [ ] 網站可以正常訪問(https://您的網域.com)
|
||||
- [ ] 首頁顯示正常(時鐘、Logo、進入程式按鈕)
|
||||
- [ ] 可以進入考勤管理頁面
|
||||
- [ ] 員工列表正確載入
|
||||
- [ ] 打卡功能正常運作
|
||||
- [ ] 管理者登入功能正常
|
||||
- [ ] 系統設定頁面可以訪問
|
||||
- [ ] 圖片上傳功能正常(如果已設定 Storage)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常見問題排除
|
||||
|
||||
### 問題 1: 白屏或 404 錯誤
|
||||
**解決方案**:
|
||||
- 確認 `.htaccess` 檔案已正確上傳
|
||||
- 檢查 Apache mod_rewrite 模組是否啟用
|
||||
- 確認檔案路徑正確
|
||||
|
||||
### 問題 2: Supabase 連線失敗
|
||||
**解決方案**:
|
||||
- 檢查瀏覽器 Console 的錯誤訊息
|
||||
- 確認 Supabase URL 和 Key 正確
|
||||
- 檢查 CORS 設定
|
||||
|
||||
### 問題 3: 資源載入失敗(CSS/JS)
|
||||
**解決方案**:
|
||||
- 確認 `assets` 資料夾已完整上傳
|
||||
- 檢查檔案權限設定
|
||||
- 清除瀏覽器快取
|
||||
|
||||
### 問題 4: 圖片上傳失敗
|
||||
**解決方案**:
|
||||
- 確認 Supabase Storage bucket 已建立
|
||||
- 檢查 Storage RLS 政策
|
||||
- 執行 `FIX_DATABASE_AND_STORAGE.sql` 腳本
|
||||
|
||||
---
|
||||
|
||||
## 📝 快速部署指令(本地準備)
|
||||
|
||||
如果您需要重新建置,執行:
|
||||
|
||||
```bash
|
||||
# 1. 安裝依賴(首次)
|
||||
npm install
|
||||
|
||||
# 2. 建置生產版本
|
||||
npm run build
|
||||
|
||||
# 3. 檢查建置結果
|
||||
dir dist
|
||||
```
|
||||
|
||||
建置完成後,`dist` 資料夾中的所有內容即可上傳到 SiteGround。
|
||||
|
||||
---
|
||||
|
||||
## 🎉 完成!
|
||||
|
||||
您的考勤管理系統現在應該已經在 SiteGround 上運行了!
|
||||
|
||||
如有任何問題,請檢查:
|
||||
1. SiteGround 錯誤日誌(Site Tools → Statistics → Error Log)
|
||||
2. 瀏覽器開發者工具的 Console 和 Network 標籤
|
||||
3. Supabase 專案的 Logs
|
||||
|
||||
---
|
||||
|
||||
**版本**: v2.4.0 Enterprise Edition
|
||||
**建置日期**: 2026-01-03
|
||||
**建置工具**: Vite 5.4.21
|
||||
28
20260101 time check/create_admin_users.sql
Normal file
28
20260101 time check/create_admin_users.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Create admin_users table
|
||||
CREATE TABLE IF NOT EXISTS public.admin_users (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create policy to allow public read access (for login check)
|
||||
-- Ideally this should be more restricted, but for this app structure we need it readable
|
||||
CREATE POLICY "Allow public read access" ON public.admin_users
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Create policy to allow authenticated users (or public for setup) to update/insert
|
||||
-- For simplicity in this local app context without true auth service:
|
||||
CREATE POLICY "Allow public write access" ON public.admin_users
|
||||
FOR ALL USING (true) WITH CHECK (true);
|
||||
|
||||
-- Insert default admin user if not exists
|
||||
INSERT INTO public.admin_users (username, password)
|
||||
SELECT 'admin', 'admin123'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.admin_users WHERE username = 'admin'
|
||||
);
|
||||
37
20260101 time check/deploy-prepare.bat
Normal file
37
20260101 time check/deploy-prepare.bat
Normal file
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo 考勤管理系統 - SiteGround 部署準備
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
echo [1/3] 清理舊的建置檔案...
|
||||
if exist dist rmdir /s /q dist
|
||||
echo 完成!
|
||||
echo.
|
||||
|
||||
echo [2/3] 建置生產版本...
|
||||
call npm run build
|
||||
if errorlevel 1 (
|
||||
echo 建置失敗!請檢查錯誤訊息。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo 完成!
|
||||
echo.
|
||||
|
||||
echo [3/3] 檢查建置結果...
|
||||
dir dist
|
||||
echo.
|
||||
|
||||
echo ========================================
|
||||
echo 建置完成!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 接下來的步驟:
|
||||
echo 1. 開啟 dist 資料夾
|
||||
echo 2. 選擇所有檔案和資料夾
|
||||
echo 3. 使用 FTP 上傳到 SiteGround 的 public_html
|
||||
echo.
|
||||
echo 詳細部署指南請參考:SITEGROUND_DEPLOYMENT_GUIDE.md
|
||||
echo.
|
||||
pause
|
||||
@@ -4,11 +4,71 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
|
||||
<!-- Prevent caching -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
|
||||
<title>Employee Clock In</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
|
||||
|
||||
<!-- Cache clearing script -->
|
||||
<script>
|
||||
// Clear browser cache on app load
|
||||
(function () {
|
||||
const APP_VERSION = '2.4.0';
|
||||
const CACHE_KEY = 'app_version';
|
||||
|
||||
// Check if version has changed
|
||||
const storedVersion = localStorage.getItem(CACHE_KEY);
|
||||
|
||||
if (storedVersion !== APP_VERSION) {
|
||||
console.log('🔄 New version detected, clearing cache...');
|
||||
|
||||
// Clear localStorage (except important data)
|
||||
const keysToKeep = ['adminAuth', 'VITE_SUPABASE_URL', 'VITE_SUPABASE_ANON_KEY'];
|
||||
const tempStorage = {};
|
||||
|
||||
keysToKeep.forEach(key => {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) tempStorage[key] = value;
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
// Restore important data
|
||||
Object.keys(tempStorage).forEach(key => {
|
||||
localStorage.setItem(key, tempStorage[key]);
|
||||
});
|
||||
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
|
||||
// Update version
|
||||
localStorage.setItem(CACHE_KEY, APP_VERSION);
|
||||
|
||||
// Clear Service Worker cache if exists
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => caches.delete(name));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Cache cleared successfully!');
|
||||
}
|
||||
|
||||
// Force reload CSS and JS with version parameter
|
||||
const version = Date.now();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Add version to dynamic imports
|
||||
window.__APP_VERSION__ = version;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Force HTTPS
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# SPA Routing Rule
|
||||
# If the requested filename is NOT a file that exists
|
||||
RewriteBase /
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
# AND NOT a directory that exists
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
# Then rewrite the request to index.html
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
|
||||
# Disable directory browsing
|
||||
Options -Indexes
|
||||
# Prevent caching of HTML files (always get fresh version)
|
||||
<FilesMatch "\.(html|htm)$">
|
||||
<IfModule mod_headers.c>
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# Enable GZIP compression
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
|
||||
</IfModule>
|
||||
|
||||
# Browser caching for static assets only
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/gif "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType text/css "access plus 1 month"
|
||||
ExpiresByType application/javascript "access plus 1 month"
|
||||
ExpiresByType text/javascript "access plus 1 month"
|
||||
</IfModule>
|
||||
|
||||
# Add version to static files for cache busting
|
||||
<IfModule mod_headers.c>
|
||||
# For CSS and JS files, use ETag for cache validation
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "public, max-age=2592000"
|
||||
FileETag MTime Size
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
18
20260101 time check/setup_brand_storage.sql
Normal file
18
20260101 time check/setup_brand_storage.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Create a storage bucket for system assets if it doesn't exist
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('system-assets', 'system-assets', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy to allow public access to system assets
|
||||
CREATE POLICY "Public Access" ON storage.objects
|
||||
FOR SELECT USING (bucket_id = 'system-assets');
|
||||
|
||||
-- Policy to allow authenticated users (admins) to upload/update/delete
|
||||
CREATE POLICY "Admin Write Access" ON storage.objects
|
||||
FOR INSERT WITH CHECK (bucket_id = 'system-assets');
|
||||
|
||||
CREATE POLICY "Admin Update Access" ON storage.objects
|
||||
FOR UPDATE USING (bucket_id = 'system-assets');
|
||||
|
||||
CREATE POLICY "Admin Delete Access" ON storage.objects
|
||||
FOR DELETE USING (bucket_id = 'system-assets');
|
||||
@@ -378,7 +378,7 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
||||
}
|
||||
|
||||
const actionText = activity.type === 'clock_in' ? '打卡上班' : '打卡下班'
|
||||
const iconColor = activity.type === 'clock_in' ? 'text-green-500' : 'text-blue-500'
|
||||
const actionColor = activity.type === 'clock_in' ? 'text-blue-500' : 'text-red-500' // 上班藍色,下班紅色
|
||||
const icon = activity.type === 'clock_in' ? 'login' : 'logout'
|
||||
|
||||
// 預設頭像 - 使用員工姓名首字母生成顏色
|
||||
@@ -397,11 +397,11 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{activity.employees?.name || '未知員工'}
|
||||
<span className="text-gray-500 dark:text-gray-400 font-normal"> {actionText}</span>
|
||||
<span className={`font-normal ml-1 ${actionColor}`}> {actionText}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{timeText}</p>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined ${iconColor} text-[20px]`}>{icon}</span>
|
||||
<span className={`material-symbols-outlined ${actionColor} text-[20px]`}>{icon}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
function AdminLogin({ onBack, onLoginSuccess }) {
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -36,20 +37,52 @@ function AdminLogin({ onBack, onLoginSuccess }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = () => {
|
||||
// Simple authentication - you can replace this with actual authentication
|
||||
if (username === 'admin' && password === 'admin123') {
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setError('') // Clear previous errors
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
setError('請輸入帳號和密碼')
|
||||
setTimeout(() => setError(''), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// Try Database Auth ONLY
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('*')
|
||||
.eq('username', username)
|
||||
.eq('password', password)
|
||||
.maybeSingle() // Use maybeSingle to avoid error on no rows
|
||||
|
||||
if (error) {
|
||||
console.error('Database query error:', error)
|
||||
setError('系統錯誤,請稍後再試')
|
||||
setTimeout(() => setError(''), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
setError('帳號或密碼錯誤')
|
||||
setTimeout(() => setError(''), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication successful
|
||||
const authUsername = data.username
|
||||
|
||||
// 如果勾選「記住我」,保存登入狀態
|
||||
if (rememberMe) {
|
||||
// 保存帳號
|
||||
localStorage.setItem('adminUsername', username)
|
||||
localStorage.setItem('adminUsername', authUsername)
|
||||
|
||||
// 保存登入狀態,有效期 7 天
|
||||
const expiry = new Date().getTime() + (7 * 24 * 60 * 60 * 1000)
|
||||
localStorage.setItem('adminAuth', JSON.stringify({
|
||||
authenticated: true,
|
||||
expiry: expiry,
|
||||
username: username
|
||||
username: authUsername
|
||||
}))
|
||||
} else {
|
||||
// 不記住則清除保存的帳號
|
||||
@@ -57,9 +90,11 @@ function AdminLogin({ onBack, onLoginSuccess }) {
|
||||
localStorage.removeItem('adminAuth')
|
||||
}
|
||||
|
||||
console.log('✅ Login successful for user:', authUsername)
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
setError('帳號或密碼錯誤')
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
setError('系統錯誤,請稍後再試')
|
||||
setTimeout(() => setError(''), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
import AdminDashboard from './AdminDashboard'
|
||||
import AdminLogin from './AdminLogin'
|
||||
import LandingPage from './LandingPage'
|
||||
|
||||
function App() {
|
||||
const [time, setTime] = useState(new Date())
|
||||
@@ -15,6 +16,75 @@ function App() {
|
||||
const [showAdminDashboard, setShowAdminDashboard] = useState(false)
|
||||
const [showAdminLogin, setShowAdminLogin] = useState(false)
|
||||
const [isAdminAuthenticated, setIsAdminAuthenticated] = useState(false)
|
||||
const [showLandingPage, setShowLandingPage] = useState(true)
|
||||
|
||||
// Load App Icon (Favicon)
|
||||
// Load App Icon (Favicon) & Setup PWA Manifest
|
||||
useEffect(() => {
|
||||
import('./supabaseClient').then(({ supabase }) => {
|
||||
supabase.from('system_settings')
|
||||
.select('value')
|
||||
.eq('key', 'app_icon')
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
if (data && data.value) {
|
||||
const iconUrl = data.value
|
||||
|
||||
// 1. Update Standard Favicon
|
||||
let link = document.querySelector("link[rel~='icon']")
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}
|
||||
link.href = iconUrl
|
||||
|
||||
// 2. Update Apple Touch Icon (iOS)
|
||||
let appleLink = document.querySelector("link[rel='apple-touch-icon']")
|
||||
if (!appleLink) {
|
||||
appleLink = document.createElement('link')
|
||||
appleLink.rel = 'apple-touch-icon'
|
||||
document.getElementsByTagName('head')[0].appendChild(appleLink)
|
||||
}
|
||||
appleLink.href = iconUrl
|
||||
|
||||
// 3. Generate Dynamic Manifest (Android)
|
||||
const manifest = {
|
||||
name: "考勤管理系統",
|
||||
short_name: "考勤",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#3b82f6",
|
||||
icons: [
|
||||
{
|
||||
src: iconUrl,
|
||||
sizes: "192x192",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: iconUrl,
|
||||
sizes: "512x512",
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const stringManifest = JSON.stringify(manifest)
|
||||
const blob = new Blob([stringManifest], { type: 'application/json' })
|
||||
const manifestURL = URL.createObjectURL(blob)
|
||||
|
||||
let manifestLink = document.querySelector("link[rel='manifest']")
|
||||
if (!manifestLink) {
|
||||
manifestLink = document.createElement('link')
|
||||
manifestLink.rel = 'manifest'
|
||||
document.getElementsByTagName('head')[0].appendChild(manifestLink)
|
||||
}
|
||||
manifestLink.href = manifestURL
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
const [isWorking, setIsWorking] = useState(false) // 追蹤今日上班狀態
|
||||
|
||||
// Fetch employees from Supabase
|
||||
@@ -116,6 +186,33 @@ function App() {
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// Idle Timer: Return to Landing Page after 10 seconds of inactivity
|
||||
// DISABLE timer if Admin is authenticated
|
||||
useEffect(() => {
|
||||
if (showLandingPage || isAdminAuthenticated) return
|
||||
|
||||
let idleTimer
|
||||
const resetIdleTimer = () => {
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(() => {
|
||||
setShowLandingPage(true)
|
||||
// Optional: Reset sensitive views for security when idling out
|
||||
setShowAdminDashboard(false)
|
||||
setShowAdminLogin(false)
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']
|
||||
events.forEach(event => document.addEventListener(event, resetIdleTimer))
|
||||
|
||||
resetIdleTimer()
|
||||
|
||||
return () => {
|
||||
clearTimeout(idleTimer)
|
||||
events.forEach(event => document.removeEventListener(event, resetIdleTimer))
|
||||
}
|
||||
}, [showLandingPage, isAdminAuthenticated])
|
||||
|
||||
const handleClockAction = async (type) => {
|
||||
if (!selectedEmployee) return
|
||||
|
||||
@@ -153,14 +250,8 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation dialog
|
||||
// Removed confirmation dialog for better UX - action executes immediately
|
||||
const actionText = type === 'clock_in' ? '上班打卡' : '下班打卡'
|
||||
const modeText = isAdminMode ? `為 ${selectedEmployee.name} 代理` : '本人' // Note: isAdminMode is from outer scope
|
||||
const confirmMessage = `確認要執行 ${actionText} 嗎?\n\n員工:${selectedEmployee.name}\n模式:${modeText}${actionText}\n時間:${new Date().toLocaleString('zh-TW')}`
|
||||
|
||||
if (!window.confirm(confirmMessage)) {
|
||||
return // User cancelled
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('attendance_logs').insert([{
|
||||
employee_id: selectedEmployee.id,
|
||||
@@ -175,8 +266,9 @@ function App() {
|
||||
// Update work status
|
||||
checkWorkStatus(selectedEmployee)
|
||||
|
||||
alert(type === 'clock_in' ? '上班打卡成功!' : '下班打卡成功!')
|
||||
console.log(`✅ ${type === 'clock_in' ? '上班打卡' : '下班打卡'}成功!`, selectedEmployee.name, new Date().toLocaleString('zh-TW'))
|
||||
} else {
|
||||
console.error('❌ 打卡失敗:', error.message)
|
||||
alert('打卡失敗:' + error.message)
|
||||
}
|
||||
}
|
||||
@@ -229,6 +321,11 @@ function App() {
|
||||
setIsAdminMode(true) // 啟用管理者模式
|
||||
}
|
||||
|
||||
// Show Landing Page first
|
||||
if (showLandingPage) {
|
||||
return <LandingPage onEnter={() => setShowLandingPage(false)} />
|
||||
}
|
||||
|
||||
// Show Admin Login page
|
||||
if (showAdminLogin && !isAdminAuthenticated) {
|
||||
return <AdminLogin onBack={() => setShowAdminLogin(false)} onLoginSuccess={handleLoginSuccess} />
|
||||
@@ -242,8 +339,11 @@ function App() {
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-white dark:bg-slate-900 relative overflow-hidden flex flex-col transition-all duration-300">
|
||||
{/* Top App Bar */}
|
||||
<div className="flex items-center px-4 py-3 justify-between bg-white dark:bg-slate-900 sticky top-0 z-10 border-b border-slate-50 dark:border-slate-800 sm:px-8 sm:py-5">
|
||||
<button className="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||
<div className="flex items-center px-4 py-3 justify-between bg-white dark:bg-slate-900 sticky top-0 z-50 border-b border-slate-50 dark:border-slate-800 sm:px-8 sm:py-5">
|
||||
<button
|
||||
onClick={() => setShowLandingPage(true)}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-slate-900 dark:text-white">arrow_back_ios_new</span>
|
||||
</button>
|
||||
<h2 className="text-slate-900 dark:text-white text-lg sm:text-2xl font-bold leading-tight flex-1 text-center">考勤管理</h2>
|
||||
@@ -307,7 +407,7 @@ function App() {
|
||||
|
||||
{/* Employee Selection (Always Shown) */}
|
||||
<div className="px-6 pt-2 pb-2">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<p className="text-slate-400 text-xs font-bold uppercase tracking-wider">選擇員工 (SELECT EMPLOYEE)</p>
|
||||
<button className="text-primary text-sm font-bold">搜尋</button>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ function EmployeeManagement({ onBack }) {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-screen w-full flex-col overflow-hidden bg-white dark:bg-slate-900">
|
||||
{/* Top App Bar */}
|
||||
<header className="sticky top-0 z-20 flex items-center justify-between px-4 py-3 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200 dark:border-slate-800">
|
||||
<header className="sticky top-0 z-40 flex items-center justify-between px-4 py-3 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||
<span className="material-symbols-outlined text-slate-900 dark:text-white">arrow_back</span>
|
||||
@@ -161,7 +161,7 @@ function EmployeeManagement({ onBack }) {
|
||||
</header>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="px-4 py-3 bg-white dark:bg-slate-900 sticky top-[65px] z-10">
|
||||
<div className="px-4 py-3 bg-white dark:bg-slate-900 sticky top-[65px] z-30">
|
||||
<div className="relative flex items-center w-full h-12 rounded-xl bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden group focus-within:ring-2 focus-within:ring-primary focus-within:border-primary transition-all">
|
||||
<div className="flex items-center justify-center pl-4 text-slate-400 group-focus-within:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
|
||||
202
20260101 time check/src/LandingPage.jsx
Normal file
202
20260101 time check/src/LandingPage.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const LandingPage = ({ onEnter }) => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
const [logo, setLogo] = useState(null);
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
|
||||
// Slide to unlock states
|
||||
const [sliderPosition, setSliderPosition] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const sliderRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch company logo
|
||||
import('./supabaseClient').then(({ supabase }) => {
|
||||
supabase.from('system_settings')
|
||||
.select('value')
|
||||
.eq('key', 'company_logo')
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
if (data && data.value) setLogo(data.value);
|
||||
setLoadingLogo(false);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' };
|
||||
return date.toLocaleDateString('zh-TW', options);
|
||||
};
|
||||
|
||||
// Slide to unlock handlers
|
||||
const handleStart = (clientX) => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMove = (clientX) => {
|
||||
if (!isDragging || isUnlocked) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const maxSlide = containerRect.width - 60; // 60px is slider width
|
||||
const newPosition = Math.min(Math.max(0, clientX - containerRect.left - 30), maxSlide);
|
||||
|
||||
setSliderPosition(newPosition);
|
||||
|
||||
// Check if unlocked (90% of the way)
|
||||
if (newPosition > maxSlide * 0.9) {
|
||||
setIsUnlocked(true);
|
||||
setSliderPosition(maxSlide);
|
||||
setTimeout(() => {
|
||||
onEnter();
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
if (!isUnlocked) {
|
||||
setSliderPosition(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
const handleMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
handleStart(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
handleMove(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
handleEnd();
|
||||
};
|
||||
|
||||
// Touch events
|
||||
const handleTouchStart = (e) => {
|
||||
handleStart(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
handleMove(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
handleEnd();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [isDragging, sliderPosition]);
|
||||
|
||||
return (
|
||||
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-slate-100 min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-white dark:bg-slate-900 h-[800px] max-h-[90vh] shadow-2xl relative overflow-hidden flex flex-col rounded-[2rem]">
|
||||
<div className="flex flex-col items-center pt-16 pb-8 px-6">
|
||||
<div className="w-full max-w-[250px] flex items-center justify-center mb-6">
|
||||
{logo ? (
|
||||
<img src={logo} alt="Company Logo" className="w-full h-auto object-contain" />
|
||||
) : (
|
||||
<div className="w-32 h-32 bg-primary/5 rounded-2xl flex items-center justify-center shadow-sm">
|
||||
<span className="material-symbols-outlined text-primary text-5xl">apartment</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white tracking-tight">考勤管理</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2 text-sm">歡迎回來,祝您有個美好的一天</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center relative">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-blue-100 dark:bg-blue-900/20 rounded-full blur-3xl pointer-events-none opacity-60"></div>
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div className="text-6xl font-bold text-slate-900 dark:text-white tabular-nums tracking-tighter" id="clock">
|
||||
{formatTime(time)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-slate-500 dark:text-slate-400 font-medium">
|
||||
<span className="material-symbols-outlined text-lg">calendar_today</span>
|
||||
<span>{formatDate(time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide to Unlock */}
|
||||
<div className="px-8 pb-16 pt-4 w-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-16 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner"
|
||||
>
|
||||
{/* Background gradient that fills as you slide */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-primary to-blue-600 transition-all duration-300"
|
||||
style={{
|
||||
width: `${(sliderPosition / (containerRef.current?.offsetWidth - 60 || 1)) * 100}%`,
|
||||
opacity: 0.2
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Text */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className={`font-semibold transition-all duration-300 ${sliderPosition > 50 ? 'opacity-0' : 'opacity-100'
|
||||
} ${isUnlocked ? 'text-white' : 'text-primary'}`}>
|
||||
{isUnlocked ? '解鎖成功!' : '滑動以進入程式'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slider button */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`absolute top-2 left-2 w-12 h-12 bg-primary rounded-full shadow-lg flex items-center justify-center cursor-grab active:cursor-grabbing transition-all ${isDragging ? 'scale-110' : 'scale-100'
|
||||
} ${isUnlocked ? 'bg-green-500' : ''}`}
|
||||
style={{
|
||||
transform: `translateX(${sliderPosition}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.3s ease-out'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<span className={`material-symbols-outlined transition-colors ${isUnlocked ? 'text-white' : 'text-white'
|
||||
}`}>
|
||||
{isUnlocked ? 'check' : 'chevron_right'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-600">
|
||||
v2.4.0 • Enterprise Edition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -35,6 +35,123 @@ function SalaryExportPreview({ employee, records, startDate, endDate, onBack, on
|
||||
return diffDays
|
||||
}
|
||||
|
||||
// 生成 CSV 內容
|
||||
const generateCSV = () => {
|
||||
// CSV 標題行
|
||||
const headers = ['日期', '星期', '上班時間', '下班時間', '工時(H)', '時薪(TWD)', '工資(TWD)', '備註']
|
||||
|
||||
// 員工資訊
|
||||
const employeeInfo = [
|
||||
['員工姓名', employee.name],
|
||||
['員工編號', employee.id],
|
||||
['部門', employee.department || '未設定'],
|
||||
['職位', employee.position || '未設定'],
|
||||
['結算期間', `${startDate} 至 ${endDate}`],
|
||||
['總天數', calculateDaysDiff(startDate, endDate)],
|
||||
[''] // 空行
|
||||
]
|
||||
|
||||
// 每日明細
|
||||
const detailRows = records.map(record => {
|
||||
const dateObj = new Date(record.date)
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(dateObj.getDate()).padStart(2, '0')
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const weekday = weekdays[dateObj.getDay()]
|
||||
|
||||
const dailyHours = parseFloat(record.totalHours || 0)
|
||||
const dailyWage = Math.round(dailyHours * hourlyRate)
|
||||
const isOff = record.status === 'off'
|
||||
|
||||
// 格式化打卡時間
|
||||
let clockInTime = '--:--'
|
||||
let clockOutTime = '--:--'
|
||||
let note = ''
|
||||
|
||||
if (isOff) {
|
||||
note = '休假'
|
||||
} else if (record.sessions && record.sessions.length > 0) {
|
||||
const firstSession = record.sessions[0]
|
||||
if (firstSession.start) {
|
||||
clockInTime = new Date(firstSession.start.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
if (firstSession.end) {
|
||||
clockOutTime = new Date(firstSession.end.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
if (record.isLate) {
|
||||
note = '遲到'
|
||||
}
|
||||
} else {
|
||||
note = '無打卡'
|
||||
}
|
||||
|
||||
return [
|
||||
`${month}/${day}`,
|
||||
weekday,
|
||||
clockInTime,
|
||||
clockOutTime,
|
||||
isOff ? '-' : dailyHours.toFixed(1),
|
||||
isOff ? '-' : hourlyRate,
|
||||
isOff ? '-' : dailyWage,
|
||||
note
|
||||
]
|
||||
})
|
||||
|
||||
// 總計行
|
||||
const summaryRows = [
|
||||
[''], // 空行
|
||||
['總計', '', '', '', totalHours.toFixed(1), hourlyRate, estimatedSalary, ''],
|
||||
[''], // 空行
|
||||
['工作天數', workDaysCount],
|
||||
['總工時', `${totalHours.toFixed(1)} 小時`],
|
||||
['時薪', `${hourlyRate} 元`],
|
||||
['預估總薪資', `${estimatedSalary} 元`]
|
||||
]
|
||||
|
||||
// 組合所有行
|
||||
const allRows = [
|
||||
...employeeInfo,
|
||||
headers,
|
||||
...detailRows,
|
||||
...summaryRows
|
||||
]
|
||||
|
||||
// 轉換為 CSV 格式(處理逗號和換行)
|
||||
const csvContent = allRows.map(row =>
|
||||
row.map(cell => {
|
||||
const cellStr = String(cell || '')
|
||||
// 如果包含逗號、引號或換行,需要用引號包起來
|
||||
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
|
||||
return `"${cellStr.replace(/"/g, '""')}"`
|
||||
}
|
||||
return cellStr
|
||||
}).join(',')
|
||||
).join('\n')
|
||||
|
||||
return '\uFEFF' + csvContent // 加上 BOM 以支援 Excel 正確顯示中文
|
||||
}
|
||||
|
||||
// 下載 CSV 檔案
|
||||
const handleExportCSV = () => {
|
||||
const csvContent = generateCSV()
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
// 從開始日期提取年份和月份,格式:年月薪資表-姓名.csv
|
||||
const dateObj = new Date(startDate)
|
||||
const year = dateObj.getFullYear()
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
|
||||
const fileName = `${year}${month}薪資表-${employee.name}.csv`
|
||||
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', fileName)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-gray-50 dark:bg-gray-900 font-display pb-28 lg:pb-8">
|
||||
{/* Header */}
|
||||
@@ -127,6 +244,13 @@ function SalaryExportPreview({ employee, records, startDate, endDate, onBack, on
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex flex-col gap-3 pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="w-full py-4 rounded-xl bg-green-600 hover:bg-green-700 text-white font-bold text-lg shadow-lg transition-all active:scale-[0.98] flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-2xl">table_chart</span>
|
||||
匯出 CSV 檔案
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirmExport}
|
||||
className="w-full py-4 rounded-xl bg-primary hover:bg-primary-dark text-white font-bold text-lg shadow-lg shadow-primary/30 transition-all active:scale-[0.98] flex items-center justify-center gap-2"
|
||||
@@ -135,7 +259,7 @@ function SalaryExportPreview({ employee, records, startDate, endDate, onBack, on
|
||||
確認匯出明細
|
||||
</button>
|
||||
<p className="text-center text-xs text-slate-400">
|
||||
將匯出 PDF 格式的薪資明細表
|
||||
CSV 可用 Excel 開啟 / PDF 為完整明細表
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,19 +351,26 @@ function SalaryExportPreview({ employee, records, startDate, endDate, onBack, on
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">({totalHours.toFixed(1)}H)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 font-bold text-sm hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
className="px-4 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 font-bold text-sm hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="px-4 py-2.5 rounded-xl bg-green-600 text-white font-bold text-sm shadow-lg hover:bg-green-700 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>table_chart</span>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirmExport}
|
||||
className="px-5 py-2.5 rounded-xl bg-primary text-white font-bold text-sm shadow-lg shadow-primary/30 hover:bg-primary-dark transition-colors flex items-center gap-2"
|
||||
className="px-4 py-2.5 rounded-xl bg-primary text-white font-bold text-sm shadow-lg shadow-primary/30 hover:bg-primary-dark transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>download</span>
|
||||
確認匯出
|
||||
匯出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ function SystemSettings({ onBack, onNavigate }) {
|
||||
const [allowAccumulation, setAllowAccumulation] = useState(true)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSuccessMessage, setShowSuccessMessage] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
@@ -81,7 +82,9 @@ function SystemSettings({ onBack, onNavigate }) {
|
||||
|
||||
if (error) throw error
|
||||
|
||||
alert('設定已儲存')
|
||||
// Show success message
|
||||
setShowSuccessMessage(true)
|
||||
setTimeout(() => setShowSuccessMessage(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error)
|
||||
alert('儲存失敗: ' + error.message)
|
||||
@@ -117,6 +120,16 @@ function SystemSettings({ onBack, onNavigate }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Success Notification Toast */}
|
||||
{showSuccessMessage && (
|
||||
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-slideDown">
|
||||
<div className="bg-green-500 text-white px-6 py-3 rounded-xl shadow-2xl flex items-center gap-3">
|
||||
<span className="material-icons-round text-2xl">check_circle</span>
|
||||
<span className="font-semibold">設定已成功儲存!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Layout */}
|
||||
<main className="max-w-7xl mx-auto px-4 lg:px-8 pt-20 lg:grid lg:grid-cols-12 lg:gap-10 lg:items-start">
|
||||
|
||||
@@ -401,8 +414,30 @@ function SystemSettings({ onBack, onNavigate }) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Brand Settings */}
|
||||
<section className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<span className="material-icons-round text-primary text-xl">palette</span>
|
||||
<h2 className="text-base font-semibold text-subtext-light dark:text-subtext-dark">品牌與外觀設定</h2>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-surface-dark rounded-2xl p-5 shadow-soft border border-gray-100 dark:border-gray-800 flex-1">
|
||||
<BrandSettingsManager />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Admin Account Settings */}
|
||||
<section className="flex flex-col mt-6">
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<span className="material-icons-round text-primary text-xl">manage_accounts</span>
|
||||
<h2 className="text-base font-semibold text-subtext-light dark:text-subtext-dark">管理員帳號設定</h2>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-surface-dark rounded-2xl p-5 shadow-soft border border-gray-100 dark:border-gray-800">
|
||||
<AdminAccountManager />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-2 pb-6 lg:pb-0 lg:flex lg:justify-end lg:gap-4 lg:pt-4">
|
||||
<button
|
||||
@@ -427,10 +462,10 @@ function SystemSettings({ onBack, onNavigate }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main >
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="lg:hidden fixed bottom-0 w-full bg-white dark:bg-surface-dark border-t border-gray-200 dark:border-gray-800 pb-safe pt-2 z-50">
|
||||
< nav className="lg:hidden fixed bottom-0 w-full bg-white dark:bg-surface-dark border-t border-gray-200 dark:border-gray-800 pb-safe pt-2 z-50" >
|
||||
<div className="max-w-md mx-auto grid grid-cols-3 h-16 pb-2">
|
||||
<button
|
||||
onClick={() => onNavigate && onNavigate('overview')}
|
||||
@@ -455,10 +490,421 @@ function SystemSettings({ onBack, onNavigate }) {
|
||||
<span className="text-[10px] font-medium text-primary">設定</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</nav >
|
||||
<div className="lg:hidden h-6 w-full bg-white dark:bg-surface-dark fixed bottom-0 z-40"></div>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemSettings
|
||||
|
||||
function BrandSettingsManager() {
|
||||
const [logoUrl, setLogoUrl] = useState('')
|
||||
const [iconUrl, setIconUrl] = useState('')
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||||
const [uploadingIcon, setUploadingIcon] = useState(false)
|
||||
const [bucketMissing, setBucketMissing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchBrandSettings()
|
||||
checkBucket()
|
||||
}, [])
|
||||
|
||||
const checkBucket = async () => {
|
||||
// Try to list the bucket to see if it exists (or we have access)
|
||||
try {
|
||||
const { error } = await supabase.storage.from('system-assets').list()
|
||||
if (error && (error.message.includes('not found') || error.message.includes('Buckets'))) {
|
||||
setBucketMissing(true)
|
||||
}
|
||||
} catch (e) {
|
||||
setBucketMissing(true)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBrandSettings = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('system_settings')
|
||||
.select('*')
|
||||
.in('key', ['company_logo', 'app_icon'])
|
||||
|
||||
if (data) {
|
||||
data.forEach(item => {
|
||||
if (item.key === 'company_logo') setLogoUrl(item.value)
|
||||
if (item.key === 'app_icon') setIconUrl(item.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFile = async (file, path) => {
|
||||
const fileExt = file.name.split('.').pop()
|
||||
const fileName = `${Math.random()}.${fileExt}`
|
||||
const filePath = `${path}/${fileName}`
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('system-assets')
|
||||
.upload(filePath, file)
|
||||
|
||||
if (uploadError) throw uploadError
|
||||
|
||||
const { data } = supabase.storage
|
||||
.from('system-assets')
|
||||
.getPublicUrl(filePath)
|
||||
|
||||
return data.publicUrl
|
||||
}
|
||||
|
||||
if (bucketMissing) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
|
||||
<span className="material-icons-round text-red-500 text-4xl mb-3">cloud_off</span>
|
||||
<h3 className="text-lg font-bold text-red-700 dark:text-red-400 mb-2">尚未設定雲端儲存空間</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4 max-w-lg mx-auto">
|
||||
系統檢測到 Supabase Storage 尚未初始化,無法上傳圖片。請將以下 SQL 代碼複製並在 Supabase 後台的 <strong>SQL Editor</strong> 中執行:
|
||||
</p>
|
||||
<div className="relative text-left max-w-2xl mx-auto">
|
||||
<pre className="bg-gray-800 text-gray-200 p-4 rounded-lg text-xs overflow-x-auto font-mono">
|
||||
{`-- 建立儲存空間 system-assets
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('system-assets', 'system-assets', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 開放公開讀取權限
|
||||
CREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'system-assets');
|
||||
|
||||
-- 開放寫入權限 (Admin)
|
||||
CREATE POLICY "Admin Write" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'system-assets');
|
||||
CREATE POLICY "Admin Update" ON storage.objects FOR UPDATE USING (bucket_id = 'system-assets');`}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`INSERT INTO storage.buckets (id, name, public) VALUES ('system-assets', 'system-assets', true) ON CONFLICT (id) DO NOTHING; CREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'system-assets'); CREATE POLICY "Admin Write" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'system-assets'); CREATE POLICY "Admin Update" ON storage.objects FOR UPDATE USING (bucket_id = 'system-assets');`)
|
||||
alert('SQL 已複製到剪貼簿!')
|
||||
}}
|
||||
className="absolute top-2 right-2 bg-white/20 hover:bg-white/30 text-white px-3 py-1 rounded text-xs transition-colors"
|
||||
>
|
||||
複製 SQL
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setBucketMissing(false); checkBucket(); }}
|
||||
className="mt-5 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-6 rounded-lg shadow-md transition-colors"
|
||||
>
|
||||
執行後點此重試
|
||||
</button>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
const handleLogoUpload = async (e) => {
|
||||
try {
|
||||
setUploadingLogo(true)
|
||||
if (!e.target.files || e.target.files.length === 0) return
|
||||
|
||||
const file = e.target.files[0]
|
||||
const publicUrl = await uploadFile(file, 'logos')
|
||||
|
||||
await supabase
|
||||
.from('system_settings')
|
||||
.upsert({ key: 'company_logo', value: publicUrl, description: '公司Logo' })
|
||||
|
||||
setLogoUrl(publicUrl)
|
||||
alert('公司 Logo 更新成功!')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert('上傳失敗: ' + error.message)
|
||||
} finally {
|
||||
setUploadingLogo(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIconUpload = async (e) => {
|
||||
try {
|
||||
setUploadingIcon(true)
|
||||
if (!e.target.files || e.target.files.length === 0) return
|
||||
|
||||
const file = e.target.files[0]
|
||||
const publicUrl = await uploadFile(file, 'icons')
|
||||
|
||||
await supabase
|
||||
.from('system_settings')
|
||||
.upsert({ key: 'app_icon', value: publicUrl, description: 'APP 圖示 (Favicon/Touch Icon)' })
|
||||
|
||||
setIconUrl(publicUrl)
|
||||
// Immediately update favicon in DOM
|
||||
updateFavicon(publicUrl)
|
||||
alert('APP 圖示更新成功!')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert('上傳失敗: ' + error.message)
|
||||
} finally {
|
||||
setUploadingIcon(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateFavicon = (url) => {
|
||||
// 1. Update Standard Favicon
|
||||
let link = document.querySelector("link[rel~='icon']")
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}
|
||||
link.href = url
|
||||
|
||||
// 2. Update Apple Touch Icon
|
||||
let appleLink = document.querySelector("link[rel='apple-touch-icon']")
|
||||
if (!appleLink) {
|
||||
appleLink = document.createElement('link')
|
||||
appleLink.rel = 'apple-touch-icon'
|
||||
document.getElementsByTagName('head')[0].appendChild(appleLink)
|
||||
}
|
||||
appleLink.href = url
|
||||
|
||||
// 3. Update Manifest
|
||||
const manifest = {
|
||||
name: "考勤管理系統",
|
||||
short_name: "考勤",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#3b82f6",
|
||||
icons: [
|
||||
{
|
||||
src: url,
|
||||
sizes: "192x192",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: url,
|
||||
sizes: "512x512",
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const stringManifest = JSON.stringify(manifest)
|
||||
const blob = new Blob([stringManifest], { type: 'application/json' })
|
||||
const manifestURL = URL.createObjectURL(blob)
|
||||
|
||||
let manifestLink = document.querySelector("link[rel='manifest']")
|
||||
if (!manifestLink) {
|
||||
manifestLink = document.createElement('link')
|
||||
manifestLink.rel = 'manifest'
|
||||
document.getElementsByTagName('head')[0].appendChild(manifestLink)
|
||||
}
|
||||
manifestLink.href = manifestURL
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Logo Upload */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white">公司 Logo</h3>
|
||||
<p className="text-xs text-subtext-light dark:text-subtext-dark mt-1">
|
||||
顯示於程式起始頁 (Landing Page) 標題上方。建議尺寸: 200x200px (透明背景 PNG/SVG)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 h-24 bg-gray-100 dark:bg-gray-800 rounded-2xl flex items-center justify-center border border-gray-200 dark:border-gray-700 overflow-hidden relative group">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="Company Logo" className="w-full h-full object-contain p-2" />
|
||||
) : (
|
||||
<span className="material-icons-round text-gray-400 text-3xl">image</span>
|
||||
)}
|
||||
{uploadingLogo && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-white border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="cursor-pointer bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-gray-700 dark:text-white font-medium py-2 px-4 rounded-lg border border-gray-300 dark:border-gray-500 shadow-sm text-sm inline-flex items-center gap-2 transition-colors">
|
||||
<span className="material-icons-round text-lg">upload</span>
|
||||
上傳 Logo
|
||||
<input type="file" className="hidden" accept="image/*" onChange={handleLogoUpload} disabled={uploadingLogo} />
|
||||
</label>
|
||||
{logoUrl && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('確定要移除 Logo 嗎?')) {
|
||||
setLogoUrl('')
|
||||
supabase.from('system_settings').upsert({ key: 'company_logo', value: '' }).then()
|
||||
}
|
||||
}}
|
||||
className="ml-2 text-xs text-red-500 hover:text-red-700 font-medium p-2"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Icon Upload */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white">APP 圖示 (Icon)</h3>
|
||||
<p className="text-xs text-subtext-light dark:text-subtext-dark mt-1">
|
||||
顯示於瀏覽器分頁、書籤及手機主畫面捷徑。建議尺寸: 512x512px (PNG)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 h-24 bg-gray-100 dark:bg-gray-800 rounded-2xl flex items-center justify-center border border-gray-200 dark:border-gray-700 overflow-hidden relative group">
|
||||
{iconUrl ? (
|
||||
<img src={iconUrl} alt="App Icon" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="material-icons-round text-gray-400 text-3xl">token</span>
|
||||
)}
|
||||
{uploadingIcon && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-white border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="cursor-pointer bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-gray-700 dark:text-white font-medium py-2 px-4 rounded-lg border border-gray-300 dark:border-gray-500 shadow-sm text-sm inline-flex items-center gap-2 transition-colors">
|
||||
<span className="material-icons-round text-lg">upload</span>
|
||||
上傳圖示
|
||||
<input type="file" className="hidden" accept="image/png,image/jpeg,image/svg+xml" onChange={handleIconUpload} disabled={uploadingIcon} />
|
||||
</label>
|
||||
{iconUrl && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('確定要移除圖示嗎?')) {
|
||||
setIconUrl('')
|
||||
supabase.from('system_settings').upsert({ key: 'app_icon', value: '' }).then()
|
||||
}
|
||||
}}
|
||||
className="ml-2 text-xs text-red-500 hover:text-red-700 font-medium p-2"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminAccountManager() {
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current admin username if multiple supported, but for now we assume 'admin' or fetch from DB
|
||||
fetchAdminUser()
|
||||
}, [])
|
||||
|
||||
const fetchAdminUser = async () => {
|
||||
const { data } = await supabase.from('admin_users').select('username').limit(1).single()
|
||||
if (data) setUsername(data.username)
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!newPassword) {
|
||||
alert('請輸入新密碼')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
alert('密碼長度至少需要 6 個字元')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('兩次密碼輸入不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// First check if admin user exists
|
||||
const { data: existingUser, error: fetchError } = await supabase
|
||||
.from('admin_users')
|
||||
.select('id, username')
|
||||
.eq('username', username)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Fetch error:', fetchError)
|
||||
throw new Error('無法找到管理員帳號')
|
||||
}
|
||||
|
||||
// Update password
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.update({
|
||||
password: newPassword,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('username', username)
|
||||
.select()
|
||||
|
||||
if (error) {
|
||||
console.error('Update error:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
console.log('✅ Password updated successfully:', data)
|
||||
alert('密碼更新成功!')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err)
|
||||
alert('更新失敗: ' + (err.message || '未知錯誤'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-sm text-gray-500 mb-4">在此修改管理員登入憑證。請使用強密碼以確保系統安全。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">管理員帳號</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
disabled
|
||||
className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">新密碼</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="輸入新密碼"
|
||||
className="w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-start-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">確認新密碼</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次輸入新密碼"
|
||||
className="w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-start-2 flex justify-end">
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-6 rounded-lg shadow-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '更新中...' : '更新密碼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export default {
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
keyframes: {
|
||||
slideDown: {
|
||||
'0%': { transform: 'translate(-50%, -100%)', opacity: '0' },
|
||||
'100%': { transform: 'translate(-50%, 0)', opacity: '1' }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
slideDown: 'slideDown 0.3s ease-out'
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -2,10 +2,12 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [react()],
|
||||
// 開發模式使用根路徑,生產模式使用 /time/ 子目錄
|
||||
base: command === 'serve' ? '/' : '/time/',
|
||||
server: {
|
||||
host: '0.0.0.0', // 允許區域網路存取
|
||||
port: 5173,
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
23
20260104-pos/.gitignore
vendored
Normal file
23
20260104-pos/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
1
20260104-pos/.gitkeep
Normal file
1
20260104-pos/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# This file keeps the directory in Git
|
||||
123
20260104-pos/README.md
Normal file
123
20260104-pos/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# POS System - 20260104
|
||||
|
||||
一個基於 React + Vite + Appwrite 的現代化 POS(銷售點)系統。
|
||||
|
||||
## 🚀 技術棧
|
||||
|
||||
- **前端框架**: React 18
|
||||
- **建置工具**: Vite 5
|
||||
- **資料庫**: Appwrite
|
||||
- **樣式**: Vanilla CSS
|
||||
|
||||
## 📋 Appwrite 配置
|
||||
|
||||
- **Endpoint**: https://appwrite.raraso.com/v1
|
||||
- **Project ID**: 695c4288001eda5cbe67
|
||||
|
||||
## 🛠️ 安裝與執行
|
||||
|
||||
### 安裝依賴
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 啟動開發伺服器
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
開發伺服器會在 http://localhost:5173 啟動
|
||||
|
||||
### 建置生產版本
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 預覽生產版本
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📁 專案結構
|
||||
|
||||
```
|
||||
20260104-pos/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ └── appwrite.js # Appwrite 配置與初始化
|
||||
│ ├── App.jsx # 主應用組件
|
||||
│ ├── App.css # 應用樣式
|
||||
│ ├── main.jsx # 應用入口
|
||||
│ └── index.css # 全域樣式
|
||||
├── index.html # HTML 模板
|
||||
├── vite.config.js # Vite 配置
|
||||
└── package.json # 專案依賴
|
||||
```
|
||||
|
||||
## 🗄️ Appwrite 資料庫設定
|
||||
|
||||
### 建議的 Collections 結構
|
||||
|
||||
1. **products** (商品)
|
||||
- name (string) - 商品名稱
|
||||
- price (number) - 價格
|
||||
- stock (number) - 庫存
|
||||
- category (string) - 分類
|
||||
- barcode (string) - 條碼
|
||||
|
||||
2. **sales** (銷售記錄)
|
||||
- items (array) - 商品清單
|
||||
- total (number) - 總金額
|
||||
- payment_method (string) - 付款方式
|
||||
- created_at (datetime) - 建立時間
|
||||
|
||||
3. **customers** (客戶)
|
||||
- name (string) - 姓名
|
||||
- phone (string) - 電話
|
||||
- email (string) - 電子郵件
|
||||
- points (number) - 積分
|
||||
|
||||
## 🔐 權限設定
|
||||
|
||||
在 Appwrite Console 中為每個 Collection 設定適當的權限:
|
||||
|
||||
- **Read**: 允許已登入使用者讀取
|
||||
- **Create**: 允許已登入使用者建立
|
||||
- **Update**: 允許已登入使用者更新
|
||||
- **Delete**: 僅允許管理員刪除
|
||||
|
||||
## 📝 開發指南
|
||||
|
||||
1. 在 Appwrite Console 建立所需的 Database 和 Collections
|
||||
2. 設定 Collection 的屬性和權限
|
||||
3. 在 `src/lib/appwrite.js` 中已配置好連接
|
||||
4. 開始開發 POS 功能模組
|
||||
|
||||
## 🎨 設計特色
|
||||
|
||||
- 現代化漸層背景
|
||||
- 響應式設計
|
||||
- 深色/淺色模式支援
|
||||
- 流暢的動畫效果
|
||||
- 清晰的狀態提示
|
||||
|
||||
## 📦 依賴套件
|
||||
|
||||
- `react` - UI 框架
|
||||
- `react-dom` - React DOM 渲染
|
||||
- `appwrite` - Appwrite SDK
|
||||
- `vite` - 建置工具
|
||||
- `@vitejs/plugin-react` - React 插件
|
||||
|
||||
## 🔄 下一步開發計劃
|
||||
|
||||
- [ ] 實作使用者登入/註冊
|
||||
- [ ] 建立商品管理介面
|
||||
- [ ] 開發收銀結帳功能
|
||||
- [ ] 新增銷售報表
|
||||
- [ ] 實作庫存管理
|
||||
- [ ] 客戶管理系統
|
||||
|
||||
## 📄 授權
|
||||
|
||||
Private - 僅供內部使用
|
||||
21
20260104-pos/index.html
Normal file
21
20260104-pos/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-TW" class="light">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GiftShop POS - Sales Interface</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&family=Noto+Sans:wght@400;500;700&display=swap"
|
||||
rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
2967
20260104-pos/package-lock.json
generated
Normal file
2967
20260104-pos/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
20260104-pos/package.json
Normal file
25
20260104-pos/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pos-system",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"appwrite": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
20260104-pos/postcss.config.js
Normal file
6
20260104-pos/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
66
20260104-pos/src/App.jsx
Normal file
66
20260104-pos/src/App.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import Header from './components/Header'
|
||||
import ProductGrid from './components/ProductGrid'
|
||||
import OrderSidebar from './components/OrderSidebar'
|
||||
import { products } from './data/products'
|
||||
|
||||
function App() {
|
||||
const [cart, setCart] = useState([
|
||||
{ id: 1, name: 'Premium Tea Set', price: 450, quantity: 2, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCk5X_zlg7bR31pGI7VAbUKKNdxRy8QpAuYZrB1wIxJvPwobUfHEurN0shUn8xE_FbrGdW_0Cmyvuiq7kAP27vYmr9S8eKcdVgez1JxZ65pq_HChx0NJMKLfU9bqh2eXakog0FEoTeXzwrKEuF01eqTOSyebbjAq1o8sh9-xnoxECC_Bl6DjsyHjfCEkcoV-RNus8oVLpRJp5rEFWMP6JAEqg5OW5nGkP83ABdTSkewL2vSm4VoMdkHl78piWM0LPgCvv9-1AFeskY' },
|
||||
{ id: 3, name: 'Leather Journal', price: 150, quantity: 1, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAv_BqgzCIIUTBAUucxgsFR8xYsHFrRyHEB1tQZHvQiNPzYP8Pyheg_Ghm2nyuOEcPFUwsZy_ABy4wSc-pk0Uz566NWQO9EzA_IJsKCiNPK_hyoik9nXcV0LL6mx0d0okHT0i1I-OLrhJAVkkzP7VA9O5o9J5nSA0DyjjdGW1-ger-DlJxPQK7Yq_7kC4ixPp6MEk3QrLubn9MumBL4em6iusCsm8seQFfuiNTIDP8rpMNFBn4n2sTHobXEnElM2BrGgzSaEPiBNK8' },
|
||||
{ id: 8, name: 'Desk Plant', price: 18, quantity: 3, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBKuqelix3T6AuDUGKEHUgJmK76JryjPZEoqq6W6Gqq9UxPT0LiHZ_CfWO8m8DzC_sVcQfjm6GsM3NROg3sXQvbai4L2wE38nFuxzu2kBBFxiI5VoHuQaJRFe3ZhDFYK4N8_dj2IeuHVXa1baAqixDhmkT1IhSqyserzIT6oRfgnD5m_ih7yYR_5-JIpJVRoBg5sbnO65wGCDmCN-BiMa1_Q4cEOisxR95C4uXRN49dYhCzUHZgXSwHxxxMPyND-R05zleQlVta3Rc' }
|
||||
])
|
||||
const [selectedCategory, setSelectedCategory] = useState('All')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const addToCart = (product) => {
|
||||
const existingItem = cart.find(item => item.id === product.id)
|
||||
if (existingItem) {
|
||||
setCart(cart.map(item =>
|
||||
item.id === product.id
|
||||
? { ...item, quantity: item.quantity + 1 }
|
||||
: item
|
||||
))
|
||||
} else {
|
||||
setCart([...cart, { ...product, quantity: 1 }])
|
||||
}
|
||||
}
|
||||
|
||||
const updateQuantity = (id, newQuantity) => {
|
||||
if (newQuantity === 0) {
|
||||
setCart(cart.filter(item => item.id !== id))
|
||||
} else {
|
||||
setCart(cart.map(item =>
|
||||
item.id === id ? { ...item, quantity: newQuantity } : item
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProducts = products.filter(product => {
|
||||
const matchesCategory = selectedCategory === 'All' || product.category === selectedCategory
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-background-light dark:bg-background-dark font-display text-[#292524] dark:text-[#f5f5f4] h-screen overflow-hidden flex flex-col">
|
||||
<Header />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<ProductGrid
|
||||
products={filteredProducts}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onProductClick={addToCart}
|
||||
/>
|
||||
<OrderSidebar
|
||||
cart={cart}
|
||||
updateQuantity={updateQuantity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
46
20260104-pos/src/components/CartItem.jsx
Normal file
46
20260104-pos/src/components/CartItem.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
export default function CartItem({ item, updateQuantity }) {
|
||||
const handleDecrease = () => {
|
||||
updateQuantity(item.id, item.quantity - 1)
|
||||
}
|
||||
|
||||
const handleIncrease = () => {
|
||||
updateQuantity(item.id, item.quantity + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center p-3 rounded-lg hover:bg-[#f7f5f2] dark:hover:bg-white/5 group">
|
||||
<div
|
||||
className="size-14 rounded-md bg-cover bg-center shrink-0 border border-[#e6e4df] dark:border-white/10"
|
||||
style={{ backgroundImage: `url("${item.image}")` }}
|
||||
></div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#292524] dark:text-white text-sm font-bold truncate">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-[#78716c] dark:text-stone-400 text-xs">
|
||||
${item.price.toFixed(2)} / unit
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<p className="text-[#292524] dark:text-white font-bold text-sm">
|
||||
${(item.price * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-white dark:bg-black/20 border border-[#e6e4df] dark:border-white/10 rounded-md h-7 px-1">
|
||||
<button
|
||||
onClick={handleDecrease}
|
||||
className="size-5 flex items-center justify-center text-stone-500 hover:text-primary"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">remove</span>
|
||||
</button>
|
||||
<span className="text-xs font-bold w-4 text-center">{item.quantity}</span>
|
||||
<button
|
||||
onClick={handleIncrease}
|
||||
className="size-5 flex items-center justify-center text-stone-500 hover:text-primary"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
20260104-pos/src/components/Header.jsx
Normal file
29
20260104-pos/src/components/Header.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="flex items-center justify-between whitespace-nowrap border-b border-solid border-[#e6e4df] dark:border-white/10 px-6 py-3 bg-[#fdfcfb] dark:bg-[#292524] shrink-0 z-20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-8 bg-primary/10 rounded-lg flex items-center justify-center text-primary">
|
||||
<span className="material-symbols-outlined text-2xl">storefront</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold leading-tight tracking-[-0.015em] hidden sm:block text-[#292524] dark:text-[#e7e5e4]">
|
||||
GiftShop POS
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-1 justify-end items-center gap-4 sm:gap-8">
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-[#eff1ed] dark:bg-[#3f4039]/20 rounded-full border border-[#dce2da] dark:border-[#52594a]/50">
|
||||
<div className="size-2 rounded-full bg-[#7d9c75] animate-pulse"></div>
|
||||
<span className="text-xs font-medium text-[#53664d] dark:text-[#a3bba6]">Online</span>
|
||||
</div>
|
||||
<button className="flex min-w-[84px] cursor-pointer items-center justify-center overflow-hidden rounded-lg h-9 px-4 bg-primary/10 hover:bg-primary/20 text-primary dark:text-primary-100 text-sm font-bold transition-colors">
|
||||
<span className="truncate">Backend Portal</span>
|
||||
</button>
|
||||
<div
|
||||
className="bg-center bg-no-repeat bg-cover rounded-full size-10 border-2 border-white dark:border-white/10 shadow-sm"
|
||||
style={{
|
||||
backgroundImage: 'url("https://lh3.googleusercontent.com/aida-public/AB6AXuAnGZjtIqq1sx4CppSrybnmXwa_e8RAvZMTO5v7l4cIQF6AmHUKL-hu6uHl90jsdYujjTHwPpuCX12ReVUMougLy3sByTKDa-CE4ThXm6rPr0XkwSO4pdCxWKXoPCSgYM7hBrrJ3aNpm6cRmu6lKE4lQo9ljnXv-yyl1WaQ5XYIQ90u3JLsOoZP3wAOsLXmDFiLYIMNy5gshrODRFwEcOhQDslShKfkyn3w2x54I4iVIpZ3gtNZrQtxA5v0cAEWEOI9C5j4o0cO5Zc")'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
87
20260104-pos/src/components/OrderSidebar.jsx
Normal file
87
20260104-pos/src/components/OrderSidebar.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import CartItem from './CartItem'
|
||||
|
||||
export default function OrderSidebar({ cart, updateQuantity }) {
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
const taxRate = 0.08
|
||||
const tax = subtotal * taxRate
|
||||
const discount = 0
|
||||
const total = subtotal + tax - discount
|
||||
|
||||
return (
|
||||
<aside className="w-[400px] flex flex-col bg-[#fdfcfb] dark:bg-[#1c1917] border-l border-[#e6e4df] dark:border-white/10 shadow-xl z-10">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-[#e6e4df] dark:border-white/10 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-[#292524] dark:text-white text-xl font-bold leading-tight">
|
||||
Order #2910
|
||||
</h3>
|
||||
<p className="text-[#78716c] dark:text-stone-400 text-sm mt-1">
|
||||
Walk-in Customer
|
||||
</p>
|
||||
</div>
|
||||
<button className="p-2 rounded-full hover:bg-[#f0eee9] dark:hover:bg-white/10 text-[#78716c] dark:text-white">
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{cart.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
updateQuantity={updateQuantity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer with Total and Checkout */}
|
||||
<div className="p-6 pt-2 bg-[#fdfcfb] dark:bg-[#1c1917] border-t border-[#e6e4df] dark:border-white/10 mt-auto shadow-[0_-4px_16px_rgba(0,0,0,0.02)]">
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<button className="flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#e6e4df] dark:border-white/10 text-sm font-bold text-[#78716c] dark:text-stone-300 hover:bg-[#f0eee9] dark:hover:bg-white/5 transition-colors">
|
||||
<span className="material-symbols-outlined text-lg">percent</span>
|
||||
Add Discount
|
||||
</button>
|
||||
<button className="flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#e6e4df] dark:border-white/10 text-sm font-bold text-[#78716c] dark:text-stone-300 hover:bg-[#f0eee9] dark:hover:bg-white/5 transition-colors">
|
||||
<span className="material-symbols-outlined text-lg">edit_note</span>
|
||||
Add Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Price Breakdown */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between items-center text-sm text-[#78716c] dark:text-stone-400">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-medium text-[#292524] dark:text-white">
|
||||
${subtotal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-[#78716c] dark:text-stone-400">
|
||||
<span>Tax (8%)</span>
|
||||
<span className="font-medium text-[#292524] dark:text-white">
|
||||
${tax.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-[#53664d] dark:text-[#a3bba6]">
|
||||
<span>Discount</span>
|
||||
<span className="font-medium">-${discount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="h-px bg-[#e6e4df] dark:bg-white/10 my-2"></div>
|
||||
<div className="flex justify-between items-end">
|
||||
<span className="text-lg font-bold text-[#292524] dark:text-white">Total</span>
|
||||
<span className="text-3xl font-extrabold text-[#292524] dark:text-white tracking-tight">
|
||||
${total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charge Button */}
|
||||
<button className="w-full bg-primary hover:bg-primary/90 text-white h-14 rounded-xl flex items-center justify-between px-6 shadow-lg shadow-primary/30 transition-all active:scale-[0.98]">
|
||||
<span className="font-bold text-lg">Charge</span>
|
||||
<span className="material-symbols-outlined text-2xl">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
38
20260104-pos/src/components/ProductCard.jsx
Normal file
38
20260104-pos/src/components/ProductCard.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export default function ProductCard({ product, onClick }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="group cursor-pointer flex flex-col gap-3 p-3 rounded-xl hover:bg-[#f0eee9] dark:hover:bg-white/5 transition-colors border border-transparent hover:border-[#e6e4df] dark:hover:border-white/5"
|
||||
>
|
||||
<div
|
||||
className="w-full relative bg-[#efece6] dark:bg-white/5 aspect-square bg-cover bg-center rounded-lg overflow-hidden"
|
||||
style={{ backgroundImage: `url("${product.image}")` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors"></div>
|
||||
{product.onSale && (
|
||||
<div className="absolute top-2 right-2 bg-[#b33f24] text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-wider">
|
||||
Sale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-[#292524] dark:text-white text-base font-bold leading-tight">
|
||||
{product.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[#78716c] dark:text-stone-400 text-sm mt-1">
|
||||
{product.description}
|
||||
</p>
|
||||
{product.onSale ? (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<p className="text-primary font-bold text-lg">${product.price.toFixed(2)}</p>
|
||||
<p className="text-[#a8a29e] text-sm line-through">${product.originalPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-primary font-bold text-lg mt-2">${product.price.toFixed(2)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
20260104-pos/src/components/ProductGrid.jsx
Normal file
62
20260104-pos/src/components/ProductGrid.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { categories } from '../data/products'
|
||||
import ProductCard from './ProductCard'
|
||||
|
||||
export default function ProductGrid({
|
||||
products,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
onProductClick
|
||||
}) {
|
||||
return (
|
||||
<main className="flex-1 flex flex-col bg-[#fdfcfb] dark:bg-background-dark relative overflow-hidden">
|
||||
<div className="p-6 pb-2 shrink-0 flex flex-col gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex w-full items-center rounded-xl bg-[#e6e4df] dark:bg-white/5 h-12 px-4 transition-all focus-within:ring-2 focus-within:ring-primary/20">
|
||||
<span className="material-symbols-outlined text-[#78716c] dark:text-stone-400">search</span>
|
||||
<input
|
||||
className="flex-1 bg-transparent border-none text-base px-3 text-[#292524] dark:text-white placeholder:text-[#78716c] focus:ring-0 focus:outline-none"
|
||||
placeholder="Search gifts by name, SKU, or barcode..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button className="p-1.5 rounded-md hover:bg-black/5 dark:hover:bg-white/10 text-[#78716c] dark:text-stone-400">
|
||||
<span className="material-symbols-outlined">barcode_scanner</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`flex h-9 shrink-0 items-center justify-center gap-x-2 rounded-lg px-5 transition-all ${selectedCategory === category
|
||||
? 'bg-primary text-white shadow-md shadow-primary/20 active:scale-95'
|
||||
: 'bg-[#e6e4df] dark:bg-white/5 hover:bg-[#dcd9d2] dark:hover:bg-white/10 text-[#292524] dark:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm ${selectedCategory === category ? 'font-bold' : 'font-medium'}`}>
|
||||
{category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6 pt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4 pb-20">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onClick={() => onProductClick(product)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
78
20260104-pos/src/data/products.js
Normal file
78
20260104-pos/src/data/products.js
Normal file
@@ -0,0 +1,78 @@
|
||||
export const products = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Premium Tea Set',
|
||||
description: 'Ceramic & Wood',
|
||||
price: 450.00,
|
||||
category: 'Home Decor',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDVKQxH1nm0Xc9HD0n6lcvMAT9z3N58lqMOFCxhhcgOfnzqT-_7Cbnr78DgGBB5J-lMpeDnqGLZAEWwmx9aGOqs0tQNZ0eqH-lk0_aHGto677S7sMJzzU57Sft-uHHTLOwixg7dwFfueeRkBaf3Mus1nQSL6A7g_nws6BSr-hQKf9SfKSzJfGNfk5NnJLyLvEuAR0lKCEkqIWkq1zuIrfTV7Ofewh1KkB9rnBvEXLy4bkcliPc3015le-Kv9u6PnUDDW3Zn2jiAByo'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Artisan Mug',
|
||||
description: 'Handmade',
|
||||
price: 35.00,
|
||||
category: 'Home Decor',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBDH2IahkK4sLOC7JMTpK1f-_93V9Fv7FjtjNhXt4JNIJ6Xdzv9g_IbNySFSFSWp6KHxglqtljAP7vWAWmNYvcR-XCif4lEkZrcqeXVEw54rqipC8JcrmC0iX8VVwSwhhW-BeRjGlDD46-MZ9OI3dRwzlN51tGxFYgJjmnItorQfnFWgps02HznquH6MOkF-TsfL924UCMnCqpxNOHRcneWvLDp4YC-bTDsellusqFUnED49YMSzuevtx64q59cjsrDcF7fD3Yg8sQ'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Leather Journal',
|
||||
description: 'Genuine Leather',
|
||||
price: 150.00,
|
||||
category: 'Stationery',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuD5SYzeHl-rA5gHOSSka9GfMv3EUXndih7ERC9rPZpz5IPTvCJqowzLlVj94FW1_rQtEACV_MJXD8Qk8IF1Qz8usiSsbVhK1fml2hyYK5E3QQb8PaIkdxMq4EsdeBVQjLnyYXCqlW_4G0iqeOkPtPXwFwl8DyeWZ76iB3pHsQMLWtLLStA4Ly7x4x6_baAFvqpgLyNC_jEWtOAHpTRrpiOIzDYTuCVy9EgMO2SbhOFbTgYvvnDKt1-5H7DsawwzUqO0CkGAGhHpA5k'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Fountain Pen',
|
||||
description: 'Gold Plated',
|
||||
price: 50.00,
|
||||
originalPrice: 75.00,
|
||||
onSale: true,
|
||||
category: 'Stationery',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBXemON-Lqfwof5NGqIAcIbVVOXUDa1sQIXXTZu-gGqTNXE2N2Wh5YQPvpWoXfOghT7OtAz00_c459uUODm30VagwCXSMhzmwvcmb4Wwn77D40hBhvhD8R0Vn76I6DVDRbedBa2bqpTkSu2Rkt7FZoOJ5BUCau4LakHUseC1w2HTxTfEnfevAi3y1PV4f14w8tbjtQo7uUgrCjoiJlk3qJB20ak1wm219kkdCIy4sPD6zMagc_HFKJmUJ1Z3i__iF0py_nMgFPKbN8'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Silk Scarf',
|
||||
description: '100% Silk',
|
||||
price: 250.00,
|
||||
category: 'Accessories',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAkrWqiGEwRmG_0EAcvxzipDyEdvheHWMg8_GbS8Uuyo2oDId0x25m4Ikj1cHwfPOxcmhpMOU6xxCtrF1oneiIpxp12LBjkohfBcySfsOukeg30NzF81asp3SSUiqUbnJzh-IefUiOD_nMTi96Jeav3NRcODtL26k_1FS1Fxn77Mt_QD4Jlp3mhvjX3Yl5iojlVr32l280t7aC28aNeUp7Pxe-aZ-LIdAMKi8bGksZBtP-O88u1hl7URyXzQ_GE1X3Z47B9jCIC0rY'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Crystal Vase',
|
||||
description: 'Hand Blown',
|
||||
price: 300.00,
|
||||
category: 'Home Decor',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDnrVTRq1sOvRzZujQyY_hisdA6lc7xW4tGaTdahmPFHDWDP_RivNF4U26HOs52zjA-dTtG0gdHb_CQKDOFj5hFOspYpJano0xBqqI-Hp-sFHLbtym2L0WJFvHxH60yot-mxgGcje0YH3DmKpam2BjKd0gpFM9plTIQndRfhrI-a9q1OPU84ahwcsqtUyXZl4RPPDO39yNaLBXnP2sDkacqFxRD3mR8EB0bHCPxgR-8pT2id_U-mwElfpPk3wWx23RvCYWheNc879k'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Scented Candle',
|
||||
description: 'Lavender',
|
||||
price: 25.00,
|
||||
category: 'Home Decor',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBK3NihRuvP2tUcfIGIeOoxqsVt2JPS-z-Sw_UyRobjcKMHSQpgo1hhk2K0WtMA35NGgusRNFt36cZdGYsYGxVoNhMImFToKbob2Hc0T_b5jnC7tMnKsIUMQxIa0Re4XCJmwCU2maihtonWKeykHdP6H3wgN7rhmRUwDgBYY--23eLbTYrODECGh6g3y998QyN1bKz9pisyTHvhxUE7MYaSPb2GxBwBnWAnyJ02vXGKDCN7CNxYMk7_OhiEOXLyoHjkxIFGv9xRZFE'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Desk Plant',
|
||||
description: 'Succulent',
|
||||
price: 18.00,
|
||||
category: 'Home Decor',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCA1v0o9nmt6ldwLYWCWVQpwFk7XF2-6eDGJGOgcnfrqrZO2_BFQV7pOJHXU57JXu3jwDyqg5Xlx3rXiYrYNpJqHBNkiw59bhsMgmPkzuxKQ8nfPUCVpwyExhnZWLPRvWroeZqxqBdedH64NcQxHuuI-fuARjfGIqAfAriXugp6M1BtZ-jbblwhf03TZ7Y4enDisqc6tzZMt-XSKndrKXwrxejov3MItTOehfasI0_V1pCxgsWHvNnr7PrH_1UilAMk7WaGGFM1Z4w'
|
||||
}
|
||||
]
|
||||
|
||||
export const categories = [
|
||||
'All',
|
||||
'Seasonal',
|
||||
'Corporate',
|
||||
'Accessories',
|
||||
'Home Decor',
|
||||
'Stationery',
|
||||
'Clearance'
|
||||
]
|
||||
14
20260104-pos/src/index.css
Normal file
14
20260104-pos/src/index.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
18
20260104-pos/src/lib/appwrite.js
Normal file
18
20260104-pos/src/lib/appwrite.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Client, Databases, Account } from 'appwrite';
|
||||
|
||||
// Appwrite 配置
|
||||
export const APPWRITE_CONFIG = {
|
||||
endpoint: 'https://appwrite.raraso.com/v1',
|
||||
project: '695c4288001eda5cbe67'
|
||||
};
|
||||
|
||||
// 初始化 Appwrite Client
|
||||
const client = new Client()
|
||||
.setEndpoint(APPWRITE_CONFIG.endpoint)
|
||||
.setProject(APPWRITE_CONFIG.project);
|
||||
|
||||
// 初始化服務
|
||||
export const databases = new Databases(client);
|
||||
export const account = new Account(client);
|
||||
|
||||
export default client;
|
||||
10
20260104-pos/src/main.jsx
Normal file
10
20260104-pos/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
28
20260104-pos/tailwind.config.js
Normal file
28
20260104-pos/tailwind.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#966144",
|
||||
"primary-100": "#f3e9e2",
|
||||
"background-light": "#f7f5f2",
|
||||
"background-dark": "#1c1917",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Manrope", "sans-serif"]
|
||||
},
|
||||
borderRadius: {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
14
20260104-pos/vite.config.js
Normal file
14
20260104-pos/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true
|
||||
},
|
||||
css: {
|
||||
postcss: './postcss.config.js',
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
# Admin Portal 流程測試指南
|
||||
號# Admin Portal 流程測試指南
|
||||
|
||||
## 🎯 預期行為
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ function Login({ onLogin, onAdminClick }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetMessage, setResetMessage] = useState('');
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
// 清除之前的錯誤訊息
|
||||
@@ -52,6 +57,56 @@ function Login({ onLogin, onAdminClick }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
setResetMessage('');
|
||||
setResetSuccess(false);
|
||||
|
||||
if (!resetEmail) {
|
||||
setResetMessage('請輸入您的電子郵件地址');
|
||||
return;
|
||||
}
|
||||
|
||||
// 簡單的電子郵件格式驗證
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(resetEmail)) {
|
||||
setResetMessage('請輸入有效的電子郵件地址');
|
||||
return;
|
||||
}
|
||||
|
||||
setResetLoading(true);
|
||||
try {
|
||||
// 檢查帳號是否存在
|
||||
const result = await getOperatorByEmail(resetEmail);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
setResetMessage('❌ 找不到此電子郵件的帳號');
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 模擬發送重置密碼郵件
|
||||
// 實際應用中,這裡應該調用後端 API 發送重置密碼郵件
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
setResetSuccess(true);
|
||||
setResetMessage('✅ 密碼重置連結已發送到您的電子郵件!請檢查您的收件箱。');
|
||||
|
||||
// 3秒後關閉對話框
|
||||
setTimeout(() => {
|
||||
setShowForgotPassword(false);
|
||||
setResetEmail('');
|
||||
setResetMessage('');
|
||||
setResetSuccess(false);
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Password reset error:', error);
|
||||
setResetMessage('❌ 發送重置郵件失敗: ' + error.message);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col items-center justify-center p-6 text-center font-display text-slate-900">
|
||||
{/* Logo */}
|
||||
@@ -140,7 +195,16 @@ function Login({ onLogin, onAdminClick }) {
|
||||
</div>
|
||||
|
||||
<div className="text-right mb-6">
|
||||
<a href="#" className="text-blue-500 text-sm font-bold hover:text-blue-600">Forgot Password?</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-blue-500 text-sm font-bold hover:text-blue-600 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowForgotPassword(true);
|
||||
}}
|
||||
>
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
@@ -202,6 +266,135 @@ function Login({ onLogin, onAdminClick }) {
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
{showForgotPassword && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-6 z-50 animate-fadeIn">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-md w-full shadow-2xl animate-slideUp">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-[#111418]">重置密碼</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForgotPassword(false);
|
||||
setResetEmail('');
|
||||
setResetMessage('');
|
||||
setResetSuccess(false);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Description */}
|
||||
<p className="text-gray-600 mb-6 text-left">
|
||||
請輸入您的電子郵件地址,我們將發送密碼重置連結給您。
|
||||
</p>
|
||||
|
||||
{/* Message Display */}
|
||||
{resetMessage && (
|
||||
<div className={`mb-4 p-4 rounded-xl flex items-start gap-3 ${resetSuccess
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`flex-shrink-0 mt-0.5 ${resetSuccess ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{resetSuccess ? (
|
||||
<>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<p className={`text-sm font-medium ${resetSuccess ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{resetMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Input */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-bold mb-2 text-[#111418] text-left">
|
||||
電子郵件地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
className="w-full border border-gray-200 rounded-xl py-3.5 px-4 pr-10 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white placeholder-gray-400 text-base transition-all"
|
||||
value={resetEmail}
|
||||
onChange={(e) => {
|
||||
setResetEmail(e.target.value);
|
||||
setResetMessage('');
|
||||
}}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleForgotPassword()}
|
||||
disabled={resetLoading || resetSuccess}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForgotPassword(false);
|
||||
setResetEmail('');
|
||||
setResetMessage('');
|
||||
setResetSuccess(false);
|
||||
}}
|
||||
className="flex-1 border border-gray-200 text-gray-700 font-bold py-3.5 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
disabled={resetLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleForgotPassword}
|
||||
disabled={resetLoading || resetSuccess}
|
||||
className="flex-1 bg-[#1380FF] hover:bg-blue-600 text-white font-bold py-3.5 rounded-xl shadow-lg shadow-blue-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{resetLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
發送中...
|
||||
</>
|
||||
) : resetSuccess ? (
|
||||
'已發送'
|
||||
) : (
|
||||
'發送重置連結'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ body {
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
@@ -48,4 +56,26 @@ body {
|
||||
80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,194 +1 @@
|
||||
-- ========================================
|
||||
-- 完整修復 Items 資料表 - 一次性解決方案
|
||||
-- Complete Fix for Items Table - One-time Solution
|
||||
-- ========================================
|
||||
--
|
||||
-- 執行此 SQL 後,請務必執行以下步驟:
|
||||
-- 1. 前往 Supabase Dashboard
|
||||
-- 2. Settings → API
|
||||
-- 3. 點擊 "Reload schema cache" 按鈕
|
||||
--
|
||||
-- ========================================
|
||||
|
||||
-- 新增所有缺少的欄位
|
||||
DO $$
|
||||
BEGIN
|
||||
-- description
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='description') THEN
|
||||
ALTER TABLE items ADD COLUMN description TEXT;
|
||||
RAISE NOTICE '✓ Added column: description';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: description';
|
||||
END IF;
|
||||
|
||||
-- location
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='location') THEN
|
||||
ALTER TABLE items ADD COLUMN location TEXT;
|
||||
RAISE NOTICE '✓ Added column: location';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: location';
|
||||
END IF;
|
||||
|
||||
-- warehouse
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='warehouse') THEN
|
||||
ALTER TABLE items ADD COLUMN warehouse TEXT;
|
||||
RAISE NOTICE '✓ Added column: warehouse';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: warehouse';
|
||||
END IF;
|
||||
|
||||
-- price
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='price') THEN
|
||||
ALTER TABLE items ADD COLUMN price NUMERIC(10, 2) DEFAULT 0;
|
||||
RAISE NOTICE '✓ Added column: price';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: price';
|
||||
END IF;
|
||||
|
||||
-- cost_price
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='cost_price') THEN
|
||||
ALTER TABLE items ADD COLUMN cost_price NUMERIC(10, 2) DEFAULT 0;
|
||||
RAISE NOTICE '✓ Added column: cost_price';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: cost_price';
|
||||
END IF;
|
||||
|
||||
-- selling_price
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='selling_price') THEN
|
||||
ALTER TABLE items ADD COLUMN selling_price NUMERIC(10, 2) DEFAULT 0;
|
||||
RAISE NOTICE '✓ Added column: selling_price';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: selling_price';
|
||||
END IF;
|
||||
|
||||
-- unit_type
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='unit_type') THEN
|
||||
ALTER TABLE items ADD COLUMN unit_type TEXT;
|
||||
RAISE NOTICE '✓ Added column: unit_type';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: unit_type';
|
||||
END IF;
|
||||
|
||||
-- supplier
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='supplier') THEN
|
||||
ALTER TABLE items ADD COLUMN supplier TEXT;
|
||||
RAISE NOTICE '✓ Added column: supplier';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: supplier';
|
||||
END IF;
|
||||
|
||||
-- sku
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='sku') THEN
|
||||
ALTER TABLE items ADD COLUMN sku TEXT;
|
||||
RAISE NOTICE '✓ Added column: sku';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: sku';
|
||||
END IF;
|
||||
|
||||
-- barcode
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='barcode') THEN
|
||||
ALTER TABLE items ADD COLUMN barcode TEXT;
|
||||
RAISE NOTICE '✓ Added column: barcode';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: barcode';
|
||||
END IF;
|
||||
|
||||
-- batch
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='batch') THEN
|
||||
ALTER TABLE items ADD COLUMN batch TEXT;
|
||||
RAISE NOTICE '✓ Added column: batch';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: batch';
|
||||
END IF;
|
||||
|
||||
-- expiry
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='expiry') THEN
|
||||
ALTER TABLE items ADD COLUMN expiry DATE;
|
||||
RAISE NOTICE '✓ Added column: expiry';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: expiry';
|
||||
END IF;
|
||||
|
||||
-- aisle
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='aisle') THEN
|
||||
ALTER TABLE items ADD COLUMN aisle TEXT;
|
||||
RAISE NOTICE '✓ Added column: aisle';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: aisle';
|
||||
END IF;
|
||||
|
||||
-- shelf
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='shelf') THEN
|
||||
ALTER TABLE items ADD COLUMN shelf TEXT;
|
||||
RAISE NOTICE '✓ Added column: shelf';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: shelf';
|
||||
END IF;
|
||||
|
||||
-- bin
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='bin') THEN
|
||||
ALTER TABLE items ADD COLUMN bin TEXT;
|
||||
RAISE NOTICE '✓ Added column: bin';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: bin';
|
||||
END IF;
|
||||
|
||||
-- image_url
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='image_url') THEN
|
||||
ALTER TABLE items ADD COLUMN image_url TEXT;
|
||||
RAISE NOTICE '✓ Added column: image_url';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: image_url';
|
||||
END IF;
|
||||
|
||||
-- images
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='images') THEN
|
||||
ALTER TABLE items ADD COLUMN images TEXT;
|
||||
RAISE NOTICE '✓ Added column: images';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: images';
|
||||
END IF;
|
||||
|
||||
-- updated_at
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='items' AND column_name='updated_at') THEN
|
||||
ALTER TABLE items ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now());
|
||||
RAISE NOTICE '✓ Added column: updated_at';
|
||||
ELSE
|
||||
RAISE NOTICE '- Column already exists: updated_at';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 建立索引
|
||||
CREATE INDEX IF NOT EXISTS idx_items_name ON items(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_sku ON items(sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_barcode ON items(barcode);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_warehouse ON items(warehouse);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_supplier ON items(supplier);
|
||||
|
||||
-- 顯示最終的資料表結構
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'items'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- ========================================
|
||||
-- 完成!
|
||||
-- ========================================
|
||||
--
|
||||
-- 執行結果說明:
|
||||
-- ✓ = 欄位已成功新增
|
||||
-- - = 欄位已存在,跳過
|
||||
--
|
||||
-- 下一步:
|
||||
-- 1. 前往 Supabase Dashboard
|
||||
-- 2. Settings → API
|
||||
-- 3. 點擊 "Reload schema cache" 按鈕 ⚠️ 這一步很重要!
|
||||
-- 4. 回到應用程式重新整理頁面
|
||||
-- 5. 再次測試新增商品
|
||||
--
|
||||
-- ========================================
|
||||
|
||||
Reference in New Issue
Block a user