feat: Cải thiện UI Mobile Dialog, thêm thời gian sử dụng và sửa lỗi ESLint

## UI/UX
- Sửa layout Dialog chi tiết trên Mobile (tránh rớt chữ Bluetooth/Receiver)
- Thêm logic tính toán thời gian sử dụng (⏱️ X năm Y tháng)
- Thêm break-words/break-all cho các trường text dài

## Code Quality
- Sửa lỗi ESLint và TypeScript trong src/app/actions.ts
- Loại bỏ các any type không an toàn
- Cải thiện xử lý lỗi (unknown type + instanceof Error)
This commit is contained in:
Dương Cầm
2026-01-25 11:00:51 +07:00
parent 4649d66b16
commit f7d1cd6fd3
2 changed files with 82 additions and 28 deletions

View File

@@ -123,9 +123,9 @@ export async function createItem(data: ItemFormData) {
revalidatePath("/");
return { success: true };
} catch (error: any) {
} catch (error: unknown) {
console.error("CREATE ERROR:", error);
return { success: false, error: "Lỗi lưu dữ liệu: " + (error.message || "") };
return { success: false, error: "Lỗi lưu dữ liệu: " + (error instanceof Error ? error.message : "Unknown error") };
}
}
@@ -276,9 +276,10 @@ export async function updateItem(id: string, data: ItemFormData) {
revalidatePath("/");
return { success: true };
} catch (error: any) {
} catch (error: unknown) {
console.error("UPDATE ERROR:", error);
return { success: false, error: "Lỗi cập nhật: " + error.message };
const errorMessage = error instanceof Error ? error.message : "Unkown error";
return { success: false, error: "Lỗi cập nhật: " + errorMessage };
}
}
@@ -320,7 +321,6 @@ export async function deleteItem(id: string) {
details: "Đã xóa thiết bị vĩnh viễn"
});
await triggerWebhooks("item.deleted", { id, name: item?.name });
revalidatePath("/");
return { success: true };
} catch (e) {
return { success: false, error: "Không thể xóa" };
@@ -396,8 +396,9 @@ export async function bulkMoveItems(ids: string[], locationId: string | null) {
revalidatePath("/");
return { success: true };
} catch (e: any) {
return { success: false, error: "Lỗi chuyển kho hàng loạt: " + e.message };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: "Lỗi chuyển kho hàng loạt: " + msg };
}
}
@@ -438,8 +439,9 @@ export async function bulkDeleteItems(ids: string[]) {
});
revalidatePath("/");
return { success: true };
} catch (e: any) {
return { success: false, error: "Lỗi xóa hàng loạt: " + e.message };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: "Lỗi xóa hàng loạt: " + msg };
}
}
@@ -568,8 +570,9 @@ export async function updateUserProfile(id: string, newUsername: string, newPass
avatar: updated.avatar ?? null
}
};
} catch (e: any) {
return { success: false, error: "Lỗi cập nhật: " + e.message };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: "Lỗi cập nhật: " + msg };
}
}
@@ -586,7 +589,7 @@ export async function saveThemeSettings(id: string, theme: string, colors: strin
data: { theme, colors }
});
return { success: true };
} catch (e: any) {
} catch (e) {
return { success: false, error: "Lỗi lưu cài đặt" };
}
}
@@ -602,7 +605,7 @@ export async function addBrandAction(name: string) {
await prisma.brand.create({ data: { name } });
revalidatePath("/");
return { success: true };
} catch (e: any) {
} catch (e) {
return { success: false, error: "Hãng đã tồn tại hoặc lỗi khác" };
}
}
@@ -625,11 +628,11 @@ export async function createTemplate(data: TemplateData) {
config: result.data.config
}
});
revalidatePath("/");
revalidatePath("/settings");
return { success: true };
} catch (e: any) {
return { success: false, error: "Lỗi tạo mẫu: " + e.message };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: "Lỗi tạo mẫu: " + msg };
}
}
@@ -646,10 +649,9 @@ export async function deleteTemplate(id: string) {
// -----------------------
try {
await prisma.template.delete({ where: { id } });
revalidatePath("/");
revalidatePath("/settings");
return { success: true };
} catch (e: any) {
} catch (e) {
return { success: false, error: "Lỗi xóa mẫu" };
}
}
@@ -661,6 +663,7 @@ export async function exportDatabase() {
try {
// Lấy users nhưng loại bỏ password để bảo mật
const usersRaw = await prisma.user.findMany();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const usersSafe = usersRaw.map(({ password, ...rest }) => rest);
const data = {
@@ -676,8 +679,9 @@ export async function exportDatabase() {
exportedAt: new Date().toISOString()
};
return { success: true, data: JSON.stringify(data, null, 2) };
} catch (e: any) {
return { success: false, error: "Lỗi xuất dữ liệu: " + e.message };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: "Lỗi xuất dữ liệu: " + msg };
}
}

