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 (
);
};
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 && (
)}
);
};
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 (
);
};
// --- Navigation Wrapper ---
if (loading) 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')}>
建立新發票
最近文件
| 編號 | 客戶 | 日期 | 金額 | 狀態 | 操作 |
|---|---|---|---|---|---|
| {inv.number} | {inv.client?.name || '-'} | {inv.date} | {settings.currency} {inv.total?.toLocaleString()} | ||
| 暫無資料,開始建立第一張發票吧! | |||||
客戶管理
{editClient.id ? '編輯客戶' : '新客戶資料'}
{clients.map(client => (
))}
{client.name}
{client.email}
{client.address}
{/* Left: Editor Form */}
{currentInvoice.id && (
)}
{/* Right: A4 Preview */}
);
};
const SettingsView = () => {
const [localSettings, setLocalSettings] = useState(settings);
return (
文件設定
{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)}
{/* This is the element that gets captured by html2canvas */}
{/* Header */}
{/* Client Info */}
{/* Items Table */}
{/* Totals */}
{/* Footer / Bank Info */}
{currentInvoice.type === 'invoice' ? 'INVOICE' : 'QUOTATION'}
# {currentInvoice.number}
{settings.companyName}
{settings.address}
{settings.email}
{settings.phone}
Bill To:
{currentInvoice.client ? ( <>{currentInvoice.client.name}
{currentInvoice.client.address}
{currentInvoice.client.contact}
>
) : (
Select a client...
)}
Date:
{currentInvoice.date}
Due Date:
{currentInvoice.dueDate}
| Description | Qty | Price | Amount |
|---|---|---|---|
| {item.description} | {item.quantity} | {item.price.toFixed(2)} | {(item.quantity * item.price).toFixed(2)} |
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)}
Payment Information
{settings.bankInfo}
Thank you for your business!
系統設定
setLocalSettings({...localSettings, companyName: e.target.value})}
/>
setLocalSettings({...localSettings, currency: e.target.value})}
placeholder="e.g. HKD, USD"
/>
setLocalSettings({...localSettings, phone: e.target.value})}
/>
系統載入中...
;
return (
{/* Sidebar */}
CloudInvoice
{/* Main Content */}
);
}
已連線
{/* Mobile Header */}
{toast && setToast(null)} />}
{view === 'dashboard' && }
{view === 'clients' && }
{view === 'settings' && }
{view === 'invoices' && (
)}
{view === 'editor' && }
CloudInvoice
發票記錄
| 編號 | 類型 | 客戶 | 日期 | 金額 | 狀態 | |
|---|---|---|---|---|---|---|
| {inv.number} | {inv.type} | {inv.client?.name || '-'} | {inv.date} | {settings.currency} {inv.total?.toLocaleString()} |