Compare commits

...

10 Commits

Author SHA1 Message Date
Raraso
9c74204e27 Feat: Initialize POS system with React, Vite, and TailwindCSS 2026-01-06 08:38:39 +08:00
Raraso
4202aa3804 Add: 20260104-pos 空目錄 2026-01-06 06:58:26 +08:00
Antigravity
ca26b2b172 更新 Inventory 登录页面样式 2026-01-04 16:04:44 +08:00
Antigravity
c51f969f19 Fix: 修復管理員密碼更新與登入驗證
修復內容:
- 修復管理員密碼更新功能(增強錯誤處理、驗證密碼長度)
- 移除硬編碼 fallback 驗證(admin123)
- 現在只能使用資料庫中的密碼登入
- 增加輸入驗證和詳細錯誤訊息
- 改善成功/失敗反饋

技術改進:
- AdminLogin.jsx: 移除 fallback 驗證,僅使用資料庫驗證
- SystemSettings.jsx: 增強密碼更新邏輯
- 新增密碼長度檢查(最少 6 字元)
- 更新時同步 updated_at 時間戳

安全性提升:
- 舊密碼完全失效
- 只有資料庫密碼有效
- 防止多重驗證漏洞
2026-01-03 20:22:37 +08:00
Antigravity
033a02fbc7 Feature: 滑動解鎖進入程式 + 快取清除機制
新增功能:
- 將首頁進入方式改為滑動解鎖(藍色滑塊和文字)
- 實現多層次快取清除機制(版本檢測、自動清除)
- 新增 .htaccess 快取控制(HTML 不快取、靜態資源優化)
- 調整 Logo 尺寸為 250px
- 修復「回上頁」按鈕功能

技術改進:
- 支援滑鼠和觸控雙模式
- 智能版本檢測與快取管理
- 保留重要資料(登入狀態、Supabase 設定)
- 優化建置輸出(含快取清除腳本)

文件:
- CACHE_CLEARING_GUIDE.md - 快取清除機制說明
- SITEGROUND_DEPLOYMENT_GUIDE.md - SiteGround 部署指南
- DEPLOYMENT_CHECKLIST.md - 部署檢查清單
2026-01-03 19:57:33 +08:00
Antigravity
ead1675190 Fix: 修復打卡功能、品牌設定上傳、系統設定儲存提示
- 修復打卡功能:移除阻擋性確認對話框,改善使用者體驗
- 新增品牌與外觀設定:支援公司 Logo 和 APP 圖示上傳
- 新增動態 PWA Manifest 生成:改善手機 APP 體驗
- 新增系統設定儲存成功提示:使用現代化 Toast 通知
- 新增 LandingPage 起始頁面:動態時鐘與品牌 Logo 顯示
- 新增多個資料庫修復腳本:協助初始化 Supabase 環境
- 優化 Tailwind 配置:新增 slideDown 動畫效果
2026-01-03 19:33:53 +08:00
Antigravity
bd7c1a17ea Merge branch 'main' of http://192.168.1.8:8418/raraso/raraso-gitea 2026-01-03 17:09:43 +08:00
Antigravity
44f7c5c21a Update Inventory files 2026-01-03 17:06:50 +08:00
RARASO Admin
3407040b6e UI Fix: Adjust z-index and spacing to prevent employee image obstruction 2026-01-03 16:43:34 +08:00
RARASO Admin
e6e3fae89e Update: CSV export feature and Admin Dashboard UI 2026-01-03 16:35:33 +08:00
44 changed files with 5956 additions and 242 deletions

View 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

View 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 文件
**祝您使用愉快!** 🎉

View 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';
-- 完成!現在打卡功能應該可以正常運作了

View 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';

View 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';

View 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';
-- 完成!現在可以回到系統設定頁面上傳圖片了

View 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

View 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'
);

View 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

View File

@@ -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

View File

@@ -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>

View 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');

View File

@@ -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>
)
})

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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>

View 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;

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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: [],

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
# This file keeps the directory in Git

123
20260104-pos/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

25
20260104-pos/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

66
20260104-pos/src/App.jsx Normal file
View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'
]

View 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;
}
}

View 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
View 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>,
)

View 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: [],
}

View 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',
}
})

View File

@@ -1,4 +1,4 @@
# Admin Portal 流程測試指南
# Admin Portal 流程測試指南
## 🎯 預期行為

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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. 再次測試新增商品
--
-- ========================================