Hagen Cloud

import React, { useState, useEffect, useRef } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, onAuthStateChanged, signInAnonymously, signInWithCustomToken, signOut } from 'firebase/auth'; import { getFirestore, collection, addDoc, updateDoc, deleteDoc, doc, query, onSnapshot, orderBy, serverTimestamp } from 'firebase/firestore'; import { FileText, Users, Settings, Plus, Download, Save, Trash2, CheckCircle, Clock, Search, Menu, X, Printer, Copy, Home, DollarSign } from 'lucide-react'; // --- Firebase Configuration & Init --- const firebaseConfig = JSON.parse(__firebase_config); const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id'; // --- Helper: Dynamic Script Loader for PDF generation --- const loadScript = (src) => { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } const script = document.createElement('script'); script.src = src; script.onload = resolve; script.onerror = reject; document.body.appendChild(script); }); }; // --- Components --- const Toast = ({ message, type, onClose }) => { useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]); const bgColors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' }; return (
{type === 'success' && } {message}
); }; const StatusBadge = ({ status }) => { const styles = { draft: 'bg-gray-100 text-gray-600', sent: 'bg-blue-100 text-blue-600', paid: 'bg-green-100 text-green-600', overdue: 'bg-red-100 text-red-600' }; const labels = { draft: '草稿', sent: '已發送', paid: '已付款', overdue: '逾期' }; return ( {labels[status] || status} ); }; // --- Main App Component --- export default function App() { const [user, setUser] = useState(null); const [view, setView] = useState('dashboard'); // dashboard, invoices, clients, settings, editor const [loading, setLoading] = useState(true); const [toast, setToast] = useState(null); // Data State const [invoices, setInvoices] = useState([]); const [clients, setClients] = useState([]); const [settings, setSettings] = useState({ companyName: '您的公司名稱', address: '香港九龍...', email: 'contact@company.com', phone: '+852 1234 5678', bankInfo: '銀行名稱: HSBC\n帳號: 123-456-789', currency: 'HKD', logoUrl: '' }); // Editor State const [currentInvoice, setCurrentInvoice] = useState(null); const [isPdfLoading, setIsPdfLoading] = useState(false); // --- Auth & Data Loading --- useEffect(() => { const initAuth = async () => { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { await signInWithCustomToken(auth, __initial_auth_token); } else { await signInAnonymously(auth); } }; initAuth(); const unsubscribe = onAuthStateChanged(auth, (u) => { setUser(u); setLoading(false); }); // Preload PDF libraries loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'); loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'); return () => unsubscribe(); }, []); useEffect(() => { if (!user) return; // Listen to Invoices // Note: Using simple query as per rules (no compound queries without index) const invoicesRef = collection(db, 'artifacts', appId, 'users', user.uid, 'invoices'); const unsubInvoices = onSnapshot(invoicesRef, (snapshot) => { const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); // Sort in memory data.sort((a, b) => (b.date?.seconds || 0) - (a.date?.seconds || 0)); setInvoices(data); }, (err) => console.error("Invoice fetch error", err)); // Listen to Clients const clientsRef = collection(db, 'artifacts', appId, 'users', user.uid, 'clients'); const unsubClients = onSnapshot(clientsRef, (snapshot) => { const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setClients(data); }, (err) => console.error("Client fetch error", err)); // Listen to Settings (Using a specific ID 'main') const settingsRef = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'main'); const unsubSettings = onSnapshot(settingsRef, (docSnap) => { if (docSnap.exists()) { setSettings(prev => ({ ...prev, ...docSnap.data() })); } }, (err) => console.error("Settings fetch error", err)); return () => { unsubInvoices(); unsubClients(); unsubSettings(); }; }, [user]); // --- Actions --- const showToast = (msg, type = 'info') => setToast({ message: msg, type }); const saveSettings = async (newSettings) => { if (!user) return; try { await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'main'), newSettings) .catch(() => { // Create if not exists const ref = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'main'); return import('firebase/firestore').then(m => m.setDoc(ref, newSettings)); }); setSettings(newSettings); showToast('公司設定已儲存', 'success'); } catch (e) { showToast('儲存失敗', 'error'); } }; const createNewInvoice = (type = 'invoice') => { const newInv = { type, // 'invoice' or 'quotation' number: `${type === 'invoice' ? 'INV' : 'QUO'}-${new Date().getFullYear()}-${Math.floor(Math.random() * 10000)}`, date: new Date().toISOString().split('T')[0], dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], client: null, items: [{ description: '', quantity: 1, price: 0 }], status: 'draft', notes: '', subtotal: 0, taxRate: 0, discount: 0, total: 0 }; setCurrentInvoice(newInv); setView('editor'); }; const saveInvoice = async () => { if (!user || !currentInvoice) return; // Calculate totals before save const subtotal = currentInvoice.items.reduce((acc, item) => acc + (item.quantity * item.price), 0); const taxAmount = subtotal * (currentInvoice.taxRate / 100); const total = subtotal + taxAmount - (currentInvoice.discount || 0); const docData = { ...currentInvoice, subtotal, total, updatedAt: serverTimestamp(), dateObj: new Date(currentInvoice.date) // stored for sorting }; try { if (currentInvoice.id) { await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'invoices', currentInvoice.id), docData); showToast('文件已更新', 'success'); } else { const ref = await addDoc(collection(db, 'artifacts', appId, 'users', user.uid, 'invoices'), { ...docData, createdAt: serverTimestamp() }); setCurrentInvoice({ ...docData, id: ref.id }); showToast('新文件已建立', 'success'); } } catch (e) { console.error(e); showToast('儲存失敗', 'error'); } }; const deleteInvoice = async (id) => { if (!confirm('確定要刪除此文件嗎?此動作無法復原。')) return; try { await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'invoices', id)); showToast('文件已刪除', 'info'); if (currentInvoice?.id === id) setView('invoices'); } catch (e) { showToast('刪除失敗', 'error'); } }; const saveClient = async (clientData) => { if (!user) return; try { if (clientData.id) { await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'clients', clientData.id), clientData); } else { await addDoc(collection(db, 'artifacts', appId, 'users', user.uid, 'clients'), clientData); } showToast('客戶資料已儲存', 'success'); } catch (e) { showToast('客戶儲存失敗', 'error'); } }; const generatePDF = async () => { setIsPdfLoading(true); try { const element = document.getElementById('invoice-preview'); if (!element) throw new Error('Preview not found'); const canvas = await window.html2canvas(element, { scale: 2, useCORS: true }); const imgData = canvas.toDataURL('image/png'); const { jsPDF } = window.jspdf; const pdf = new jsPDF('p', 'mm', 'a4'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = (canvas.height * pdfWidth) / canvas.width; pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight); pdf.save(`${currentInvoice.number}.pdf`); showToast('PDF 下載已開始', 'success'); } catch (e) { console.error(e); showToast('PDF 產生失敗,請稍後再試', 'error'); } finally { setIsPdfLoading(false); } }; // --- Views --- const DashboardView = () => { const totalRev = invoices .filter(i => i.status === 'paid') .reduce((acc, curr) => acc + (curr.total || 0), 0); const pendingCount = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').length; return (
已收款總額
{settings.currency} {totalRev.toLocaleString()}
待處理單據
{pendingCount} 張
createNewInvoice('invoice')}>
建立新發票

