Merge branch 'main' of http://192.168.1.8:8418/raraso/raraso-gitea
This commit is contained in:
71
20260101 time check/DEPLOY_TO_SITEGROUND.md
Normal file
71
20260101 time check/DEPLOY_TO_SITEGROUND.md
Normal 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。您的應用程式是預先構建好的靜態檔案。
|
||||||
82
20260101 time check/DEPLOY_TO_SYNOLOGY.md
Normal file
82
20260101 time check/DEPLOY_TO_SYNOLOGY.md
Normal 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 提供,但資料仍然位於雲端。這是推薦的混合部署方式。
|
||||||
108
20260101 time check/SYNOLOGY_AUTOMATION.md
Normal file
108
20260101 time check/SYNOLOGY_AUTOMATION.md
Normal 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.
|
||||||
31
20260101 time check/create_storage_bucket.sql
Normal file
31
20260101 time check/create_storage_bucket.sql
Normal 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' );
|
||||||
46
20260101 time check/create_system_settings.sql
Normal file
46
20260101 time check/create_system_settings.sql
Normal 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';
|
||||||
48
20260101 time check/fix_storage_rls.sql
Normal file
48
20260101 time check/fix_storage_rls.sql
Normal 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' );
|
||||||
@@ -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=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"
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||||
rel="stylesheet" />
|
rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body
|
<body
|
||||||
|
|||||||
100
20260101 time check/nas_deploy.sh
Normal file
100
20260101 time check/nas_deploy.sh
Normal 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
|
||||||
26
20260101 time check/public/.htaccess
Normal file
26
20260101 time check/public/.htaccess
Normal 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
|
||||||
@@ -3,6 +3,7 @@ import { supabase } from './supabaseClient'
|
|||||||
import EmployeeManagement from './EmployeeManagement'
|
import EmployeeManagement from './EmployeeManagement'
|
||||||
import DepartmentManagement from './DepartmentManagement'
|
import DepartmentManagement from './DepartmentManagement'
|
||||||
import Reports from './Reports'
|
import Reports from './Reports'
|
||||||
|
import SystemSettings from './SystemSettings'
|
||||||
|
|
||||||
function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
||||||
const [currentTime, setCurrentTime] = useState(new Date())
|
const [currentTime, setCurrentTime] = useState(new Date())
|
||||||
@@ -18,6 +19,7 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
|||||||
const [showEmployeeManagement, setShowEmployeeManagement] = useState(false)
|
const [showEmployeeManagement, setShowEmployeeManagement] = useState(false)
|
||||||
const [showDepartmentManagement, setShowDepartmentManagement] = useState(false)
|
const [showDepartmentManagement, setShowDepartmentManagement] = useState(false)
|
||||||
const [showReports, setShowReports] = useState(false)
|
const [showReports, setShowReports] = useState(false)
|
||||||
|
const [showSystemSettings, setShowSystemSettings] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000)
|
const timer = setInterval(() => setCurrentTime(new Date()), 1000)
|
||||||
@@ -167,6 +169,18 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
|||||||
return <Reports onBack={() => setShowReports(false)} />
|
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 (
|
return (
|
||||||
<div className="w-full min-h-screen bg-background-light dark:bg-slate-900 relative overflow-hidden flex flex-col pb-24">
|
<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 */}
|
{/* Top Navigation Bar */}
|
||||||
@@ -364,7 +378,7 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionText = activity.type === 'clock_in' ? '打卡上班' : '打卡下班'
|
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'
|
const icon = activity.type === 'clock_in' ? 'login' : 'logout'
|
||||||
|
|
||||||
// 預設頭像 - 使用員工姓名首字母生成顏色
|
// 預設頭像 - 使用員工姓名首字母生成顏色
|
||||||
@@ -383,11 +397,11 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
{activity.employees?.name || '未知員工'}
|
{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>
|
||||||
<p className="text-xs text-gray-400 mt-1">{timeText}</p>
|
<p className="text-xs text-gray-400 mt-1">{timeText}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`material-symbols-outlined ${iconColor} text-[20px]`}>{icon}</span>
|
<span className={`material-symbols-outlined ${actionColor} text-[20px]`}>{icon}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -414,7 +428,10 @@ function AdminDashboard({ onBack, onLogout, onGoToAttendance }) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium">訊息</span>
|
<span className="text-[10px] font-medium">訊息</span>
|
||||||
</button>
|
</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="material-symbols-outlined text-[28px]">settings</span>
|
||||||
<span className="text-[10px] font-medium">設定</span>
|
<span className="text-[10px] font-medium">設定</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function App() {
|
|||||||
const [showAdminDashboard, setShowAdminDashboard] = useState(false)
|
const [showAdminDashboard, setShowAdminDashboard] = useState(false)
|
||||||
const [showAdminLogin, setShowAdminLogin] = useState(false)
|
const [showAdminLogin, setShowAdminLogin] = useState(false)
|
||||||
const [isAdminAuthenticated, setIsAdminAuthenticated] = useState(false)
|
const [isAdminAuthenticated, setIsAdminAuthenticated] = useState(false)
|
||||||
|
const [isWorking, setIsWorking] = useState(false) // 追蹤今日上班狀態
|
||||||
|
|
||||||
// Fetch employees from Supabase
|
// Fetch employees from Supabase
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,9 +80,35 @@ function App() {
|
|||||||
if (!error) setLogs(data || [])
|
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
|
// Fetch logs when employee or date changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs(selectedEmployee, selectedDate)
|
fetchLogs(selectedEmployee, selectedDate)
|
||||||
|
checkWorkStatus(selectedEmployee)
|
||||||
}, [selectedEmployee, selectedDate])
|
}, [selectedEmployee, selectedDate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,6 +119,12 @@ function App() {
|
|||||||
const handleClockAction = async (type) => {
|
const handleClockAction = async (type) => {
|
||||||
if (!selectedEmployee) return
|
if (!selectedEmployee) return
|
||||||
|
|
||||||
|
// 檢查:如果已經上班打卡,不能重複打卡 (UI 已擋,此為後端防護)
|
||||||
|
if (type === 'clock_in' && isWorking) {
|
||||||
|
alert('⚠️ 您已經打過上班卡了!請先執行下班打卡。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 檢查:下班打卡前必須先有上班打卡
|
// 檢查:下班打卡前必須先有上班打卡
|
||||||
if (type === 'clock_out') {
|
if (type === 'clock_out') {
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +172,9 @@ function App() {
|
|||||||
if (!error) {
|
if (!error) {
|
||||||
// Refresh logs for currently selected date
|
// Refresh logs for currently selected date
|
||||||
fetchLogs(selectedEmployee, selectedDate)
|
fetchLogs(selectedEmployee, selectedDate)
|
||||||
|
// Update work status
|
||||||
|
checkWorkStatus(selectedEmployee)
|
||||||
|
|
||||||
alert(type === 'clock_in' ? '上班打卡成功!' : '下班打卡成功!')
|
alert(type === 'clock_in' ? '上班打卡成功!' : '下班打卡成功!')
|
||||||
} else {
|
} else {
|
||||||
alert('打卡失敗:' + error.message)
|
alert('打卡失敗:' + error.message)
|
||||||
@@ -206,7 +242,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full min-h-screen bg-white dark:bg-slate-900 relative overflow-hidden flex flex-col transition-all duration-300">
|
<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 */}
|
{/* 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">
|
<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>
|
<span className="material-symbols-outlined text-slate-900 dark:text-white">arrow_back_ios_new</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -271,7 +307,7 @@ function App() {
|
|||||||
|
|
||||||
{/* Employee Selection (Always Shown) */}
|
{/* Employee Selection (Always Shown) */}
|
||||||
<div className="px-6 pt-2 pb-2">
|
<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>
|
<p className="text-slate-400 text-xs font-bold uppercase tracking-wider">選擇員工 (SELECT EMPLOYEE)</p>
|
||||||
<button className="text-primary text-sm font-bold">搜尋</button>
|
<button className="text-primary text-sm font-bold">搜尋</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,8 +383,10 @@ function App() {
|
|||||||
{selectedDate === new Date().toISOString().split('T')[0] ? formatDate(time) : selectedDate}
|
{selectedDate === new Date().toISOString().split('T')[0] ? formatDate(time) : selectedDate}
|
||||||
</p>
|
</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="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>
|
<div className={`w-2 h-2 ${isWorking ? 'bg-green-500' : 'bg-slate-300'} rounded-full`}></div>
|
||||||
<span className="text-green-700 dark:text-green-400 text-xs font-bold">員工在線</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,36 +409,36 @@ function App() {
|
|||||||
{/* Main Action Buttons */}
|
{/* Main Action Buttons */}
|
||||||
<div className="flex flex-row justify-center items-center gap-4 sm:gap-8 px-4 mb-10">
|
<div className="flex flex-row justify-center items-center gap-4 sm:gap-8 px-4 mb-10">
|
||||||
{/* Clock In */}
|
{/* 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>
|
<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
|
<button
|
||||||
onClick={() => handleClockAction('clock_in')}
|
onClick={() => handleClockAction('clock_in')}
|
||||||
disabled={!selectedEmployee}
|
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`}
|
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">
|
<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="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-base sm:text-lg font-bold tracking-wide">{isAdminMode ? '協助上班' : '上班打卡'}</span>
|
||||||
<span className="text-[10px] font-medium opacity-80 uppercase tracking-widest">CLOCK IN</span>
|
<span className="text-[10px] font-medium opacity-80 uppercase tracking-widest">CLOCK IN</span>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clock Out */}
|
{/* 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>
|
<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
|
<button
|
||||||
onClick={() => handleClockAction('clock_out')}
|
onClick={() => handleClockAction('clock_out')}
|
||||||
disabled={!selectedEmployee}
|
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`}
|
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">
|
<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="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-base sm:text-lg font-bold tracking-wide">{isAdminMode ? '協助下班' : '下班打卡'}</span>
|
||||||
<span className="text-[10px] font-medium opacity-80 uppercase tracking-widest">CLOCK OUT</span>
|
<span className="text-[10px] font-medium opacity-80 uppercase tracking-widest">CLOCK OUT</span>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
function EmployeeForm({ onBack, employeeId = null }) {
|
function EmployeeForm({ onBack, employeeId = null }) {
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
english_name: '',
|
english_name: '',
|
||||||
@@ -286,15 +287,15 @@ function EmployeeForm({ onBack, employeeId = null }) {
|
|||||||
{/* Avatar Upload */}
|
{/* Avatar Upload */}
|
||||||
<div className="flex flex-col items-center justify-center mb-6">
|
<div className="flex flex-col items-center justify-center mb-6">
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
id="avatar-upload"
|
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="relative group cursor-pointer"
|
className="relative group cursor-pointer"
|
||||||
onClick={() => document.getElementById('avatar-upload')?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
{previewUrl || formData.avatar_url ? (
|
{previewUrl || formData.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
@@ -321,7 +322,7 @@ function EmployeeForm({ onBack, employeeId = null }) {
|
|||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => document.getElementById('avatar-upload')?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
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"
|
className="text-xs bg-primary/10 text-primary px-3 py-1.5 rounded-lg hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
import EmployeeForm from './EmployeeForm'
|
import EmployeeForm from './EmployeeForm'
|
||||||
|
import PersonalAttendanceRecord from './PersonalAttendanceRecord'
|
||||||
|
|
||||||
function EmployeeManagement({ onBack }) {
|
function EmployeeManagement({ onBack }) {
|
||||||
const [employees, setEmployees] = useState([])
|
const [employees, setEmployees] = useState([])
|
||||||
@@ -9,6 +10,8 @@ function EmployeeManagement({ onBack }) {
|
|||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingEmployeeId, setEditingEmployeeId] = useState(null)
|
const [editingEmployeeId, setEditingEmployeeId] = useState(null)
|
||||||
const [openMenuId, setOpenMenuId] = useState(null)
|
const [openMenuId, setOpenMenuId] = useState(null)
|
||||||
|
const [showAttendanceRecord, setShowAttendanceRecord] = useState(false)
|
||||||
|
const [selectedEmployeeId, setSelectedEmployeeId] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEmployees()
|
fetchEmployees()
|
||||||
@@ -112,12 +115,28 @@ function EmployeeManagement({ onBack }) {
|
|||||||
fetchEmployees() // Refresh list
|
fetchEmployees() // Refresh list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewAttendance = (id) => {
|
||||||
|
setSelectedEmployeeId(id)
|
||||||
|
setShowAttendanceRecord(true)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttendanceBack = () => {
|
||||||
|
setShowAttendanceRecord(false)
|
||||||
|
setSelectedEmployeeId(null)
|
||||||
|
}
|
||||||
|
|
||||||
const filteredEmployees = employees.filter(emp =>
|
const filteredEmployees = employees.filter(emp =>
|
||||||
emp.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
emp.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
emp.id?.toString().includes(searchQuery) ||
|
emp.id?.toString().includes(searchQuery) ||
|
||||||
emp.english_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
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
|
// Show form if requested
|
||||||
if (showForm) {
|
if (showForm) {
|
||||||
return <EmployeeForm onBack={handleFormClose} employeeId={editingEmployeeId} />
|
return <EmployeeForm onBack={handleFormClose} employeeId={editingEmployeeId} />
|
||||||
@@ -126,7 +145,7 @@ function EmployeeManagement({ onBack }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-full min-h-screen w-full flex-col overflow-hidden bg-white dark:bg-slate-900">
|
<div className="relative flex h-full min-h-screen w-full flex-col overflow-hidden bg-white dark:bg-slate-900">
|
||||||
{/* Top App Bar */}
|
{/* 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">
|
<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">
|
<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>
|
<span className="material-symbols-outlined text-slate-900 dark:text-white">arrow_back</span>
|
||||||
@@ -142,7 +161,7 @@ function EmployeeManagement({ onBack }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* 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="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">
|
<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>
|
<span className="material-symbols-outlined">search</span>
|
||||||
@@ -234,6 +253,16 @@ function EmployeeManagement({ onBack }) {
|
|||||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||||
編輯資料
|
編輯資料
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
646
20260101 time check/src/PersonalAttendanceRecord.jsx
Normal file
646
20260101 time check/src/PersonalAttendanceRecord.jsx
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
import SalaryExportPreview from './SalaryExportPreview'
|
||||||
|
|
||||||
function Reports({ onBack }) {
|
function Reports({ onBack }) {
|
||||||
const [selectedMonth, setSelectedMonth] = useState('')
|
const [selectedMonth, setSelectedMonth] = useState('')
|
||||||
@@ -7,8 +8,11 @@ function Reports({ onBack }) {
|
|||||||
const [employeesList, setEmployeesList] = useState([])
|
const [employeesList, setEmployeesList] = useState([])
|
||||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState('all')
|
const [selectedEmployeeId, setSelectedEmployeeId] = useState('all')
|
||||||
const [reportData, setReportData] = useState([])
|
const [reportData, setReportData] = useState([])
|
||||||
|
const [rawLogs, setRawLogs] = useState([]) // Store raw logs for preview
|
||||||
const [stats, setStats] = useState({ totalEmployees: 0, abnormalCount: 0 })
|
const [stats, setStats] = useState({ totalEmployees: 0, abnormalCount: 0 })
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [previewTarget, setPreviewTarget] = useState(null) // { employee, records, startDate, endDate }
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMonths()
|
fetchMonths()
|
||||||
@@ -48,28 +52,46 @@ function Reports({ onBack }) {
|
|||||||
|
|
||||||
if (logError) throw logError
|
if (logError) throw logError
|
||||||
|
|
||||||
|
setRawLogs(logs) // Store raw logs
|
||||||
|
|
||||||
// 4. 資料處理
|
// 4. 資料處理
|
||||||
let abnormal = 0
|
let abnormal = 0
|
||||||
const processedData = employees.map(emp => {
|
const processedData = employees.map(emp => {
|
||||||
const empLogs = logs.filter(log => log.employee_id === emp.id)
|
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)
|
// 計算出勤天數 (Unique Days)
|
||||||
const uniqueDays = new Set(empLogs.map(log =>
|
const uniqueDays = new Set(empLogs.map(log =>
|
||||||
new Date(log.created_at).toDateString()
|
new Date(log.created_at).toDateString()
|
||||||
)).size
|
)).size
|
||||||
|
|
||||||
// 計算遲到 (假設 09:00 上班)
|
// 計算總工時與遲到
|
||||||
// 邏輯:每天第一筆 clock_in 若晚於 09:00 則計為遲到
|
let totalHours = 0
|
||||||
let lateCount = 0
|
let lateCount = 0
|
||||||
const daysProcessed = new Set()
|
let currentIn = null
|
||||||
|
const daysProcessed = new Set() // For late count uniqueness
|
||||||
|
|
||||||
empLogs.forEach(log => {
|
empLogs.forEach(log => {
|
||||||
const dateStr = new Date(log.created_at).toDateString()
|
const recordTime = new Date(log.created_at)
|
||||||
if (log.type === 'clock_in' && !daysProcessed.has(dateStr)) {
|
|
||||||
daysProcessed.add(dateStr)
|
// Late Calculation
|
||||||
const logDate = new Date(log.created_at)
|
const dateStr = recordTime.toDateString()
|
||||||
if (logDate.getHours() > 9 || (logDate.getHours() === 9 && logDate.getMinutes() > 0)) {
|
if (log.type === 'clock_in') {
|
||||||
lateCount++
|
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++
|
if (status !== 'full_attendance') abnormal++
|
||||||
|
|
||||||
|
// Calculate Salary
|
||||||
|
const hourlyRate = emp.hourly_rate || 180
|
||||||
|
const estimatedSalary = Math.round(totalHours * hourlyRate)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...emp,
|
...emp,
|
||||||
attendanceDays: uniqueDays,
|
attendanceDays: uniqueDays,
|
||||||
|
totalHours: totalHours.toFixed(1),
|
||||||
|
hourlyRate,
|
||||||
|
estimatedSalary,
|
||||||
lateCount,
|
lateCount,
|
||||||
status
|
status
|
||||||
}
|
}
|
||||||
@@ -171,6 +200,121 @@ function Reports({ onBack }) {
|
|||||||
return `${year} 年 ${month} 月`
|
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 (
|
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">
|
<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 */}
|
{/* Top App Bar */}
|
||||||
@@ -311,31 +455,51 @@ function Reports({ onBack }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredReportData.map(emp => (
|
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 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={`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'}`}>
|
<div className="flex items-center mb-3">
|
||||||
{emp.avatar_url ? (
|
<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'}`}>
|
||||||
<img alt={emp.name} className="w-full h-full object-cover" src={emp.avatar_url} />
|
{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>
|
) : (
|
||||||
)}
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-white truncate">{emp.name}</p>
|
<div className="grid grid-cols-4 gap-2 border-t border-slate-100 dark:border-slate-700/50 pt-3">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
<div className="flex flex-col items-center">
|
||||||
ID: {emp.id.toString().padStart(5, '0')} • {emp.department || '無部門'}
|
<span className="text-[10px] text-slate-400 font-medium mb-0.5">工作天數</span>
|
||||||
</p>
|
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">{emp.attendanceDays} 天</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-center border-l border-slate-100 dark:border-slate-700/50">
|
||||||
{emp.status === 'full_attendance' && (
|
<span className="text-[10px] text-slate-400 font-medium mb-0.5">總工時</span>
|
||||||
<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>
|
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">{emp.totalHours || '0.0'} H</span>
|
||||||
)}
|
</div>
|
||||||
{emp.status === 'late' && (
|
<div className="flex flex-col items-center border-l border-slate-100 dark:border-slate-700/50">
|
||||||
<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>
|
<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>
|
||||||
{emp.status === 'missing' && (
|
</div>
|
||||||
<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 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-[10px] text-slate-400 dark:text-slate-500">{emp.attendanceDays} 天</span>
|
<span className="text-sm font-bold text-primary">${emp.estimatedSalary?.toLocaleString() || '0'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</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="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">
|
<div className="flex flex-col gap-3 max-w-[480px] mx-auto w-full">
|
||||||
<button
|
<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]"
|
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>
|
<span className="material-symbols-outlined">download</span>
|
||||||
|
|||||||
382
20260101 time check/src/SalaryExportPreview.jsx
Normal file
382
20260101 time check/src/SalaryExportPreview.jsx
Normal 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
|
||||||
464
20260101 time check/src/SystemSettings.jsx
Normal file
464
20260101 time check/src/SystemSettings.jsx
Normal 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
|
||||||
@@ -2,10 +2,12 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ command }) => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// 開發模式使用根路徑,生產模式使用 /time/ 子目錄
|
||||||
|
base: command === 'serve' ? '/' : '/time/',
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // 允許區域網路存取
|
host: '0.0.0.0', // 允許區域網路存取
|
||||||
port: 5173,
|
port: 5173,
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user