Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ $RECYCLE.BIN/
__MACOSX/
.AppleDouble
.LSOverride
Icon[
]
Icon?
._*
.DocumentRevisions-V100
.fseventsd
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/hooks/useAnalysisPdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { requestPdfExportToken } from '@/api/projects'
import { notify } from '@/components/ui/toastConfig'
import { useTheme } from '@/hooks/useTheme'
import { useLocale } from '@/hooks/useLocale'
import { useTranslation } from 'react-i18next'

/** Strips characters that would break a download filename across OSes. */
function sanitizeFileName(value: string) {
Expand Down Expand Up @@ -52,14 +53,15 @@ function triggerDownload(blob: Blob, filename: string) {
* runs the full export flow described in the file header.
*/
export function useAnalysisPdf() {
const { t } = useTranslation('analysis')
const [isExporting, setIsExporting] = useState(false)
const { resolved: resolvedTheme } = useTheme()
const { locale } = useLocale()

async function handleExportPdf(projectId: string, projectName: string, beforeExport?: () => Promise<void>) {
const exportUrl = import.meta.env.VITE_PDF_EXPORT_URL
if (!exportUrl) {
notify.error('PDF export service is not configured')
notify.error(t('toasts.pdfServiceNotConfigured'))
return
}

Expand Down Expand Up @@ -98,7 +100,7 @@ export function useAnalysisPdf() {

const blob = await response.blob()
triggerDownload(blob, filename)
notify.success(`PDF exported: ${filename}`)
notify.success(t('toasts.pdfExported', { filename }))
} catch (error) {
notify.error(error instanceof Error ? error.message : 'Failed to export the PDF report')
} finally {
Expand Down
25 changes: 14 additions & 11 deletions frontend/src/hooks/useCanvasInteractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,17 @@ import type { WorkbenchPanelState } from '@/hooks/usePanelState'
import { computeSegmentHulls } from '@/lib/segmentVisualization'
import type { SolarPanel, RoofSegment } from '@/lib/buildingInsights'
import { PANEL_MODELS, getPanelModel, type PanelModel } from '@shared/types'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'

function getPlacementErrorMessage(reason: 'bounds' | 'mask' | 'overlap') {
function getPlacementErrorMessage(t: TFunction, reason: 'bounds' | 'mask' | 'overlap') {
switch (reason) {
case 'mask':
return 'That placement leaves the detected roof boundary.'
return t('toasts.placementMask')
case 'overlap':
return 'That placement overlaps another panel.'
return t('toasts.placementOverlap')
default:
return 'That placement leaves the roof image bounds.'
return t('toasts.placementBounds')
}
}

Expand Down Expand Up @@ -122,6 +124,7 @@ export function useCanvasInteractions({
solarPanels,
roofSegments
}: UseCanvasInteractionsOptions) {
const { t } = useTranslation('workbench')
const rotationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const groupRotateStateRef = useRef<{
snapshots: Map<string, { centerPx: { x: number; y: number }; rotation: number }>
Expand Down Expand Up @@ -283,7 +286,7 @@ export function useCanvasInteractions({
if (!locationId) return

setPendingPanelId(panelId)
notify.info('Recomputing panel yield from cached monthly flux data...')
notify.info(t('toasts.recomputingPanelYield'))

try {
const result = await recomputeFlux(locationId, {
Expand Down Expand Up @@ -491,7 +494,7 @@ export function useCanvasInteractions({
// Bounds and mask failures still revert because they are real boundary violations
if (placementError && (!enteredViaOverlap || (placementError !== 'overlap' && placementError !== 'bounds'))) {
resetPosition()
notify.error(getPlacementErrorMessage(placementError))
notify.error(getPlacementErrorMessage(t, placementError))
return
}

Expand Down Expand Up @@ -604,7 +607,7 @@ export function useCanvasInteractions({
}))
)
resetPosition()
notify.error(`Group move failed: ${getPlacementErrorMessage(placementError)}`)
notify.error(t('toasts.groupMoveFailed', { reason: getPlacementErrorMessage(t, placementError) }))
return
}
moves.push({ id: sp.id, prevCenter, nextCenter })
Expand All @@ -615,7 +618,7 @@ export function useCanvasInteractions({

if (!locationId) return
setPendingPanelId(panelId)
notify.info(`Recomputing yield for ${moves.length} panels...`)
notify.info(t('toasts.recomputingPanels', { count: moves.length }))

try {
const batchResponse = await recomputeFluxBatch(locationId, {
Expand All @@ -640,7 +643,7 @@ export function useCanvasInteractions({
} catch {
bulkUpdatePanels(moves.map((mv) => ({ id: mv.id, center: mv.prevCenter })))
resetPosition()
notify.error('Failed to recompute group move. Positions reverted.')
notify.error(t('toasts.groupMoveRecomputeFailed'))
} finally {
setPendingPanelId(null)
}
Expand Down Expand Up @@ -675,7 +678,7 @@ export function useCanvasInteractions({
// Bounds and mask still toast because they are real boundary violations
if (placementError === 'overlap') return
if (placementError) {
notify.error(getPlacementErrorMessage(placementError))
notify.error(getPlacementErrorMessage(t, placementError))
return
}

Expand Down Expand Up @@ -796,7 +799,7 @@ export function useCanvasInteractions({
if (!locationId || visiblePanels.length === 0) return

setIsModelRecomputing(true)
notify.info('Recalculating energy for new panel dimensions...')
notify.info(t('toasts.recalculatingDimensions'))

const nextModel = getPanelModel(nextModelId) ?? PANEL_MODELS[1]!

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/hooks/useOverlayImages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { getOverlayUrl } from '@/api/locations'
import { notify } from '@/components/ui/toastConfig'
import { useTranslation } from 'react-i18next'

function useLoadedImage(src: string | undefined) {
const [image, setImage] = useState<HTMLImageElement | null>(null)
Expand Down Expand Up @@ -73,6 +74,7 @@ export function useOverlayImages(
locationId: string | undefined,
overlayMode: OverlayMode
) {
const { t } = useTranslation('workbench')
const [overlayImageUrl, setOverlayImageUrl] = useState<string | null>(null)
const [isOverlayLoading, setIsOverlayLoading] = useState(false)

Expand All @@ -94,7 +96,7 @@ export function useOverlayImages(
.catch(() => {
if (!cancelled) {
setOverlayImageUrl(null)
notify.error(`Failed to load ${overlayMode} overlay`)
notify.error(t('toasts.overlayLoadFailed', { mode: overlayMode }))
}
})
.finally(() => {
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/hooks/useWorkbenchSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { recomputeFluxBatch } from '@/api/locations'
import { saveLayout } from '@/api/projects'
import { notify } from '@/components/ui/toastConfig'
import type { PanelEdit, PanelModel } from '@shared/types'
import { useTranslation } from 'react-i18next'

/**
* Inputs to `useWorkbenchSave`.
Expand Down Expand Up @@ -53,6 +54,7 @@ export function useWorkbenchSave({
serializeLayout,
updatePanelEnergy
}: UseWorkbenchSaveOptions) {
const { t } = useTranslation('workbench')
const navigate = useNavigate()
const queryClient = useQueryClient()
const [isSaving, setIsSaving] = useState(false)
Expand All @@ -68,7 +70,7 @@ export function useWorkbenchSave({
const serializedLayout = serializeLayout()
const activePanels = serializedLayout.filter((panel) => panel.status !== 'deleted')

notify.info(`Recomputing monthly energy for ${activePanels.length} active panels before saving...`)
notify.info(t('toasts.recomputingBeforeSave', { count: activePanels.length }))

const batchResponse = await recomputeFluxBatch(locationId, {
panels: activePanels.map((panel) => ({
Expand Down Expand Up @@ -101,7 +103,7 @@ export function useWorkbenchSave({
})

setIsBatchRecomputing(false)
notify.info('Saving the refreshed layout to your project...')
notify.info(t('toasts.savingLayout'))
const updatedProject = await saveLayout(projectId, { editedLayout: nextLayout, selectedPanelModelId })
queryClient.setQueryData(['project', projectId], updatedProject)
navigate(`/project/${projectId}/analysis`)
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/en/analysis.json
Original file line number Diff line number Diff line change
Expand Up @@ -465,5 +465,9 @@
"creditForfeiture": "Excess credits are forfeited at the end of each calendar year. No cash payment is made for unused credits.",
"systemCost": "System cost is estimated bottom-up: distributor panel pricing + inverter SKU lookup + roof-type-dependent mounting + electrical BOS + permits + labour markup + installer margin. Assumes mid-tier installer pricing and single-storey installation. Typical Malaysian turnkey quotes land within ±10% of this figure. Always confirm with a licensed SEDA-registered installer.",
"paybackProjections": "Payback and savings projections do not account for annual maintenance (around RM 500/yr) or inverter replacement (typically needed at year 10 to 15, costing around RM 3,000 to 6,000). Tariff escalation can be configured in Advanced view (default 0%). RP4 revisions in Malaysia have historically trended around 3 to 5% per year. Actual long-term returns may differ."
},
"toasts": {
"pdfServiceNotConfigured": "PDF export service is not configured",
"pdfExported": "PDF exported: {{filename}}"
}
}
13 changes: 13 additions & 0 deletions frontend/src/locales/en/workbench.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,18 @@
"step5Body": "Happy with the layout? Click \"Save & Continue\" to move on to the savings analysis. You can always come back to adjust.",
"step6Title": "Meet Sol",
"step6Body": "Stuck or curious? Tap Sol, your friendly solar guide, to ask anything about this layout, your savings, NEM credits, or how solar works in Malaysia."
},
"toasts": {
"recomputingPanelYield": "Recomputing panel yield from cached monthly flux data...",
"groupMoveFailed": "Group move failed: {{reason}}",
"recomputingPanels": "Recomputing yield for {{count}} panels...",
"groupMoveRecomputeFailed": "Failed to recompute group move. Positions reverted.",
"recalculatingDimensions": "Recalculating energy for new panel dimensions...",
"recomputingBeforeSave": "Recomputing monthly energy for {{count}} active panels before saving...",
"savingLayout": "Saving the refreshed layout to your project...",
"overlayLoadFailed": "Failed to load {{mode}} overlay",
"placementMask": "That placement leaves the detected roof boundary.",
"placementOverlap": "That placement overlaps another panel.",
"placementBounds": "That placement leaves the roof image bounds."
}
}
4 changes: 4 additions & 0 deletions frontend/src/locales/ms/analysis.json
Original file line number Diff line number Diff line change
Expand Up @@ -465,5 +465,9 @@
"creditForfeiture": "Kredit berlebihan dilucutkan pada akhir setiap tahun kalendar. Tiada bayaran tunai dibuat untuk kredit yang tidak digunakan.",
"systemCost": "Kos sistem dianggarkan secara bawah ke atas: harga panel pengedar + carian SKU penyongsang + pemasangan bergantung pada jenis bumbung + BOS elektrik + permit + markup buruh + margin pemasang. Menganggap harga pemasang peringkat pertengahan dan pemasangan satu tingkat. Sebut harga turnkey Malaysia yang biasa jatuh dalam julat ±10% daripada angka ini. Sentiasa sahkan dengan pemasang berlesen yang berdaftar dengan SEDA.",
"paybackProjections": "Unjuran bayar balik dan penjimatan tidak mengambil kira penyelenggaraan tahunan (sekitar RM 500/thn) atau penggantian penyongsang (biasanya diperlukan pada tahun 10 hingga 15, kos sekitar RM 3,000 hingga 6,000). Eskalasi tarif boleh dikonfigurasi dalam paparan Lanjutan (lalai 0%). Semakan RP4 di Malaysia secara sejarah cenderung sekitar 3 hingga 5% setahun. Pulangan jangka panjang sebenar mungkin berbeza."
},
"toasts": {
"pdfServiceNotConfigured": "Perkhidmatan eksport PDF belum dikonfigurasikan",
"pdfExported": "PDF dieksport: {{filename}}"
}
}
13 changes: 13 additions & 0 deletions frontend/src/locales/ms/workbench.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,18 @@
"step5Body": "Berpuas hati dengan susun atur? Klik \"Simpan & Teruskan\" untuk beralih ke analisis penjimatan. Anda sentiasa boleh kembali untuk membuat pelarasan.",
"step6Title": "Kenali Sol",
"step6Body": "Tersangkut atau ingin tahu lebih lanjut? Tekan Sol, pemandu solar mesra anda, untuk bertanya tentang susun atur ini, penjimatan anda, kredit NEM, atau cara solar berfungsi di Malaysia."
},
"toasts": {
"recomputingPanelYield": "Mengira semula hasil panel daripada data fluks bulanan tersimpan...",
"groupMoveFailed": "Pemindahan kumpulan gagal: {{reason}}",
"recomputingPanels": "Mengira semula hasil untuk {{count}} panel...",
"groupMoveRecomputeFailed": "Gagal mengira semula pemindahan kumpulan. Kedudukan telah dikembalikan.",
"recalculatingDimensions": "Mengira semula tenaga untuk dimensi panel baharu...",
"recomputingBeforeSave": "Mengira semula tenaga bulanan untuk {{count}} panel aktif sebelum menyimpan...",
"savingLayout": "Menyimpan susun atur yang dikemas kini ke projek anda...",
"overlayLoadFailed": "Gagal memuatkan lapisan {{mode}}",
"placementMask": "Penempatan itu keluar daripada sempadan bumbung yang dikesan.",
"placementOverlap": "Penempatan itu bertindih dengan panel lain.",
"placementBounds": "Penempatan itu keluar daripada sempadan imej bumbung."
}
}
4 changes: 4 additions & 0 deletions frontend/src/locales/zh/analysis.json
Original file line number Diff line number Diff line change
Expand Up @@ -465,5 +465,9 @@
"creditForfeiture": "多余积分在每个日历年末失效,未使用的积分不予现金支付。",
"systemCost": "系统成本自下而上估算:经销商太阳能板定价 + 逆变器型号查询 + 因屋顶类型而异的支架费 + 电气 BOS + 报批费 + 人工加成 + 安装商利润。假设中端安装商定价和单层楼安装。马来西亚典型交钥匙报价通常在此数字的 ±10% 范围内,请务必向持牌 SEDA 注册安装商确认。",
"paybackProjections": "回本期和节省预测不包含年度维护费(约 RM 500/年)或逆变器更换费(通常在第 10 至 15 年需要,费用约 RM 3,000 至 6,000)。电价涨幅可在高级模式中配置(默认 0%),马来西亚 RP4 历次调整约在每年 3 至 5% 左右,实际长期回报可能有所不同。"
},
"toasts": {
"pdfServiceNotConfigured": "PDF 导出服务尚未配置",
"pdfExported": "PDF 已导出:{{filename}}"
}
}
13 changes: 13 additions & 0 deletions frontend/src/locales/zh/workbench.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,18 @@
"step5Body": "对布局满意了吗?点击「保存并继续」进入节省分析。您随时可以返回调整。",
"step6Title": "认识 Sol",
"step6Body": "卡住或想了解更多?点击 Sol,你的太阳能小帮手,可以问任何关于这个布局、节省金额、NEM 净电量积分,或马来西亚太阳能运作方式的问题。"
},
"toasts": {
"recomputingPanelYield": "正在依据已缓存的月度辐照数据重新计算板面发电量……",
"groupMoveFailed": "批量移动失败:{{reason}}",
"recomputingPanels": "正在重新计算 {{count}} 块板的发电量……",
"groupMoveRecomputeFailed": "批量移动重新计算失败,位置已还原。",
"recalculatingDimensions": "正在按新的板面尺寸重新计算发电量……",
"recomputingBeforeSave": "保存前正在重新计算 {{count}} 块启用板的月度发电量……",
"savingLayout": "正在将更新后的布局保存到您的项目……",
"overlayLoadFailed": "加载{{mode}}图层失败",
"placementMask": "该位置超出了检测到的屋顶范围。",
"placementOverlap": "该位置与另一块板重叠。",
"placementBounds": "该位置超出了屋顶图像范围。"
}
}
Loading