This commit is contained in:
Antigravity
2026-01-03 17:09:43 +08:00
18 changed files with 2314 additions and 58 deletions

View File

@@ -0,0 +1,71 @@
# 部署到 SiteGround (共享主機)
SiteGround 是一個託管 React 應用程式的絕佳選擇,因為它的伺服器快速且穩定。由於您的應用程式是 "靜態" (Static) React 網站 (連接到 Supabase 獲取資料),您不需要 Node.js 伺服器。您只需使用 SiteGround 的標準 Apache 網頁伺服器來提供檔案。
## 步驟 1在本地建立您的專案
1. 在 VS Code 中打開您的終端機 (Terminal)。
2. 執行建置指令:
```bash
npm run build
```
3. 這將建立一個 **`dist`** 資料夾。這些是您 **唯一** 需要上傳的檔案。
## 步驟 2準備 .htaccess 檔案
為了防止在重新整理頁面 (例如 `/reports`) 時出現 "404 Not Found" 錯誤,您必須包含一個設定檔。
1. 在您的專案 `public` 資料夾中建立一個名為 `.htaccess` 的檔案 (注意前面有點),或者在建置 (Build) 後直接在 `dist` 資料夾中建立。
2. 貼上以下內容:
```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>
```
**注意:** 如果您要上傳到子資料夾 (例如 `yourdomain.com/attendance`),請將 `RewriteBase /` 改為 `RewriteBase /attendance/`,並將 `RewriteRule . /index.html` 改為 `RewriteRule . /attendance/index.html`。
## 步驟 3上傳到 SiteGround
您有兩種簡單的上傳方式。
### 選項 A使用 Site Tools (檔案管理員) - 最適合一次性設定
1. 登入 **SiteGround** 並前往 **Websites** > **Site Tools**。
2. 前往 **Site** > **File Manager**。
3. 導航到 **`public_html`**。
* 如果您希望應用程式在主網域上,請留在這裡。
* 如果您希望它在子資料夾中,請建立一個 (例如 `attendance`)。
4. 如果您在根目錄 `public_html` 中,請 **刪除** 任何預設檔案 (如 `default.html` 或 `cgi-bin`)。
5. **上傳** 您本地 `dist` 資料夾的 **所有內容**。
* 點擊 "File Upload" 或拖放檔案。
* 確保 `index.html` 位於您希望網站載入的資料夾中。
### 選項 B使用 FTP (FileZilla) - 最適合頻繁更新
1. 在 Site Tools 中,前往 **Site** > **FTP Accounts**。
2. 建立一個標準 FTP 帳號。
3. 在您的電腦上打開 **FileZilla** (或任何 FTP 用戶端)。
4. 使用建立的憑證連線。
5. 導航到 `public_html`。
6. 將您本地 `dist` 資料夾的 **所有內容** 拖放到伺服器上。
## 步驟 4驗證
1. 打開您的網域 (例如 `https://yourdomain.com`)。
2. 您的應用程式應該會立即載入。
3. 嘗試導航到子頁面並重新整理瀏覽器,以確保 `.htaccess` 運作正常。
---
## SiteGround 的重要注意事項
* **快取 (Caching)**SiteGround 有積極的快取機制 (Nginx Direct Delivery / SuperCacher)。如果您上傳了新版本但看不到變更,請前往 **Site Tools** > **Speed** > **Caching** 並清除 Dynamic Cache。
* **資料庫 (Database)**:由於您使用 Supabase**不需要** 在 SiteGround 上建立 MySQL 資料庫。您的應用程式會自動連接到 Supabase 雲端。
* **Node.js**:您 **不需要** 在 Site Tools 中啟用 Node.js。您的應用程式是預先構建好的靜態檔案。

View File

@@ -0,0 +1,82 @@
# 部署到 Synology NAS (Web Station)
由於您的專案是 React 單頁應用程式 (SPA),部署到 Synology NAS 非常簡單。我們將使用 **Web Station** 套件來託管您構建好的靜態檔案 (Static Files)。
## 先決條件 (Prerequisites)
1. **Synology NAS**:已安裝 DSM。
2. **Web Station**:已從套件中心安裝。
3. **Apache HTTP Server (2.4)****Nginx**:已從套件中心安裝 (推薦使用 Apache因為透過 `.htaccess` 設定 React 路由比較容易,但 Nginx 也可以)。
---
## 步驟 1建立專案 (Build)
首先,您需要從程式碼產生生產環境用的檔案。
1. 打開您的專案終端機 (Terminal)。
2. 執行建置指令:
```bash
npm run build
```
3. 這將在您的專案目錄中產生一個 `dist` 資料夾,其中包含 `index.html`、CSS、JS 和其他資源檔案。
## 步驟 2上傳檔案到 NAS
1. 在您的 Synology NAS 上打開 **File Station**。
2. 導航到 `web` 共用資料夾 (安裝 Web Station 後會自動建立)。
3. 為您的專案建立一個新資料夾,例如 `attendance-app`。
4. 將您電腦上 `dist` 資料夾內的 **所有內容** 上傳到 NAS 的 `web/attendance-app` 資料夾中。
* 您應該在 `web/attendance-app` 裡直接看到 `index.html`。
## 步驟 3設定 Web Station
1. 打開 **Web Station**。
2. 前往 **網頁服務入口 (Web Service Portal)**。
3. 點擊 **新增 (Create)** > **虛擬主機 (Virtual Host)**。
4. **主機類型 (Host Type)**:以連接埠為基礎 (Port-based內部使用最簡單)。
5. **HTTP**:勾選。
6. **連接埠 (Port)**:選擇一個未使用的連接埠,例如 `8080`。
7. **文件根目錄 (Document Root)**:瀏覽並選擇 `web/attendance-app`。
8. **後端伺服器 (Backend Server)**:選擇 **Apache HTTP Server 2.4**。
* *注意:使用 Apache 讓我們可以通過一個簡單的檔案來修復重新整理時的 "Page Not Found" 錯誤。*
9. 點擊 **新增 (Create)**。
## 步驟 4修正 404 錯誤 (React 應用程式的關鍵)
由於 React 是在內部處理路由 (例如:點擊連結會改變網址但不會重新載入頁面),如果您在子頁面 (如 `/reports`) 按下重新整理NAS 伺服器會試圖尋找名為 `reports` 的資料夾但找不到,導致錯誤。我們需要告訴伺服器永遠回傳 `index.html`。
**如果您在步驟 3 選擇了 Apache**
1. 在您的電腦上建立一個名為 `.htaccess` 的文字檔 (確保檔名以點開頭)。
2. 將以下內容貼上到檔案中:
```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>
```
3. 將這個 `.htaccess` 檔案上傳到 NAS 的 `web/attendance-app` 資料夾中 (與 `index.html` 放在一起)。
**如果您選擇了 Nginx**
1. 在 Synology UI 中設定比較複雜。最簡單的變通方法是將您的 React Router 改為使用 "Hash Routing" 而不是 "Browser Routing"。
* 在您的程式碼 (`main.jsx` 或 `App.jsx`) 中,將 `BrowserRouter` 替換為 `HashRouter`。
* 這會將網址變成像 `http://nas-ip:8080/#/reports`。這樣無需伺服器設定即可運作。
## 步驟 5存取您的應用程式
現在您可以透過以下網址存取您的應用程式:
`http://[您的_NAS_IP_位址]:8080`
---
## 資料庫說明 (Database Note)
您的應用程式連接到 Supabase (雲端)。這意味著 **您的 NAS 需要網際網路連線** 才能獲取資料。靜態檔案由您的 NAS 提供,但資料仍然位於雲端。這是推薦的混合部署方式。

View File

@@ -0,0 +1,108 @@
# Automating Updates from Gitea to Web Station on Synology
Since your project is a React application, "syncing" isn't just copying files. The source code (in Gitea) looks different from the website files (in Web Station). You need a process to **Build** the code into static HTML/JS/CSS.
Here are the two best ways to achieve this on a Synology NAS.
---
## Method 1: The "Pull & Build" Script (Recommended)
This method uses the **Synology Task Scheduler** to automatically pull the latest code from Gitea, build it using the NAS's CPU, and copy the results to Web Station.
### Prerequisites
1. **Node.js** installed on Synology (Package Center > Node.js v18 or v20).
2. **Git** Server installed (Package Center > Git Server) so `git` command works in terminal.
### Setup Steps
1. **Prepare a Build Directory**
* Create a folder in File Station to hold the source code (NOT inside the `web` folder).
* Example: `/volume1/homes/admin/projects/attendance-source`
2. **Clone the Repository**
* SSH into your NAS (or use Task Scheduler once) to clone your repo into that folder:
* `git clone http://localhost:3000/username/repo.git /volume1/homes/admin/projects/attendance-source`
3. **Create the Update Script**
* Create a file named `deploy.sh` on your NAS (e.g., in the project folder).
* Paste the content below (adjust paths as needed):
```bash
#!/bin/bash
# 1. Define Paths
PROJECT_DIR="/volume1/homes/admin/projects/attendance-source"
WEB_DIR="/volume1/web/attendance-app"
NODE_PATH="/var/packages/Node.js_v20/target/bin" # Adjust version (v18/v20)
export PATH=$NODE_PATH:$PATH
# 2. Go to Source Project
cd "$PROJECT_DIR"
# 3. Pull latest code from Gitea
echo "Pulling latest changes..."
git pull origin main
# 4. Install Dependencies & Build
# (Only runs npm install if package.json changed, to save time)
echo "Installing dependencies..."
npm install
echo "Building React App..."
npm run build
# 5. Deploy to Web Station
echo "Deploying to Web Station..."
# Clear old files (optional, be careful)
# rm -rf "$WEB_DIR"/*
# Copy new files
mkdir -p "$WEB_DIR"
cp -r dist/* "$WEB_DIR"/
echo "Done! Website updated."
```
4. **Automate with Task Scheduler**
* Open **Control Panel** > **Task Scheduler**.
* Create > Scheduled Task > User-defined script.
* **Schedule**: Set it to run every hour, or every day (depending on how often you update).
* **Task Settings**: In "User-defined script", simply enter:
`bash /volume1/homes/admin/projects/attendance-source/deploy.sh`
---
## Method 2: Build on PC, Sync to NAS (Easiest for Beginners)
If compiling on the NAS is too slow or complicated to set up, you can build on your powerful PC and just "sync" the result to the NAS.
### Steps
1. **Map the Network Drive**
* In Windows File Explorer, map your NAS `web` folder to a drive letter (e.g., `Z:`).
2. **Create a Deploy Script on PC**
* Create a file `deploy_to_nas.bat` in your project folder on your PC.
```batch
@echo off
echo Building Project...
call npm run build
echo Deploying to NAS...
REM Adjust "Z:\attendance-app" to your actual mapped path
xcopy /E /I /Y "dist\*" "Z:\attendance-app\"
echo Deployment Complete!
pause
```
3. **Usage**
* Whenever you want to update the site, just double-click `deploy_to_nas.bat` on your PC. It will build and push the files to the NAS immediately.
---
### Which one should I choose?
* **Choose Method 2** if you are the only developer. It's faster, uses your PC's CPU, and doesn't require setting up Node.js or SSH on the NAS.
* **Choose Method 1** if you have a team pushing code to Gitea and want the site to update automatically regardless of who pushes.

