From d4b6194ae7d9c6452ffc6c8f36456b5a32b49e07 Mon Sep 17 00:00:00 2001 From: Tenzin khenrab Date: Tue, 16 Jun 2026 10:57:24 +0530 Subject: [PATCH] added tibetan translation, clean UI --- .../Http/Controllers/Api/AuthController.php | 38 ++ .../Controllers/Api/BroadcastController.php | 2 +- .../Http/Controllers/Api/LunchController.php | 9 + .../Controllers/Api/MonthlyBillController.php | 2 +- backend/app/Models/User.php | 20 ++ .../app/Services/MonthlyBillingService.php | 4 +- ...an_and_nickname_columns_to_users_table.php | 27 ++ backend/routes/api.php | 1 + frontend/app/dashboard/page.tsx | 78 +++-- frontend/app/layout.tsx | 2 +- frontend/app/login/page.tsx | 32 +- frontend/app/page.tsx | 52 +-- frontend/app/profile/page.tsx | 311 +++++++++++++++++ frontend/app/user_dashboard/billing/page.tsx | 2 +- frontend/app/user_dashboard/history/page.tsx | 106 ++++-- frontend/app/user_dashboard/page.tsx | 66 ++-- frontend/app/vote/page.tsx | 135 ++++---- .../billing/accountant-billing-panel.tsx | 324 +++++++++++------- .../components/billing/month-year-picker.tsx | 158 +++++++++ .../components/billing/user-billing-panel.tsx | 46 ++- frontend/components/header.tsx | 46 ++- .../providers/language-provider.tsx | 181 +++++++--- frontend/lib/billing.ts | 3 + 23 files changed, 1237 insertions(+), 408 deletions(-) create mode 100644 backend/database/migrations/2026_06_15_124800_add_tibetan_and_nickname_columns_to_users_table.php create mode 100644 frontend/app/profile/page.tsx create mode 100644 frontend/components/billing/month-year-picker.tsx diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 24dbe70..c61c2bf 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -9,6 +9,8 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; +use Illuminate\Support\Facades\Storage; + class AuthController extends Controller { use PasswordValidationRules; @@ -82,4 +84,40 @@ public function user(Request $request) { return response()->json($request->user()); } + + public function updateProfile(Request $request) + { + $user = $request->user(); + + $request->validate([ + 'name' => 'required|string|max:255', + 'name_bo' => 'nullable|string|max:255', + 'nickname' => 'nullable|string|max:255', + 'nickname_bo' => 'nullable|string|max:255', + 'avatar' => 'nullable|image|mimes:jpeg,jpg,png,webp|max:5120', + ]); + + $updates = [ + 'name' => $request->name, + 'name_bo' => $request->name_bo, + 'nickname' => $request->nickname, + 'nickname_bo' => $request->nickname_bo, + ]; + + if ($request->hasFile('avatar')) { + $oldAvatar = $user->avatar; + if ($oldAvatar && ! str_starts_with($oldAvatar, 'http') && ! str_starts_with($oldAvatar, 'data:')) { + Storage::disk('public')->delete($oldAvatar); + } + + $updates['avatar'] = $request->file('avatar')->store('avatars', 'public'); + } + + $user->update($updates); + + return response()->json([ + 'message' => 'Profile updated successfully', + 'user' => $user->fresh(), + ]); + } } diff --git a/backend/app/Http/Controllers/Api/BroadcastController.php b/backend/app/Http/Controllers/Api/BroadcastController.php index b4718a5..d89e3c1 100644 --- a/backend/app/Http/Controllers/Api/BroadcastController.php +++ b/backend/app/Http/Controllers/Api/BroadcastController.php @@ -15,7 +15,7 @@ public function index(Request $request) $userId = $request->user()->id; // Get active broadcasts from the last 24 hours - $broadcasts = Broadcast::with('user:id,name,role') + $broadcasts = Broadcast::with('user:id,name,name_bo,nickname,nickname_bo,role') ->where('created_at', '>=', Carbon::now()->subHours(24)) ->orderBy('created_at', 'desc') ->get(); diff --git a/backend/app/Http/Controllers/Api/LunchController.php b/backend/app/Http/Controllers/Api/LunchController.php index 46ca79f..ec59cbd 100644 --- a/backend/app/Http/Controllers/Api/LunchController.php +++ b/backend/app/Http/Controllers/Api/LunchController.php @@ -429,6 +429,9 @@ public function chefDashboardData(Request $request) $employeeDetails[] = [ 'id' => $emp->id, 'name' => $emp->name, + 'name_bo' => $emp->name_bo, + 'nickname' => $emp->nickname, + 'nickname_bo' => $emp->nickname_bo, 'department' => $emp->department ?? 'General', 'status' => $status, 'votedAt' => $votedAt, @@ -448,6 +451,9 @@ public function chefDashboardData(Request $request) $employeeDetails[] = [ 'id' => $emp->id, 'name' => $emp->name, + 'name_bo' => $emp->name_bo, + 'nickname' => $emp->nickname, + 'nickname_bo' => $emp->nickname_bo, 'department' => $emp->department ?? 'General', 'status' => $status, 'votedAt' => '--:--', @@ -542,6 +548,9 @@ public function participationReport(Request $request) $usersData[] = [ 'id' => $user->id, 'name' => $user->name, + 'name_bo' => $user->name_bo, + 'nickname' => $user->nickname, + 'nickname_bo' => $user->nickname_bo, 'role' => $user->role, 'department' => $user->department ?? 'General', 'status' => $status, diff --git a/backend/app/Http/Controllers/Api/MonthlyBillController.php b/backend/app/Http/Controllers/Api/MonthlyBillController.php index f654488..a7f45c8 100644 --- a/backend/app/Http/Controllers/Api/MonthlyBillController.php +++ b/backend/app/Http/Controllers/Api/MonthlyBillController.php @@ -82,7 +82,7 @@ public function show(MonthlyBill $monthlyBill): JsonResponse $monthlyBill->load([ 'uploader:id,name,email', - 'userBills' => fn ($q) => $q->with('user:id,name,email,department')->orderByDesc('amount_due'), + 'userBills' => fn ($q) => $q->with('user:id,name,name_bo,nickname,nickname_bo,email,department')->orderByDesc('amount_due'), ]); $monthlyBill->payment_statistics = $this->billingService->paymentStatistics($monthlyBill); diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 7c4cbed..3899447 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -12,8 +12,13 @@ use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; +use Illuminate\Support\Facades\Storage; + #[Fillable([ 'name', + 'name_bo', + 'nickname', + 'nickname_bo', 'email', 'password', 'role', @@ -38,6 +43,21 @@ class User extends Authenticatable Notifiable, TwoFactorAuthenticatable; + protected $appends = ['avatar_url']; + + public function getAvatarUrlAttribute(): ?string + { + if (! $this->avatar) { + return null; + } + + if (str_starts_with($this->avatar, 'http://') || str_starts_with($this->avatar, 'https://') || str_starts_with($this->avatar, 'data:')) { + return $this->avatar; + } + + return Storage::disk('public')->url($this->avatar); + } + /* |-------------------------------------------------------------------------- | Relationships diff --git a/backend/app/Services/MonthlyBillingService.php b/backend/app/Services/MonthlyBillingService.php index 4c5fdd9..0fa55ab 100644 --- a/backend/app/Services/MonthlyBillingService.php +++ b/backend/app/Services/MonthlyBillingService.php @@ -158,7 +158,7 @@ public function createMonthlyBill( return $monthlyBill->load([ 'uploader:id,name,email', - 'userBills.user:id,name,email,department', + 'userBills.user:id,name,name_bo,nickname,nickname_bo,email,department', ]); }); } @@ -202,6 +202,6 @@ public function updatePaymentStatus(UserMonthlyBill $userBill, string $paymentSt 'paid_at' => $paymentStatus === 'paid' ? now() : null, ]); - return $userBill->fresh(['user:id,name,email,department']); + return $userBill->fresh(['user:id,name,name_bo,nickname,nickname_bo,email,department']); } } diff --git a/backend/database/migrations/2026_06_15_124800_add_tibetan_and_nickname_columns_to_users_table.php b/backend/database/migrations/2026_06_15_124800_add_tibetan_and_nickname_columns_to_users_table.php new file mode 100644 index 0000000..455bef2 --- /dev/null +++ b/backend/database/migrations/2026_06_15_124800_add_tibetan_and_nickname_columns_to_users_table.php @@ -0,0 +1,27 @@ +string('name_bo')->nullable()->after('name'); + $table->string('nickname')->nullable()->after('name_bo'); + $table->string('nickname_bo')->nullable()->after('nickname'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['name_bo', 'nickname', 'nickname_bo']); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index afa9b87..6fc2663 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -40,6 +40,7 @@ // Auth User Route::get('/user', [AuthController::class, 'user']); + Route::post('/user/profile', [AuthController::class, 'updateProfile']); Route::post('/logout', [AuthController::class, 'logout']); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index ce8b125..9e1be26 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -108,7 +108,14 @@ export default function ChefDashboard() { const router = useRouter(); const { showToast } = useToast(); const { t } = useLanguage(); - const [user, setUser] = useState<{ name: string } | null>(null); + const [user, setUser] = useState<{ + name: string; + role?: string; + name_bo?: string; + nickname?: string; + nickname_bo?: string; + avatar_url?: string; + } | null>(null); const [data, setData] = useState(null); const [chartData, setChartData] = useState([]); const [loading, setLoading] = useState(true); @@ -342,64 +349,61 @@ export default function ChefDashboard() { -
+
{/* Hero Section */} -
+
-