View File

@@ -27,6 +27,42 @@ const formatCurrency = (amount: number | null | undefined) => {
return new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(amount);
};
const calculateUsageDuration = (startDate: string | Date | null | undefined) => {
if (!startDate) return null;
const start = new Date(startDate);
const now = new Date();
// Nếu ngày mua > hiện tại (vô lý)
if (start > now) return "Chưa sử dụng";
let years = now.getFullYear() - start.getFullYear();
let months = now.getMonth() - start.getMonth();
let days = now.getDate() - start.getDate();
if (days < 0) {
months--;
// Lấy số ngày của tháng trước đó
const prevMonth = new Date(now.getFullYear(), now.getMonth(), 0);
days += prevMonth.getDate();
}
if (months < 0) {
years--;
months += 12;
}
const parts = [];
if (years > 0) parts.push(`${years} năm`);
if (months > 0) parts.push(`${months} tháng`);
// Nếu chưa đầy 1 tháng, hiển thị số ngày
if (years === 0 && months === 0) {
if (days === 0) return "Vừa mới mua";
return `${days} ngày`;
}
return parts.join(" ");
};
// --- Local UI Components for speed ---
const Badge = ({ children, className }: { children: React.ReactNode, className?: string }) => (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold ${className}`}>{children}</span>
@@ -170,19 +206,33 @@ function ViewMode({ item, setMode, onDelete }: { item: any, setMode: (m: "EDIT")
<div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wider mb-3">Chi tiết kỹ thuật</h3>
<div className="grid grid-cols-2 gap-4 bg-slate-50 dark:bg-gray-900/50 p-4 rounded-xl border border-slate-100 dark:border-gray-700">
{item.brand && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Hãng sản xuất</p><p className="font-medium text-gray-900 dark:text-gray-100">{item.brand}</p></div>}
{item.model && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Model</p><p className="font-mono font-medium text-gray-900 dark:text-gray-100">{item.model}</p></div>}
{/* Removed duplicate Model */}
{item.brand && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Hãng sản xuất</p><p className="font-medium text-gray-900 dark:text-gray-100 break-words">{item.brand}</p></div>}
{item.model && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Model</p><p className="font-mono font-medium text-gray-900 dark:text-gray-100 break-words">{item.model}</p></div>}
{item.color && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Màu sắc</p><div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full border border-gray-300" style={{ backgroundColor: getColorHex(item.color) }}></div><p className="font-medium text-gray-900 dark:text-gray-100">{item.color}</p></div></div>}
{item.purchaseDate && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Ngày mua</p><p className="font-medium text-gray-900 dark:text-gray-100">{formatDateVN(item.purchaseDate)}</p></div>}
{item.purchaseDate && (
<>
<div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Ngày mua</p><p className="font-medium text-gray-900 dark:text-gray-100">{formatDateVN(item.purchaseDate)}</p></div>
<div>
<p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Đã dùng</p>
<p className="font-medium text-blue-600 dark:text-blue-400">
{calculateUsageDuration(item.purchaseDate)}
</p>
</div>
</>
)}
{item.purchasePrice && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Giá mua</p><p className="font-medium text-gray-900 dark:text-gray-100">{formatCurrency(item.purchasePrice)}</p></div>}
{item.warrantyEnd && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Bảo hành đến</p><p className="font-medium text-green-700 dark:text-green-400">{formatDateVN(item.warrantyEnd)}</p></div>}
{item.purchaseLocation && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Nơi mua</p><p className="font-medium text-gray-900 dark:text-gray-100">{item.purchaseLocation}</p></div>}
{item.purchaseUrl && <div className="col-span-2"><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Link mua hàng</p><a href={item.purchaseUrl} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 dark:text-blue-400 hover:underline break-words block">{item.purchaseUrl}</a></div>}
{item.purchaseLocation && <div><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Nơi mua</p><p className="font-medium text-gray-900 dark:text-gray-100 break-words">{item.purchaseLocation}</p></div>}
{/* Dynamic Specs */}
{/* Force full width on mobile for URL to avoid layout break */}
{item.purchaseUrl && <div className="col-span-2"><p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">Link mua hàng</p><a href={item.purchaseUrl} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 dark:text-blue-400 hover:underline break-all block">{item.purchaseUrl}</a></div>}
{/* Dynamic Specs - Smart Layout */}
{Object.entries(item.specs ? JSON.parse(item.specs as string) : {}).map(([k, v]: any) => (
<div key={k} className="col-span-1">
<div key={k} className={`${String(v).length > 20 ? 'col-span-2' : 'col-span-1'}`}>
<p className="text-[10px] text-gray-500 dark:text-gray-400 uppercase">{k}</p>
<p className="font-medium text-gray-900 dark:text-gray-100 break-words" title={v}>{v}</p>
</div>