View File

@@ -0,0 +1,31 @@
-- Create the 'employee-avatars' bucket if it doesn't exist
INSERT INTO storage.buckets (id, name, public)
VALUES ('employee-avatars', 'employee-avatars', true)
ON CONFLICT (id) DO NOTHING;
-- Enable Row Level Security (RLS) on storage.objects if not already enabled
-- (It usually is by default, but good to be safe)
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
-- 1. Allow public read access (Viewer) to all files in the bucket
CREATE POLICY "Public Access"
ON storage.objects FOR SELECT
USING ( bucket_id = 'employee-avatars' );
-- 2. Allow authenticated users (e.g., admin) to upload files
CREATE POLICY "Authenticated Upload"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK ( bucket_id = 'employee-avatars' );
-- 3. Allow authenticated users to update files
CREATE POLICY "Authenticated Update"
ON storage.objects FOR UPDATE
TO authenticated
USING ( bucket_id = 'employee-avatars' );
-- 4. Allow authenticated users to delete files
CREATE POLICY "Authenticated Delete"
ON storage.objects FOR DELETE
TO authenticated
USING ( bucket_id = 'employee-avatars' );

View File

@@ -0,0 +1,46 @@
-- 0. Ensure the trigger function exists
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 1. Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT,
description TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. Enable RLS (Recommended for Supabase)
ALTER TABLE system_settings ENABLE ROW LEVEL SECURITY;
-- 3. Create Policies (Allowing open access for simplicity as this is a demo/dev environment)
DROP POLICY IF EXISTS "Allow public read access" ON system_settings;
CREATE POLICY "Allow public read access" ON system_settings FOR SELECT USING (true);
DROP POLICY IF EXISTS "Allow public update access" ON system_settings;
CREATE POLICY "Allow public update access" ON system_settings FOR UPDATE USING (true);
DROP POLICY IF EXISTS "Allow public insert access" ON system_settings;
CREATE POLICY "Allow public insert access" ON system_settings FOR INSERT WITH CHECK (true);
-- 4. Insert default values
INSERT INTO system_settings (key, value, description)
VALUES
('work_start_time', '09:00', '標準上班時間'),
('work_end_time', '18:00', '標準下班時間')
ON CONFLICT (key) DO NOTHING;
-- 5. Trigger for updated_at
DROP TRIGGER IF EXISTS update_system_settings_updated_at ON system_settings;
CREATE TRIGGER update_system_settings_updated_at
BEFORE UPDATE ON system_settings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- 6. Force PostgREST schema cache reload
NOTIFY pgrst, 'reload config';

View File

@@ -0,0 +1,48 @@
-- Drop existing policies to avoid conflicts or "already exists" errors
DROP POLICY IF EXISTS "Public Access" ON storage.objects;
DROP POLICY IF EXISTS "Authenticated Upload" ON storage.objects;
DROP POLICY IF EXISTS "Authenticated Update" ON storage.objects;
DROP POLICY IF EXISTS "Authenticated Delete" ON storage.objects;
-- Drop possibly created "Allow Public Upload" policies if user tried other fixes
DROP POLICY IF EXISTS "Allow Public Upload" ON storage.objects;
DROP POLICY IF EXISTS "Allow Public Update" ON storage.objects;
DROP POLICY IF EXISTS "Allow Public Delete" ON storage.objects;
-- Create the 'employee-avatars' bucket if it doesn't exist (Idempotent)
INSERT INTO storage.buckets (id, name, public)
VALUES ('employee-avatars', 'employee-avatars', true)
ON CONFLICT (id) DO NOTHING;
-- Enable RLS (Should be on, but ensuring)
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
-------------------------------------------------------------------------
-- POLICIES FOR 'employee-avatars' BUCKET
-- Note: 'public' role includes both 'anon' and 'authenticated' users.
-------------------------------------------------------------------------
-- 1. Allow everyone to VIEW files (Read Access)
CREATE POLICY "Public Access"
ON storage.objects FOR SELECT
TO public
USING ( bucket_id = 'employee-avatars' );
-- 2. Allow everyone to UPLOAD files (Insert Access)
-- Since the app uses the ANON key without real Supabase Auth login,
-- we must grant access to 'public' or 'anon'.
CREATE POLICY "Allow Public Upload"
ON storage.objects FOR INSERT
TO public
WITH CHECK ( bucket_id = 'employee-avatars' );
-- 3. Allow everyone to UPDATE their files
CREATE POLICY "Allow Public Update"
ON storage.objects FOR UPDATE
TO public
USING ( bucket_id = 'employee-avatars' );
-- 4. Allow everyone to DELETE files
CREATE POLICY "Allow Public Delete"
ON storage.objects FOR DELETE
TO public
USING ( bucket_id = 'employee-avatars' );

View File

@@ -8,6 +8,7 @@
<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" />
</head>
<body

View File

@@ -0,0 +1,100 @@
#!/bin/bash
# ==========================================
# Synology NAS 自動佈署腳本 (Gitea -> Web Station)
# ==========================================
# 請根據您的 NAS 環境修改下方的變數設定
#
# 注意:此腳本應在 NAS 上執行,建議存放於專案資料夾內
# ==========================================
# 1. 環境變數設定
# ------------------------------------------
# 專案原始碼存放位置 (NAS 上的路徑,請先在此建立資料夾並執行 git clone)
PROJECT_DIR="/volume1/homes/admin/projects/20260101-time-check"
# Web Station 網站根目錄 (編譯後的檔案要放的地方)
WEB_DIR="/volume1/web/attendance"
# Node.js 執行檔路徑
# Synology Package Center 安裝的路徑通常如下,請確認版本 (v18 或 v20)
# 若找不到,可以在 NAS 終端機輸入 `which node` 查看
export PATH="/var/packages/Node.js_v20/target/bin:$PATH"
# 2. 開始更新程序
# ------------------------------------------
echo "[Deploy] Starting deployment process..."
date
# 切換到專案目錄
if [ ! -d "$PROJECT_DIR" ]; then
echo "Error: Project directory $PROJECT_DIR does not exist."
exit 1
fi
cd "$PROJECT_DIR"
# 3. 拉取最新程式碼 (Git Pull)
# ------------------------------------------
echo "[Deploy] Pulling latest code from Gitea..."
# 注意:若您的 Gitea 是私人專案,需先設定 SSH Key 或儲存帳密,以免卡在輸入密碼
git pull origin main
# 檢查 Git 是否成功
if [ $? -ne 0 ]; then
echo "Error: Git pull failed."
exit 1
fi
# 4. 安裝相依套件與編譯 (Build)
# ------------------------------------------
echo "[Deploy] Installing dependencies..."
# 使用 --no-audit 加快速度,若 package.json 沒變動 npm 會自動跳過安裝
npm install --no-audit
echo "[Deploy] Building the React application..."
npm run build
# 檢查編譯是否成功
if [ $? -ne 0 ]; then
echo "Error: Build failed."
exit 1
fi
# 5. 部署到 Web Station
# ------------------------------------------
echo "[Deploy] Deploying files to Web Station ($WEB_DIR)..."
# 確保目標資料夾存在
mkdir -p "$WEB_DIR"
# 備份舊版 (選擇性,若不需要可註解掉)
# mv "$WEB_DIR" "$WEB_DIR_bak_$(date +%Y%m%d_%H%M%S)"
# 複製 dist 資料夾內容到網站根目錄
# 使用 rsync 比較安全且快速 (Synology 通常有內建)
if command -v rsync >/dev/null 2>&1; then
rsync -av --delete dist/ "$WEB_DIR/"
else
# 若無 rsync 則使用 cp
cp -r dist/* "$WEB_DIR/"
fi
# 6. 修復 React Router 404 問題
# ------------------------------------------
# 如果是使用 Apache需要 .htaccess
if [ ! -f "$WEB_DIR/.htaccess" ]; then
echo "[Deploy] Creating .htaccess for React Router..."
cat > "$WEB_DIR/.htaccess" <<EOF
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
EOF
fi
echo "[Deploy] Deployment finished successfully!"
date

View File

@@ -0,0 +1,26 @@
# Disable caching for HTML files to ensure fresh content
<FilesMatch "\.(html)$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires 0
</FilesMatch>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /time/
# 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
RewriteCond %{REQUEST_FILENAME} !-f
# AND NOT a directory that exists
RewriteCond %{REQUEST_FILENAME} !-d
# Then rewrite the request to /time/index.html
RewriteRule . /time/index.html [L]
</IfModule>
# Disable directory browsing
Options -Indexes

View File

@@ -3,6 +3,7 @@ import { supabase } from './supabaseClient'
import EmployeeManagement from './EmployeeManagement'
import DepartmentManagement from './DepartmentManagement'
import Reports from './Reports'
import SystemSettings from './SystemSettings'
function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
const [currentTime, setCurrentTime] = useState(new Date())
@@ -18,6 +19,7 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
const [showEmployeeManagement, setShowEmployeeManagement] = useState(false)
const [showDepartmentManagement, setShowDepartmentManagement] = useState(false)
const [showReports, setShowReports] = useState(false)
const [showSystemSettings, setShowSystemSettings] = useState(false)
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000)
@@ -167,6 +169,18 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
return <Reports onBack={() => setShowReports(false)} />
}
// Show System Settings if requested
if (showSystemSettings) {
return (
<SystemSettings
onBack={() => setShowSystemSettings(false)}
onNavigate={(tab) => {
if (tab === 'overview') setShowSystemSettings(false)
}}
/>
)
}
return (
<div className="w-full min-h-screen bg-background-light dark:bg-slate-900 relative overflow-hidden flex flex-col pb-24">
{/* Top Navigation Bar */}
@@ -364,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'
// 預設頭像 - 使用員工姓名首字母生成顏色
@@ -383,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>
)
})
@@ -414,7 +428,10 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
</div>
<span className="text-[10px] font-medium">訊息</span>
</button>
<button className="flex flex-col items-center justify-center gap-1 w-16 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<button
onClick={() => setShowSystemSettings(true)}
className={`flex flex-col items-center justify-center gap-1 w-16 transition-colors ${showSystemSettings ? 'text-primary' : 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'}`}
>
<span className="material-symbols-outlined text-[28px]">settings</span>
<span className="text-[10px] font-medium">設定</span>
</button>

View File