- {t('chef_greeting')} -

-
+
{todayMeal.image ? ( ) : ( - + )}
-

{t('todays_special')}

-

{t(todayMeal.title || '')}

+

{t('todays_special')}

+

{t(todayMeal.title || '')}

{/* Stats Grid */} -
+
} + icon={} delay={0.1} /> } + icon={} delay={0.2} /> } + icon={} delay={0.3} /> } + icon={} delay={0.4} />
@@ -408,10 +412,10 @@ export default function ChefDashboard() { {/* Main List Section */}
- -
-

{t('participation_details')}

-
+ +
+

{t('participation_details')}

+
{['all', 'joining', 'skipped', 'no_response'].map((f) => { const getFilterLabel = (filterVal: string) => { if (filterVal === 'all') return t('all_filter'); @@ -424,7 +428,7 @@ export default function ChefDashboard() { {/* Header */} -
-

+
+

{activeTab === "login" ? t('welcome_back') : t('get_started')}

-

+

{activeTab === "login" ? t('login_subtitle') : t('signup_subtitle')} @@ -198,13 +198,13 @@ export default function AuthPage() { {t('sign_in')} {t('sign_up')} @@ -239,7 +239,7 @@ export default function AuthPage() { name="email" type="email" placeholder="name@company.com" - className="pl-12 h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:bg-white dark:focus:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-3xl transition-all text-lg font-medium text-foreground dark:text-[#F5F5F5]" + className="pl-12 h-12 sm:h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:bg-white dark:focus:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-2xl sm:rounded-3xl transition-all text-base sm:text-lg font-medium text-foreground dark:text-[#F5F5F5]" onChange={handleInputChange} />

@@ -256,7 +256,7 @@ export default function AuthPage() { name="password" type={showPassword ? "text" : "password"} placeholder="••••••••" - className="pl-12 h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:bg-white dark:focus:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-3xl transition-all text-lg font-medium text-foreground dark:text-[#F5F5F5]" + className="pl-12 h-12 sm:h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:bg-white dark:focus:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-2xl sm:rounded-3xl transition-all text-base sm:text-lg font-medium text-foreground dark:text-[#F5F5F5]" onChange={handleInputChange} />

@@ -306,7 +306,7 @@ export default function AuthPage() { name="email" type="email" placeholder="john@company.com" - className="pl-12 h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-3xl transition-all text-lg font-medium text-foreground dark:text-[#F5F5F5]" + className="pl-12 h-12 sm:h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-2xl sm:rounded-3xl transition-all text-base sm:text-lg font-medium text-foreground dark:text-[#F5F5F5]" onChange={handleInputChange} />
@@ -320,7 +320,7 @@ export default function AuthPage() { name="password" type={showPassword ? "text" : "password"} placeholder="Min. 8 characters" - className="pl-12 h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-3xl transition-all text-lg font-medium text-foreground dark:text-[#F5F5F5]" + className="pl-12 h-12 sm:h-16 border-2 border-gray-100 dark:border-[#323232] bg-white dark:bg-[#272727] focus:ring-4 focus:ring-[#2E5A88]/5 focus:border-[#2E5A88] rounded-2xl sm:rounded-3xl transition-all text-base sm:text-lg font-medium text-foreground dark:text-[#F5F5F5]" onChange={handleInputChange} /> @@ -102,7 +102,7 @@ export default function HomePage() { router.push('/login'); } }} - className="flex items-center justify-center gap-2 px-10 py-4 rounded-full border-2 border-gray-200 font-bold text-lg hover:bg-white hover:text-[#2E5A88] hover:border-[#2E5A88] transition-all" + className="w-full sm:w-auto flex items-center justify-center gap-2 px-8 py-3.5 sm:px-10 sm:py-4 rounded-full border-2 border-gray-200 font-bold text-base sm:text-lg hover:bg-white hover:text-[#2E5A88] hover:border-[#2E5A88] transition-all" > {t('dashboard')} @@ -111,27 +111,27 @@ export default function HomePage() {
{/* WEEKLY MENU PREVIEW */} -
-
+
+
-
-

{t('weekly_menu_title')}

-

{t('weekly_menu_subtitle')}

+
+

{t('weekly_menu_title')}

+

{t('weekly_menu_subtitle')}

-
+
{WEEKDAY_LABELS.map(({ key, label }) => { const meal = menu.find((item) => item.weekday === key); return ( -

{label}

+

{label}

{meal?.image_url ? ( @@ -148,7 +148,7 @@ export default function HomePage() {
-

+

{meal ? t(meal.title) : (language === 'bo' ? 'ཟས་ཐོ་མྱུར་དུ་འགོད་པ།' : 'Menu coming soon')}

@@ -160,10 +160,10 @@ export default function HomePage() {
{/* HOW IT WORKS */} -
+
-

{t('how_it_works')}

-
+

{t('how_it_works')}

+
{[ { icon: ClipboardList, @@ -181,12 +181,12 @@ export default function HomePage() { desc: t('how_step3_desc'), }, ].map((item, i) => ( -
+
-

{item.title}

-

{item.desc}

+

{item.title}

+

{item.desc}

))}
@@ -195,20 +195,20 @@ export default function HomePage() { {/* CTA */} {!user && ( -
-
+
+
-

+

{t('cta_title')}

-

+

{t('cta_desc')}

diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx new file mode 100644 index 0000000..a3ada76 --- /dev/null +++ b/frontend/app/profile/page.tsx @@ -0,0 +1,311 @@ +"use client"; + +import React, { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion } from 'framer-motion'; +import { ArrowLeft, Camera, Save, User, Sparkles, Utensils } from 'lucide-react'; +import { useLanguage } from '@/components/providers/language-provider'; +import { useToast } from '@/components/providers/toast-provider'; +import Header from '@/components/header'; + +const GlassCard = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+); + +export default function ProfilePage() { + const router = useRouter(); + const { t, language } = useLanguage(); + const { showToast } = useToast(); + + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + // Form inputs + const [name, setName] = useState(''); + const [nameBo, setNameBo] = useState(''); + const [nickname, setNickname] = useState(''); + const [nicknameBo, setNicknameBo] = useState(''); + + // Avatar file and preview + const [avatarFile, setAvatarFile] = useState(null); + const [avatarPreview, setAvatarPreview] = useState(''); + const fileInputRef = useRef(null); + + useEffect(() => { + const token = localStorage.getItem('token'); + const savedUser = localStorage.getItem('user'); + + if (!token || !savedUser) { + router.push('/login'); + return; + } + + try { + const parsedUser = JSON.parse(savedUser); + setUser(parsedUser); + setName(parsedUser.name || ''); + setNameBo(parsedUser.name_bo || ''); + setNickname(parsedUser.nickname || ''); + setNicknameBo(parsedUser.nickname_bo || ''); + setAvatarPreview(parsedUser.avatar_url || ''); + setLoading(false); + } catch (e) { + console.error("Failed to parse user data", e); + router.push('/login'); + } + }, [router]); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (file.size > 5 * 1024 * 1024) { + showToast(language === 'bo' ? 'པར་གྱི་ཆེ་ཆུང་ 5MB ལས་ཆུང་བ་དགོས།' : 'Photo size must be less than 5MB', 'error'); + return; + } + setAvatarFile(file); + setAvatarPreview(URL.createObjectURL(file)); + } + }; + + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + showToast(language === 'bo' ? 'དབྱིན་ཡིག་གི་མིང་འབྲི་རོགས།' : 'English name is required', 'error'); + return; + } + + setSaving(true); + try { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('name', name); + formData.append('name_bo', nameBo); + formData.append('nickname', nickname); + formData.append('nickname_bo', nicknameBo); + + if (avatarFile) { + formData.append('avatar', avatarFile); + } + + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/user/profile`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + }, + body: formData + }); + + if (res.ok) { + const data = await res.json(); + localStorage.setItem('user', JSON.stringify(data.user)); + setUser(data.user); + showToast(t('profile_updated'), 'success'); + + // Redirect back after a short delay + setTimeout(() => { + if (data.user.role === 'chef') { + router.push('/dashboard'); + } else { + router.push('/user_dashboard'); + } + }, 1200); + } else { + const errData = await res.json(); + showToast(errData.message || (language === 'bo' ? 'ཉར་ཚགས་བྱེད་པར་སྐྱོན་བྱུང་སོང་།' : 'Failed to save profile'), 'error'); + } + } catch (error) { + console.error(error); + showToast(language === 'bo' ? 'དྲ་རྒྱའི་མཐུད་ལམ་ལ་སྐྱོན་བྱུང་སོང་།' : 'Network error updating profile', 'error'); + } finally { + setSaving(false); + } + }; + + const handleBack = () => { + if (user?.role === 'chef') { + router.push('/dashboard'); + } else { + router.push('/user_dashboard'); + } + }; + + if (loading) return ; + + return ( +
+ {/* Background Blobs */} +
+
+
+
+ +
{ + localStorage.removeItem('token'); + localStorage.removeItem('user'); + router.push('/'); + }} onNavigateHome={() => router.push('/')} /> + +
+ + {/* Back Button */} + + + {/* Profile Form Card */} + +
+
+

+ {t('profile_settings')} +

+
+
+ +
+ {/* Profile Photo Uploader */} +
+ + +
+
+ Avatar Preview +
+
+ +
+
+ + + + +
+ + {/* Text Fields Grid */} +
+ {/* Username English */} +
+ + setName(e.target.value)} + placeholder="e.g. Andre Taylor" + className="w-full px-4 py-3 bg-white/50 dark:bg-[#202020]/50 border border-slate-200 dark:border-[#323232] rounded-2xl text-sm font-medium focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all text-slate-800 dark:text-[#F5F5F5]" + /> +
+ + {/* Username Tibetan */} +
+ + setNameBo(e.target.value)} + placeholder="e.g. བསྟན་འཛིན་ཆོས་འཕེལ།" + className="w-full px-4 py-3 bg-white/50 dark:bg-[#202020]/50 border border-slate-200 dark:border-[#323232] rounded-2xl text-sm font-medium focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all text-slate-800 dark:text-[#F5F5F5]" + /> +
+ + {/* Nickname English */} +
+ + setNickname(e.target.value)} + placeholder="e.g. Food Explorer" + className="w-full px-4 py-3 bg-white/50 dark:bg-[#202020]/50 border border-slate-200 dark:border-[#323232] rounded-2xl text-sm font-medium focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all text-slate-800 dark:text-[#F5F5F5]" + /> +
+ + {/* Nickname Tibetan */} +
+ + setNicknameBo(e.target.value)} + placeholder="e.g. ལྟོགས་པ་ཆེན་པོ།" + className="w-full px-4 py-3 bg-white/50 dark:bg-[#202020]/50 border border-slate-200 dark:border-[#323232] rounded-2xl text-sm font-medium focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-all text-slate-800 dark:text-[#F5F5F5]" + /> +
+
+ + {/* Submit Button */} + +
+
+
+
+ ); +} + +// --- Loading State Component --- + +function LoadingState() { + const { t } = useLanguage(); + return ( +
+ + + +

{t('setting_table')}

+
+ ); +} diff --git a/frontend/app/user_dashboard/billing/page.tsx b/frontend/app/user_dashboard/billing/page.tsx index 611a829..6895235 100644 --- a/frontend/app/user_dashboard/billing/page.tsx +++ b/frontend/app/user_dashboard/billing/page.tsx @@ -54,7 +54,7 @@ export default function BillingPage() { className="flex items-center gap-2 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 font-medium mb-8 transition-colors cursor-pointer" > - {t('back_to_dashboard') || 'Back to Dashboard'} + {t('back_to_dashboard')}
diff --git a/frontend/app/user_dashboard/history/page.tsx b/frontend/app/user_dashboard/history/page.tsx index 0a8a5cb..40a1a3d 100644 --- a/frontend/app/user_dashboard/history/page.tsx +++ b/frontend/app/user_dashboard/history/page.tsx @@ -12,11 +12,16 @@ import { } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; +import { useLanguage, toTibetanDigits } from '@/components/providers/language-provider'; +import MonthYearPicker from '@/components/billing/month-year-picker'; export default function ActivityHistoryPage() { const router = useRouter(); + const { t, language } = useLanguage(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); + const [filterMonth, setFilterMonth] = useState(null); + const [filterYear, setFilterYear] = useState(null); useEffect(() => { const fetchHistory = async () => { @@ -27,11 +32,11 @@ export default function ActivityHistoryPage() { router.push('/login'); return; } - + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/user/activity`, { headers: { 'Authorization': `Bearer ${token}` } }); - + if (res.ok) { const data = await res.json(); setHistory(Array.isArray(data) ? data : []); @@ -46,42 +51,73 @@ export default function ActivityHistoryPage() { fetchHistory(); }, [router]); + const filteredHistory = history.filter((activity) => { + if (filterMonth === null || filterYear === null) return true; + const date = new Date(activity.lunch_day.lunch_date); + return (date.getMonth() + 1) === filterMonth && date.getFullYear() === filterYear; + }); + return (
-
-
-
- +
+
+
+ +
+
+

{t('activity_history')}

-
-

Activity History

-

A complete record of your meal participations

+ + {/* Month Filter Controls */} +
+ {filterMonth !== null && ( + + )} + { + setFilterMonth(m); + setFilterYear(y); + }} + buttonClassName="px-4 py-2 text-xs" + placeholder={filterMonth === null ? t('filter_by_month') : undefined} + />
{loading ? (
-

Loading history...

+

{t('loading_history')}

- ) : history.length > 0 ? ( + ) : filteredHistory.length > 0 ? (
- {history.map((activity, index) => ( - ( +
@@ -90,23 +126,43 @@ export default function ActivityHistoryPage() {

- {activity.status === 'opted_in' ? 'Joined Lunch' : 'Skipped Lunch'} + {activity.status === 'opted_in' ? t('history_joined_lunch') : t('history_skipped_lunch')}

- {activity.lunch_day?.menu?.title || 'Unknown Menu'} + {activity.lunch_day?.menu ? t(activity.lunch_day.menu.title) : t('unknown_menu')}

- {new Date(activity.lunch_day.lunch_date).toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric' - })} + {(() => { + const date = new Date(activity.lunch_day.lunch_date); + if (language === 'bo') { + const tibetanWeekdays = [ + 'གཟའ་ཉི་མ།', // Sunday + 'གཟའ་ཟླ་བ།', // Monday + 'གཟའ་མིག་དམར།', // Tuesday + 'གཟའ་ལྷག་པ།', // Wednesday + 'གཟའ་ཕུར་བུ།', // Thursday + 'གཟའ་པ་སངས།', // Friday + 'གཟའ་སྤེན་པ།' // Saturday + ]; + const weekday = tibetanWeekdays[date.getDay()]; + const m = toTibetanDigits(date.getMonth() + 1); + const d = toTibetanDigits(date.getDate()); + const y = toTibetanDigits(date.getFullYear()); + return `${weekday} ཕྱི་ཟླ་ ${m} པའི་ཚེས་ ${d} ལོ་ ${y}`; + } else { + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }); + } + })()}
@@ -115,8 +171,8 @@ export default function ActivityHistoryPage() { ) : (
-

No History Found

-

You haven't participated in any meals yet.

+

{t('no_history_found')}

+

{t('no_history_desc')}

)}
diff --git a/frontend/app/user_dashboard/page.tsx b/frontend/app/user_dashboard/page.tsx index 3756cce..3c4b31d 100644 --- a/frontend/app/user_dashboard/page.tsx +++ b/frontend/app/user_dashboard/page.tsx @@ -8,7 +8,8 @@ import { Utensils, History, ChevronRight, - TrendingUp + TrendingUp, + Settings } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; @@ -49,8 +50,16 @@ const GlassCard = ({ children, className = "" }: { children: React.ReactNode; cl export default function UserDashboard() { const { resolvedTheme } = useTheme(); const router = useRouter(); - const { t } = useLanguage(); - const [user, setUser] = useState<{ name: string; email: string; role?: string } | null>(null); + const { t, language } = useLanguage(); + const [user, setUser] = useState<{ + name: string; + email: string; + role?: string; + name_bo?: string; + nickname?: string; + nickname_bo?: string; + avatar_url?: string; + } | null>(null); const [loading, setLoading] = useState(true); // Real Stats @@ -127,49 +136,54 @@ export default function UserDashboard() { router.push('/'); }} onNavigateHome={() => router.push('/')} /> -
+
{/* Welcome Section */} -
+
-

- {t('greeting')}, {user?.name?.split(' ')[0] || 'Member'} -

-

{t('user_dashboard_desc')}

+ router.push('/profile')} + className="px-4 py-2 bg-white/70 hover:bg-white dark:bg-[#1E1E1E]/40 border border-slate-200/60 dark:border-[#323232] rounded-2xl shadow-sm text-sm font-bold text-[#1F2A44] dark:text-[#F5F5F5] transition-all flex items-center justify-center gap-2 max-w-max self-start sm:self-auto cursor-pointer" + > + + {t('profile_settings')} +
{/* Main Unified Dashboard Summary Card */} - -

{t('dashboard_summary')}

+ +

{t('dashboard_summary')}

{/* LEFT COLUMN - Stats Cards & Monthly Summary */}
-
+
{/* Total Meals Eaten */} -
-
- +
+
+
-

{stats.totalLunchEaten}

-

{t('total_meals_eaten')}

-

{t('since_joined')}

+

{stats.totalLunchEaten}

+

{t('total_meals_eaten')}

+

{t('since_joined')}

{/* Current Month */} -
-
- +
+
+
-

{stats.joinedThisMonth + stats.skippedThisMonth}

-

{t('current_month')}

-

{t('days_tracked')}

+

{stats.joinedThisMonth + stats.skippedThisMonth}

+

{t('current_month')}

+

{t('days_tracked')}

{/* Monthly Summary */} -
+

{t('monthly_summary')} @@ -219,7 +233,7 @@ export default function UserDashboard() { {/* RIGHT COLUMN - Recent Activity & Billing Button */}
{/* Recent Activity */} -
+

diff --git a/frontend/app/vote/page.tsx b/frontend/app/vote/page.tsx index 6a1188d..78a8f20 100644 --- a/frontend/app/vote/page.tsx +++ b/frontend/app/vote/page.tsx @@ -64,12 +64,12 @@ export default function LunchVotePage() { const weekday = date.toLocaleString('en-US', { weekday: 'long' }); const month = date.toLocaleString('en-US', { month: 'long' }); const day = date.getDate(); - + let suffix = 'th'; if (day === 1 || day === 21 || day === 31) suffix = 'st'; else if (day === 2 || day === 22) suffix = 'nd'; else if (day === 3 || day === 23) suffix = 'rd'; - + return `${weekday}, ${month} ${day}${suffix}`; } }; @@ -376,49 +376,44 @@ export default function LunchVotePage() { router.push('/'); }} /> -
+
{/* Header Section */} -
-
-

{t('lunch_at_work')}

-

- {t('confirm_attendance_for')} {formattedDate || t('fallback_menu_date')} -

-
-
+
+ +
{t('voting_deadline')} - + {isDeadlineMet ? t('closed') : (language === 'bo' ? toTibetanDigits(timeLeft) : timeLeft)}
-
+
{/* Main Content Card */}
-
+
Lunch -
- {t('todays_special')} +
+ {t('todays_special')}
-
-

{t(todayMeal?.title || DAILY_MENU.dishName)}

-

+

+

{t(todayMeal?.title || DAILY_MENU.dishName)}

+

{t(getDishDescriptionKey(todayMeal?.title || DAILY_MENU.dishName))}

- -
- -

+ +

+ +

{t('meal_details_info')}

@@ -427,48 +422,48 @@ export default function LunchVotePage() {
{/* Voting Side Panel */}
-
-

{t('will_you_join')}

- -
+
+

{t('will_you_join')}

+ +
- +
{/* Deadline Status */} -
+
{isDeadlineMet ? (
{t('voting_locked')} @@ -478,7 +473,7 @@ export default function LunchVotePage() { {t('response_cutoff')}

)} - + {attendance && !isDeadlineMet && (
+ {/* Calendar Section */} -
-
+
+
-

- +

+ {t('plan_your_meals')}

-

- {t('calendar_subtitle')} -

- -
+ +
{/* Month Selector */}
- + {getMonthName(calendarMonth, language)} {calendarYear}
{/* Month Bulk Actions */} {getUnlockedWeekdaysInMonth().length > 0 && ( -
+
@@ -582,7 +575,7 @@ export default function LunchVotePage() { if (!day) { return
; } - + if (day.is_weekend) { return (
); } - + const isJoining = day.status === 'opted_in'; const dayNum = parseInt(day.date.split('-')[2], 10); const formattedDayNum = language === 'bo' ? toTibetanDigits(dayNum) : dayNum; - + return (
handleToggleDay(day)} - className={`p-4 rounded-2xl border flex flex-col items-center justify-between text-center transition-all duration-300 h-24 select-none relative group ${ - day.is_locked - ? 'opacity-80 bg-gray-50 dark:bg-[#202020]/75 border-gray-200 dark:border-[#323232] cursor-not-allowed' - : isJoining + className={`p-4 rounded-2xl border flex flex-col items-center justify-between text-center transition-all duration-300 h-24 select-none relative group ${day.is_locked + ? 'opacity-80 bg-gray-50 dark:bg-[#202020]/75 border-gray-200 dark:border-[#323232] cursor-not-allowed' + : isJoining ? 'bg-green-50/40 dark:bg-green-950/10 border-green-500/20 dark:border-green-900/30 hover:bg-green-50 dark:hover:bg-green-900/20 hover:border-green-500 cursor-pointer' : 'bg-red-50/30 dark:bg-red-950/10 border-red-500/20 dark:border-red-900/30 hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-500 cursor-pointer' - }`} + }`} > {/* Day Number and Lock Indicator */}
- + {formattedDayNum} {day.is_locked && ( )}
- + {/* Menu / Dish Name */}
{day.menu ? t(day.menu.title) : t('menu_not_set')}
- + {/* Status Badge */}
{isJoining ? ( - + {t('legend_joining')} ) : ( - + {t('legend_skipped')} diff --git a/frontend/components/billing/accountant-billing-panel.tsx b/frontend/components/billing/accountant-billing-panel.tsx index a959896..f9c77aa 100644 --- a/frontend/components/billing/accountant-billing-panel.tsx +++ b/frontend/components/billing/accountant-billing-panel.tsx @@ -30,7 +30,8 @@ import { UserMonthlyBill, } from '@/lib/billing'; import { useToast } from '@/components/providers/toast-provider'; -import { useLanguage } from '@/components/providers/language-provider'; +import { useLanguage, toTibetanDigits } from '@/components/providers/language-provider'; +import MonthYearPicker from './month-year-picker'; const MONTHS = [ { value: 1, label: 'January' }, @@ -65,8 +66,8 @@ function PaymentBadge({ status }: { status: PaymentStatus }) { return ( {paid ? : } @@ -76,7 +77,109 @@ function PaymentBadge({ status }: { status: PaymentStatus }) { } export default function AccountantBillingPanel() { - const { t } = useLanguage(); + const { t, language } = useLanguage(); + + const formatMonthYear = (month: number, year: number) => { + if (language === 'bo') { + return `ཕྱི་ཟླ་ ${toTibetanDigits(month)} པ་ལོ་ ${toTibetanDigits(year)}`; + } + return monthLabel(month, year); + }; + + const formatPrice = (amount: number) => { + const formatted = formatCurrency(amount); + return language === 'bo' ? toTibetanDigits(formatted) : formatted; + }; + + const formatFullDate = (dateStr: string) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + if (language === 'bo') { + const tibetanWeekdays = [ + 'གཟའ་ཉི་མ།', // Sunday + 'གཟའ་ཟླ་བ།', // Monday + 'གཟའ་མིག་དམར།', // Tuesday + 'གཟའ་ལྷག་པ།', // Wednesday + 'གཟའ་ཕུར་བུ།', // Thursday + 'གཟའ་པ་སངས།', // Friday + 'གཟའ་སྤེན་པ།' // Saturday + ]; + const weekday = tibetanWeekdays[date.getDay()]; + const m = toTibetanDigits(date.getMonth() + 1); + const d = toTibetanDigits(date.getDate()); + const y = toTibetanDigits(date.getFullYear()); + return `${weekday} ཕྱི་ཟླ་ ${m} པའི་ཚེས་ ${d} ལོ་ ${y}`; + } else { + return date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } + }; + + const formatDateRange = (startStr: string | undefined, endStr: string | undefined) => { + if (!startStr || !endStr) return ''; + const startDate = new Date(startStr); + const endDate = new Date(endStr); + + if (language === 'bo') { + const sm = toTibetanDigits(startDate.getMonth() + 1); + const sd = toTibetanDigits(startDate.getDate()); + const em = toTibetanDigits(endDate.getMonth() + 1); + const ed = toTibetanDigits(endDate.getDate()); + const ey = toTibetanDigits(endDate.getFullYear()); + return `ཕྱི་ཟླ་ ${sm} ཚེས་ ${sd} ནས་ ཕྱི་ཟླ་ ${em} ཚེས་ ${ed} ལོ་ ${ey} བར།`; + } else { + const sFormatted = startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const eFormatted = endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + return `${sFormatted} to ${eFormatted}`; + } + }; + + const formatMonthDay = (dateStr: string) => { + const date = new Date(dateStr); + if (language === 'bo') { + const m = toTibetanDigits(date.getMonth() + 1); + const d = toTibetanDigits(date.getDate()); + return `ཕྱི་ཟླ་ ${m} ཚེས་ ${d}`; + } else { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + }; + + const formatPaidAt = (paidAtStr: string | null | undefined) => { + if (!paidAtStr) return '—'; + const date = new Date(paidAtStr); + if (language === 'bo') { + const m = toTibetanDigits(date.getMonth() + 1); + const d = toTibetanDigits(date.getDate()); + const y = toTibetanDigits(date.getFullYear()); + return `ཕྱི་ཟླ་ ${m} ཚེས་ ${d} ལོ་ ${y}`; + } else { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + }; + + const formatVotedAt = (votedAtStr: string | null | undefined) => { + if (!votedAtStr) return '—'; + if (language === 'bo') { + let formatted = votedAtStr; + if (votedAtStr.toUpperCase().includes('AM')) { + formatted = 'སྔ་དྲོ་ ' + votedAtStr.toUpperCase().replace('AM', ''); + } else if (votedAtStr.toUpperCase().includes('PM')) { + formatted = 'ཕྱི་དྲོ་ ' + votedAtStr.toUpperCase().replace('PM', ''); + } + return t('voted_at', { time: toTibetanDigits(formatted.trim()) }); + } + return t('voted_at', { time: votedAtStr }); + }; + const { showToast } = useToast(); const now = new Date(); const [selectedMonth, setSelectedMonth] = useState(now.getMonth() + 1); @@ -310,12 +413,11 @@ export default function AccountantBillingPanel() {
{/* Main Tab Switcher */} -
+
+ + {activeTab === 'billing' && bill && ( +
+ { + setSelectedMonth(m); + setSelectedYear(y); + }} + /> +
+ )}
{activeTab === 'billing' && ( @@ -359,28 +473,7 @@ export default function AccountantBillingPanel() { )} )} - - +
{loading ? ( @@ -392,11 +485,11 @@ export default function AccountantBillingPanel() { <>
{[ - { label: t('total_bill'), value: formatCurrency(bill.total_bill), icon: }, - { label: t('total_plates'), value: bill.total_plates, icon: }, - { label: t('per_plate_cost'), value: formatCurrency(bill.plate_cost), icon: }, - { label: t('paid_users'), value: stats.paid_users, icon: }, - { label: t('unpaid_users'), value: stats.unpaid_users, icon: }, + { label: t('total_bill'), value: formatPrice(bill.total_bill), icon: }, + { label: t('total_plates'), value: language === 'bo' ? toTibetanDigits(bill.total_plates) : bill.total_plates, icon: }, + { label: t('per_plate_cost'), value: formatPrice(bill.plate_cost), icon: }, + { label: t('paid_users'), value: language === 'bo' ? toTibetanDigits(stats.paid_users) : stats.paid_users, icon: }, + { label: t('unpaid_users'), value: language === 'bo' ? toTibetanDigits(stats.unpaid_users) : stats.unpaid_users, icon: }, ].map((card, i) => ( @@ -413,7 +506,7 @@ export default function AccountantBillingPanel() { {bill.bill_image_url && (

- {t('bill_receipt', { period: monthLabel(bill.month, bill.year) })} + {t('bill_receipt', { period: formatMonthYear(bill.month, bill.year) })}

{t('monthly_lunch_billing')}
@@ -422,7 +515,7 @@ export default function AccountantBillingPanel() {

- {t('user_billing', { period: monthLabel(bill.month, bill.year) })} + {t('user_billing', { period: formatMonthYear(bill.month, bill.year) })}

{(['all', 'paid', 'unpaid'] as const).map((f) => ( @@ -433,8 +526,8 @@ export default function AccountantBillingPanel() { setPage(1); }} className={`px-4 py-2 rounded-xl text-sm font-medium capitalize transition-all duration-300 ${paymentFilter === f - ? 'bg-[#2E5A88] dark:bg-[#D7E8F4] text-white dark:text-[#1C1C1C] shadow-md shadow-[#2E5A88]/10 dark:shadow-none' - : 'bg-slate-50 dark:bg-[#202020] text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-[#323232]/50' + ? 'bg-[#2E5A88] dark:bg-[#D7E8F4] text-white dark:text-[#1C1C1C] shadow-md shadow-[#2E5A88]/10 dark:shadow-none' + : 'bg-slate-50 dark:bg-[#202020] text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-[#323232]/50' }`} > {f === 'all' ? t('all_filter') : t(f)} @@ -492,20 +585,14 @@ export default function AccountantBillingPanel() { animate={{ opacity: 1 }} className="border-t border-slate-50 dark:border-[#323232] hover:bg-slate-50/50 dark:hover:bg-[#202020]/50 transition-colors" > - {row.user?.name} - {row.joined_count} - {formatCurrency(row.amount_due)} + {language === 'bo' ? (row.user?.name_bo || row.user?.name) : row.user?.name} + {language === 'bo' ? toTibetanDigits(row.joined_count) : row.joined_count} + {formatPrice(row.amount_due)} - {row.paid_at - ? new Date(row.paid_at).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }) - : '—'} + {formatPaidAt(row.paid_at)} @@ -681,30 +777,15 @@ export default function AccountantBillingPanel() {
)} {participationView === 'monthly' && ( -
- - -
+ { + setReportMonth(m); + setReportYear(y); + }} + buttonClassName="px-4 py-2 text-xs" + /> )}
@@ -716,25 +797,20 @@ export default function AccountantBillingPanel() {
) : reportData ? (
- {participationView === 'daily' && ( + {participationView === 'daily' && (

- {new Date(reportData.date).toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - })} + {formatFullDate(reportData.date)}

{reportData.has_menu ? (

{t('todays_special')}: {t(reportData.menu_title)}

) : ( -

{t('no_menu_set')}

+

{t('no_menu_set')}

)}
@@ -742,13 +818,13 @@ export default function AccountantBillingPanel() {

{t('total_opted_in')}

- {reportData?.users?.filter((u: any) => u.status === 'joining').length ?? 0} + {language === 'bo' ? toTibetanDigits(reportData?.users?.filter((u: any) => u.status === 'joining').length ?? 0) : (reportData?.users?.filter((u: any) => u.status === 'joining').length ?? 0)}

{t('total_opted_out')}

- {reportData?.users?.filter((u: any) => u.status === 'skipped').length ?? 0} + {language === 'bo' ? toTibetanDigits(reportData?.users?.filter((u: any) => u.status === 'skipped').length ?? 0) : (reportData?.users?.filter((u: any) => u.status === 'skipped').length ?? 0)}

@@ -759,18 +835,18 @@ export default function AccountantBillingPanel() { {t('user_column')} - Role - Department + {t('column_role')} + {t('column_department')} {t('status_column')} - Voted At + {t('column_voted_at')} {reportData?.users?.map((u: any) => ( - {u.name} - {u.role} - {u.department} + {language === 'bo' ? (u.name_bo || u.name) : u.name} + {t('role_' + u.role.toLowerCase())} + {t((u.department || 'General').toLowerCase())} {u.status === 'joining' ? ( @@ -786,7 +862,7 @@ export default function AccountantBillingPanel() { )} - {u.voted_at} + {formatVotedAt(u.voted_at)} ))} @@ -803,7 +879,7 @@ export default function AccountantBillingPanel() { {t('weekly_participation')}

- Period: {reportData?.week_start ? new Date(reportData.week_start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''} to {reportData?.week_end ? new Date(reportData.week_end).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : ''} + {t('billing_period')}: {formatDateRange(reportData?.week_start, reportData?.week_end)}

@@ -814,8 +890,8 @@ export default function AccountantBillingPanel() { {t('user_column')} {reportData?.days?.map((d: any) => ( -
{new Date(d.date).toLocaleDateString(undefined, { weekday: 'short' })}
-
{new Date(d.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
+
{t(new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' }))}
+
{formatMonthDay(d.date)}
{d.has_menu ? (
{t(d.menu_title)}
) : ( @@ -831,8 +907,8 @@ export default function AccountantBillingPanel() { {reportData?.users?.map((u: any) => ( -
{u.name}
-
{u.role} • {u.department}
+
{language === 'bo' ? (u.name_bo || u.name) : u.name}
+
{t('role_' + u.role.toLowerCase())} • {t((u.department || 'General').toLowerCase())}
{reportData?.days?.map((d: any) => { const status = u.days?.[d.date]; @@ -852,8 +928,8 @@ export default function AccountantBillingPanel() { ); })} - {u.joined_count} - {u.skipped_count} + {language === 'bo' ? toTibetanDigits(u.joined_count) : u.joined_count} + {language === 'bo' ? toTibetanDigits(u.skipped_count) : u.skipped_count} ))} @@ -868,10 +944,10 @@ export default function AccountantBillingPanel() {

- {MONTHS.find(m => m.value === reportMonth)?.label} {reportYear} - {t('participation_report')} + {formatMonthYear(reportMonth, reportYear)} - {t('participation_report')}

- Total Days: {reportData?.total_lunch_days ?? 0} lunch days + {t('total_days')}: {language === 'bo' ? toTibetanDigits(reportData?.total_lunch_days ?? 0) : (reportData?.total_lunch_days ?? 0)} {t('lunch_days')}

@@ -881,8 +957,8 @@ export default function AccountantBillingPanel() { {t('user_column')} - Role - Department + {t('column_role')} + {t('column_department')} {t('total_eligible')} {t('total_opted_in')} {t('total_opted_out')} @@ -891,12 +967,12 @@ export default function AccountantBillingPanel() { {reportData?.users?.map((u: any) => ( - {u.name} - {u.role} - {u.department} - {u.total_eligible_days} - {u.joined_count} - {u.skipped_count} + {language === 'bo' ? (u.name_bo || u.name) : u.name} + {t('role_' + u.role.toLowerCase())} + {t((u.department || 'General').toLowerCase())} + {language === 'bo' ? toTibetanDigits(u.total_eligible_days) : u.total_eligible_days} + {language === 'bo' ? toTibetanDigits(u.joined_count) : u.joined_count} + {language === 'bo' ? toTibetanDigits(u.skipped_count) : u.skipped_count} ))} @@ -907,7 +983,7 @@ export default function AccountantBillingPanel() {
) : (
- No report data available. + {t('no_report_data')}
)}
diff --git a/frontend/components/billing/month-year-picker.tsx b/frontend/components/billing/month-year-picker.tsx new file mode 100644 index 0000000..6ff2c1c --- /dev/null +++ b/frontend/components/billing/month-year-picker.tsx @@ -0,0 +1,158 @@ +"use client"; + +import React, { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; +import { monthLabel } from '@/lib/billing'; +import { useLanguage, toTibetanDigits } from '@/components/providers/language-provider'; + +interface MonthYearPickerProps { + selectedMonth: number; + selectedYear: number; + onChange: (month: number, year: number) => void; + className?: string; + buttonClassName?: string; + placeholder?: string; +} + +const MONTHS = [ + { value: 1, label: 'Jan' }, + { value: 2, label: 'Feb' }, + { value: 3, label: 'Mar' }, + { value: 4, label: 'Apr' }, + { value: 5, label: 'May' }, + { value: 6, label: 'Jun' }, + { value: 7, label: 'Jul' }, + { value: 8, label: 'Aug' }, + { value: 9, label: 'Sep' }, + { value: 10, label: 'Oct' }, + { value: 11, label: 'Nov' }, + { value: 12, label: 'Dec' }, +]; + +export default function MonthYearPicker({ + selectedMonth, + selectedYear, + onChange, + className = '', + buttonClassName = '', + placeholder, +}: MonthYearPickerProps) { + const { language } = useLanguage(); + const [isOpen, setIsOpen] = useState(false); + const [pickerYear, setPickerYear] = useState(selectedYear); + const containerRef = useRef(null); + + // Keep picker year in sync with prop when selectedYear changes + useEffect(() => { + setPickerYear(selectedYear); + }, [selectedYear]); + + // Click outside to close + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handlePrevYear = (e: React.MouseEvent) => { + e.stopPropagation(); + setPickerYear((prev) => prev - 1); + }; + + const handleNextYear = (e: React.MouseEvent) => { + e.stopPropagation(); + setPickerYear((prev) => prev + 1); + }; + + const handleMonthSelect = (month: number) => { + onChange(month, pickerYear); + setIsOpen(false); + }; + + const formatMonthYear = (month: number, year: number) => { + if (language === 'bo') { + return `ཕྱི་ཟླ་ ${toTibetanDigits(month)} པ་ལོ་ ${toTibetanDigits(year)}`; + } + return monthLabel(month, year); + }; + + const currentLabel = placeholder || formatMonthYear(selectedMonth, selectedYear); + + return ( +
+ {/* Trigger Button */} + + + {/* Popover Dropdown */} + + {isOpen && ( + + {/* Year Selector Header */} +
+ + + {language === 'bo' ? toTibetanDigits(pickerYear) : pickerYear} + + +
+ + {/* Months Grid */} +
+ {MONTHS.map((m) => { + const isSelected = selectedMonth === m.value && selectedYear === pickerYear; + return ( + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/frontend/components/billing/user-billing-panel.tsx b/frontend/components/billing/user-billing-panel.tsx index 968e806..b7e299b 100644 --- a/frontend/components/billing/user-billing-panel.tsx +++ b/frontend/components/billing/user-billing-panel.tsx @@ -10,7 +10,12 @@ import { UserBillingResponse, UserMonthlyBill, } from '@/lib/billing'; -import { useLanguage } from '@/components/providers/language-provider'; +import { useLanguage, toTibetanDigits } from '@/components/providers/language-provider'; + +const formatPrice = (amount: number, language: string) => { + const formatted = formatCurrency(amount); + return language === 'bo' ? toTibetanDigits(formatted) : formatted; +}; const GlassCard = ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( {paid ? : } {paid ? t('paid') : t('unpaid')} @@ -38,10 +42,17 @@ function StatusBadge({ status }: { status: string }) { } function BillingRow({ item }: { item: UserMonthlyBill }) { - const { t } = useLanguage(); + const { t, language } = useLanguage(); const mb = item.monthly_bill; if (!mb) return null; - + + const formatMonthYear = (month: number, year: number) => { + if (language === 'bo') { + return `ཕྱི་ཟླ་ ${toTibetanDigits(month)} པ་ལོ་ ${toTibetanDigits(year)}`; + } + return monthLabel(month, year); + }; + return (
@@ -49,12 +60,12 @@ function BillingRow({ item }: { item: UserMonthlyBill }) {
-

{monthLabel(mb.month, mb.year)}

+

{formatMonthYear(mb.month, mb.year)}

{t('meals_joined_count', { count: item.joined_count })}

-

{formatCurrency(item.amount_due)}

+

{formatPrice(item.amount_due, language)}

@@ -62,10 +73,17 @@ function BillingRow({ item }: { item: UserMonthlyBill }) { } export default function UserBillingPanel() { - const { t } = useLanguage(); + const { t, language } = useLanguage(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const formatMonthYear = (month: number, year: number) => { + if (language === 'bo') { + return `ཕྱི་ཟླ་ ${toTibetanDigits(month)} པ་ལོ་ ${toTibetanDigits(year)}`; + } + return monthLabel(month, year); + }; + useEffect(() => { const token = localStorage.getItem('token'); if (!token) { @@ -108,30 +126,30 @@ export default function UserBillingPanel() {

{t('monthly_lunch_billing')}

- + {display && display.monthly_bill ? (
{t('meals_joined')}
-

{display.joined_count}

+

{language === 'bo' ? toTibetanDigits(display.joined_count) : display.joined_count}

{t('cost_per_plate')}

- {formatCurrency(display.monthly_bill.plate_cost)} + {formatPrice(display.monthly_bill.plate_cost, language)}

{t('total_due')}

-

{formatCurrency(display.amount_due)}

+

{formatPrice(display.amount_due, language)}

- {monthLabel(display.monthly_bill.month, display.monthly_bill.year)} + {formatMonthYear(display.monthly_bill.month, display.monthly_bill.year)}

diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx index 39a3cb0..1856168 100644 --- a/frontend/components/header.tsx +++ b/frontend/components/header.tsx @@ -9,7 +9,8 @@ import { LogOut, User, Menu, - X + X, + Settings } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; @@ -25,7 +26,7 @@ interface HeaderProps { export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) { const router = useRouter(); const pathname = usePathname(); - const { t } = useLanguage(); + const { t, language } = useLanguage(); const [currentUser, setCurrentUser] = useState(user || null); const [broadcasts, setBroadcasts] = useState([]); @@ -253,16 +254,31 @@ export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) {currentUser ? (
-

{currentUser.name}

+

+ {language === 'bo' ? (currentUser.name_bo || currentUser.name) : currentUser.name} +

{currentUser.role === 'chef' ? t('role_chef') - : (currentUser.role === 'accountant' ? t('role_accountant') : randomRole)} + : (currentUser.role === 'accountant' + ? t('role_accountant') + : (language === 'bo' ? (currentUser.nickname_bo || currentUser.nickname || randomRole) : (currentUser.nickname || randomRole)))}

- User + User
+ @@ -405,6 +421,14 @@ export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) > Home + {currentUser && ( + + )} {/* Options Area (Language & Theme selectors) */} @@ -431,16 +455,18 @@ export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) handleProfileClick(); setIsSidebarOpen(false); }} - className="w-full bg-slate-50 dark:bg-[#202020] border border-slate-100 dark:border-[#323232] text-slate-800 dark:text-[#F5F5F5] cursor-pointer py-3 rounded-xl text-center hover:bg-slate-100 dark:hover:bg-[#272727] transition-colors flex items-center justify-center gap-3 font-bold text-sm" - > -
+ className="w-full bg-slate-50 dark:bg-[#202020] border border-slate-100 dark:border-[#323232] text-slate-800 dark:text-[#F5F5F5] cursor-pointer py-3 rounded-xl text-center hover:bg-slate-100 dark:hover:bg-[#272727] transition-colors flex items-center justify-center gap-3 font-bold text-sm" + > +
User Profile
- {currentUser.name} + + {language === 'bo' ? (currentUser.name_bo || currentUser.name) : currentUser.name} +