Buatkan project web fullstack bernama "ServiceBook" menggunakan **SvelteKit** dengan UI memakai **Tailwind CSS**, ikon **Lucide** dan palet warna berupa **soft color blocks + white** (bersih, minimal, modern). Aplikasi ini untuk pencatatan keuangan usaha servis HP.
Browse filesDesain/Style:
- Framework: SvelteKit (latest)
- Styling: Tailwind CSS (JIT), gunakan utility-first, responsive.
- Icons: Lucide icons
- Theme: background putih, gunakan block warna soft (pastel) untuk cards/kategori:
- Primary soft: #E6F4FF (soft blue)
- Accent soft: #FFF3E6 (soft orange)
- Success soft: #E8F7E8 (soft green)
- Danger soft: #FFE6E6 (soft red)
- Text: #0F172A (very dark blue/near-black)
- Use white (#FFFFFF) for main backgrounds and elevated cards with subtle shadow
- Use accessible contrast, rounded corners (md), and 8β12px spacing system.
Fungsionalitas utama:
1. Input transaksi:
- Fields: date, type (Pemasukan / Pengeluaran), description, category, amount, paymentMethod (optional)
- Validasi: jumlah > 0, date required, category required
2. Listing transaksi:
- Paginated / infinite scroll
- Indikator warna/icon: hijau untuk pemasukan, merah untuk pengeluaran
- Edit & Delete tiap transaksi
3. Laporan & Dashboard:
- Ringkasan: total pemasukan, total pengeluaran, laba/rugi
- Grafik bulanan (lihat detail di fitur grafik)
4. Filter & Search:
- Filter by date range: today, this week, this month, custom range
- Filter by category
- Full-text search by description
5. Export Data:
- Export ke PDF (generate report PDF, printable) β pakai jsPDF/html2canvas
- Export ke Excel (.xlsx) β pakai SheetJS (xlsx)
- Share laporan via WhatsApp / Email β support Web Share API dan WhatsApp prefilled message URL
6. Offline Mode & Sync:
- Local cache menggunakan **IndexedDB** via **Dexie.js** (karena ini web app)
- Auto-sync background: ketika online, push lokal changes ke cloud DB (Turso) and pull remote changes
- Tampilkan status sync (last synced time, pending changes count, connection status)
- Optionally: for mobile packaged app (Capacitor), use SQLite or Room on Android for stronger offline guarantee
7. Backend / Database:
- Cloud DB: **Turso** (libSQL). Provide REST/HTTP or GraphQL wrapper service if needed.
- Sync strategy: optimistic local writes -> queue pending ops -> background sync with conflict resolution by last-write-wins (and keep change history)
- DB schema (libSQL):
```
CREATE TABLE transactions (
id TEXT PRIMARY KEY, -- UUID
date TEXT NOT NULL, -- ISO 8601
type TEXT NOT NULL, -- "Pemasukan" or "Pengeluaran"
description TEXT,
category TEXT,
amount REAL NOT NULL,
user_id TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE transaction_history (
history_id TEXT PRIMARY KEY,
transaction_id TEXT,
action TEXT, -- "create","update","delete"
payload TEXT, -- json snapshot
performed_at TEXT,
performed_by TEXT
);
```
8. Authentication & Multi-user:
- Implement authentication (Login/Register) using **Supabase Auth** or **Clerk** (or custom JWT). Auth provider choice must support multi-device sync.
- Data separated per user (user_id on each transaction).
9. Edit & History:
- Edit existing transactions with proper validation
- Save changes to `transaction_history` for audit trail (who, when, what changed)
- UI: show history/timeline modal per transaction
10. Reminder & Notifications:
- Local reminders: schedule daily input reminder and target omzet notifications (use browser Notifications API + service worker for PWA)
- Alert when monthly expense for category exceeds threshold (configurable)
11. Charts:
- Use chart library: **Chart.js** or **ApexCharts** via Svelte wrapper
- Provide:
- Bar/Line chart: monthly trend (income vs expense per month)
- Pie chart: percentage breakdown by category for selected period
- Line chart: profit/loss trend over time
- Charts should be interactive (hover tooltips, toggle series)
12. Additional UX:
- PWA ready (installable)
- Theme toggle (light/dark) persisted to local storage/DataStore equivalent
- Export / import backup (.json)
- Role-based UI (admin vs basic user) if needed
Tech Stack & Libraries suggested:
- Frontend: SvelteKit
- Styling: Tailwind CSS
- Icons: Lucide
- Charts: Chart.js or ApexCharts (Svelte wrapper)
- Local cache: Dexie.js (IndexedDB)
- Cloud DB: Turso (libSQL) β use HTTP API or small sync microservice
- Auth: Supabase Auth / Clerk / or custom JWT
- Export: jsPDF + html2canvas (PDF), SheetJS (xlsx)
- Background sync / worker: Service Worker + Background Sync / periodic sync (PWA)
- Notifications: Web Notifications API + Service Worker
- Optional: Capacitor for building Android/iOS native wrapper (if want mobile offline with SQLite/Room)
- Utilities: uuid, dayjs/date-fns
API endpoints (examples):
- POST /api/auth/register
- POST /api/auth/login
- GET /api/transactions?userId=...
- POST /api/transactions
- PUT /api/transactions/:id
- DELETE /api/transactions/:id
- POST /api/sync (batch sync handler)
- GET /api/report?from=YYYY-MM-DD&to=YYYY-MM-DD
Deliverables:
- Full SvelteKit repo scaffolded with pages:
- / (dashboard)
- /transactions (list)
- /transactions/new
- /transactions/:id/edit
- /reports
- /settings
- /auth/login, /auth/register
- Implement Dexie-based local DB wrapper with queueing logic for offline writes and sync module to Turso
- Implement Chart components (monthly bar/line, category pie)
- Export to PDF & Excel functions, and share via WhatsApp/Email
- PWA & Service Worker support for offline cache & notifications
- README with setup, Turso config/env vars, and sync architecture notes
Important notes for implementer:
- Replace Room Database with IndexedDB (Dexie) for web; mention option to use native SQLite/Room when wrapping as mobile app (Capacitor) to keep parity with original Android idea.
- Provide clear migration/sync logic to handle multiple devices and conflict resolution.
- Keep UI lightweight (Svelte + Tailwind) so app runs smoothly on mid-range mobile devices and desktops.
- README.md +8 -5
- package.json +35 -0
- src/app.css +30 -0
- src/app.html +15 -0
- src/components/Footer.svelte +8 -0
- src/components/Navbar.svelte +53 -0
- src/components/dashboard/CategoryPie.svelte +62 -0
- src/components/dashboard/MonthlyChart.svelte +71 -0
- src/components/dashboard/SummaryCards.svelte +51 -0
- src/components/reports/ProfitTrend.svelte +67 -0
- src/components/reports/ReportControls.svelte +49 -0
- src/components/transactions/RecentTransactions.svelte +82 -0
- src/components/transactions/TransactionForm.svelte +137 -0
- src/components/transactions/TransactionTable.svelte +122 -0
- src/lib/db.js +19 -0
- src/routes/+layout.server.js +5 -0
- src/routes/+layout.svelte +19 -0
- src/routes/+page.svelte +27 -0
- src/routes/auth/login/+page.svelte +96 -0
- src/routes/reports/+page.svelte +79 -0
- src/routes/settings/+page.svelte +108 -0
- src/routes/transactions/+page.svelte +44 -0
- svelte.config.js +17 -0
|
@@ -1,10 +1,13 @@
|
|
| 1 |
---
|
| 2 |
-
title: Buatkan
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Buatkan-project-web-fullstack-bernama--S
|
| 3 |
+
colorFrom: red
|
| 4 |
+
colorTo: gray
|
| 5 |
+
emoji: π³
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- deepsite-v3
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Welcome to your new DeepSite project!
|
| 13 |
+
This project was created with [DeepSite](https://deepsite.hf.co).
|
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
json
|
| 2 |
+
{
|
| 3 |
+
"name": "servicebook",
|
| 4 |
+
"version": "0.0.1",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "vite dev",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"preview": "vite preview",
|
| 9 |
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
| 10 |
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
| 11 |
+
},
|
| 12 |
+
"devDependencies": {
|
| 13 |
+
"@sveltejs/adapter-auto": "^2.2.0",
|
| 14 |
+
"@sveltejs/kit": "^1.27.4",
|
| 15 |
+
"autoprefixer": "^10.4.16",
|
| 16 |
+
"chart.js": "^4.4.0",
|
| 17 |
+
"lucide-svelte": "^0.308.0",
|
| 18 |
+
"postcss": "^8.4.31",
|
| 19 |
+
"svelte": "^4.2.0",
|
| 20 |
+
"svelte-check": "^3.6.0",
|
| 21 |
+
"tailwindcss": "^3.3.5",
|
| 22 |
+
"tslib": "^2.6.2",
|
| 23 |
+
"typescript": "^5.2.2",
|
| 24 |
+
"vite": "^4.5.0"
|
| 25 |
+
},
|
| 26 |
+
"dependencies": {
|
| 27 |
+
"@supabase/supabase-js": "^2.39.0",
|
| 28 |
+
"dexie": "^3.2.4",
|
| 29 |
+
"dexie-export-import": "^1.0.4",
|
| 30 |
+
"jspdf": "^2.5.1",
|
| 31 |
+
"xlsx": "^0.18.5"
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--primary-soft: #e6f4ff;
|
| 7 |
+
--accent-soft: #fff3e6;
|
| 8 |
+
--success-soft: #e8f7e8;
|
| 9 |
+
--danger-soft: #ffe6e6;
|
| 10 |
+
--text: #0f172a;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/* Custom utility classes */
|
| 14 |
+
@layer utilities {
|
| 15 |
+
.card {
|
| 16 |
+
@apply bg-white rounded-lg shadow-sm border border-slate-100 p-4;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.btn-primary {
|
| 20 |
+
@apply bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.btn-secondary {
|
| 24 |
+
@apply bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-lg transition-colors;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.input-field {
|
| 28 |
+
@apply w-full px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all;
|
| 29 |
+
}
|
| 30 |
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>ServiceBook - Phone Repair Finance</title>
|
| 8 |
+
<meta name="description" content="Track your phone repair service finances" />
|
| 9 |
+
<meta name="theme-color" content="#FFFFFF" />
|
| 10 |
+
%sveltekit.head%
|
| 11 |
+
</head>
|
| 12 |
+
<body data-sveltekit-preload-data="hover">
|
| 13 |
+
<div style="display: contents">%sveltekit.body%</div>
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<footer class="bg-slate-50 border-t border-slate-100 py-6 mt-8">
|
| 3 |
+
<div class="container mx-auto px-4 text-center text-slate-500 text-sm">
|
| 4 |
+
<p>Β© {new Date().getFullYear()} ServiceBook - Phone Repair Finance Manager</p>
|
| 5 |
+
</div>
|
| 6 |
+
</footer>
|
| 7 |
+
|
| 8 |
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { LucideHome, LucidePieChart, LucideList, LucideSettings, LucideLogIn, LucideUser } from 'lucide-svelte';
|
| 4 |
+
import { page } from '$app/stores';
|
| 5 |
+
|
| 6 |
+
export let session;
|
| 7 |
+
</script>
|
| 8 |
+
|
| 9 |
+
<nav class="bg-white border-b border-slate-100 shadow-sm">
|
| 10 |
+
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
|
| 11 |
+
<a href="/" class="flex items-center space-x-2">
|
| 12 |
+
<span class="text-xl font-bold text-slate-900">ServiceBook</span>
|
| 13 |
+
</a>
|
| 14 |
+
|
| 15 |
+
<div class="flex items-center space-x-6">
|
| 16 |
+
<a href="/" class="flex items-center space-x-1 text-slate-600 hover:text-slate-900 transition-colors">
|
| 17 |
+
<LucideHome size={18} />
|
| 18 |
+
<span class={$page.url.pathname === '/' ? 'font-medium' : ''}>Dashboard</span>
|
| 19 |
+
</a>
|
| 20 |
+
|
| 21 |
+
<a href="/transactions" class="flex items-center space-x-1 text-slate-600 hover:text-slate-900 transition-colors">
|
| 22 |
+
<LucideList size={18} />
|
| 23 |
+
<span class={$page.url.pathname.startsWith('/transactions') ? 'font-medium' : ''}>Transactions</span>
|
| 24 |
+
</a>
|
| 25 |
+
|
| 26 |
+
<a href="/reports" class="flex items-center space-x-1 text-slate-600 hover:text-slate-900 transition-colors">
|
| 27 |
+
<LucidePieChart size={18} />
|
| 28 |
+
<span class={$page.url.pathname.startsWith('/reports') ? 'font-medium' : ''}>Reports</span>
|
| 29 |
+
</a>
|
| 30 |
+
|
| 31 |
+
{#if session}
|
| 32 |
+
<a href="/settings" class="flex items-center space-x-1 text-slate-600 hover:text-slate-900 transition-colors">
|
| 33 |
+
<LucideSettings size={18} />
|
| 34 |
+
<span class={$page.url.pathname.startsWith('/settings') ? 'font-medium' : ''}>Settings</span>
|
| 35 |
+
</a>
|
| 36 |
+
|
| 37 |
+
<div class="flex items-center space-x-2">
|
| 38 |
+
<div class="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center">
|
| 39 |
+
<LucideUser size={16} class="text-slate-600" />
|
| 40 |
+
</div>
|
| 41 |
+
<span class="text-sm font-medium text-slate-700">{session.user.email}</span>
|
| 42 |
+
</div>
|
| 43 |
+
{:else}
|
| 44 |
+
<a href="/auth/login" class="flex items-center space-x-1 text-slate-600 hover:text-slate-900 transition-colors">
|
| 45 |
+
<LucideLogIn size={18} />
|
| 46 |
+
<span>Login</span>
|
| 47 |
+
</a>
|
| 48 |
+
{/if}
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</nav>
|
| 52 |
+
|
| 53 |
+
</html>
|
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { onMount } from 'svelte';
|
| 4 |
+
import { Chart } from 'chart.js/auto';
|
| 5 |
+
|
| 6 |
+
let chart;
|
| 7 |
+
let canvas;
|
| 8 |
+
|
| 9 |
+
onMount(() => {
|
| 10 |
+
if (canvas) {
|
| 11 |
+
chart = new Chart(canvas, {
|
| 12 |
+
type: 'doughnut',
|
| 13 |
+
data: {
|
| 14 |
+
labels: ['Screen Repair', 'Battery', 'Software', 'Accessories', 'Other'],
|
| 15 |
+
datasets: [{
|
| 16 |
+
data: [35, 25, 20, 15, 5],
|
| 17 |
+
backgroundColor: [
|
| 18 |
+
'#FF6384',
|
| 19 |
+
'#36A2EB',
|
| 20 |
+
'#FFCE56',
|
| 21 |
+
'#4BC0C0',
|
| 22 |
+
'#9966FF'
|
| 23 |
+
],
|
| 24 |
+
borderWidth: 0
|
| 25 |
+
}]
|
| 26 |
+
},
|
| 27 |
+
options: {
|
| 28 |
+
responsive: true,
|
| 29 |
+
plugins: {
|
| 30 |
+
legend: {
|
| 31 |
+
position: 'right'
|
| 32 |
+
},
|
| 33 |
+
tooltip: {
|
| 34 |
+
callbacks: {
|
| 35 |
+
label: function(context) {
|
| 36 |
+
const label = context.label || '';
|
| 37 |
+
const value = context.raw || 0;
|
| 38 |
+
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
| 39 |
+
const percentage = Math.round((value / total) * 100);
|
| 40 |
+
return `${label}: ${percentage}% (Rp ${value.toLocaleString()})`;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return () => {
|
| 50 |
+
if (chart) {
|
| 51 |
+
chart.destroy();
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
});
|
| 55 |
+
</script>
|
| 56 |
+
|
| 57 |
+
<div class="card h-full">
|
| 58 |
+
<h2 class="text-lg font-semibold text-slate-900 mb-4">Expense Categories</h2>
|
| 59 |
+
<canvas bind:this={canvas}></canvas>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
</html>
|
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { onMount } from 'svelte';
|
| 4 |
+
import { Chart } from 'chart.js/auto';
|
| 5 |
+
|
| 6 |
+
let chart;
|
| 7 |
+
let canvas;
|
| 8 |
+
|
| 9 |
+
onMount(() => {
|
| 10 |
+
if (canvas) {
|
| 11 |
+
chart = new Chart(canvas, {
|
| 12 |
+
type: 'bar',
|
| 13 |
+
data: {
|
| 14 |
+
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
| 15 |
+
datasets: [
|
| 16 |
+
{
|
| 17 |
+
label: 'Income',
|
| 18 |
+
data: [12000000, 15000000, 18000000, 14000000, 16000000, 19000000, 21000000, 23000000, 20000000, 22000000, 24000000, 26000000],
|
| 19 |
+
backgroundColor: '#4CAF50',
|
| 20 |
+
borderRadius: 4
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
label: 'Expenses',
|
| 24 |
+
data: [8000000, 9000000, 10000000, 8500000, 9500000, 11000000, 12000000, 13000000, 11500000, 12500000, 14000000, 15000000],
|
| 25 |
+
backgroundColor: '#F44336',
|
| 26 |
+
borderRadius: 4
|
| 27 |
+
}
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
options: {
|
| 31 |
+
responsive: true,
|
| 32 |
+
plugins: {
|
| 33 |
+
legend: {
|
| 34 |
+
position: 'top'
|
| 35 |
+
},
|
| 36 |
+
tooltip: {
|
| 37 |
+
callbacks: {
|
| 38 |
+
label: function(context) {
|
| 39 |
+
return `${context.dataset.label}: Rp ${context.raw.toLocaleString()}`;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
scales: {
|
| 45 |
+
y: {
|
| 46 |
+
beginAtZero: true,
|
| 47 |
+
ticks: {
|
| 48 |
+
callback: function(value) {
|
| 49 |
+
return `Rp ${value.toLocaleString()}`;
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return () => {
|
| 59 |
+
if (chart) {
|
| 60 |
+
chart.destroy();
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
});
|
| 64 |
+
</script>
|
| 65 |
+
|
| 66 |
+
<div class="card h-full">
|
| 67 |
+
<h2 class="text-lg font-semibold text-slate-900 mb-4">Monthly Income vs Expenses</h2>
|
| 68 |
+
<canvas bind:this={canvas}></canvas>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
</html>
|
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { LucideArrowUp, LucideArrowDown, LucideDollarSign } from 'lucide-svelte';
|
| 4 |
+
|
| 5 |
+
export let income = 0;
|
| 6 |
+
export let expenses = 0;
|
| 7 |
+
export let profit = income - expenses;
|
| 8 |
+
</script>
|
| 9 |
+
|
| 10 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 11 |
+
<!-- Income Card -->
|
| 12 |
+
<div class="card bg-[var(--success-soft)]">
|
| 13 |
+
<div class="flex items-start justify-between">
|
| 14 |
+
<div>
|
| 15 |
+
<p class="text-sm text-slate-600">Total Income</p>
|
| 16 |
+
<p class="text-2xl font-bold text-slate-900">Rp {income.toLocaleString()}</p>
|
| 17 |
+
</div>
|
| 18 |
+
<div class="p-2 rounded-full bg-green-100 text-green-600">
|
| 19 |
+
<LucideArrowUp size={20} />
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<!-- Expense Card -->
|
| 25 |
+
<div class="card bg-[var(--danger-soft)]">
|
| 26 |
+
<div class="flex items-start justify-between">
|
| 27 |
+
<div>
|
| 28 |
+
<p class="text-sm text-slate-600">Total Expenses</p>
|
| 29 |
+
<p class="text-2xl font-bold text-slate-900">Rp {expenses.toLocaleString()}</p>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="p-2 rounded-full bg-red-100 text-red-600">
|
| 32 |
+
<LucideArrowDown size={20} />
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<!-- Profit Card -->
|
| 38 |
+
<div class="card bg-[var(--primary-soft)]">
|
| 39 |
+
<div class="flex items-start justify-between">
|
| 40 |
+
<div>
|
| 41 |
+
<p class="text-sm text-slate-600">Profit/Loss</p>
|
| 42 |
+
<p class="text-2xl font-bold text-slate-900">Rp {profit.toLocaleString()}</p>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="p-2 rounded-full bg-blue-100 text-blue-600">
|
| 45 |
+
<LucideDollarSign size={20} />
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
</html>
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { onMount } from 'svelte';
|
| 4 |
+
import { Chart } from 'chart.js/auto';
|
| 5 |
+
|
| 6 |
+
let chart;
|
| 7 |
+
let canvas;
|
| 8 |
+
|
| 9 |
+
onMount(() => {
|
| 10 |
+
if (canvas) {
|
| 11 |
+
chart = new Chart(canvas, {
|
| 12 |
+
type: 'line',
|
| 13 |
+
data: {
|
| 14 |
+
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
| 15 |
+
datasets: [{
|
| 16 |
+
label: 'Profit/Loss',
|
| 17 |
+
data: [4000000, 6000000, 8000000, 5500000, 6500000, 8000000, 9000000, 10000000, 8500000, 9500000, 10000000, 11000000],
|
| 18 |
+
borderColor: '#3B82F6',
|
| 19 |
+
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
| 20 |
+
fill: true,
|
| 21 |
+
tension: 0.3,
|
| 22 |
+
pointRadius: 4,
|
| 23 |
+
pointBackgroundColor: '#3B82F6'
|
| 24 |
+
}]
|
| 25 |
+
},
|
| 26 |
+
options: {
|
| 27 |
+
responsive: true,
|
| 28 |
+
plugins: {
|
| 29 |
+
legend: {
|
| 30 |
+
display: false
|
| 31 |
+
},
|
| 32 |
+
tooltip: {
|
| 33 |
+
callbacks: {
|
| 34 |
+
label: function(context) {
|
| 35 |
+
return `Profit: Rp ${context.raw.toLocaleString()}`;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
scales: {
|
| 41 |
+
y: {
|
| 42 |
+
beginAtZero: false,
|
| 43 |
+
ticks: {
|
| 44 |
+
callback: function(value) {
|
| 45 |
+
return `Rp ${value.toLocaleString()}`;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return () => {
|
| 55 |
+
if (chart) {
|
| 56 |
+
chart.destroy();
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
});
|
| 60 |
+
</script>
|
| 61 |
+
|
| 62 |
+
<div>
|
| 63 |
+
<h2 class="text-lg font-semibold text-slate-900 mb-4">Profit Trend</h2>
|
| 64 |
+
<canvas bind:this={canvas}></canvas>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
</html>
|
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
export let dateRange = 'this-month';
|
| 4 |
+
|
| 5 |
+
const ranges = [
|
| 6 |
+
{ value: 'today', label: 'Today' },
|
| 7 |
+
{ value: 'this-week', label: 'This Week' },
|
| 8 |
+
{ value: 'this-month', label: 'This Month' },
|
| 9 |
+
{ value: 'this-year', label: 'This Year' },
|
| 10 |
+
{ value: 'custom', label: 'Custom Range' }
|
| 11 |
+
];
|
| 12 |
+
</script>
|
| 13 |
+
|
| 14 |
+
<div class="card flex items-center space-x-4">
|
| 15 |
+
<div>
|
| 16 |
+
<label for="dateRange" class="block text-sm font-medium text-slate-700 mb-1">Date Range</label>
|
| 17 |
+
<select
|
| 18 |
+
id="dateRange"
|
| 19 |
+
bind:value={dateRange}
|
| 20 |
+
class="input-field"
|
| 21 |
+
>
|
| 22 |
+
{#each ranges as range}
|
| 23 |
+
<option value={range.value}>{range.label}</option>
|
| 24 |
+
{/each}
|
| 25 |
+
</select>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
{#if dateRange === 'custom'}
|
| 29 |
+
<div>
|
| 30 |
+
<label for="startDate" class="block text-sm font-medium text-slate-700 mb-1">From</label>
|
| 31 |
+
<input
|
| 32 |
+
id="startDate"
|
| 33 |
+
type="date"
|
| 34 |
+
class="input-field"
|
| 35 |
+
/>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div>
|
| 39 |
+
<label for="endDate" class="block text-sm font-medium text-slate-700 mb-1">To</label>
|
| 40 |
+
<input
|
| 41 |
+
id="endDate"
|
| 42 |
+
type="date"
|
| 43 |
+
class="input-field"
|
| 44 |
+
/>
|
| 45 |
+
</div>
|
| 46 |
+
{/if}
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
</html>
|
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { LucideArrowUpRight, LucideArrowDownRight, LucideMoreVertical } from 'lucide-svelte';
|
| 4 |
+
|
| 5 |
+
export let transactions = [
|
| 6 |
+
{
|
| 7 |
+
id: 1,
|
| 8 |
+
date: '2023-05-15',
|
| 9 |
+
description: 'iPhone 12 screen replacement',
|
| 10 |
+
type: 'income',
|
| 11 |
+
category: 'Screen Repair',
|
| 12 |
+
amount: 1200000
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
id: 2,
|
| 16 |
+
date: '2023-05-14',
|
| 17 |
+
description: 'Bought battery for iPhone X',
|
| 18 |
+
type: 'expense',
|
| 19 |
+
category: 'Battery',
|
| 20 |
+
amount: 350000
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
id: 3,
|
| 24 |
+
date: '2023-05-12',
|
| 25 |
+
description: 'Samsung S21 screen repair',
|
| 26 |
+
type: 'income',
|
| 27 |
+
category: 'Screen Repair',
|
| 28 |
+
amount: 900000
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 4,
|
| 32 |
+
date: '2023-05-10',
|
| 33 |
+
description: 'Software troubleshooting',
|
| 34 |
+
type: 'income',
|
| 35 |
+
category: 'Software',
|
| 36 |
+
amount: 300000
|
| 37 |
+
}
|
| 38 |
+
];
|
| 39 |
+
</script>
|
| 40 |
+
|
| 41 |
+
<div class="card">
|
| 42 |
+
<div class="flex items-center justify-between mb-4">
|
| 43 |
+
<h2 class="text-lg font-semibold text-slate-900">Recent Transactions</h2>
|
| 44 |
+
<a href="/transactions" class="text-sm text-blue-500 hover:underline">View All</a>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="space-y-3">
|
| 48 |
+
{#each transactions as transaction}
|
| 49 |
+
<div class="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 transition-colors">
|
| 50 |
+
<div class="flex items-center space-x-3">
|
| 51 |
+
{#if transaction.type === 'income'}
|
| 52 |
+
<div class="p-2 rounded-full bg-green-100 text-green-600">
|
| 53 |
+
<LucideArrowUpRight size={16} />
|
| 54 |
+
</div>
|
| 55 |
+
{:else}
|
| 56 |
+
<div class="p-2 rounded-full bg-red-100 text-red-600">
|
| 57 |
+
<LucideArrowDownRight size={16} />
|
| 58 |
+
</div>
|
| 59 |
+
{/if}
|
| 60 |
+
|
| 61 |
+
<div>
|
| 62 |
+
<p class="font-medium text-slate-900">{transaction.description}</p>
|
| 63 |
+
<p class="text-xs text-slate-500">
|
| 64 |
+
{transaction.date} β’ {transaction.category}
|
| 65 |
+
</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<div class="flex items-center space-x-4">
|
| 70 |
+
<p class:font-bold={true} class:text-green-600={transaction.type === 'income'} class:text-red-600={transaction.type === 'expense'}>
|
| 71 |
+
{transaction.type === 'income' ? '+' : '-'}Rp {transaction.amount.toLocaleString()}
|
| 72 |
+
</p>
|
| 73 |
+
<button class="text-slate-400 hover:text-slate-600">
|
| 74 |
+
<LucideMoreVertical size={16} />
|
| 75 |
+
</button>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
{/each}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
</html>
|
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { createEventDispatcher } from 'svelte';
|
| 4 |
+
import { LucideSave, LucideX } from 'lucide-svelte';
|
| 5 |
+
|
| 6 |
+
const dispatch = createEventDispatcher();
|
| 7 |
+
|
| 8 |
+
export let transaction = {
|
| 9 |
+
date: new Date().toISOString().split('T')[0],
|
| 10 |
+
type: 'income',
|
| 11 |
+
description: '',
|
| 12 |
+
category: '',
|
| 13 |
+
amount: 0,
|
| 14 |
+
paymentMethod: 'cash'
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export let categories = [];
|
| 18 |
+
|
| 19 |
+
const paymentMethods = [
|
| 20 |
+
{ value: 'cash', label: 'Cash' },
|
| 21 |
+
{ value: 'bank', label: 'Bank Transfer' },
|
| 22 |
+
{ value: 'card', label: 'Credit Card' },
|
| 23 |
+
{ value: 'ewallet', label: 'E-Wallet' }
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
function handleSubmit() {
|
| 27 |
+
if (!transaction.amount || transaction.amount <= 0) return;
|
| 28 |
+
if (!transaction.category) return;
|
| 29 |
+
|
| 30 |
+
dispatch('submit', transaction);
|
| 31 |
+
}
|
| 32 |
+
</script>
|
| 33 |
+
|
| 34 |
+
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
| 35 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 36 |
+
<div>
|
| 37 |
+
<label for="date" class="block text-sm font-medium text-slate-700 mb-1">Date</label>
|
| 38 |
+
<input
|
| 39 |
+
id="date"
|
| 40 |
+
type="date"
|
| 41 |
+
bind:value={transaction.date}
|
| 42 |
+
class="input-field"
|
| 43 |
+
required
|
| 44 |
+
/>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div>
|
| 48 |
+
<label for="type" class="block text-sm font-medium text-slate-700 mb-1">Type</label>
|
| 49 |
+
<select
|
| 50 |
+
id="type"
|
| 51 |
+
bind:value={transaction.type}
|
| 52 |
+
class="input-field"
|
| 53 |
+
>
|
| 54 |
+
<option value="income">Income</option>
|
| 55 |
+
<option value="expense">Expense</option>
|
| 56 |
+
</select>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div>
|
| 61 |
+
<label for="description" class="block text-sm font-medium text-slate-700 mb-1">Description</label>
|
| 62 |
+
<input
|
| 63 |
+
id="description"
|
| 64 |
+
type="text"
|
| 65 |
+
bind:value={transaction.description}
|
| 66 |
+
class="input-field"
|
| 67 |
+
placeholder="Repair iPhone screen, Buy spare parts..."
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 72 |
+
<div>
|
| 73 |
+
<label for="category" class="block text-sm font-medium text-slate-700 mb-1">Category</label>
|
| 74 |
+
<select
|
| 75 |
+
id="category"
|
| 76 |
+
bind:value={transaction.category}
|
| 77 |
+
class="input-field"
|
| 78 |
+
required
|
| 79 |
+
>
|
| 80 |
+
<option value="">Select a category</option>
|
| 81 |
+
{#each categories as category}
|
| 82 |
+
<option value={category}>{category}</option>
|
| 83 |
+
{/each}
|
| 84 |
+
</select>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div>
|
| 88 |
+
<label for="amount" class="block text-sm font-medium text-slate-700 mb-1">Amount</label>
|
| 89 |
+
<div class="relative">
|
| 90 |
+
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">Rp</span>
|
| 91 |
+
<input
|
| 92 |
+
id="amount"
|
| 93 |
+
type="number"
|
| 94 |
+
bind:value={transaction.amount}
|
| 95 |
+
class="input-field pl-10"
|
| 96 |
+
min="0"
|
| 97 |
+
step="1000"
|
| 98 |
+
required
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div>
|
| 105 |
+
<label for="paymentMethod" class="block text-sm font-medium text-slate-700 mb-1">Payment Method</label>
|
| 106 |
+
<select
|
| 107 |
+
id="paymentMethod"
|
| 108 |
+
bind:value={transaction.paymentMethod}
|
| 109 |
+
class="input-field"
|
| 110 |
+
>
|
| 111 |
+
{#each paymentMethods as method}
|
| 112 |
+
<option value={method.value}>{method.label}</option>
|
| 113 |
+
{/each}
|
| 114 |
+
</select>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div class="flex justify-end space-x-3 pt-4">
|
| 118 |
+
<button
|
| 119 |
+
type="button"
|
| 120 |
+
on:click={() => dispatch('cancel')}
|
| 121 |
+
class="btn-secondary flex items-center space-x-1"
|
| 122 |
+
>
|
| 123 |
+
<LucideX size={18} />
|
| 124 |
+
<span>Cancel</span>
|
| 125 |
+
</button>
|
| 126 |
+
|
| 127 |
+
<button
|
| 128 |
+
type="submit"
|
| 129 |
+
class="btn-primary flex items-center space-x-1"
|
| 130 |
+
>
|
| 131 |
+
<LucideSave size={18} />
|
| 132 |
+
<span>Save</span>
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
+
</form>
|
| 136 |
+
|
| 137 |
+
</html>
|
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { LucideArrowUpRight, LucideArrowDownRight, LucideEdit, LucideTrash2 } from 'lucide-svelte';
|
| 4 |
+
|
| 5 |
+
let transactions = [
|
| 6 |
+
{
|
| 7 |
+
id: 1,
|
| 8 |
+
date: '2023-05-15',
|
| 9 |
+
description: 'iPhone 12 screen replacement',
|
| 10 |
+
type: 'income',
|
| 11 |
+
category: 'Screen Repair',
|
| 12 |
+
amount: 1200000,
|
| 13 |
+
paymentMethod: 'cash'
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
id: 2,
|
| 17 |
+
date: '2023-05-14',
|
| 18 |
+
description: 'Bought battery for iPhone X',
|
| 19 |
+
type: 'expense',
|
| 20 |
+
category: 'Battery',
|
| 21 |
+
amount: 350000,
|
| 22 |
+
paymentMethod: 'bank'
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
id: 3,
|
| 26 |
+
date: '2023-05-12',
|
| 27 |
+
description: 'Samsung S21 screen repair',
|
| 28 |
+
type: 'income',
|
| 29 |
+
category: 'Screen Repair',
|
| 30 |
+
amount: 900000,
|
| 31 |
+
paymentMethod: 'ewallet'
|
| 32 |
+
}
|
| 33 |
+
];
|
| 34 |
+
|
| 35 |
+
let currentPage = 1;
|
| 36 |
+
const itemsPerPage = 10;
|
| 37 |
+
|
| 38 |
+
function handleEdit(transaction) {
|
| 39 |
+
// TODO: Implement edit
|
| 40 |
+
console.log('Edit:', transaction);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function handleDelete(id) {
|
| 44 |
+
// TODO: Implement delete
|
| 45 |
+
console.log('Delete:', id);
|
| 46 |
+
}
|
| 47 |
+
</script>
|
| 48 |
+
|
| 49 |
+
<div class="overflow-x-auto">
|
| 50 |
+
<table class="min-w-full divide-y divide-slate-200">
|
| 51 |
+
<thead class="bg-slate-50">
|
| 52 |
+
<tr>
|
| 53 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Date</th>
|
| 54 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Description</th>
|
| 55 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Category</th>
|
| 56 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Payment</th>
|
| 57 |
+
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">Amount</th>
|
| 58 |
+
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">Actions</th>
|
| 59 |
+
</tr>
|
| 60 |
+
</thead>
|
| 61 |
+
<tbody class="bg-white divide-y divide-slate-200">
|
| 62 |
+
{#each transactions as transaction (transaction.id)}
|
| 63 |
+
<tr class="hover:bg-slate-50">
|
| 64 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{transaction.date}</td>
|
| 65 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-slate-900">{transaction.description}</td>
|
| 66 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{transaction.category}</td>
|
| 67 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500 capitalize">{transaction.paymentMethod}</td>
|
| 68 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-right {transaction.type === 'income' ? 'text-green-600' : 'text-red-600'}">
|
| 69 |
+
{transaction.type === 'income' ? '+' : '-'}Rp {transaction.amount.toLocaleString()}
|
| 70 |
+
</td>
|
| 71 |
+
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
| 72 |
+
<div class="flex space-x-2 justify-end">
|
| 73 |
+
<button
|
| 74 |
+
on:click={() => handleEdit(transaction)}
|
| 75 |
+
class="text-blue-500 hover:text-blue-700"
|
| 76 |
+
title="Edit"
|
| 77 |
+
>
|
| 78 |
+
<LucideEdit size={16} />
|
| 79 |
+
</button>
|
| 80 |
+
<button
|
| 81 |
+
on:click={() => handleDelete(transaction.id)}
|
| 82 |
+
class="text-red-500 hover:text-red-700"
|
| 83 |
+
title="Delete"
|
| 84 |
+
>
|
| 85 |
+
<LucideTrash2 size={16} />
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
</td>
|
| 89 |
+
</tr>
|
| 90 |
+
{/each}
|
| 91 |
+
</tbody>
|
| 92 |
+
</table>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{#if transactions.length === 0}
|
| 96 |
+
<div class="text-center py-8">
|
| 97 |
+
<p class="text-slate-500">No transactions yet. Add your first transaction!</p>
|
| 98 |
+
</div>
|
| 99 |
+
{/if}
|
| 100 |
+
|
| 101 |
+
<div class="flex items-center justify-between mt-4">
|
| 102 |
+
<div class="text-sm text-slate-500">
|
| 103 |
+
Showing {Math.min(currentPage * itemsPerPage, transactions.length)} of {transactions.length} transactions
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div class="flex space-x-2">
|
| 107 |
+
<button
|
| 108 |
+
disabled={currentPage === 1}
|
| 109 |
+
class="px-3 py-1 rounded border border-slate-200 text-slate-700 disabled:opacity-50"
|
| 110 |
+
>
|
| 111 |
+
Previous
|
| 112 |
+
</button>
|
| 113 |
+
<button
|
| 114 |
+
disabled={currentPage * itemsPerPage >= transactions.length}
|
| 115 |
+
class="px-3 py-1 rounded border border-slate-200 text-slate-700 disabled:opacity-50"
|
| 116 |
+
>
|
| 117 |
+
Next
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Dexie from 'dexie';
|
| 2 |
+
|
| 3 |
+
export const db = new Dexie('ServiceBookDB');
|
| 4 |
+
|
| 5 |
+
db.version(1).stores({
|
| 6 |
+
transactions: '++id, userId, date, type, category, amount',
|
| 7 |
+
syncQueue: '++id, action, payload, timestamp',
|
| 8 |
+
settings: 'key'
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
db.version(2).upgrade(trans => {
|
| 12 |
+
return trans.table('transactions').toCollection().modify(transaction => {
|
| 13 |
+
transaction.synced = false;
|
| 14 |
+
transaction.updatedAt = new Date().toISOString();
|
| 15 |
+
});
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
// Export the database instance
|
| 19 |
+
export default db;
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const load = async ({ locals }) => {
|
| 2 |
+
return {
|
| 3 |
+
session: await locals.getSession()
|
| 4 |
+
};
|
| 5 |
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import '../app.css';
|
| 4 |
+
import Navbar from '$components/Navbar.svelte';
|
| 5 |
+
import Footer from '$components/Footer.svelte';
|
| 6 |
+
import { session } from '$stores/auth';
|
| 7 |
+
</script>
|
| 8 |
+
|
| 9 |
+
<div class="min-h-screen flex flex-col bg-white">
|
| 10 |
+
<Navbar {session} />
|
| 11 |
+
|
| 12 |
+
<main class="flex-1 container mx-auto px-4 py-8">
|
| 13 |
+
<slot />
|
| 14 |
+
</main>
|
| 15 |
+
|
| 16 |
+
<Footer />
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { page } from '$app/stores';
|
| 4 |
+
import SummaryCards from '$components/dashboard/SummaryCards.svelte';
|
| 5 |
+
import MonthlyChart from '$components/dashboard/MonthlyChart.svelte';
|
| 6 |
+
import CategoryPie from '$components/dashboard/CategoryPie.svelte';
|
| 7 |
+
import RecentTransactions from '$components/transactions/RecentTransactions.svelte';
|
| 8 |
+
|
| 9 |
+
export let data;
|
| 10 |
+
|
| 11 |
+
$: session = data.session;
|
| 12 |
+
</script>
|
| 13 |
+
|
| 14 |
+
<div class="space-y-8">
|
| 15 |
+
<h1 class="text-3xl font-bold text-slate-900">Dashboard</h1>
|
| 16 |
+
|
| 17 |
+
<SummaryCards />
|
| 18 |
+
|
| 19 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 20 |
+
<MonthlyChart />
|
| 21 |
+
<CategoryPie />
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<RecentTransactions />
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
</html>
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { goto } from '$app/navigation';
|
| 4 |
+
import { LucideLogIn } from 'lucide-svelte';
|
| 5 |
+
|
| 6 |
+
let email = '';
|
| 7 |
+
let password = '';
|
| 8 |
+
let error = '';
|
| 9 |
+
let loading = false;
|
| 10 |
+
|
| 11 |
+
async function handleLogin() {
|
| 12 |
+
if (!email || !password) {
|
| 13 |
+
error = 'Please fill all fields';
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
loading = true;
|
| 18 |
+
error = '';
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
// TODO: Implement actual login
|
| 22 |
+
console.log('Logging in with:', email, password);
|
| 23 |
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 24 |
+
goto('/');
|
| 25 |
+
} catch (err) {
|
| 26 |
+
error = err.message || 'Login failed';
|
| 27 |
+
} finally {
|
| 28 |
+
loading = false;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
</script>
|
| 32 |
+
|
| 33 |
+
<div class="max-w-md mx-auto my-12">
|
| 34 |
+
<div class="card p-8">
|
| 35 |
+
<div class="text-center mb-8">
|
| 36 |
+
<h1 class="text-2xl font-bold text-slate-900 mb-1">Welcome back</h1>
|
| 37 |
+
<p class="text-slate-600">Login to manage your phone repair finances</p>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
{#if error}
|
| 41 |
+
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded-lg text-sm">
|
| 42 |
+
{error}
|
| 43 |
+
</div>
|
| 44 |
+
{/if}
|
| 45 |
+
|
| 46 |
+
<form on:submit|preventDefault={handleLogin} class="space-y-4">
|
| 47 |
+
<div>
|
| 48 |
+
<label for="email" class="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
| 49 |
+
<input
|
| 50 |
+
id="email"
|
| 51 |
+
type="email"
|
| 52 |
+
bind:value={email}
|
| 53 |
+
class="input-field"
|
| 54 |
+
placeholder="your@email.com"
|
| 55 |
+
required
|
| 56 |
+
/>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div>
|
| 60 |
+
<label for="password" class="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
| 61 |
+
<input
|
| 62 |
+
id="password"
|
| 63 |
+
type="password"
|
| 64 |
+
bind:value={password}
|
| 65 |
+
class="input-field"
|
| 66 |
+
placeholder="β’β’β’β’β’β’β’β’"
|
| 67 |
+
required
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="pt-2">
|
| 72 |
+
<button
|
| 73 |
+
type="submit"
|
| 74 |
+
class="btn-primary w-full flex items-center justify-center space-x-2"
|
| 75 |
+
disabled={loading}
|
| 76 |
+
>
|
| 77 |
+
{#if loading}
|
| 78 |
+
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 79 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 80 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 81 |
+
</svg>
|
| 82 |
+
{:else}
|
| 83 |
+
<LucideLogIn size={16} />
|
| 84 |
+
{/if}
|
| 85 |
+
<span>Login</span>
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div class="text-center text-sm text-slate-500 pt-2">
|
| 90 |
+
Don't have an account? <a href="/auth/register" class="text-blue-500 hover:underline">Register</a>
|
| 91 |
+
</div>
|
| 92 |
+
</form>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
</html>
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import MonthlyChart from '$components/dashboard/MonthlyChart.svelte';
|
| 4 |
+
import CategoryPie from '$components/dashboard/CategoryPie.svelte';
|
| 5 |
+
import ProfitTrend from '$components/reports/ProfitTrend.svelte';
|
| 6 |
+
import ReportControls from '$components/reports/ReportControls.svelte';
|
| 7 |
+
import { LucideDownload, LucideShare2 } from 'lucide-svelte';
|
| 8 |
+
|
| 9 |
+
let dateRange = 'this-month';
|
| 10 |
+
let exporting = false;
|
| 11 |
+
|
| 12 |
+
async function exportToPDF() {
|
| 13 |
+
exporting = true;
|
| 14 |
+
// TODO: Implement PDF export
|
| 15 |
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 16 |
+
exporting = false;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
async function shareReport() {
|
| 20 |
+
try {
|
| 21 |
+
// TODO: Implement share
|
| 22 |
+
if (navigator.share) {
|
| 23 |
+
await navigator.share({
|
| 24 |
+
title: 'ServiceBook Report',
|
| 25 |
+
text: 'Check out my phone repair business financial report',
|
| 26 |
+
url: window.location.href
|
| 27 |
+
});
|
| 28 |
+
} else {
|
| 29 |
+
// Fallback for browsers without Web Share API
|
| 30 |
+
window.open(`whatsapp://send?text=Check out my report: ${window.location.href}`);
|
| 31 |
+
}
|
| 32 |
+
} catch (err) {
|
| 33 |
+
console.log('Sharing cancelled', err);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
</script>
|
| 37 |
+
|
| 38 |
+
<div class="space-y-6">
|
| 39 |
+
<div class="flex items-center justify-between">
|
| 40 |
+
<h1 class="text-2xl font-bold text-slate-900">Reports</h1>
|
| 41 |
+
<div class="flex space-x-2">
|
| 42 |
+
<button
|
| 43 |
+
on:click={shareReport}
|
| 44 |
+
class="btn-secondary flex items-center space-x-1"
|
| 45 |
+
>
|
| 46 |
+
<LucideShare2 size={16} />
|
| 47 |
+
<span>Share</span>
|
| 48 |
+
</button>
|
| 49 |
+
<button
|
| 50 |
+
on:click={exportToPDF}
|
| 51 |
+
class="btn-secondary flex items-center space-x-1"
|
| 52 |
+
disabled={exporting}
|
| 53 |
+
>
|
| 54 |
+
{#if exporting}
|
| 55 |
+
<svg class="animate-spin -ml-1 mr-1 h-4 w-4 text-slate-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 56 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 57 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 58 |
+
</svg>
|
| 59 |
+
{:else}
|
| 60 |
+
<LucideDownload size={16} />
|
| 61 |
+
{/if}
|
| 62 |
+
<span>Export</span>
|
| 63 |
+
</button>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<ReportControls bind:dateRange />
|
| 68 |
+
|
| 69 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 70 |
+
<MonthlyChart />
|
| 71 |
+
<CategoryPie />
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="card">
|
| 75 |
+
<ProfitTrend />
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
</html>
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import { LucideSave } from 'lucide-svelte';
|
| 4 |
+
|
| 5 |
+
let categories = ['Screen Repair', 'Battery', 'Software', 'Accessories', 'Other'];
|
| 6 |
+
let newCategory = '';
|
| 7 |
+
let saving = false;
|
| 8 |
+
|
| 9 |
+
function addCategory() {
|
| 10 |
+
if (newCategory && !categories.includes(newCategory)) {
|
| 11 |
+
categories = [...categories, newCategory];
|
| 12 |
+
newCategory = '';
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function removeCategory(category) {
|
| 17 |
+
categories = categories.filter(c => c !== category);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
async function saveSettings() {
|
| 21 |
+
saving = true;
|
| 22 |
+
// TODO: Save settings
|
| 23 |
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 24 |
+
saving = false;
|
| 25 |
+
}
|
| 26 |
+
</script>
|
| 27 |
+
|
| 28 |
+
<div class="space-y-6">
|
| 29 |
+
<h1 class="text-2xl font-bold text-slate-900">Settings</h1>
|
| 30 |
+
|
| 31 |
+
<div class="card space-y-4">
|
| 32 |
+
<h2 class="text-lg font-medium text-slate-900">Transaction Categories</h2>
|
| 33 |
+
|
| 34 |
+
<div class="flex items-center space-x-2">
|
| 35 |
+
<input
|
| 36 |
+
type="text"
|
| 37 |
+
bind:value={newCategory}
|
| 38 |
+
placeholder="New category name"
|
| 39 |
+
class="input-field flex-1"
|
| 40 |
+
/>
|
| 41 |
+
<button
|
| 42 |
+
on:click={addCategory}
|
| 43 |
+
class="btn-secondary"
|
| 44 |
+
>
|
| 45 |
+
Add
|
| 46 |
+
</button>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
| 50 |
+
{#each categories as category}
|
| 51 |
+
<div class="flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2">
|
| 52 |
+
<span class="text-slate-800">{category}</span>
|
| 53 |
+
<button
|
| 54 |
+
on:click={() => removeCategory(category)}
|
| 55 |
+
class="text-red-500 hover:text-red-700"
|
| 56 |
+
>
|
| 57 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 58 |
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
| 59 |
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
| 60 |
+
</svg>
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
{/each}
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="card space-y-4">
|
| 68 |
+
<h2 class="text-lg font-medium text-slate-900">Export/Import Data</h2>
|
| 69 |
+
|
| 70 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 71 |
+
<div>
|
| 72 |
+
<h3 class="text-sm font-medium text-slate-700 mb-2">Export Data</h3>
|
| 73 |
+
<p class="text-sm text-slate-500 mb-3">Download a backup of your transactions</p>
|
| 74 |
+
<button class="btn-secondary">
|
| 75 |
+
Export to JSON
|
| 76 |
+
</button>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div>
|
| 80 |
+
<h3 class="text-sm font-medium text-slate-700 mb-2">Import Data</h3>
|
| 81 |
+
<p class="text-sm text-slate-500 mb-3">Restore from a previous backup</p>
|
| 82 |
+
<button class="btn-secondary">
|
| 83 |
+
Import from JSON
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div class="flex justify-end">
|
| 90 |
+
<button
|
| 91 |
+
on:click={saveSettings}
|
| 92 |
+
class="btn-primary flex items-center space-x-1"
|
| 93 |
+
disabled={saving}
|
| 94 |
+
>
|
| 95 |
+
{#if saving}
|
| 96 |
+
<svg class="animate-spin -ml-1 mr-1 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 97 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 98 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 99 |
+
</svg>
|
| 100 |
+
{:else}
|
| 101 |
+
<LucideSave size={16} />
|
| 102 |
+
{/if}
|
| 103 |
+
<span>Save Settings</span>
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
</html>
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
svelte
|
| 2 |
+
<script>
|
| 3 |
+
import TransactionForm from '$components/transactions/TransactionForm.svelte';
|
| 4 |
+
import TransactionTable from '$components/transactions/TransactionTable.svelte';
|
| 5 |
+
import { LucidePlus } from 'lucide-svelte';
|
| 6 |
+
|
| 7 |
+
let showForm = false;
|
| 8 |
+
let categories = ['Screen Repair', 'Battery', 'Software', 'Accessories', 'Other'];
|
| 9 |
+
|
| 10 |
+
function handleSubmit(newTransaction) {
|
| 11 |
+
// TODO: Save transaction
|
| 12 |
+
console.log('New transaction:', newTransaction);
|
| 13 |
+
showForm = false;
|
| 14 |
+
}
|
| 15 |
+
</script>
|
| 16 |
+
|
| 17 |
+
<div class="space-y-6">
|
| 18 |
+
<div class="flex items-center justify-between">
|
| 19 |
+
<h1 class="text-2xl font-bold text-slate-900">Transactions</h1>
|
| 20 |
+
<button
|
| 21 |
+
on:click={() => showForm = true}
|
| 22 |
+
class="btn-primary flex items-center space-x-1"
|
| 23 |
+
>
|
| 24 |
+
<LucidePlus size={16} />
|
| 25 |
+
<span>Add Transaction</span>
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
{#if showForm}
|
| 30 |
+
<div class="card">
|
| 31 |
+
<TransactionForm
|
| 32 |
+
categories={categories}
|
| 33 |
+
on:submit={handleSubmit}
|
| 34 |
+
on:cancel={() => showForm = false}
|
| 35 |
+
/>
|
| 36 |
+
</div>
|
| 37 |
+
{/if}
|
| 38 |
+
|
| 39 |
+
<div class="card">
|
| 40 |
+
<TransactionTable />
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
| 2 |
+
import adapter from '@sveltejs/adapter-auto';
|
| 3 |
+
|
| 4 |
+
/** @type {import('@sveltejs/kit').Config} */
|
| 5 |
+
const config = {
|
| 6 |
+
kit: {
|
| 7 |
+
adapter: adapter(),
|
| 8 |
+
alias: {
|
| 9 |
+
$components: 'src/components',
|
| 10 |
+
$lib: 'src/lib',
|
| 11 |
+
$stores: 'src/stores'
|
| 12 |
+
}
|
| 13 |
+
},
|
| 14 |
+
preprocess: [vitePreprocess()]
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default config;
|