@@ -15,6 +15,7 @@ function App() {
const [showAdminDashboard, setShowAdminDashboard] = useState(false)
const [showAdminLogin, setShowAdminLogin] = useState(false)
const [isAdminAuthenticated, setIsAdminAuthenticated] = useState(false)
const [isWorking, setIsWorking] = useState(false) // 追蹤今日上班狀態
// Fetch employees from Supabase
useEffect(() => {
@@ -79,9 +80,35 @@ function App() {
if (!error) setLogs(data || [])
}
// Check Current Work Status for Today
const checkWorkStatus = async (employee) => {
if (!employee) return
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
// Find the latest log for today
const { data, error } = await supabase
.from('attendance_logs')
.select('type')
.eq('employee_id', employee.id)
.gte('created_at', todayStart.toISOString())
.order('created_at', { ascending: false })
.limit(1)
if (!error && data && data.length > 0) {
// If the latest record is 'clock_in', then they are working.
setIsWorking(data[0].type === 'clock_in')
} else {
// No records today, or error -> Assume not working
setIsWorking(false)
}
}
// Fetch logs when employee or date changes
useEffect(() => {
fetchLogs(selectedEmployee, selectedDate)
checkWorkStatus(selectedEmployee)
}, [selectedEmployee, selectedDate])
useEffect(() => {
@@ -92,6 +119,12 @@ function App() {
const handleClockAction = async (type) => {
if (!selectedEmployee) return
// 檢查:如果已經上班打卡,不能重複打卡 (UI 已擋,此為後端防護)
if (type === 'clock_in' && isWorking) {
alert('⚠️ 您已經打過上班卡了!請先執行下班打卡。')
return
}
// 檢查:下班打卡前必須先有上班打卡
if (type === 'clock_out') {
try {
@@ -139,6 +172,9 @@ function App() {
if (!error) {
// Refresh logs for currently selected date
fetchLogs(selectedEmployee, selectedDate)
// Update work status
checkWorkStatus(selectedEmployee)
alert(type === 'clock_in' ? '上班打卡成功!' : '下班打卡成功!')
} else {
alert('打卡失敗:' + error.message)
@@ -206,7 +242,7 @@ 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">
<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 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>
@@ -271,7 +307,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>
@@ -347,8 +383,10 @@ function App() {
{selectedDate === new Date().toISOString().split('T')[0] ? formatDate(time) : selectedDate}
</p>
<div className="flex items-center gap-1 bg-green-50 dark:bg-green-900/20 px-3 py-1 rounded-full border border-green-100 dark:border-green-800">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-green-700 dark:text-green-400 text-xs font-bold">員工在線</span>
<div className={`w-2 h-2 ${isWorking ? 'bg-green-500' : 'bg-slate-300'} rounded-full`}></div>
<span className={`${isWorking ? 'text-green-700 dark:text-green-400' : 'text-slate-400'} text-xs font-bold`}>
{isWorking ? '工作中 (On Duty)' : '未執勤 (Off Duty)'}
</span>
</div>
</div>
</div>
@@ -371,36 +409,36 @@ function App() {
{/* Main Action Buttons */}
<div className="flex flex-row justify-center items-center gap-4 sm:gap-8 px-4 mb-10">
{/* Clock In */}
<div className="relative group">
<div className={`relative group ${isWorking ? 'opacity-40 grayscale pointer-events-none' : ''}`}>
<div className="absolute -inset-2 bg-gradient-to-r from-primary to-blue-400 rounded-full blur opacity-20 group-hover:opacity-40 transition duration-500"></div>
<button
onClick={() => handleClockAction('clock_in')}
disabled={!selectedEmployee}
className={`relative flex items-center justify-center w-36 h-36 sm:w-44 sm:h-44 rounded-full text-white shadow-2xl transform active:scale-95 transition-all duration-300 ${isAdminMode ? 'bg-[#101922] dark:bg-slate-800 border-[6px] border-white dark:border-slate-900' : 'bg-primary border-none'} disabled:opacity-50`}
disabled={!selectedEmployee || isWorking}
className={`relative flex items-center justify-center w-36 h-36 sm:w-44 sm:h-44 rounded-full text-white shadow-2xl transform active:scale-95 transition-all duration-300 ${isAdminMode ? 'bg-[#101922] dark:bg-slate-800 border-[6px] border-white dark:border-slate-900' : 'bg-primary border-none'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-2xl sm:text-3xl mb-1">{isAdminMode ? 'login' : 'touch_app'}</span>
<span className="text-base sm:text-lg font-bold tracking-wide">{isAdminMode ? '協助上班' : '上班打卡'}</span>
<span className="text-[10px] font-medium opacity-80 uppercase tracking-widest">CLOCK IN</span>
</div>
<div className={`absolute inset-0 rounded-full border border-primary/30 animate-ping pointer-events-none ${!isAdminMode ? 'scale-100' : ''}`}></div>
<div className={`absolute inset-0 rounded-full border border-primary/30 animate-ping pointer-events-none ${!isAdminMode && !isWorking ? 'scale-100' : 'hidden'}`}></div>
</button>
</div>
{/* Clock Out */}
<div className="relative group">
<div className={`relative group ${!isWorking ? 'opacity-40 grayscale pointer-events-none' : ''}`}>
<div className="absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-400 rounded-full blur opacity-20 group-hover:opacity-40 transition duration-500"></div>
<button
onClick={() => handleClockAction('clock_out')}
disabled={!selectedEmployee}
className={`relative flex items-center justify-center w-36 h-36 sm:w-44 sm:h-44 rounded-full text-white shadow-2xl transform active:scale-95 transition-all duration-300 ${isAdminMode ? 'bg-[#101922] dark:bg-slate-800 border-[6px] border-white dark:border-slate-900' : 'bg-red-500 border-none'} disabled:opacity-50`}
disabled={!selectedEmployee || !isWorking}
className={`relative flex items-center justify-center w-36 h-36 sm:w-44 sm:h-44 rounded-full text-white shadow-2xl transform active:scale-95 transition-all duration-300 ${isAdminMode ? 'bg-[#101922] dark:bg-slate-800 border-[6px] border-white dark:border-slate-900' : 'bg-red-500 border-none'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-2xl sm:text-3xl mb-1">{isAdminMode ? 'logout' : 'do_not_disturb_on'}</span>
<span className="text-base sm:text-lg font-bold tracking-wide">{isAdminMode ? '協助下班' : '下班打卡'}</span>
<span className="text-[10px] font-medium opacity-80 uppercase tracking-widest">CLOCK OUT</span>
</div>
<div className={`absolute inset-0 rounded-full border border-red-500/30 animate-ping pointer-events-none delay-700 ${!isAdminMode ? 'scale-100' : ''}`}></div>
<div className={`absolute inset-0 rounded-full border border-red-500/30 animate-ping pointer-events-none delay-700 ${!isAdminMode && isWorking ? 'scale-100' : 'hidden'}`}></div>
</button>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { supabase } from './supabaseClient'
function EmployeeForm({ onBack, employeeId = null }) {
const fileInputRef = useRef(null)
const [formData, setFormData] = useState({
name: '',
english_name: '',
@@ -286,15 +287,15 @@ function EmployeeForm({ onBack, employeeId = null }) {
{/* Avatar Upload */}
<div className="flex flex-col items-center justify-center mb-6">
<input
ref={fileInputRef}
type="file"
id="avatar-upload"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
<div
className="relative group cursor-pointer"
onClick={() => document.getElementById('avatar-upload')?.click()}
onClick={() => fileInputRef.current?.click()}
>
{previewUrl || formData.avatar_url ? (
<img
@@ -321,7 +322,7 @@ function EmployeeForm({ onBack, employeeId = null }) {
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => document.getElementById('avatar-upload')?.click()}
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs bg-primary/10 text-primary px-3 py-1.5 rounded-lg hover:bg-primary/20 transition-colors disabled:opacity-50"
>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import EmployeeForm from './EmployeeForm'
import PersonalAttendanceRecord from './PersonalAttendanceRecord'
function EmployeeManagement({ onBack }) {
const [employees, setEmployees] = useState([])
@@ -9,6 +10,8 @@ function EmployeeManagement({ onBack }) {
const [showForm, setShowForm] = useState(false)
const [editingEmployeeId, setEditingEmployeeId] = useState(null)
const [openMenuId, setOpenMenuId] = useState(null)
const [showAttendanceRecord, setShowAttendanceRecord] = useState(false)
const [selectedEmployeeId, setSelectedEmployeeId] = useState(null)
useEffect(() => {
fetchEmployees()
@@ -112,12 +115,28 @@ function EmployeeManagement({ onBack }) {
fetchEmployees() // Refresh list
}
const handleViewAttendance = (id) => {
setSelectedEmployeeId(id)
setShowAttendanceRecord(true)
setOpenMenuId(null)
}
const handleAttendanceBack = () => {
setShowAttendanceRecord(false)
setSelectedEmployeeId(null)
}
const filteredEmployees = employees.filter(emp =>
emp.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
emp.id?.toString().includes(searchQuery) ||
emp.english_name?.toLowerCase().includes(searchQuery.toLowerCase())
)
// Show attendance record if requested
if (showAttendanceRecord && selectedEmployeeId) {
return <PersonalAttendanceRecord onBack={handleAttendanceBack} employeeId={selectedEmployeeId} />
}
// Show form if requested
if (showForm) {
return <EmployeeForm onBack={handleFormClose} employeeId={editingEmployeeId} />
@@ -126,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>
@@ -142,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>
@@ -234,6 +253,16 @@ function EmployeeManagement({ onBack }) {
<span className="material-symbols-outlined text-[18px]">edit</span>
編輯資料
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleViewAttendance(employee.id)
}}
className="w-full px-4 py-2.5 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center gap-2 transition-colors"
>
<span className="material-symbols-outlined text-[18px]">schedule</span>
個人打卡紀錄
</button>
<button
onClick={(e) => {
e.stopPropagation()

View File

@@ -0,0 +1,646 @@
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import SalaryExportPreview from './SalaryExportPreview'
function PersonalAttendanceRecord({ onBack, employeeId }) {
const [employee, setEmployee] = useState(null)
const [attendanceRecords, setAttendanceRecords] = useState([])
const [loading, setLoading] = useState(true)
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [showCalendar, setShowCalendar] = useState(false)
const [showExportPreview, setShowExportPreview] = useState(false)
const [stats, setStats] = useState({
workDays: 0,
totalHours: 0,
lateCount: 0
})
const [calendarDays, setCalendarDays] = useState([])
const [isCalendarExpanded, setIsCalendarExpanded] = useState(true)
const [currentViewDate, setCurrentViewDate] = useState(new Date())
useEffect(() => {
// Set default date range (current month)
const now = new Date()
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
setStartDate(firstDay.toISOString().split('T')[0])
setEndDate(lastDay.toISOString().split('T')[0])
setCurrentViewDate(firstDay)
fetchEmployeeData()
}, [employeeId])
useEffect(() => {
if (startDate && endDate) {
fetchAttendanceRecords()
}
}, [startDate, endDate])
useEffect(() => {
generateCalendarDays(currentViewDate)
}, [currentViewDate])
const fetchEmployeeData = async () => {
try {
const { data, error } = await supabase
.from('employees')
.select('*')
.eq('id', employeeId)
.single()
if (error) throw error
setEmployee(data)
} catch (error) {
console.error('Error fetching employee:', error)
}
}
const fetchAttendanceRecords = async () => {
try {
setLoading(true)
const { data, error } = await supabase
.from('attendance_logs')
.select('*')
.eq('employee_id', employeeId)
.gte('created_at', startDate)
.lte('created_at', endDate + 'T23:59:59')
.order('created_at', { ascending: false })
if (error) throw error
// Group records by date
const groupedRecords = groupRecordsByDate(data || [])
setAttendanceRecords(groupedRecords)
calculateStats(groupedRecords)
} catch (error) {
console.error('Error fetching attendance records:', error)
} finally {
setLoading(false)
}
}
const groupRecordsByDate = (records) => {
const grouped = {}
records.forEach(record => {
const date = record.created_at.split('T')[0]
if (!grouped[date]) {
grouped[date] = {
date,
records: []
}
}
grouped[date].records.push(record)
})
// Convert to array and calculate hours for each day
return Object.values(grouped).map(day => {
const sortedRecords = day.records.sort((a, b) =>
new Date(a.created_at) - new Date(b.created_at)
)
let totalHours = 0
const sessions = []
let currentIn = null
sortedRecords.forEach(record => {
const recordTime = new Date(record.created_at)
if (record.type === 'clock_in') {
if (currentIn) {
// Previous In was incomplete, record it
sessions.push({
start: currentIn,
end: null,
hours: 0
})
}
currentIn = record
} else if (record.type === 'clock_out') {
if (currentIn) {
// Match found
const diff = recordTime - new Date(currentIn.created_at)
const hours = diff / (1000 * 60 * 60)
totalHours += hours
sessions.push({
start: currentIn,
end: record,
hours: hours.toFixed(1)
})
currentIn = null
} else {
// Orphan out
sessions.push({
start: null,
end: record,
hours: 0
})
}
}
})
// Handle trailing In
if (currentIn) {
sessions.push({
start: currentIn,
end: null,
hours: 0
})
}
// Check if late (based on very first clock-in of the day)
let isLate = false
const firstIn = sortedRecords.find(r => r.type === 'clock_in')
if (firstIn) {
const clockInTime = new Date(firstIn.created_at)
const nineAM = new Date(clockInTime)
nineAM.setHours(9, 0, 0, 0)
isLate = clockInTime > nineAM
}
return {
...day,
totalHours: totalHours.toFixed(1), // Total hours for the day
isLate,
sessions, // List of all sessions
status: sessions.length === 0 ? 'off' : isLate ? 'late' : 'normal'
}
})
}
const calculateStats = (records) => {
const workDays = records.filter(r => r.status !== 'off').length
const totalHours = records.reduce((sum, r) => sum + parseFloat(r.totalHours || 0), 0)
const lateCount = records.filter(r => r.isLate).length
setStats({
workDays,
totalHours: totalHours.toFixed(1),
lateCount
})
}
const formatTime = (dateString, isLate = false) => {
if (!dateString) return '--:--'
const date = new Date(dateString)
const timeStr = date.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false })
// If late, return formatted JSX component, but here we just return string for safety in helper,
// usage site will handle styling. Or we can just return string.
// Actually, the original design used this helper in the render.
// Let's keep it simple and handle styling in render.
return timeStr
}
const formatDate = (dateString) => {
const date = new Date(dateString)
const month = date.getMonth() + 1
const day = date.getDate()
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const weekday = weekdays[date.getDay()]
return { month, day, weekday }
}
const generateCalendarDays = (dateForMonth) => {
const year = dateForMonth.getFullYear()
const month = dateForMonth.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const days = []
// Add empty cells for days before the 1st of the month
for (let i = 0; i < firstDay.getDay(); i++) {
days.push(null)
}
// Add days of the month
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push(new Date(year, month, i))
}
setCalendarDays(days)
}
const handleDateClick = (clickedDate) => {
if (!clickedDate) return
// Adjust to local date string to avoid timezone issues when setting state
// We use the "YYYY-MM-DD" format.
// A simple way is to use the year/month/day from the clicked object directly
const year = clickedDate.getFullYear()
const month = String(clickedDate.getMonth() + 1).padStart(2, '0')
const day = String(clickedDate.getDate()).padStart(2, '0')
const clickedStr = `${year}-${month}-${day}`
const start = startDate ? new Date(startDate) : null
// Case 1: No range selected, or both selected (resetting) -> Start new range
if ((!startDate && !endDate) || (startDate && endDate)) {
setStartDate(clickedStr)
setEndDate('') // Clear end date to start fresh selection
return
}
// Case 2: Only start exists
if (startDate && !endDate) {
if (clickedStr < startDate) {
// Clicked before start -> make it new start
setStartDate(clickedStr)
} else {
// Clicked after start -> make it end
setEndDate(clickedStr)
}
}
}
const handlePrevMonth = () => {
const newDate = new Date(currentViewDate)
newDate.setMonth(newDate.getMonth() - 1)
setCurrentViewDate(newDate)
}
const handleNextMonth = () => {
const newDate = new Date(currentViewDate)
newDate.setMonth(newDate.getMonth() + 1)
setCurrentViewDate(newDate)
}
const getStatusBadge = (status) => {
switch (status) {
case 'normal':
return {
text: '正常',
className: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
}
case 'late':
return {
text: '遲到',
className: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
case 'off':
return {
text: '休假',
className: 'bg-slate-200 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
}
default:
return {
text: '未知',
className: 'bg-slate-200 text-slate-500'
}
}
}
const handleSearch = () => {
fetchAttendanceRecords()
}
const handleExport = () => {
setShowExportPreview(true)
}
const handleConfirmExport = () => {
// TODO: Implement actual export logic (e.g., generate CSV or PDF)
alert('匯出功能實作中...\n這裡會下載 CSV 或 PDF 檔案。')
// setShowExportPreview(false)
}
if (!employee) {
return (
<div className="flex items-center justify-center min-h-screen bg-background-light dark:bg-background-dark">
<div className="text-text-secondary-light dark:text-text-secondary-dark">載入中...</div>
</div>
)
}
// Show Export Preview if requested
if (showExportPreview) {
return (
<SalaryExportPreview
employee={employee}
records={attendanceRecords}
startDate={startDate}
endDate={endDate}
onBack={() => setShowExportPreview(false)}
onConfirmExport={handleConfirmExport}
/>
)
}
return (
<div className="relative flex h-full min-h-screen w-full flex-col bg-background-light dark:bg-background-dark">
{/* Header */}
<header className="sticky top-0 z-10 flex items-center bg-white dark:bg-surface-dark px-4 py-3 justify-between shadow-sm border-b border-border-light dark:border-border-dark lg:px-6">
<button
onClick={onBack}
className="text-text-primary-light dark:text-text-primary-dark flex size-10 shrink-0 items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: '24px' }}>arrow_back</span>
</button>
<h2 className="text-text-primary-light dark:text-text-primary-dark text-lg font-bold leading-tight tracking-[-0.015em] flex-1 text-center pr-10">
個人打卡紀錄
</h2>
</header>
<div className="flex-1 w-full max-w-7xl mx-auto p-4 lg:p-6 lg:grid lg:grid-cols-12 lg:gap-6 lg:items-start">
{/* Left Sidebar ({Desktop}) / Top Section (Mobile) */}
<div className="flex flex-col gap-4 lg:col-span-4 lg:sticky lg:top-20">
{/* Employee Info Card */}
<div className="flex items-center gap-4 rounded-xl bg-white dark:bg-surface-dark p-4 shadow-sm border border-border-light dark:border-border-dark relative overflow-hidden">
<div className="absolute -right-6 -top-6 size-24 rounded-full bg-primary/5 blur-2xl"></div>
<div className="relative shrink-0">
{employee.avatar_url ? (
<img
alt="Employee Avatar"
className="size-16 rounded-full object-cover border-2 border-white dark:border-surface-dark shadow-md"
src={employee.avatar_url}
/>
) : (
<div className="size-16 rounded-full bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center text-indigo-600 dark:text-indigo-300 font-bold text-2xl border-2 border-white dark:border-surface-dark shadow-md">
{employee.name?.substring(0, 2) || '??'}
</div>
)}
<span className="absolute bottom-0 right-0 block size-3.5 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-surface-dark"></span>
</div>
<div className="relative flex flex-col justify-center flex-1 min-w-0">
<div className="flex items-baseline justify-between">
<h1 className="text-xl font-bold text-text-primary-light dark:text-text-primary-dark truncate">{employee.name}</h1>
<span className="text-xs font-mono text-text-secondary-light dark:text-text-secondary-dark opacity-70">ID: {employee.id}</span>
</div>
<p className="text-sm font-medium text-text-secondary-light dark:text-text-secondary-dark truncate mt-0.5">
{employee.position || '未設定職位'} <span className="mx-1 opacity-40">|</span> {employee.department || '未分配部門'}
</p>
</div>
</div>
{/* Stats Cards */}
<div className="flex flex-wrap gap-3 lg:grid lg:grid-cols-3">
<div className="flex min-w-[30%] flex-1 basis-[fit-content] flex-col gap-1 rounded-xl border border-border-light dark:border-border-dark bg-white dark:bg-surface-dark p-4 items-center text-center shadow-sm">
<p className="text-primary tracking-tight text-2xl font-bold leading-tight">{stats.workDays}</p>
<p className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-medium">出勤天數</p>
</div>
<div className="flex min-w-[30%] flex-1 basis-[fit-content] flex-col gap-1 rounded-xl border border-border-light dark:border-border-dark bg-white dark:bg-surface-dark p-4 items-center text-center shadow-sm">
<p className="text-text-primary-light dark:text-text-primary-dark tracking-tight text-2xl font-bold leading-tight">{stats.totalHours}</p>
<p className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-medium">總工時 (H)</p>
</div>
<div className="flex min-w-[30%] flex-1 basis-[fit-content] flex-col gap-1 rounded-xl border border-border-light dark:border-border-dark bg-white dark:bg-surface-dark p-4 items-center text-center shadow-sm">
<p className="text-red-500 tracking-tight text-2xl font-bold leading-tight">{stats.lateCount}</p>
<p className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-medium">遲到次數</p>
</div>
</div>
{/* Date Range Selector */}
<div className="bg-white dark:bg-surface-dark rounded-xl shadow-sm border border-border-light dark:border-border-dark p-4">
<h3 className="text-text-primary-light dark:text-text-primary-dark text-lg font-bold leading-tight">查詢範圍</h3>
</div>
<div className="bg-white dark:bg-surface-dark rounded-xl shadow-sm border border-border-light dark:border-border-dark p-4">
<div className="flex items-center gap-2 mb-4">
<div className="flex-1 relative">
<input
className="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-border-dark rounded-lg py-2 pl-3 pr-2 text-sm font-medium text-text-primary-light dark:text-text-primary-dark focus:ring-2 focus:ring-primary focus:border-primary outline-none"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<span className="text-text-secondary-light dark:text-text-secondary-dark text-sm"></span>
<div className="flex-1 relative">
<input
className="w-full bg-slate-50 dark:bg-background-dark border border-border-light dark:border-border-dark rounded-lg py-2 pl-3 pr-2 text-sm font-medium text-text-primary-light dark:text-text-primary-dark focus:ring-2 focus:ring-primary focus:border-primary outline-none"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<button
onClick={handleSearch}
className="bg-primary hover:bg-primary-dark text-white rounded-lg p-2 transition-colors shadow-sm shadow-primary/30"
>
<span className="material-symbols-outlined block" style={{ fontSize: '20px' }}>search</span>
</button>
</div>
{/* Calendar Grid */}
{isCalendarExpanded && (
<>
<div className="flex items-center justify-between mb-4 px-2">
<span className="text-text-primary-light dark:text-text-primary-dark font-bold text-sm">
{currentViewDate.toLocaleDateString('zh-TW', { year: 'numeric', month: 'long' })}
</span>
</div>
<div className="grid grid-cols-7 gap-y-1 text-center mb-2">
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
<div className="text-text-secondary-light dark:text-text-secondary-dark text-xs font-bold py-2"></div>
{calendarDays.map((dateObj, index) => {
if (dateObj === null) return <div key={`empty-${index}`}></div>
const year = dateObj.getFullYear()
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
const day = String(dateObj.getDate()).padStart(2, '0')
const dateStr = `${year}-${month}-${day}`
const dayNum = dateObj.getDate()
// Determine state of this date
const isStart = startDate === dateStr
const isEnd = endDate === dateStr
const isInRange = startDate && endDate && dateStr > startDate && dateStr < endDate
const isSelected = isStart || isEnd
return (
<button
key={dateStr}
onClick={() => handleDateClick(dateObj)}
className={`flex items-center justify-center size-9 mx-auto rounded-full text-sm font-bold transition-all relative
${isSelected
? 'bg-primary text-white shadow-md shadow-primary/30 z-10'
: isInRange
? 'bg-primary/10 text-primary z-0'
: 'text-text-primary-light dark:text-text-primary-dark hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
>
{dayNum}
</button>
)
})}
</div>
<div className="flex justify-between mt-4 px-2">
<button
onClick={handlePrevMonth}
className="flex items-center gap-1 text-xs font-medium text-text-secondary-light dark:text-text-secondary-dark hover:text-primary transition-colors py-1 px-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
<span className="material-symbols-outlined" style={{ fontSize: '16px' }}>chevron_left</span>
上個月
</button>
<button
className="text-text-secondary-light dark:text-text-secondary-dark hover:text-primary transition-colors"
onClick={() => setIsCalendarExpanded(false)}
>
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>keyboard_arrow_up</span>
</button>
<button
onClick={handleNextMonth}
className="flex items-center gap-1 text-xs font-medium text-text-secondary-light dark:text-text-secondary-dark hover:text-primary transition-colors py-1 px-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
下個月
<span className="material-symbols-outlined" style={{ fontSize: '16px' }}>chevron_right</span>
</button>
</div>
</>
)}
{!isCalendarExpanded && (
<div className="flex justify-center mt-0">
<button
className="text-text-secondary-light dark:text-text-secondary-dark hover:text-primary transition-colors"
onClick={() => setIsCalendarExpanded(true)}
>
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>keyboard_arrow_down</span>
</button>
</div>
)}
</div>
</div>
{/* Daily Records */}
<div className="flex-1 mt-6 lg:mt-0 lg:col-span-8">
<div className="bg-white dark:bg-surface-dark lg:rounded-xl lg:shadow-sm lg:border lg:border-border-light lg:dark:border-border-dark rounded-t-3xl shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] border-t border-border-light dark:border-border-dark px-4 pb-20 lg:pb-4 lg:min-h-[calc(100vh-8rem)]">
<div className="sticky top-0 bg-white dark:bg-surface-dark z-10 px-2 py-5 border-b border-border-light dark:border-border-dark mb-4 lg:rounded-t-xl">
<div className="flex justify-between items-end">
<h3 className="text-text-primary-light dark:text-text-primary-dark text-lg font-bold leading-tight">每日明細</h3>
<span className="text-xs text-text-secondary-light dark:text-text-secondary-dark"> {attendanceRecords.length} 筆紀錄</span>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-text-secondary-light dark:text-text-secondary-dark">載入中...</div>
</div>
) : attendanceRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<span className="material-symbols-outlined text-6xl text-slate-300 dark:text-slate-700 mb-4">event_busy</span>
<p className="text-text-secondary-light dark:text-text-secondary-dark">此期間無打卡紀錄</p>
</div>
) : (
<div className="flex flex-col gap-4">
{attendanceRecords.map((record) => {
const { month, day, weekday } = formatDate(record.date)
const badge = getStatusBadge(record.status)
// 根據狀態設定邊框、背景和日期方塊顏色
let borderColor, cardBgColor, dateColor
if (record.status === 'late') {
// 遲到:淺紅色背景
borderColor = 'border-red-200 dark:border-red-900/50'
cardBgColor = 'bg-red-50/50 dark:bg-red-900/10'
dateColor = 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400'
} else if (record.status === 'off') {
// 休假:淺灰色背景
borderColor = 'border-slate-200 dark:border-slate-700'
cardBgColor = 'bg-slate-50 dark:bg-slate-900/30'
dateColor = 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
} else {
// 正常:白色背景
borderColor = 'border-border-light dark:border-border-dark'
cardBgColor = 'bg-white dark:bg-slate-800/50'
dateColor = 'bg-primary/10 text-primary dark:bg-primary/20'
}
return (
<div key={record.date} className={`group flex flex-col gap-3 rounded-xl border ${borderColor} ${cardBgColor} p-4 transition-all ${record.status === 'normal' ? 'hover:border-primary/50' : ''}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex size-11 flex-col items-center justify-center rounded-lg ${dateColor}`}>
<span className="text-xs font-medium opacity-80">{month}</span>
<span className="text-lg font-bold leading-none">{day}</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p className="text-text-primary-light dark:text-text-primary-dark text-base font-bold leading-tight">星期{weekday}</p>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold ${badge.className}`}>
{badge.text}
</span>
</div>
{record.status !== 'off' && (
<p className="text-text-secondary-light dark:text-text-secondary-dark text-xs mt-1">
當日總上班時數 <span className="text-text-primary-light dark:text-text-primary-dark font-bold text-sm">{record.totalHours}</span> 小時
</p>
)}
{record.status === 'off' && (
<p className="text-text-secondary-light dark:text-text-secondary-dark text-xs mt-1">無打卡紀錄</p>
)}
</div>
</div>
{record.status === 'off' && (
<div className="text-right">
<p className="text-text-secondary-light dark:text-text-secondary-dark text-lg font-bold">0 <span className="text-xs font-normal">小時</span></p>
</div>
)}
</div>
{record.status !== 'off' && (
<>
<div className="h-px bg-border-light dark:bg-border-dark w-full border-dashed"></div>
<div className="flex flex-col gap-2">
{/* List all sessions/records for the day */}
{record.sessions && record.sessions.map((session, sIdx) => (
<div key={sIdx} className="flex items-center justify-between bg-white dark:bg-slate-800 p-2 rounded-lg border border-border-light/50 dark:border-border-dark/50">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-text-secondary-light dark:text-text-secondary-dark font-medium">上班</span>
<span className={`text-sm font-bold font-mono ${record.isLate && sIdx === 0 ? 'text-red-600 dark:text-red-400' : 'text-text-primary-light dark:text-text-primary-dark'}`}>
{formatTime(session.start?.created_at)}
</span>
</div>
<div className="flex items-center text-border-light dark:text-gray-600">
<span className="material-symbols-outlined" style={{ fontSize: '16px' }}>arrow_forward</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-text-secondary-light dark:text-text-secondary-dark font-medium">下班</span>
<span className="text-text-primary-light dark:text-text-primary-dark text-sm font-bold font-mono">
{formatTime(session.end?.created_at)}
</span>
</div>
</div>
<div className="text-right">
<span className="text-xs font-medium text-text-secondary-light dark:text-text-secondary-dark">{session.hours} H</span>
</div>
</div>
))}
</div>
</>
)}
</div>
)
})}
</div>
)}
<div className="h-8"></div>
</div>
{/* Export Button */}
<div className="fixed bottom-6 right-6 lg:hidden">
<button
onClick={handleExport}
className="flex size-14 items-center justify-center rounded-full bg-primary text-white shadow-lg shadow-primary/40 hover:bg-primary-dark transition-all active:scale-95"
>
<span className="material-symbols-outlined" style={{ fontSize: '24px' }}>download</span>
</button>
</div>
</div>
</div>
</div>
)
}
export default PersonalAttendanceRecord

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import SalaryExportPreview from './SalaryExportPreview'
function Reports({ onBack }) {
const [selectedMonth, setSelectedMonth] = useState('')
@@ -7,8 +8,11 @@ function Reports({ onBack }) {
const [employeesList, setEmployeesList] = useState([])
const [selectedEmployeeId, setSelectedEmployeeId] = useState('all')
const [reportData, setReportData] = useState([])
const [rawLogs, setRawLogs] = useState([]) // Store raw logs for preview
const [stats, setStats] = useState({ totalEmployees: 0, abnormalCount: 0 })
const [loading, setLoading] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [previewTarget, setPreviewTarget] = useState(null) // { employee, records, startDate, endDate }
useEffect(() => {
fetchMonths()
@@ -48,28 +52,46 @@ function Reports({ onBack }) {
if (logError) throw logError
setRawLogs(logs) // Store raw logs
// 4. 資料處理
let abnormal = 0
const processedData = employees.map(emp => {
const empLogs = logs.filter(log => log.employee_id === emp.id)
// Sort logs to ensure correct time diff calculation
empLogs.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
// 計算出勤天數 (Unique Days)
const uniqueDays = new Set(empLogs.map(log =>
new Date(log.created_at).toDateString()
)).size
// 計算遲到 (假設 09:00 上班)
// 邏輯:每天第一筆 clock_in 若晚於 09:00 則計為遲到
// 計算總工時與遲到
let totalHours = 0
let lateCount = 0
const daysProcessed = new Set()
let currentIn = null
const daysProcessed = new Set() // For late count uniqueness
empLogs.forEach(log => {
const dateStr = new Date(log.created_at).toDateString()
if (log.type === 'clock_in' && !daysProcessed.has(dateStr)) {
daysProcessed.add(dateStr)
const logDate = new Date(log.created_at)
if (logDate.getHours() > 9 || (logDate.getHours() === 9 && logDate.getMinutes() > 0)) {
lateCount++
const recordTime = new Date(log.created_at)
// Late Calculation
const dateStr = recordTime.toDateString()
if (log.type === 'clock_in') {
if (!daysProcessed.has(dateStr)) {
daysProcessed.add(dateStr)
if (recordTime.getHours() > 9 || (recordTime.getHours() === 9 && recordTime.getMinutes() > 0)) {
lateCount++
}
}
currentIn = log
} else if (log.type === 'clock_out') {
// Work Hours Calculation
if (currentIn) {
const diff = recordTime - new Date(currentIn.created_at)
const hours = diff / (1000 * 60 * 60)
totalHours += hours
currentIn = null
}
}
})
@@ -81,9 +103,16 @@ function Reports({ onBack }) {
if (status !== 'full_attendance') abnormal++
// Calculate Salary
const hourlyRate = emp.hourly_rate || 180
const estimatedSalary = Math.round(totalHours * hourlyRate)
return {
...emp,
attendanceDays: uniqueDays,
totalHours: totalHours.toFixed(1),
hourlyRate,
estimatedSalary,
lateCount,
status
}
@@ -171,6 +200,121 @@ function Reports({ onBack }) {
return `${year}${month}`
}
// Prepare data for SalaryExportPreview
const preparePreviewData = (targetEmp) => {
if (!targetEmp || !selectedMonth) return null
const [year, month] = selectedMonth.split('-').map(Number)
const startDate = `${year}-${String(month).padStart(2, '0')}-01`
const lastDay = new Date(year, month, 0).getDate()
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`
// Filter logs for this employee
const empLogs = rawLogs.filter(log => log.employee_id === targetEmp.id)
// Group records by date matching PersonalAttendanceRecord structure
// We need to transform raw logs into the 'grouped records' format expected by SalaryExportPreview
// Logic reused/adapted from PersonalAttendanceRecord
const grouped = {}
empLogs.forEach(record => {
const date = record.created_at.split('T')[0]
if (!grouped[date]) {
grouped[date] = { date, records: [] }
}
grouped[date].records.push(record)
})
const records = Object.values(grouped).map(day => {
const sortedRecords = day.records.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
let totalHours = 0
const sessions = []
let currentIn = null
sortedRecords.forEach(record => {
const recordTime = new Date(record.created_at)
if (record.type === 'clock_in') {
if (currentIn) { sessions.push({ start: currentIn, end: null, hours: 0 }); }
currentIn = record
} else if (record.type === 'clock_out') {
if (currentIn) {
const diff = recordTime - new Date(currentIn.created_at)
const hours = diff / (1000 * 60 * 60)
totalHours += hours
sessions.push({ start: currentIn, end: record, hours: hours.toFixed(1) })
currentIn = null
} else {
sessions.push({ start: null, end: record, hours: 0 })
}
}
})
if (currentIn) { sessions.push({ start: currentIn, end: null, hours: 0 }) }
let isLate = false
const firstIn = sortedRecords.find(r => r.type === 'clock_in')
if (firstIn) {
const clockInTime = new Date(firstIn.created_at)
const nineAM = new Date(clockInTime)
nineAM.setHours(9, 0, 0, 0)
isLate = clockInTime > nineAM
}
return {
...day,
totalHours: totalHours.toFixed(1),
isLate,
sessions,
status: sessions.length === 0 ? 'off' : isLate ? 'late' : 'normal'
}
})
// Return sorted records
records.sort((a, b) => new Date(a.date) - new Date(b.date))
return {
employee: targetEmp,
records: records,
startDate,
endDate
}
}
const handleExportClick = () => {
let target = null
if (selectedEmployeeId === 'all') {
// Pick first employee from filtered list
if (filteredReportData.length > 0) {
target = filteredReportData[0]
}
} else {
target = filteredReportData.find(e => e.id.toString() === selectedEmployeeId)
}
if (target) {
const previewData = preparePreviewData(target)
setPreviewTarget(previewData)
setShowPreview(true)
} else {
alert('無可用資料可供預覽')
}
}
if (showPreview && previewTarget) {
return (
<SalaryExportPreview
employee={previewTarget.employee}
records={previewTarget.records}
startDate={previewTarget.startDate}
endDate={previewTarget.endDate}
onBack={() => setShowPreview(false)}
onConfirmExport={() => {
alert('匯出 Excel 功能實作中...')
// setShowPreview(false)
}}
/>
)
}
return (
<div className="bg-background-light dark:bg-slate-900 font-display antialiased text-slate-900 dark:text-slate-100 min-h-screen flex flex-col relative">
{/* Top App Bar */}
@@ -311,31 +455,51 @@ function Reports({ onBack }) {
</div>
) : (
filteredReportData.map(emp => (
<div key={emp.id} className={`flex items-center p-3 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-100 dark:border-slate-700 ${emp.status === 'missing' ? 'opacity-75' : ''}`}>
<div className={`size-10 rounded-full flex items-center justify-center shrink-0 mr-3 overflow-hidden ${emp.avatar_url ? 'bg-transparent' : 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-300 font-medium'}`}>
{emp.avatar_url ? (
<img alt={emp.name} className="w-full h-full object-cover" src={emp.avatar_url} />
) : (
<span>{emp.initials || emp.name.charAt(0)}</span>
)}
<div key={emp.id} className={`flex flex-col p-4 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-100 dark:border-slate-700 ${emp.status === 'missing' ? 'opacity-75' : ''}`}>
<div className="flex items-center mb-3">
<div className={`size-10 rounded-full flex items-center justify-center shrink-0 mr-3 overflow-hidden ${emp.avatar_url ? 'bg-transparent' : 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-300 font-medium'}`}>
{emp.avatar_url ? (
<img alt={emp.name} className="w-full h-full object-cover" src={emp.avatar_url} />
) : (
<span>{emp.initials || emp.name.charAt(0)}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white truncate">{emp.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
ID: {emp.id.toString().padStart(5, '0')} {emp.department || '無部門'}
</p>
</div>
<div className="flex items-center">
{emp.status === 'full_attendance' && (
<span className="inline-flex items-center rounded-md bg-emerald-50 dark:bg-emerald-900/30 px-2 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-400 ring-1 ring-inset ring-emerald-600/10">全勤</span>
)}
{emp.status === 'late' && (
<span className="inline-flex items-center rounded-md bg-amber-50 dark:bg-amber-900/30 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-400 ring-1 ring-inset ring-amber-600/10">遲到 {emp.lateCount} </span>
)}
{emp.status === 'missing' && (
<span className="inline-flex items-center rounded-md bg-rose-50 dark:bg-rose-900/30 px-2 py-1 text-xs font-medium text-rose-700 dark:text-rose-400 ring-1 ring-inset ring-rose-600/10">無紀錄</span>
)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white truncate">{emp.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
ID: {emp.id.toString().padStart(5, '0')} {emp.department || '無部門'}
</p>
</div>
<div className="flex flex-col items-end gap-1">
{emp.status === 'full_attendance' && (
<span className="inline-flex items-center rounded-md bg-emerald-50 dark:bg-emerald-900/30 px-2 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-400 ring-1 ring-inset ring-emerald-600/10">全勤</span>
)}
{emp.status === 'late' && (
<span className="inline-flex items-center rounded-md bg-amber-50 dark:bg-amber-900/30 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-400 ring-1 ring-inset ring-amber-600/10">遲到 {emp.lateCount} </span>
)}
{emp.status === 'missing' && (
<span className="inline-flex items-center rounded-md bg-rose-50 dark:bg-rose-900/30 px-2 py-1 text-xs font-medium text-rose-700 dark:text-rose-400 ring-1 ring-inset ring-rose-600/10">無紀錄</span>
)}
<span className="text-[10px] text-slate-400 dark:text-slate-500">{emp.attendanceDays} </span>
<div className="grid grid-cols-4 gap-2 border-t border-slate-100 dark:border-slate-700/50 pt-3">
<div className="flex flex-col items-center">
<span className="text-[10px] text-slate-400 font-medium mb-0.5">工作天數</span>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">{emp.attendanceDays} </span>
</div>
<div className="flex flex-col items-center border-l border-slate-100 dark:border-slate-700/50">
<span className="text-[10px] text-slate-400 font-medium mb-0.5">總工時</span>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">{emp.totalHours || '0.0'} H</span>
</div>
<div className="flex flex-col items-center border-l border-slate-100 dark:border-slate-700/50">
<span className="text-[10px] text-slate-400 font-medium mb-0.5">時薪</span>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">${emp.hourlyRate}</span>
</div>
<div className="flex flex-col items-center border-l border-slate-100 dark:border-slate-700/50">
<span className="text-[10px] text-slate-400 font-medium mb-0.5">預估總薪資</span>
<span className="text-sm font-bold text-primary">${emp.estimatedSalary?.toLocaleString() || '0'}</span>
</div>
</div>
</div>
))
@@ -347,7 +511,7 @@ function Reports({ onBack }) {
<div className="fixed bottom-0 left-0 right-0 z-20 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-t border-slate-200 dark:border-slate-800 p-4 pb-8">
<div className="flex flex-col gap-3 max-w-[480px] mx-auto w-full">
<button
onClick={() => alert('報表匯出功能開發中')}
onClick={handleExportClick}
className="w-full bg-primary hover:bg-primary/90 text-white font-medium h-12 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-primary/20 transition-all active:scale-[0.98]"
>
<span className="material-symbols-outlined">download</span>

View File

@@ -0,0 +1,382 @@
import React from 'react'
function SalaryExportPreview({ employee, records, startDate, endDate, onBack, onConfirmExport }) {
// 預設時薪 (如果資料庫沒設定,暫設為 180)
const hourlyRate = employee?.hourly_rate || 180
// 計算總計數據
const totalHours = records.reduce((sum, record) => sum + parseFloat(record.totalHours || 0), 0)
const estimatedSalary = Math.round(totalHours * hourlyRate)
const workDaysCount = records.filter(r => r.status !== 'off').length
// 格式化數字 (加上千分位)
const formatNumber = (num) => {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
// 格式化日期範圍顯示
const formatDateRange = (start, end) => {
if (!start || !end) return '尚未選擇範圍'
return (
<div className="flex items-center gap-2 text-gray-900 dark:text-white font-bold">
<span className="material-symbols-outlined text-primary" style={{ fontSize: '20px' }}>date_range</span>
<span>{start}</span>
<span className="text-gray-500 dark:text-gray-400 font-normal mx-1"></span>
<span>{end}</span>
</div>
)
}
// 計算總天數
const calculateDaysDiff = (start, end) => {
if (!start || !end) return 0
const diffTime = Math.abs(new Date(end) - new Date(start))
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1
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 */}
<header className="sticky top-0 z-20 flex items-center bg-white dark:bg-gray-800 px-4 py-3 justify-between shadow-sm border-b border-gray-200 dark:border-gray-700 lg:hidden">
<button
onClick={onBack}
className="text-gray-900 dark:text-white flex size-10 shrink-0 items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: '24px' }}>arrow_back</span>
</button>
<h2 className="text-gray-900 dark:text-white text-lg font-bold leading-tight tracking-[-0.015em] flex-1 text-center pr-10">
工資明細匯出預覽
</h2>
</header>
<main className="max-w-7xl mx-auto px-4 pt-4 lg:px-8 lg:pt-8">
{/* Desktop Header & Back */}
<div className="hidden lg:flex items-center gap-4 mb-6">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white dark:bg-gray-800 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-slate-600 dark:text-slate-300 font-medium"
>
<span className="material-symbols-outlined text-xl">arrow_back</span>
返回列表
</button>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">工資明細匯出預覽</h1>
</div>
<div className="lg:grid lg:grid-cols-12 lg:gap-8 lg:items-start">
{/* Left Sidebar Info */}
<div className="lg:col-span-4 space-y-4 lg:sticky lg:top-8">
{/* Employee Card */}
<div className="flex items-center gap-4 rounded-xl bg-white dark:bg-gray-800 p-4 shadow-sm border border-gray-200 dark:border-gray-700 relative overflow-hidden">
<div className="absolute -right-6 -top-6 size-24 rounded-full bg-primary/5 blur-2xl"></div>
<div className="relative shrink-0">
{employee.avatar_url ? (
<img
alt="Employee Avatar"
className="size-16 rounded-full object-cover border-2 border-white dark:border-gray-800 shadow-md"
src={employee.avatar_url}
/>
) : (
<div className="size-16 rounded-full bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center text-indigo-600 dark:text-indigo-300 font-bold text-lg border-2 border-white dark:border-gray-800 shadow-md">
{employee.initials || employee.name?.substring(0, 2) || '??'}
</div>
)}
<span className="absolute bottom-0 right-0 block size-3.5 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800"></span>
</div>
<div className="relative flex flex-col justify-center flex-1 min-w-0">
<div className="flex items-baseline justify-between">
<h1 className="text-xl font-bold text-gray-900 dark:text-white truncate">{employee.name}</h1>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 opacity-70">ID: {employee.id}</span>
</div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate mt-0.5">
{employee.position || '未設定職位'} <span className="mx-1 opacity-40">|</span> {employee.department || '未設定部門'}
</p>
</div>
</div>
{/* Date Range */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-1">結算範圍</span>
{formatDateRange(startDate, endDate)}
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">共計 {calculateDaysDiff(startDate, endDate)} </span>
</div>
<div className="size-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>date_range</span>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 items-center text-center shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-10">
<span className="material-symbols-outlined text-6xl text-primary">payments</span>
</div>
<p className="text-primary tracking-tight text-4xl font-bold leading-tight relative z-10">${formatNumber(estimatedSalary)}</p>
<p className="text-gray-500 dark:text-gray-400 text-sm font-bold relative z-10">預估總薪資</p>
</div>
<div className="flex flex-col gap-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 items-center text-center shadow-sm">
<p className="text-gray-900 dark:text-white tracking-tight text-2xl font-bold leading-tight">{totalHours.toFixed(1)}</p>
<p className="text-gray-500 dark:text-gray-400 text-xs font-medium">總工時 (H)</p>
</div>
<div className="flex flex-col gap-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 items-center text-center shadow-sm">
<p className="text-gray-900 dark:text-white tracking-tight text-2xl font-bold leading-tight">${hourlyRate}</p>
<p className="text-gray-500 dark:text-gray-400 text-xs font-medium">時薪 (TWD)</p>
</div>
</div>
{/* 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"
>
<span className="material-symbols-outlined text-2xl">download</span>
確認匯出明細
</button>
<p className="text-center text-xs text-slate-400">
CSV 可用 Excel 開啟 / PDF 為完整明細表
</p>
</div>
</div>
{/* Right Content - Table */}
<div className="lg:col-span-8 mt-6 lg:mt-0">
<div className="bg-white dark:bg-gray-800 rounded-t-3xl lg:rounded-2xl shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] lg:shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col h-full lg:min-h-[calc(100vh-8rem)]">
<div className="bg-white dark:bg-gray-800 z-10 px-6 py-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-end">
<h3 className="text-gray-900 dark:text-white text-lg font-bold leading-tight">每日薪資明細</h3>
<span className="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2.5 py-1 rounded-md text-xs font-bold">
{workDaysCount} 筆有薪紀錄
</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left border-collapse">
<thead className="bg-slate-50 dark:bg-slate-800/50 text-xs text-gray-500 dark:text-gray-400 uppercase">
<tr>
<th className="px-6 py-4 font-bold whitespace-nowrap border-b border-gray-200 dark:border-gray-700">日期</th>
<th className="px-6 py-4 font-bold whitespace-nowrap border-b border-gray-200 dark:border-gray-700">上下班時間</th>
<th className="px-6 py-4 font-bold whitespace-nowrap text-right border-b border-gray-200 dark:border-gray-700">工時</th>
<th className="px-6 py-4 font-bold whitespace-nowrap text-right border-b border-gray-200 dark:border-gray-700">時薪</th>
<th className="px-6 py-4 font-bold whitespace-nowrap text-right border-b border-gray-200 dark:border-gray-700">工資</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
{records.map((record, index) => {
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 timeDisplay = record.sessions && record.sessions.length > 0
? record.sessions.map(s => {
const start = s.start ? new Date(s.start.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false }) : '--:--'
const end = s.end ? new Date(s.end.created_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit', hour12: false }) : '--:--'
return `${start} - ${end}`
})
: ['無打卡']
const isLate = record.isLate
const isOff = record.status === 'off'
return (
<tr key={index} className={`hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors ${isLate ? 'bg-red-50/40 dark:bg-red-900/10' : ''} ${isOff ? 'bg-slate-50 dark:bg-slate-900/20 text-gray-500 dark:text-gray-400' : ''}`}>
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap align-top">
{month}/{day} <span className="text-xs text-gray-500 dark:text-gray-400 font-normal ml-1">({weekday})</span>
</td>
<td className={`px-6 py-4 whitespace-nowrap font-mono align-top ${isLate ? 'text-red-600 dark:text-red-400 font-medium' : isOff ? 'text-xs italic' : 'text-gray-900 dark:text-white'}`}>
{isOff ? '休假 (無打卡)' : (
<div className="flex flex-col gap-1">
{timeDisplay.map((t, i) => <span key={i}>{t}</span>)}
</div>
)}
</td>
<td className="px-6 py-4 text-right font-medium text-gray-900 dark:text-white whitespace-nowrap align-top">
{isOff ? '-' : dailyHours.toFixed(1)}
</td>
<td className="px-6 py-4 text-right text-gray-500 dark:text-gray-400 whitespace-nowrap align-top">
{isOff ? '-' : hourlyRate}
</td>
<td className={`px-6 py-4 text-right font-bold whitespace-nowrap align-top ${isOff ? '' : 'text-primary'}`}>
{isOff ? '-' : formatNumber(dailyWage)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
{/* Mobile Bottom Actions */}
<div className="lg:hidden fixed bottom-0 z-20 w-full left-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.08)]">
<div className="flex items-center justify-between gap-4 max-w-md mx-auto">
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium mb-0.5">預估總薪資</span>
<div className="flex items-baseline gap-1.5">
<span className="text-xl font-bold text-primary">${formatNumber(estimatedSalary)}</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">({totalHours.toFixed(1)}H)</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={onBack}
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-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>
</div>
</div>
)
}
export default SalaryExportPreview

View File

@@ -0,0 +1,464 @@
import React, { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
function SystemSettings({ onBack, onNavigate }) {
// Fixed Schedule State
const [workStartTime, setWorkStartTime] = useState('09:00')
const [workEndTime, setWorkEndTime] = useState('18:00')
// Flexible Schedule State
const [scheduleMode, setScheduleMode] = useState('fixed') // 'fixed' | 'flexible'
const [coreStartTime, setCoreStartTime] = useState('10:00')
const [coreEndTime, setCoreEndTime] = useState('16:00')
const [flexEarliestStart, setFlexEarliestStart] = useState('07:30')
const [flexLatestEnd, setFlexLatestEnd] = useState('20:30')
const [weeklyHours, setWeeklyHours] = useState('40')
const [allowAccumulation, setAllowAccumulation] = useState(true)
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
const { data, error } = await supabase
.from('system_settings')
.select('*')
.in('key', [
'work_start_time', 'work_end_time',
'schedule_mode',
'core_start_time', 'core_end_time',
'flex_earliest_start', 'flex_latest_end',
'weekly_hours', 'allow_accumulation'
])
if (error) {
console.warn('Error fetching settings:', error)
return
}
if (data) {
data.forEach(setting => {
switch (setting.key) {
case 'work_start_time': setWorkStartTime(setting.value); break;
case 'work_end_time': setWorkEndTime(setting.value); break;
case 'schedule_mode': setScheduleMode(setting.value); break;
case 'core_start_time': setCoreStartTime(setting.value); break;
case 'core_end_time': setCoreEndTime(setting.value); break;
case 'flex_earliest_start': setFlexEarliestStart(setting.value); break;
case 'flex_latest_end': setFlexLatestEnd(setting.value); break;
case 'weekly_hours': setWeeklyHours(setting.value); break;
case 'allow_accumulation': setAllowAccumulation(setting.value === 'true'); break;
}
})
}
} catch (error) {
console.error('Error fetching settings:', error)
}
}
const handleSave = async () => {
try {
setLoading(true)
const updates = [
{ key: 'work_start_time', value: workStartTime, description: '標準上班時間' },
{ key: 'work_end_time', value: workEndTime, description: '標準下班時間' },
{ key: 'schedule_mode', value: scheduleMode, description: '班表模式 (fixed/flexible)' },
{ key: 'core_start_time', value: coreStartTime, description: '核心工時開始' },
{ key: 'core_end_time', value: coreEndTime, description: '核心工時結束' },
{ key: 'flex_earliest_start', value: flexEarliestStart, description: '最早可打卡上班' },
{ key: 'flex_latest_end', value: flexLatestEnd, description: '最晚可打卡下班' },
{ key: 'weekly_hours', value: weeklyHours, description: '每週總工時要求' },
{ key: 'allow_accumulation', value: String(allowAccumulation), description: '允許工時累積' }
]
const { error } = await supabase
.from('system_settings')
.upsert(updates)
if (error) throw error
alert('設定已儲存')
} catch (error) {
console.error('Error saving settings:', error)
alert('儲存失敗: ' + error.message)
} finally {
setLoading(false)
}
}
return (
<div className="bg-background-light dark:bg-background-dark font-display antialiased text-text-light dark:text-text-dark min-h-screen pb-24 lg:pb-0">
{/* Header */}
<header className="fixed top-0 w-full z-50 bg-background-light/90 dark:bg-background-dark/90 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="lg:hidden p-1 -ml-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">系統設定</h1>
</div>
<div className="flex items-center gap-3">
<span className="hidden lg:block text-sm font-medium text-gray-500 dark:text-gray-400">Admin</span>
<div className="h-9 w-9 rounded-full overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all">
<img
alt="User Avatar"
className="h-full w-full object-cover"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAZaM-XMohMV-RwKHo9hqZWsofVteqsAE1jUnzAc6uQZ9lw1MwmchKyBGep8QK1ouzg0qyDqCiYLVFU83SfaWpF1VpAM6edY4eTvokpIlyh8ciOUX1hJPfyGRWR8Pz2EooWGaEMmMMWo3B0GMpS-AnbEIYzHRACH2wCHQOqjaztAMfbeilNoFKZv5SWoEuwJyuu7zO0s1Qey12Adoi23dQJlV8OORZPdBpifDODuFiXehCXAM0dnwfr6hObRgts4cgPWt0LhAIgWCz_"
/>
</div>
</div>
</div>
</header>
{/* 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">
{/* Desktop Sidebar Navigation */}
<aside className="hidden lg:block lg:col-span-3 lg:sticky lg:top-24 space-y-2">
<div className="bg-white dark:bg-surface-dark rounded-2xl p-4 shadow-sm border border-gray-100 dark:border-gray-800">
<nav className="space-y-1">
<button
onClick={() => onNavigate && onNavigate('overview')}
className="w-full flex items-center gap-3 px-4 py-3 text-sm font-medium text-gray-600 dark:text-gray-400 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary transition-colors"
>
<span className="material-icons-round text-xl">grid_view</span>
總覽
</button>
<button className="w-full flex items-center gap-3 px-4 py-3 text-sm font-medium text-gray-600 dark:text-gray-400 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary transition-colors relative">
<span className="material-icons-round text-xl">chat_bubble</span>
訊息
<span className="absolute right-4 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<button className="w-full flex items-center gap-3 px-4 py-3 text-sm font-bold text-primary bg-primary/5 dark:bg-primary/10 rounded-xl transition-colors">
<span className="material-icons-round text-xl">settings</span>
設定
</button>
</nav>
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<button
onClick={onBack}
className="w-full flex items-center gap-3 px-4 py-3 text-sm font-medium text-gray-600 dark:text-gray-400 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 transition-colors"
>
<span className="material-icons-round text-xl">arrow_back</span>
返回儀表板
</button>
</div>
</div>
</aside>
{/* Content Area */}
<div className="lg:col-span-9 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Attendance Time 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">schedule</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">
<div className="mb-6">
<label className="block text-base font-medium mb-3 text-gray-800 dark:text-gray-200">班表模式</label>
<div className="bg-gray-100 dark:bg-gray-800 p-1 rounded-xl flex relative">
<button
onClick={() => setScheduleMode('fixed')}
className={`flex-1 py-2 px-4 rounded-lg font-bold shadow-sm transition-all text-sm text-center ${scheduleMode === 'fixed' ? 'bg-white dark:bg-gray-700 text-primary' : 'text-subtext-light dark:text-subtext-dark hover:text-gray-900 dark:hover:text-white'}`}
>
固定班表
</button>
<button
onClick={() => setScheduleMode('flexible')}
className={`flex-1 py-2 px-4 rounded-lg font-bold shadow-sm transition-all text-sm text-center ${scheduleMode === 'flexible' ? 'bg-white dark:bg-gray-700 text-primary' : 'text-subtext-light dark:text-subtext-dark hover:text-gray-900 dark:hover:text-white'}`}
>
彈性工時
</button>
</div>
<p className="mt-3 text-xs text-subtext-light dark:text-subtext-dark leading-relaxed">
{scheduleMode === 'fixed'
? '固定班表模式下,員工需嚴格按照設定時間打卡。'
: '彈性工時模式下,員工需在核心工時在崗,並滿足每週總工時要求。'}
</p>
</div>
<div className="h-px bg-gray-100 dark:bg-gray-800 w-full mb-5"></div>
{scheduleMode === 'fixed' ? (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<div className="text-base font-bold text-gray-900 dark:text-white">上班時間</div>
<div className="text-xs text-subtext-light dark:text-subtext-dark mt-0.5">標準上班打卡時間</div>
</div>
<div className="bg-gray-50 dark:bg-gray-900 px-3 py-1 rounded-xl flex items-center gap-3 border border-gray-200 dark:border-gray-700">
<input
type="time"
value={workStartTime}
onChange={(e) => setWorkStartTime(e.target.value)}
className="bg-transparent text-lg font-bold font-mono text-gray-900 dark:text-white outline-none w-full"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-base font-bold text-gray-900 dark:text-white">下班時間</div>
<div className="text-xs text-subtext-light dark:text-subtext-dark mt-0.5">標準下班打卡時間</div>
</div>
<div className="bg-gray-50 dark:bg-gray-900 px-3 py-1 rounded-xl flex items-center gap-3 border border-gray-200 dark:border-gray-700">
<input
type="time"
value={workEndTime}
onChange={(e) => setWorkEndTime(e.target.value)}
className="bg-transparent text-lg font-bold font-mono text-gray-900 dark:text-white outline-none w-full"
/>
</div>
</div>
</div>
) : (
<div className="space-y-6">
{/* Core Work Hours */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<span className="material-icons-round text-primary text-xl">schedule</span>
<h2 className="text-base font-semibold text-subtext-light dark:text-subtext-dark">每日核心工時</h2>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border border-gray-100 dark:border-gray-700">
<p className="text-xs text-subtext-light dark:text-subtext-dark mb-4 leading-relaxed">
核心工時是員工必須在崗的固定時段不可彈性調整
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-subtext-light dark:text-subtext-dark mb-1.5 ml-1">開始時間</label>
<div className="relative">
<input
className="w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white text-base rounded-xl focus:ring-primary focus:border-primary block p-3 font-mono font-medium outline-none"
type="time"
value={coreStartTime}
onChange={(e) => setCoreStartTime(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-subtext-light dark:text-subtext-dark mb-1.5 ml-1">結束時間</label>
<div className="relative">
<input
className="w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white text-base rounded-xl focus:ring-primary focus:border-primary block p-3 font-mono font-medium outline-none"
type="time"
value={coreEndTime}
onChange={(e) => setCoreEndTime(e.target.value)}
/>
</div>
</div>
</div>
</div>
</section>
{/* Flexible Range */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<span className="material-icons-round text-primary text-xl">tune</span>
<h2 className="text-base font-semibold text-subtext-light dark:text-subtext-dark">彈性上下班範圍</h2>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border border-gray-100 dark:border-gray-700 space-y-4">
<div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700/50 pb-4">
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">最早可打卡上班</div>
<div className="text-xs text-subtext-light dark:text-subtext-dark mt-0.5">早於此時間打卡不計入工時</div>
</div>
<div className="w-32">
<input
className="w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white text-sm rounded-xl focus:ring-primary focus:border-primary block px-3 py-2 font-mono text-center outline-none"
type="time"
value={flexEarliestStart}
onChange={(e) => setFlexEarliestStart(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">最晚可打卡下班</div>
<div className="text-xs text-subtext-light dark:text-subtext-dark mt-0.5">晚於此時間視為加班或需申請</div>
</div>
<div className="w-32">
<input
className="w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white text-sm rounded-xl focus:ring-primary focus:border-primary block px-3 py-2 font-mono text-center outline-none"
type="time"
value={flexLatestEnd}
onChange={(e) => setFlexLatestEnd(e.target.value)}
/>
</div>
</div>
</div>
</section>
{/* Rules */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<span className="material-icons-round text-primary text-xl">rule</span>
<h2 className="text-base font-semibold text-subtext-light dark:text-subtext-dark">計算規則</h2>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border border-gray-100 dark:border-gray-700 space-y-5">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-900 dark:text-white" htmlFor="weekly-hours">每週總工時要求 (小時)</label>
</div>
<div className="relative">
<input
className="w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white rounded-xl focus:ring-primary focus:border-primary block p-3 pr-12 font-mono font-bold text-lg outline-none"
id="weekly-hours"
placeholder="40"
type="number"
value={weeklyHours}
onChange={(e) => setWeeklyHours(e.target.value)}
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-subtext-light dark:text-subtext-dark">Hrs</span>
</div>
</div>
<div className="h-px bg-gray-200 dark:bg-gray-700 w-full"></div>
<div className="flex items-center justify-between">
<div className="pr-4">
<div className="text-sm font-bold text-gray-900 dark:text-white">允許工時累積</div>
<div className="text-xs text-subtext-light dark:text-subtext-dark mt-1 leading-tight">
若當日工時不足允許以當週其他工作日的超額工時抵扣
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<input
className="sr-only peer"
type="checkbox"
checked={allowAccumulation}
onChange={(e) => setAllowAccumulation(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-primary"></div>
</label>
</div>
</div>
</section>
</div>
)}
</div>
</section>
{/* System Configuration */}
<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">dns</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">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-medium text-gray-800 dark:text-gray-200">Supabase 資料庫設定</h3>
<div className="flex h-2 w-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></div>
</div>
<div className="space-y-4">
<div>
<div className="relative">
<label
className="absolute -top-2 left-3 bg-white dark:bg-surface-dark px-1 text-xs font-medium text-subtext-light dark:text-subtext-dark transition-all peer-focus:text-primary"
htmlFor="api-url"
>
Project URL (API)
</label>
<input
className="peer w-full px-4 py-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-xl text-sm font-mono text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all placeholder-gray-400 dark:placeholder-gray-600"
id="api-url"
placeholder="https://your-project.supabase.co"
type="text"
defaultValue="https://<project-ref>.supabase.co"
/>
</div>
</div>
<div>
<div className="relative">
<label
className="absolute -top-2 left-3 bg-white dark:bg-surface-dark px-1 text-xs font-medium text-subtext-light dark:text-subtext-dark transition-all peer-focus:text-primary"
htmlFor="anon-key"
>
Anon Key (Public)
</label>
<input
className="peer w-full px-4 py-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-xl text-sm font-mono text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all placeholder-gray-400 dark:placeholder-gray-600"
id="anon-key"
type="password"
defaultValue="your-anon-key-placeholder"
/>
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<span className="material-icons-round text-lg">visibility_off</span>
</button>
</div>
</div>
</div>
<div className="mt-4 flex gap-2 items-start bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg border border-yellow-100 dark:border-yellow-900/30">
<span className="material-icons-round text-yellow-500 text-sm mt-0.5">warning_amber</span>
<p className="text-xs text-yellow-700 dark:text-yellow-500 leading-relaxed">
變更此設定可能會導致系統暫時無法存取資料請確認您的 Supabase 憑證正確無誤
</p>
</div>
</div>
</section>
</div>
{/* Action Buttons */}
<div className="pt-2 pb-6 lg:pb-0 lg:flex lg:justify-end lg:gap-4 lg:pt-4">
<button
onClick={onBack}
className="w-full lg:w-32 lg:order-1 bg-white dark:bg-surface-dark hover:bg-gray-50 dark:hover:bg-gray-800 active:scale-[0.98] transition-all text-gray-700 dark:text-gray-200 font-bold py-3.5 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm order-2 mt-3 lg:mt-0"
>
取消變更
</button>
<button
onClick={handleSave}
disabled={loading}
className={`w-full lg:w-48 lg:order-2 bg-primary hover:bg-primary-dark active:scale-[0.98] transition-all text-white font-bold py-3.5 rounded-xl shadow-lg shadow-primary/30 flex items-center justify-center gap-2 order-1 ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{loading ? (
<div className="animate-spin h-5 w-5 border-2 border-white border-t-transparent rounded-full"></div>
) : (
<>
<span className="material-icons-round text-xl">save</span>
<span>儲存設定</span>
</>
)}
</button>
</div>
</div>
</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">
<div className="max-w-md mx-auto grid grid-cols-3 h-16 pb-2">
<button
onClick={() => onNavigate && onNavigate('overview')}
className="flex flex-col items-center justify-center gap-1 group"
>
<div className="relative p-1 rounded-full group-hover:bg-gray-100 dark:group-hover:bg-gray-800 transition-colors">
<span className="material-icons-round text-gray-400 dark:text-gray-500 text-2xl group-hover:text-primary transition-colors">grid_view</span>
</div>
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 group-hover:text-primary transition-colors">總覽</span>
</button>
<button className="flex flex-col items-center justify-center gap-1 group">
<div className="relative p-1 rounded-full group-hover:bg-gray-100 dark:group-hover:bg-gray-800 transition-colors">
<span className="material-icons-round text-gray-400 dark:text-gray-500 text-2xl group-hover:text-primary transition-colors">chat_bubble</span>
<span className="absolute top-1 right-0.5 h-2 w-2 bg-red-500 rounded-full ring-1 ring-white dark:ring-surface-dark"></span>
</div>
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 group-hover:text-primary transition-colors">訊息</span>
</button>
<button className="flex flex-col items-center justify-center gap-1">
<div className="relative p-1 rounded-full">
<span className="material-icons-round text-primary text-2xl">settings</span>
</div>
<span className="text-[10px] font-medium text-primary">設定</span>
</button>
</div>
</nav>
<div className="lg:hidden h-6 w-full bg-white dark:bg-surface-dark fixed bottom-0 z-40"></div>
</div>
)
}
export default SystemSettings

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