最近文件

{invoices.slice(0, 5).map(inv => ( ))} {invoices.length === 0 && ( )}
編號 客戶 日期 金額 狀態 操作
{inv.number} {inv.client?.name || '-'} {inv.date} {settings.currency} {inv.total?.toLocaleString()}
暫無資料,開始建立第一張發票吧!
); }; const ClientsView = () => { const [isEditing, setIsEditing] = useState(false); const [editClient, setEditClient] = useState(null); const handleEdit = (client = null) => { setEditClient(client || { name: '', email: '', address: '', contact: '' }); setIsEditing(true); }; const submitClient = async (e) => { e.preventDefault(); await saveClient(editClient); setIsEditing(false); }; return (

客戶管理

{isEditing && (

{editClient.id ? '編輯客戶' : '新客戶資料'}

setEditClient({...editClient, name: e.target.value})} required /> setEditClient({...editClient, email: e.target.value})} /> setEditClient({...editClient, contact: e.target.value})} /> setEditClient({...editClient, address: e.target.value})} />
)}
{clients.map(client => (

{client.name}

{client.contact || '未填寫聯絡人'}

{client.email}

{client.address}

))}
); }; const EditorView = () => { if (!currentInvoice) return null; const updateItem = (index, field, value) => { const newItems = [...currentInvoice.items]; newItems[index][field] = value; setCurrentInvoice({ ...currentInvoice, items: newItems }); }; const addItem = () => { setCurrentInvoice({ ...currentInvoice, items: [...currentInvoice.items, { description: '', quantity: 1, price: 0 }] }); }; const removeItem = (index) => { const newItems = currentInvoice.items.filter((_, i) => i !== index); setCurrentInvoice({ ...currentInvoice, items: newItems }); }; const subtotal = currentInvoice.items.reduce((acc, item) => acc + (item.quantity * item.price), 0); const taxAmount = subtotal * (currentInvoice.taxRate / 100); const finalTotal = subtotal + taxAmount - (currentInvoice.discount || 0); return (
{/* Left: Editor Form */}

文件設定

{currentInvoice.id && ( )}
setCurrentInvoice({...currentInvoice, number: e.target.value})} />
setCurrentInvoice({...currentInvoice, date: e.target.value})} />
setCurrentInvoice({...currentInvoice, dueDate: e.target.value})} />
{['draft', 'sent', 'paid', 'overdue'].map(s => ( ))}

項目明細

{currentInvoice.items.map((item, idx) => (
updateItem(idx, 'description', e.target.value)} />
updateItem(idx, 'quantity', parseFloat(e.target.value) || 0)} />
updateItem(idx, 'price', parseFloat(e.target.value) || 0)} />
))}
小計 (Subtotal) {subtotal.toFixed(2)}
稅率 (Tax %) setCurrentInvoice({...currentInvoice, taxRate: parseFloat(e.target.value) || 0})} />
折扣 (Discount) setCurrentInvoice({...currentInvoice, discount: parseFloat(e.target.value) || 0})} />
總計 (Total) {settings.currency} {finalTotal.toFixed(2)}
{currentInvoice.id && (
)}
{/* Right: A4 Preview */}
{/* This is the element that gets captured by html2canvas */}
{/* Header */}

{currentInvoice.type === 'invoice' ? 'INVOICE' : 'QUOTATION'}

# {currentInvoice.number}

{settings.companyName}

{settings.address}
{settings.email}
{settings.phone}
{/* Client Info */}

Bill To:

{currentInvoice.client ? ( <>
{currentInvoice.client.name}
{currentInvoice.client.address}
{currentInvoice.client.contact}
> ) : (
Select a client...
)}
Date: {currentInvoice.date}
Due Date: {currentInvoice.dueDate}
{/* Items Table */} {currentInvoice.items.map((item, i) => ( ))}
Description Qty Price Amount
{item.description} {item.quantity} {item.price.toFixed(2)} {(item.quantity * item.price).toFixed(2)}
{/* Totals */}
Subtotal {subtotal.toFixed(2)}
{currentInvoice.taxRate > 0 && (
Tax ({currentInvoice.taxRate}%) {(subtotal * currentInvoice.taxRate / 100).toFixed(2)}
)} {currentInvoice.discount > 0 && (
Discount -{currentInvoice.discount.toFixed(2)}
)}
Total {settings.currency} {finalTotal.toFixed(2)}
{/* Footer / Bank Info */}

Payment Information

{settings.bankInfo}

Thank you for your business!

); }; const SettingsView = () => { const [localSettings, setLocalSettings] = useState(settings); return (

系統設定

setLocalSettings({...localSettings, companyName: e.target.value})} />
setLocalSettings({...localSettings, currency: e.target.value})} placeholder="e.g. HKD, USD" />
setLocalSettings({...localSettings, phone: e.target.value})} />
); }; // --- Navigation Wrapper --- if (loading) return
系統載入中...
; return (
{/* Sidebar */}
CloudInvoice
已連線
{/* Main Content */}
{/* Mobile Header */}
CloudInvoice
{toast && setToast(null)} />} {view === 'dashboard' && } {view === 'clients' && } {view === 'settings' && } {view === 'invoices' && (

發票記錄

{invoices.map(inv => ( ))}
編號 類型 客戶 日期 金額 狀態
{inv.number} {inv.type} {inv.client?.name || '-'} {inv.date} {settings.currency} {inv.total?.toLocaleString()}
)} {view === 'editor' && }
); }